Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions docs/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ small, self-contained file you can copy, run, and tweak.
| --- | --- |
| [`discover_boards.py`](../examples/discover_boards.py) | Finding every board on the LAN (with an IP fallback for networks that block broadcast), then connecting to each to print its IP, name, firmware, and game state |
| [`elvira_hurryup.py`](../examples/elvira_hurryup.py) | Polling an SRAM byte in a loop and inferring game state to draw a live ELVIRA hurry-up display |
| [`update_all_boards.py`](../examples/update_all_boards.py) | Discovering every board, checking each for a firmware update, then (after one confirmation) updating them all concurrently with a live per-board progress bar |

## Discover boards on the network

Expand Down Expand Up @@ -73,6 +74,46 @@ IP is enough to list the whole network. The script parses that payload
defensively (the exact JSON shape can vary by firmware) and always includes the
IP you supplied, so it works even if the peer table comes back empty.

## Update every board on the network

[`examples/update_all_boards.py`](../examples/update_all_boards.py) finds every
board, checks which ones have a firmware update available, asks you once, and
then updates them all at the same time — each board gets its own line with a
progress bar that fills as its update streams.

```bash
python examples/update_all_boards.py
```

Checking is read-only and needs no password; each board's
`check_for_updates()` (`/api/update/check`, which has a 10 s server-side
cooldown) is called concurrently, and "the payload contains a `url`" is
treated as the signal that an update exists. Applying is the authenticated
part: the script uses `$VECTOR_PASSWORD` when set and otherwise prompts for
the shared board password (via `getpass`) after you confirm. Then
`apply_update()` streams `{"log": ..., "percent": ...}` records as the
board downloads and flashes, and the script feeds each record's `percent`
into a shared display:

```python
def on_record(record):
progress.update(name, percent=record.get("percent"), status=record.get("log"))

m.apply_update(url=url, progress=on_record)
```

The progress display is ~25 lines: a dict of `name -> (percent, status)`
behind a lock, redrawn in place by moving the cursor up N lines with
`"\x1b[NA"` and erasing each line with `"\x1b[2K"` before rewriting it. Any
thread that reports progress repaints the whole table, so all bars stay live
even though the updates run in parallel. When stdout isn't a terminal the
cursor moves are skipped and each repaint just prints fresh lines.

A board that fails mid-update shows `FAILED: ...` on its own line instead of
killing the others, and boards reboot themselves to finish applying — use
`wait_until_reachable()` if you want to block until they're back. See
[updates](machine.md) for `check_for_updates()` / `apply_update()` details.

## ELVIRA hurry-up display

[`examples/elvira_hurryup.py`](../examples/elvira_hurryup.py) watches an
Expand Down
168 changes: 168 additions & 0 deletions examples/update_all_boards.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
"""Find every Vector board on the LAN, check each for a firmware update, and
-- after you confirm -- apply the updates with a live progress bar per board.

The flow is three phases:

1. **Discover** boards with a UDP broadcast (no password needed).
2. **Check** each board's ``/api/update/check`` concurrently and list which
ones have an update available (note: the firmware enforces a 10 s
server-side cooldown on this route, so re-runs in quick succession may
report nothing).
3. **Ask once**, then update every out-of-date board at the same time.
``Machine.apply_update()`` streams ``{"log": ..., "percent": ...}`` records
as the board downloads and flashes, so each board gets its own line with a
progress bar that fills in as its update runs.

Applying an update is an authenticated route: the script uses $VECTOR_PASSWORD
if set, and otherwise prompts for the password before updating (all boards
must share it for this script).

python examples/update_all_boards.py
"""

import getpass
import os
import sys
import threading
from concurrent.futures import ThreadPoolExecutor

import warpedpinball
from warpedpinball import TransportError, VectorError

DISCOVERY_TIMEOUT = 20.0 # seconds to listen for boards
BAR_WIDTH = 30


def check_board(board):
"""Ask one ``(name, ip)`` board whether it has an update available.

Returns ``(name, ip, url_or_None, error_or_None)``. The update-check
payload shape isn't rigidly guaranteed, so treat "has a url" as the
signal that an update exists.
"""
name, ip = board
try:
with warpedpinball.connect(ip) as m:
info = m.check_for_updates()
except (TransportError, VectorError, OSError) as error:
return name, ip, None, str(error)
url = info.get("url") if isinstance(info, dict) else None
return name, ip, url, None


class ProgressBoard:
"""One status line per board, redrawn in place with ANSI cursor moves.

Each updater thread reports its percent/status here; the writer that
happens to hold the lock repaints all lines. Falls back gracefully when
stdout isn't a terminal (each repaint just prints fresh lines).
"""

def __init__(self, names):
self.lock = threading.Lock()
self.names = names
self.state = {name: (0, "waiting") for name in names}
self.width = max(len(n) for n in names)
self.drawn = False

def update(self, name, percent=None, status=None):
with self.lock:
old_pct, old_status = self.state[name]
self.state[name] = (
old_pct if percent is None else percent,
old_status if status is None else status,
)
self._draw()

def _draw(self):
if self.drawn and sys.stdout.isatty():
sys.stdout.write(f"\x1b[{len(self.names)}A") # cursor up N lines
for name in self.names:
pct, status = self.state[name]
filled = int(BAR_WIDTH * pct / 100)
bar = "#" * filled + "-" * (BAR_WIDTH - filled)
sys.stdout.write(f"\x1b[2K{name:<{self.width}} [{bar}] {pct:3d}% {status}\n")
sys.stdout.flush()
self.drawn = True


def update_board(board, password, progress):
"""Run one board's update, feeding its streamed percent into the display.

Returns True on success.
"""
name, ip, url, _ = board

def on_record(record):
percent = record.get("percent")
log = record.get("log")
progress.update(
name,
percent=int(percent) if percent is not None else None,
status=str(log)[:40] if log else None,
)

try:
with warpedpinball.connect(ip, password=password) as m:
progress.update(name, status="updating")
m.apply_update(url=url, progress=on_record)
progress.update(name, percent=100, status="done")
return True
except (TransportError, VectorError, OSError) as error:
progress.update(name, status=f"FAILED: {error}")
return False


def main():
print(f"Listening for boards (up to {DISCOVERY_TIMEOUT:.0f}s)...")
found = warpedpinball.discover(timeout=DISCOVERY_TIMEOUT)
if not found:
print("No boards found. Are you on the same network as the machines?")
return
boards = sorted(((b.name or b.ip, b.ip) for b in found), key=lambda b: b[0].lower())

print(f"Found {len(boards)} board(s). Checking for updates...\n")
with ThreadPoolExecutor(max_workers=min(8, len(boards))) as pool:
results = list(pool.map(check_board, boards))

updatable = []
for name, ip, url, error in results:
if error:
print(f" {name:<20} {ip:<15} unreachable: {error}")
elif url:
print(f" {name:<20} {ip:<15} update available")
updatable.append((name, ip, url, None))
else:
print(f" {name:<20} {ip:<15} up to date")

if not updatable:
print("\nNothing to update.")
return

answer = input(f"\nUpdate {len(updatable)} board(s)? [y/N] ").strip().lower()
if answer not in ("y", "yes"):
print("Aborted; no boards were touched.")
return

# Applying an update is authenticated; get a password before starting.
password = os.environ.get("VECTOR_PASSWORD") or getpass.getpass(
"Board password (same for all boards): "
)

print()
progress = ProgressBoard([b[0] for b in updatable])
progress.update(updatable[0][0]) # draw the initial table
with ThreadPoolExecutor(max_workers=len(updatable)) as pool:
results = list(
pool.map(lambda b: update_board(b, password, progress), updatable)
)

failed = results.count(False)
if failed:
print(f"\n{failed} of {len(results)} update(s) failed -- see above.")
else:
print("\nAll updates applied. Boards reboot themselves to finish.")


if __name__ == "__main__":
main()
Loading