diff --git a/.gitignore b/.gitignore index fa1d0d4..cba758a 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,7 @@ build/ # Plan documents from the plan skill: local working notes. .hermes/ + +# Python bytecode from the tools tests. +__pycache__/ +*.pyc diff --git a/AGENTS.md b/AGENTS.md index 6aa3c6f..001597a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,6 +63,24 @@ session: python3 tools/keypad_test.py +There is also a browser UI, which is usually the quickest way to poke at the +firmware by hand: + + python3 tools/webui.py --frame-addr 0x200013DC \ + --status-addr 0x2000175C # then open http://127.0.0.1:8080/ + +Two things about it that matter when working on this repo: + +- **It holds the QMP socket for its lifetime**, so `key.py` cannot run at the same + time. The socket accepts a single client. +- **It reads frames with QMP `memsave`, deliberately.** Not `pmemsave`, which + takes a *physical* address and silently returns zeros for `gFrameBuffer` -- + a blank screen with no error. And not gdb, which halts the guest on every + attach: that stutters the stream and perturbs key debounce timing. + +Its tests: `tools/test_uvk5_*.py` and `tools/test_webui.py` need no emulator, +`tools/test_webui_e2e.py` boots its own. + ## Things that already went wrong **GDB breakpoints halt the guest.** A key held across a breakpoint session is @@ -98,6 +116,14 @@ reported `IDR=0x0000` for several rounds because its regex did not match gdb's output format at all. The register was fine; the reader was broken. Cross-check with `tools/gpiob_dump.sh`, which uses a different path. +**QMP `pmemsave` is physical, `memsave` is virtual.** The framebuffer symbols are +CPU virtual addresses, so `pmemsave` on `gFrameBuffer` returns a block of zeros +and reports success -- a blank screen with nothing logged anywhere. The web UI was +built on `pmemsave` first because a timing benchmark said it was fast; the +benchmark never checked the *contents*. Measure the thing you actually care +about: the bug surfaced only when a rendered frame came back with 0 lit pixels +where the gdb path reported 1693. + ## The keypad: two real bugs, both fixed The old note here said "keys reach the firmware but the UI does not react" and diff --git a/README.md b/README.md index 9f8fea4..6d8fc25 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,10 @@ keypresses silently stop working. Run the test after touching that code; docs/screenshots/ LCD captures used in this README tools/ run, screenshot, inject keys, probe state keypad_test.py keypad regression test, boots its own instance + webui.py web remote control: live LCD plus clickable keypad + uvk5_qmp.py QMP client + uvk5_lcd.py framebuffer decode, PNG encode, frame grabber + uvk5_keys.py key names the keypad model accepts harness/, stubs/, shim/, tests/ host build of the CW timing chain (stage A) ## Building @@ -108,6 +112,12 @@ This matters more than it looks. The keypad can break silently under -O2 without any compiler warning -- see the `volatile` note in [Status](#status) -- so a clean build is not evidence that keypresses work. +The rest of the tests: + + cd tools && python3 -m unittest discover -p 'test_uvk5*.py' -v # fast, no emulator + cd tools && python3 -m unittest test_webui -v # fast, no emulator + python3 tools/test_webui_e2e.py # boots its own emulator + ## Running python3 tools/make_flash.py # once, builds assets/flash.img @@ -128,6 +138,55 @@ between builds. Find them with: arm-none-eabi-nm firmware.elf | grep -E 'gFrameBuffer|gStatusLine' +## Web remote control + +`tools/webui.py` serves the LCD and a clickable keypad, so the radio can be +driven from a browser instead of `key.py` plus `screenshot.py`. + + tools/run.sh # emulator first + python3 tools/webui.py --frame-addr 0x200013DC \ + --status-addr 0x2000175C # then the server + +Open . The keypad is laid out like the radio, with the +side keys alongside. Arrow keys, Enter (MENU), Esc (EXIT) and the digits are +bound to the physical keys. + +Press duration comes from how long you actually hold the button, because the +firmware treats anything past 400 ms as a *held* key and dispatches it as a +different event. The browser sends the two edges separately rather than asking +the server for a fixed-length press. + +Endpoints, if you want to script it: + +| Route | Purpose | +| --- | --- | +| `GET /` | the page | +| `GET /stream` | multipart PNG stream, up to 15 fps | +| `GET /frame.png` | one frame | +| `POST /api/key` | `{"key": "MENU", "action": "down"}` — also `up` or `tap` | +| `POST /api/release-all` | release every key, if one ever sticks | +| `GET /api/status` | QMP `query-status` | + +Frames are read with QMP `memsave`, about 1.35 ms each, and the guest keeps +running throughout. Two details there are easy to get wrong: + +- **`memsave`, not `pmemsave`.** The framebuffer symbols are CPU virtual + addresses. `pmemsave` treats its argument as physical and returns a block of + zeros, so the screen renders blank with no error anywhere. +- **Not gdb.** `screenshot.py` reads frames through gdb, which halts the guest on + every attach. That is unusable for a live stream and it also perturbs key + debounce timing. + +Two constraints worth knowing before you use it: + +- **The QMP socket takes one client.** While the server is up, `tools/key.py` + cannot talk to the same emulator. +- **There is no authentication.** It binds loopback, and anyone who reaches the + port has full control of the emulated radio. Do not expose it. + +There is no PTT button: the keypad model has no PTT line, so the `press` property +rejects the name. Unknown keys are rejected with 400 rather than forwarded. + ## How the machine is put together Register layouts come from the vendor CMSIS header shipped with the firmware