v01/validate.pyRafa's words, verbatim:
"the simulation should run the firmware no? So that you're actually testing the flow?"
"Start the next round as well on the real firmware"
Until now the site's Meeting and Device tabs ran v01/firmware/js/goldfish.js, a hand-written JavaScript copy of the device logic. The "Light ring parity" check holds it to the Python on 18 ring frames and 6 eye-press sequences. The firmware itself (v01/firmware/goldfish/: the main loop, eye debounce, ring encoder, motor, tag driver) ran only in unit tests. Nothing checked the whole path from a spoken line or a press to the LEDs, the motor and the tag, and the page showed the copy, not the device.
| Option | For | Against |
|---|---|---|
| Keep the JS copy, add more parity cases | No download | Still a copy; the main loop, eye debounce, encoder, motor and tag are never in it |
| Run the firmware in the browser with Pyodide (CPython compiled to WebAssembly) | The page runs the same files as the device; no rewrite | About 6 MB from a CDN, a few seconds to start, and needs a network |
| Port the firmware to JavaScript | Instant | A second copy of everything, the opposite of the request |
v01/firmware/scenarios/meeting.json: names, seats, the 20 lines with their hand-written paraphrase, gist and outcome, and the site's captions-only timing. site/build.py inlines it and the page holds no copy of its own; a test fails if a line appears in the page source.goldfish/meeting.py (SimRun) puts daemon.py on the sim board (hw.sim_board) and drives it on the board's clock. It stands in for only what isn't built: the audio pipeline (a voice-activity call every 0.5 s while a line is spoken, then the recognised line with its span and the speaker's bearing) and the distiller (the script's hand-written lines, filtered by the level each line was filed at).tools/flow_check.py replays the script, plus six scripted presses standing in for the visitor, then waits out the 10-minute session gap. It checks only the device's outputs: every SPI transfer decoded back to colours, the motor pin decoded back to patterns, the ST25DV memory parsed as NDEF, and the note unpacked from the link. It is validate.py's "Firmware flow" and 12 tests in tests/test_flow.py.cdn.jsdelivr.net/npm/pyodide@314.0.7/, released 2026-09-14 (npm), when the tab is first opened. build.py ships the firmware and protocol sources (28 files, about 150 KB) and the page writes them into Pyodide's file system at their repo paths. The page then:SimRun;ring.py sent, decoded and scaled back up from the 30 % cap;goldfish.js, labelled "Engine: preview (JS)". It stays the engine if Pyodide fails to load, or offline. When the firmware arrives, what the meeting did so far is replayed into it and the label reads "Engine: firmware (Python)". The Device tab still runs the preview and says so. "Light ring parity" stays: the preview still exists. The flow test is stronger for the firmware, but it says nothing about the JS copy, so it can't replace the parity check.daemon.py, not display.py or device.py):hear(..., span=, bearing=): a span files the line in the voice's own record at the lowest level that applied over it;speaking(label) for the voice-activity detector;bearings for the CAD's LED positions.
With every voice's bearing known, the main loop draws display.seat_frame, as ADR-0015 requires; before, it drew only the segment ring.tools/pyodide_flow.mjs runs the replay in Pyodide (Python 3.14.2) from the files the page ships. Its hash of every SPI transfer, motor edge and tag byte matched CPython 3.12's.store.py works in Pyodide. Pyodide ships cryptography 47.0.0, and a seal-and-open round trip passed. The meeting doesn't use the store, so the page doesn't load it; it would add 2.4 MB.BUILD-LOG.md phase 27 and BACKLOG.md B-62 to B-66.# ADR-0019: The site's meeting runs the real firmware, and a flow test replays it - **Status:** accepted (2026-10-14) - **Date:** 2026-10-14 - **Requirements touched:** the workbench's simulators; the checks in `v01/validate.py` - **Builds on:** ADR-0011 (protocol and profile), ADR-0015 (eye gestures, seat-facing ring, buttonless phone transfer) ## Context Rafa's words, verbatim: > "the simulation should run the firmware no? So that you're actually testing the flow?" > "Start the next round as well on the real firmware" Until now the site's Meeting and Device tabs ran `v01/firmware/js/goldfish.js`, a hand-written JavaScript copy of the device logic. The "Light ring parity" check holds it to the Python on 18 ring frames and 6 eye-press sequences. The firmware itself (`v01/firmware/goldfish/`: the main loop, eye debounce, ring encoder, motor, tag driver) ran only in unit tests. Nothing checked the whole path from a spoken line or a press to the LEDs, the motor and the tag, and the page showed the copy, not the device. ## Options | Option | For | Against | |---|---|---| | Keep the JS copy, add more parity cases | No download | Still a copy; the main loop, eye debounce, encoder, motor and tag are never in it | | Run the firmware in the browser with Pyodide (CPython compiled to WebAssembly) | The page runs the same files as the device; no rewrite | About 6 MB from a CDN, a few seconds to start, and needs a network | | Port the firmware to JavaScript | Instant | A second copy of everything, the opposite of the request | ## Decision 1. **One script.** The meeting is `v01/firmware/scenarios/meeting.json`: names, seats, the 20 lines with their hand-written paraphrase, gist and outcome, and the site's captions-only timing. `site/build.py` inlines it and the page holds no copy of its own; a test fails if a line appears in the page source. 2. **One runner.** `goldfish/meeting.py` (`SimRun`) puts `daemon.py` on the sim board (`hw.sim_board`) and drives it on the board's clock. It stands in for only what isn't built: the audio pipeline (a voice-activity call every 0.5 s while a line is spoken, then the recognised line with its span and the speaker's bearing) and the distiller (the script's hand-written lines, filtered by the level each line was filed at). 3. **The flow test.** `tools/flow_check.py` replays the script, plus six scripted presses standing in for the visitor, then waits out the 10-minute session gap. It checks only the device's outputs: every SPI transfer decoded back to colours, the motor pin decoded back to patterns, the ST25DV memory parsed as NDEF, and the note unpacked from the link. It is `validate.py`'s "Firmware flow" and 12 tests in `tests/test_flow.py`. 4. **The firmware in the page.** The Meeting tab loads Pyodide 314.0.7 from `cdn.jsdelivr.net/npm/pyodide@314.0.7/`, released 2026-09-14 (npm), when the tab is first opened. `build.py` ships the firmware and protocol sources (28 files, about 150 KB) and the page writes them into Pyodide's file system at their repo paths. The page then: - feeds the script's lines and the visitor's eye edges (each down and up, as they happen) to `SimRun`; - draws the ring from the bytes `ring.py` sent, decoded and scaled back up from the 30 % cap; - vibrates the motor's patterns; - shows the note read back from the simulated tag; - ends each meeting the way a table would: it skips the ten minutes of silence, and the session gap writes the note. 5. **The JS copy stays as the preview.** The page is drawn and usable at once on `goldfish.js`, labelled "Engine: preview (JS)". It stays the engine if Pyodide fails to load, or offline. When the firmware arrives, what the meeting did so far is replayed into it and the label reads "Engine: firmware (Python)". The Device tab still runs the preview and says so. "Light ring parity" stays: the preview still exists. The flow test is stronger for the firmware, but it says nothing about the JS copy, so it can't replace the parity check. 6. **Daemon inputs the flow needed** (in `daemon.py`, not `display.py` or `device.py`): - `hear(..., span=, bearing=)`: a span files the line in the voice's own record at the lowest level that applied over it; - `speaking(label)` for the voice-activity detector; - `bearings` for the CAD's LED positions. With every voice's bearing known, the main loop draws `display.seat_frame`, as ADR-0015 requires; before, it drew only the segment ring. ## Consequences - **Cost:** - **Download:** Pyodide's core is 6.3 MB over the wire: the wasm is 3.4 MB brotli, the stdlib zip 2.5 MB, and the JS and lock file 0.3 MB. It is fetched only by visitors who open the Meeting tab, then cached by the browser. The page itself grows by about 160 KB of inlined sources. - **Load time:** 1.2–1.3 s from the browser cache on the test machine, 5.9 s cold; in Node, 1.3 s. - **Running:** about 0.3 ms per loop pass, so 30 passes a second cost about 1 % of a core. - **Same device in both runtimes.** `tools/pyodide_flow.mjs` runs the replay in Pyodide (Python 3.14.2) from the files the page ships. Its hash of every SPI transfer, motor edge and tag byte matched CPython 3.12's. - **`store.py` works in Pyodide.** Pyodide ships `cryptography` 47.0.0, and a seal-and-open round trip passed. The meeting doesn't use the store, so the page doesn't load it; it would add 2.4 MB. - **What the flow found:** listed in `BUILD-LOG.md` phase 27 and `BACKLOG.md` B-62 to B-66. - **Still a simulation.** The board, microphones, speech recogniser and distiller are stand-ins; the bytes on the bus are the firmware's own. Hardware truth is still B-59.