diff --git a/docs/examples.md b/docs/examples.md index 710d3f5..87210ce 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -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 @@ -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 diff --git a/examples/update_all_boards.py b/examples/update_all_boards.py new file mode 100644 index 0000000..3fada1b --- /dev/null +++ b/examples/update_all_boards.py @@ -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()