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
171 lines
12 KiB
Markdown
171 lines
12 KiB
Markdown
# AGENTS.md — working on keyrelay
|
||
|
||
A WiFi-controlled USB HID keyboard: laptop browser → ESP32-S3 board → USB into the target.
|
||
The target runs no software and sees a Logitech G815. Everything the board does is gated on
|
||
one idea: **the difference between a remote keyboard and an unattended injection device is a
|
||
human deciding, live, with the arming light on.** Changes that quietly move that line are the
|
||
ones to catch here.
|
||
|
||
The README is the single source of truth for behaviour, protocol, safety and measurement.
|
||
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, "Ten bugs").
|
||
|
||
|
||
## The invariants
|
||
|
||
1. **State, not events.** `S=<mods>:<usages>` restates the *entire* desired key state. Applying
|
||
the same state twice is a no-op; any one message resynchronises the target completely. Never
|
||
introduce key-up/key-down events, sequence numbers, or a resynchronisation timer. Every
|
||
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 **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.
|
||
4. **Nothing is stored.** Counters only. No keystrokes, characters, key names or text in NVS,
|
||
logs, `Serial.printf`, error messages or status frames — `src/main.cpp` prints *lengths* for
|
||
text runs, never content. No macro store, no schedule, no replay, no auto-arm.
|
||
5. **Reject, never guess.** A malformed frame leaves the previous state in force. A character
|
||
the map cannot express is `E9` and *none* of that frame is typed — a paste that quietly loses
|
||
a character is worse than one that refuses. Silent sanitising is never the default.
|
||
6. **`Ctrl+Alt+Del` is fixed on the device.** It is never composed from wire bytes; on a Linux
|
||
VT it reboots the target. Same reasoning for the VT-switch chords.
|
||
7. **One frame is one command.** ASCII, no JSON inbound, ≤ `kWsMessageCap`. Long payloads are
|
||
the *controller's* problem to split, with the board reporting how much it still holds.
|
||
8. **The keyboard is exclusive; the board is not.** A second `/ws` upgrade is refused (`E3`);
|
||
read-only HTTP is served even while a controller holds the bridge. Keep both routes going
|
||
through `serveReadonly()` so they cannot drift apart.
|
||
|
||
## Three tables that must agree
|
||
|
||
| table | where | names | pinned by |
|
||
|---|---|---|---|
|
||
| `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.
|
||
A mislabelled button parses cleanly, is correctly gated, arrives intact and is the wrong key;
|
||
Alt+Tab was `05:04` (Ctrl+Alt+A) until that test existed. `TEXT_FRAME_MAX` / `TEXT_JOB_MAX` in
|
||
the page and `TEXT_FRAME_MAX` / `MAX_PASTE_CHARS` in `relayctl.py` are hand-copied from
|
||
`config.h` and are asserted equal in tests — change one, the suite fails, that is the point.
|
||
|
||
## Build, test, flash
|
||
|
||
```bash
|
||
tests/run_tests.sh # 3406 checks, no hardware
|
||
SANITIZE=1 tests/run_tests.sh # + ASan/UBSan — use it for anything touching buffers
|
||
# 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
|
||
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`), 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
|
||
thing that checks the no-HID `#else` branches.
|
||
- **The page is served from flash.** A change to `src/web_page.h` is invisible on the network
|
||
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 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
|
||
|
||
A feature that lives on the device is not done when the source builds — it is done when the
|
||
device is running it and something observed it. Concretely:
|
||
|
||
- Protocol/page claims → the host suites, plus `curl` against the served page.
|
||
- Keystroke delivery → `tools/watch_target.py` on the target (evdev, focus-independent); it is
|
||
the only thing that says what the target actually received, including *which* keys a chord
|
||
really was.
|
||
- Text runs → check the `typed:` summary and press count, not the scrolling stream.
|
||
|
||
When you write a check, name the mechanism it exercises. "Silence disarms" sat in the README's
|
||
safety list while nothing compared `lastInboundMs()` to `kNoClientTimeoutMs`; the documented
|
||
verification passed because killing the controller process closes the socket, and the
|
||
socket-close path disarms. The check proved a different property from the one it was named after
|
||
and nobody noticed, until a run that keeps typing on its own made the difference matter. It is
|
||
wired now — but ask, of every green check, *what would still pass if this were broken?*
|
||
|
||
## Editing rules
|
||
|
||
- **Pure modules** (`keystate.h`, `proto.h`, `text.h`, `ws_codec.h`, `sha1.h`) include no
|
||
Arduino or ESP-IDF headers. That is what lets the identical code run on a host under
|
||
sanitisers. Keep it.
|
||
- **`web_page.h`** is a C++ raw string: no compiler looks at the HTML or JS. Run
|
||
`tests/test_page.js` after touching it (it executes the page against a DOM shim and checks
|
||
that every element id the script reaches for exists in the markup).
|
||
- **A protocol change is four edits, minimum**: `proto.h` (parser + `format*` + its header
|
||
comment), `web_page.h`, `relayctl.py`, and the README Wire protocol table + error-code list.
|
||
Tests that pin the wire format are `test_proto.cpp` and `test_relayctl.py`.
|
||
- **Docs move with the code.** Update in the same change: README intro bullets and check count,
|
||
Safety model, Security model trust-boundary table, Controller page, Wire protocol,
|
||
Architecture file map, Tests table, Backlog, and the docstring at the top of whichever module
|
||
you changed. This README documents *why* decisions were made — keep the reasoning, don't just
|
||
restate the behaviour.
|
||
- Secrets: never in a file, a URL, a log or a shell history. The serial `token` command does not
|
||
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 "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.
|
||
|
||
## Current defaults worth knowing
|
||
|
||
Default passphrase `changeme` (the page and `status` shout about it via `dflt:1`); AP
|
||
`keyrelay-ctl` / `keyrelay-ctl-pass`; `http://keyrelay.local` or `192.168.4.1`; paste pace
|
||
12 ms/character, 120 characters per frame, 2048 per run; silence disarm 8 s; NVS holds the
|
||
station credentials and passphrase and survives reflashing.
|