feat: relative mouse on the HID interface, and the verifier to prove it
The board now enumerates as a boot keyboard plus a relative mouse on the same HID interface. The pointer is captured, never grabbed: Pointer Lock in the page, so Esc (the browser's own, never forwarded) always frees it, and every frame restates the whole button byte so a frame lost in the WiFi cannot leave a button held. Losing the lock, the tab or the link runs the same release funnel as a stuck key, and boot protocol or `mouse off` refuses mouse frames with E11 rather than sending bytes a boot interface would misparse as keys. src/mouse.h owns the report, src/output.h the queues and the funnel; the page, relayctl.py and watch_target.py carry the button bits and the evdev decoding. The verifier's own tables are pinned now — REL_WHEEL is 8, not 2, and the first version guessed from the report's field order, so a working wheel looked dead while the summary said no scroll happened. LLM-Generated: true
This commit is contained in:
@@ -10,7 +10,8 @@ The README is the single source of truth for behaviour, protocol, safety and mea
|
||||
This file is the operating manual: what must not break, how to build and verify, and where
|
||||
the traps are. **Do not fork the protocol or the safety model into `docs/`** — a second copy
|
||||
drifts, and two paths that can disagree about arming is the exact failure this project keeps
|
||||
having to fix (see README, "Nine bugs").
|
||||
having to fix (see README, "Ten bugs").
|
||||
|
||||
|
||||
## The invariants
|
||||
|
||||
@@ -20,9 +21,20 @@ having to fix (see README, "Nine bugs").
|
||||
stuck-key horror story in HID injection tooling is an event-protocol bug.
|
||||
2. **Disarmed is the only default.** Boot, reload, reconnect, focus loss, socket loss, target
|
||||
detach, bus suspend, queue overflow and 8 s of controller silence all disarm, and disarming
|
||||
releases every key. Arming needs the passphrase, the board button, or the serial console —
|
||||
nothing else may gain the ability.
|
||||
3. **Every fail-safe cancels work in flight; nothing "pauses".** `releaseEverything()` in
|
||||
releases every key **and every mouse button**. Arming needs the passphrase, the board
|
||||
button, or the serial console — nothing else may gain the ability. The mouse is captured,
|
||||
never grabbed: Pointer Lock, so `Esc` (the browser's own, never forwarded) always frees
|
||||
it. `mouse off` over serial removes the pointer interface at the descriptor level, which
|
||||
needs a reboot and is the rollback.
|
||||
3. **Every fail-safe cancels work in flight; nothing "pauses"** — and **no fail-safe may be
|
||||
gated on the absence of the thing it detects.** `ControlServer::linked()` is false when a
|
||||
controller is gone, so gating the silence-disarm on it made the backstop for a vanished
|
||||
controller uncallable exactly when a controller vanished. Same shape: `closeLink()`
|
||||
releasing only `if (linked())` skipped the release for every path that cleared `_authed`
|
||||
first. If a rule fires when X stops existing, `X still exists` cannot be a precondition
|
||||
of it. See README bug 10; `test_relayctl.py` pins the three sentences.
|
||||
|
||||
`releaseEverything()` in
|
||||
`main.cpp` is the single funnel: it clears the report queue, raises the keys, ends the chord
|
||||
and discards a pasted run. Any new state that can outlive a disconnect (a queue, a timer, a
|
||||
run) must be cancelled there too, or it will keep acting after the operator stopped asking.
|
||||
@@ -47,6 +59,14 @@ having to fix (see README, "Nine bugs").
|
||||
| `CODE_TO_USAGE` | `src/web_page.h` | physical position → usage | `tests/test_relayctl.py` |
|
||||
| `CODE_TO_USAGE` | `tools/relayctl.py` | same, for the terminal | `tests/test_relayctl.py` |
|
||||
| `strokeFor()` | `src/text.h` | character → modifier + usage | `tests/test_text.cpp` (whole printable range walked) + tool/board agreement on all 128 ASCII codes |
|
||||
| mouse button bits | `src/mouse.h`, `src/web_page.h` (`MOUSE_BTN`), `tools/relayctl.py` (`MOUSE_BUTTONS`) | HID bit → DOM `MouseEvent.button` **and** name | `test_proto.cpp` (page↔config), `test_relayctl.py` (tool↔page↔config) |
|
||||
| evdev axis codes | `tools/watch_target.py` (`REL_*`) | kernel code → label, for the *verifier* | `test_relayctl.py` — pinned to `<linux/input-event-codes.h>`, **not** to the report's byte order |
|
||||
|
||||
The last row exists because it was guessed from the HID report's field order and was wrong
|
||||
(`REL_WHEEL` is 8, not 2): a working wheel looked dead and the summary said no scroll
|
||||
happened. Codes the verifier has no name for are now counted and printed rather than
|
||||
dropped — the same fix as giving F13–F24 names. **When you add a table that decodes
|
||||
something somebody else defined, test the table, not your confidence in it.**
|
||||
|
||||
Chord buttons carry their **wire bytes** in `data-chord`, not a name — which is why
|
||||
`tests/test_proto.cpp` asserts every labelled chord against the combination its label promises.
|
||||
@@ -58,18 +78,26 @@ the page and `TEXT_FRAME_MAX` / `MAX_PASTE_CHARS` in `relayctl.py` are hand-copi
|
||||
## Build, test, flash
|
||||
|
||||
```bash
|
||||
tests/run_tests.sh # 955 checks, no hardware
|
||||
tests/run_tests.sh # 3406 checks, no hardware
|
||||
SANITIZE=1 tests/run_tests.sh # + ASan/UBSan — use it for anything touching buffers
|
||||
/tmp/piovenv/bin/python -m platformio run -e bridge -e console
|
||||
# PlatformIO. The venv below runs Python 3.15, which esptool REFUSES ("Python version must
|
||||
# be 3.10..3.14") -- builds fine, dies at upload. Use uv's 3.14 for anything with -t upload:
|
||||
uv run --python 3.14 --with platformio python -m platformio run -e bridge -e console
|
||||
python3 tools/to_bootloader.py # 1200-baud touch → ROM download mode
|
||||
/tmp/piovenv/bin/python -m platformio run -e bridge -t upload
|
||||
uv run --python 3.14 --with platformio python -m platformio run -e bridge -t upload
|
||||
```
|
||||
|
||||
Environment facts that will otherwise cost you a cycle:
|
||||
|
||||
- **`pio` on PATH is broken** here (`ModuleNotFoundError: platformio`). Use the venv above. If
|
||||
it is gone (`/tmp`), find another interpreter with the package:
|
||||
- **`pio` on PATH is broken** here (`ModuleNotFoundError: platformio`), and
|
||||
`/tmp/piovenv` is Python 3.15 so it can *build* but not *upload*. Use the uv invocation
|
||||
above for both. If it is unavailable, find another interpreter with the package:
|
||||
`find / -maxdepth 6 -name platformio -type d -path '*site-packages*'`.
|
||||
- **`pio run -t upload` reports SUCCESS on a stale binary if the build before it failed.**
|
||||
This happened: a diagnostic build failed to compile, the upload line right after it said
|
||||
SUCCESS, and the board was running the previous firmware. Never chain build and upload
|
||||
with `;` — check the build's own exit status first, or the verification you run next is
|
||||
verifying something that is not in the tree.
|
||||
- **No `g++` on the host.** `run_tests.sh` falls back to a local podman image listed in its
|
||||
`images[]`. The page suite deliberately runs on the *host* so it works without node in there.
|
||||
- **Build `console` too.** `ARDUINO_USB_MODE=1` compiles `KR_HAVE_HID == 0`, which is the only
|
||||
@@ -78,8 +106,18 @@ Environment facts that will otherwise cost you a cycle:
|
||||
until the board is reflashed — and a tab that is already open keeps the old page in memory
|
||||
until it is reloaded. Confirm with `curl -s http://keyrelay.local/ | wc -c` against the size
|
||||
of the raw string in `src/web_page.h`, and grep the served bytes for the element you added.
|
||||
- This directory is **not a git repository**, so there is no diff or revert here. Back up
|
||||
before sweeping edits.
|
||||
- **This directory is a git repository** (one commit, `Initial import`). Use it: `git diff`
|
||||
before and after any sweeping edit. Note that the commit predates the mouse and
|
||||
`output.h` work, so `git checkout` reverts far more than you asked for — restore a
|
||||
*region*, not a file.
|
||||
- **Never do a multi-line `.*?` regex rewrite across a source file.** One did
|
||||
`#if KR_DEBUG_LINK.*?#endif` with `DOTALL` to remove a diagnostic block and deleted ~500
|
||||
lines of `main.cpp` — `onMessage()`, `setup()`, the console, the USB handlers — because
|
||||
the earliest `#if` in the file was elsewhere. The compiler was the only thing that
|
||||
noticed. Remove instrumentation with exact-string edits, or per-function slices, and
|
||||
compile immediately after. When it happens anyway: the last good `firmware.elf` still
|
||||
has the `.rodata` strings (console text, `printf` formats) and the symbol table, which is
|
||||
enough to rebuild faithfully against the surviving headers.
|
||||
|
||||
## Verification standard
|
||||
|
||||
@@ -119,7 +157,7 @@ wired now — but ask, of every green check, *what would still pass if this were
|
||||
echo it; keep it that way. `/status` takes the token as a query parameter because `curl` is
|
||||
the point of that endpoint — do not add the token to any other URL.
|
||||
- `TODO.md` holds **open items only**; clear it when they are done. The durable record is the
|
||||
README: a fixed bug belongs in "Nine bugs this verification caught" with its root cause and
|
||||
README: a fixed bug belongs in "Ten bugs this verification caught" with its root cause and
|
||||
how it was proven, a new capability in the section that describes it, and a rule worth
|
||||
repeating here. A TODO that has accumulated resolved items tells the next reader nothing about
|
||||
what is left to do.
|
||||
|
||||
Reference in New Issue
Block a user