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
12 KiB
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
-
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. -
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 offover serial removes the pointer interface at the descriptor level, which needs a reboot and is the rollback. -
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 onlyif (linked())skipped the release for every path that cleared_authedfirst. If a rule fires when X stops existing,X still existscannot be a precondition of it. See README bug 10;test_relayctl.pypins the three sentences.releaseEverything()inmain.cppis 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. -
Nothing is stored. Counters only. No keystrokes, characters, key names or text in NVS, logs,
Serial.printf, error messages or status frames —src/main.cppprints lengths for text runs, never content. No macro store, no schedule, no replay, no auto-arm. -
Reject, never guess. A malformed frame leaves the previous state in force. A character the map cannot express is
E9and 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. -
Ctrl+Alt+Delis 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. -
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. -
The keyboard is exclusive; the board is not. A second
/wsupgrade is refused (E3); read-only HTTP is served even while a controller holds the bridge. Keep both routes going throughserveReadonly()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
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:
pioon PATH is broken here (ModuleNotFoundError: platformio), and/tmp/piovenvis 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 uploadreports 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.shfalls back to a local podman image listed in itsimages[]. The page suite deliberately runs on the host so it works without node in there. - Build
consoletoo.ARDUINO_USB_MODE=1compilesKR_HAVE_HID == 0, which is the only thing that checks the no-HID#elsebranches. - The page is served from flash. A change to
src/web_page.his 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 withcurl -s http://keyrelay.local/ | wc -cagainst the size of the raw string insrc/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 diffbefore and after any sweeping edit. Note that the commit predates the mouse andoutput.hwork, sogit checkoutreverts 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.*?#endifwithDOTALLto remove a diagnostic block and deleted ~500 lines ofmain.cpp—onMessage(),setup(), the console, the USB handlers — because the earliest#ifin 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 goodfirmware.elfstill has the.rodatastrings (console text,printfformats) 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
curlagainst the served page. - Keystroke delivery →
tools/watch_target.pyon 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.his a C++ raw string: no compiler looks at the HTML or JS. Runtests/test_page.jsafter 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 aretest_proto.cppandtest_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
tokencommand does not echo it; keep it that way./statustakes the token as a query parameter becausecurlis the point of that endpoint — do not add the token to any other URL. TODO.mdholds 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.