Compare commits

...
2 Commits
Author SHA1 Message Date
ducoterra 56aa97d60d chore(agent): phase roadmap from TODO.md, 5 phases
The four open items in TODO.md become an executable pipeline: 01 the
verification gate (host suites + sanitiser + both firmware envs, and the
hardware contract so no unit blocks on a board nobody is holding), 02 HTTPS
and wss:// with an identity the board generates itself into NVS, 03 session
takeover that always lands disarmed, 04 a responsive page whose safety
statements are pinned before they are trimmed, 05 the phone as a controller
(press-and-hold pad, soft keyboard, modifier latches over a new Q= frame).

TODO.md is cleared to its empty title; the items live in .agents/phases/ now.
AGENTS.md gains the five protocol rules and nothing else.

LLM-Generated: true
2026-11-08 23:28:25 -05:00
ducoterra ece0313d02 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
2026-11-08 23:27:42 -05:00
46 changed files with 5168 additions and 324 deletions
+121
View File
@@ -0,0 +1,121 @@
# keyrelay — Master Plan
> Master design document for a phased-execution project. The phase roadmap below was
> derived from `TODO.md` on 2026-11-08; each phase overview and task file cites its
> source lines in that file. The **LOCKED DECISIONS** below are binding: no phase may
> introduce technology outside them without explicit user permission.
>
> `README.md` remains the single source of truth for behaviour, protocol and safety.
> `AGENTS.md` remains the operating manual. This file adds *what is left to build* and
> the anchors those phases may not move. If this plan and the README disagree about
> arming, the wire format or the safety model, the README wins and this file is wrong.
## Assumptions & Design Principles
- **The safety model is not on the roadmap.** Disarmed-is-the-only-default, the release
funnel, state-not-events, nothing-stored, reject-never-guess, fixed `Ctrl+Alt+Del`,
one-frame-one-command, exclusive-keyboard-not-exclusive-board: every phase adds
capability *inside* those rules. A phase that needs one of them relaxed is wrong, not
ahead of schedule.
- **Every new capability has to be cancelable.** Anything that can outlive a disconnect —
a session, a TLS context, a touch capture, a modifier latch — is cancelled in
`releaseEverything()` or on the link change that ends it, exactly like the paste run.
- **Auth is a precondition, never a consequence.** Takeover (`03`) and the soft keyboard
(`05`) build on the authenticated channel (`02`); neither may gain the ability to arm.
- **No secrets in files.** Not in the repo, not in a log, not in a URL, not in shell
history. This is why the TLS identity is generated *on the board* rather than shipped
as a PEM in the tree — the pipeline's own hygiene gate refuses tracked `*.pem|*.key|*.crt`.
- **No unit blocks on hardware.** Board access is best effort: one attempt, wrapped in
`timeout`, outcome recorded in `.agents/HW_VERIFICATION.md`. The gate is host-observed
(test suites + both firmware builds + `curl` against a reachable board *when* one is
reachable). A phase may not hang, retry forever, or leave the board in download mode
waiting for a human who is not there.
- **Docs move in the same change** (README sections named per task; `AGENTS.md` only gets
stale statements fixed, never expanded).
## Architectural Anchors
| COMPONENT | DECISION | RATIONALE | STATUS |
|-----------|----------|-----------|--------|
| Target hardware | M5Stack AtomS3 Lite (ESP32-S3FN8, 8 MB flash, **no PSRAM**), one USB port shared between USB-Serial/JTAG and TinyUSB | `platformio.ini` header, README "The one USB port" | LOCKED |
| Build | PlatformIO `espressif32@8.0.0` (Arduino core 3.3.12), envs `bridge` (HID) and `console` (`ARDUINO_USB_MODE=1`, `KR_HAVE_HID == 0`) | `console` is the only thing that compiles the no-HID branches | LOCKED |
| Control plane | One browser page served from flash (`src/web_page.h`), one WebSocket, ASCII one-frame-one-command, no JSON inbound | invariant 7 | LOCKED |
| Pure modules | `keystate.h`, `proto.h`, `text.h`, `mouse.h`, `output.h`, `ws_codec.h`, `sha1.h` include no Arduino/IDF header, so the identical code runs on a host under ASan/UBSan | `tests/run_tests.sh` | LOCKED |
| Crypto/TLS stack | **mbedTLS + esp-tls exactly as shipped by the installed core** — no new third-party dependency. Verified in `~/.platformio/packages/framework-arduinoespressif32-esp32s3-libs`: `flags/ld_libs` carries `-lmbedtls_2 -lmbedcrypto -lmbedx509 -lmbedtls -lesp-tls -llwip`; `esp_tls_server_session_create/_continue_async/_delete`, `esp_tls_conn_read/write`, `mbedtls_ssl_setup/set_bio/handshake/conf_max_frag_len`, `mbedtls_pk_write_key_pem`, `mbedtls_ecp_gen_key`, `mbedtls_ctr_drbg_seed`, `mbedtls_x509write_crt_init`, `mbedtls_base64_encode` all resolve; sdkconfig enables `MBEDTLS_ECP_C`, `MBEDTLS_ECDSA_C`, `ECP_DP_SECP256R1`, `KEY_EXCHANGE_ECDHE_ECDSA`, `SSL_ALPN` | the user chose full WSS; nothing new has to be fetched, vendored or audited | LOCKED |
| TLS transport shape | **HTTPS on 443 + `wss://`**, one TLS context for the controller and a short-lived one per read-only request; `:80` becomes redirect-only | a page loaded over `http://` can be replaced on-path by an attacker who then owns the keystroke path, so half-encryption is not a smaller risk — it is a smaller *apparent* risk | LOCKED |
| TLS identity | **Self-signed ECDSA secp256r1 certificate generated on first boot, PEM key+cert in NVS, SHA-256 fingerprint printed once on the serial console** for pinning; regenerated only if NVS is erased | keeps the private key out of the repo (AGENTS.md), survives reflashing (NVS does), and a per-board key means stealing the repo does not buy a MITM position | LOCKED |
| Plaintext rollback | `-D KR_TLS=0` builds the current `ws://`-only behaviour; documented in README as the rollback, same standing as `mouse off` | a device that cannot be reached over TLS after a bad provisioning change must have a way back that is a reflash, not a repair | LOCKED |
| Arming | Unchanged by every phase in this roadmap: TLS, takeover and mobile input are all gated on the same arming as a keystroke today | invariant 2 | LOCKED |
| Host gate | `tests/run_tests.sh` (+ `SANITIZE=1`) and both PlatformIO builds; `pio run -t upload` never runs inside `.agents/validate.sh` | AGENTS.md: an upload in a gate is a device side effect on somebody else's schedule | LOCKED |
## High-Level Architecture
```
laptop / phone browser target machine
src/web_page.h -- https:// + wss:// ---> ┌───────────────────────────────┐
│ CODE_TO_USAGE, Pointer Lock, │ AtomS3 Lite (ESP32-S3) │
│ touch pad, soft keyboard │ src/main.cpp loop(), arming│
▼ │ src/control_server.{h,cpp} │
one ASCII frame (T N X A S C Y P M R K) │ ├ src/tls.{h,cpp} mbedTLS │ USB HID → target
one status frame (S{json}) <---│ ├ src/ws_codec.h SHA-1/WS │
│ ├ src/proto.h → keystate, │
tools/relayctl.py (terminal controller, │ │ text, mouse, output │
stdlib ssl, same frames) │ └ serial console (no typing)│
└───────────────────────────────┘
```
- `ControlServer` owns exactly one controller socket plus one read-only socket, and both
read-only paths go through `serveReadonly()` so they cannot drift (invariant 8). Phase
`02` puts a transport underneath it (plain socket vs mbedTLS session) without splitting
the routing.
- `main.cpp` owns arming, the report queue and `releaseEverything()` — the single funnel
that clears the queue, raises the keys, ends the chord and discards a pasted run.
- The page is one C++ raw string; `tests/test_page.js` executes it against a DOM shim.
## Data Model
- **Key state**: `S=<mods>:<usages>` restates everything; applying the same state twice is
a no-op. No events, no sequence numbers, no resynchronisation timer.
- **Mouse**: `M=<btn>:<dx>:<dy>:<wh>:<pan>`, button byte restated every frame, deltas
signed-byte bounded.
- **Status** (`statusJson()` in `control_server.cpp`): `v up usb armed net ssid ip keys
chords drop roll leds dflt ap typed typing mouse mbtn mframes`. `typed`/`typing` are
counts, never text — that rule is not negotiable for any new field either.
- **NVS** (namespace in `src/main.cpp`, survives reflashing): `sta_ssid`, `sta_pass`,
`token`, `ap_mode`, `mouse`. Phase `02` adds `tlscrt`, `tlskey` (PEM) and `tlscrtfp`.
- **Persistence never holds text.** No macro store, no schedule, no replay, no auto-arm.
## Validation / Verification Workflow
1. **Host unit/integration** — `tests/run_tests.sh`: `test_keystate`, `test_proto`,
`test_text`, `test_mouse`, `test_output`, `test_ws_codec` compiled from the *same*
headers the board runs, plus `test_relayctl.py` (tool↔page↔`config.h` tables) and
`tests/test_page.js` (the page script, executed). `SANITIZE=1` for anything touching a
buffer. Baseline at the start of this roadmap: **3406 checks, all passing**.
2. **Firmware builds** — `uv run --python 3.14 --with platformio python -m platformio
run -e bridge -e console` (the `pio` on PATH is broken; `pio run -t upload` reports
SUCCESS on a stale binary, so build status is checked before anything is trusted).
Baseline: bridge `RAM 26.7% (87 592 B)`, `Flash 36.1% (1 135 293 B)`; console
`RAM 16.8%`, `Flash 33.6%`. Any phase that grows RAM is expected to quote the new pair.
3. **Device observation** — `tools/watch_target.py` on the target (evdev, focus
independent) is the only thing that says what the target received; `curl` against the
served page pins page claims. Best effort per the hardware rule above, logged in
`.agents/HW_VERIFICATION.md`.
4. **Gate** — `.agents/validate.sh` runs 1 + 2 (plus repo hygiene) after every task; a
phase commits only when it exits 0.
## Phase Roadmap
| # | Phase | TODO source | Objective (one line) |
|---|-------|-------------|----------------------|
| 01 | `01_verification_gate` | — (foundation) | `.agents/validate.sh` runs keyrelay's real gates (host suites, sanitiser run, both firmware builds) and the hardware contract is written down so no unit blocks on a board |
| 02 | `02_tls_control_channel` | `TODO.md` L3 | HTTPS + `wss://` with an on-board-generated self-signed EC identity, so the passphrase and the keystrokes are encrypted in transit and the board's identity can be pinned |
| 03 | `03_session_takeover` | `TODO.md` L6 | An authenticated controller can deliberately take the bridge from a session left connected; the incumbent is told, the funnel runs, and the board comes back **disarmed** |
| 04 | `04_controller_page_responsive` | `TODO.md` L4 | Less prose, tighter layout: one-screen controls on a laptop, single-column and thumb-sized on a phone, safety statements kept verbatim |
| 05 | `05_mobile_controller` | `TODO.md` L5 | Arm on a phone and the soft keyboard appears, with an on-screen modifier row; press-and-hold the pad and the target's pointer moves, release and every button lets go |
Excluded (already done, no phase): nothing — `TODO.md` held no `[x]` items.
Deferred, deliberately not in this roadmap (still in README Backlog): OS-wide evdev
capture client, GPIO panic release path, per-connection rate limiting, mDNS on the AP
interface, WPA3/PMF, report-rate telemetry, multi-run pastes, non-US paste maps.
View File
@@ -0,0 +1,49 @@
# Phase 01 — Verification gate and hardware contract
**Source:** `TODO.md` — (foundation, no TODO line). Required before the four TODO items
can be executed by subprocesses behind a pass/fail gate.
**Story:** `n/a`
**Context:** `PLAN.md` "Validation / Verification Workflow"; `tests/run_tests.sh`; `AGENTS.md` "Build, test, flash"
## Objective
Give this repository a `.agents/validate.sh` that actually fails when keyrelay is broken —
host suites (including the sanitiser run and the page suite) plus **both** firmware builds —
and write down the hardware contract so that no later unit ever blocks waiting for a board
that nobody is holding.
## Dependencies
- — (none)
## Tasks
1. `01_adapt_validate_sh.md` — author `.agents/validate.sh` with keyrelay's real checks and
the template's repo-hygiene gates, and prove it fails on a deliberately broken tree.
2. `02_hardware_contract.md` — `.agents/HW_VERIFICATION.md` + `tools/hw_probe.sh`: what can
only be checked on hardware, how to check it, the baseline RAM/flash numbers, and the
rule that a unit attempts a device step once, with a timeout, and records the outcome.
## Testing & Quality
- Unit/integration: the gate itself is the artifact. It must pass on a healthy tree **and**
fail on each injected fault: a failing C++ assertion, a page-suite id mismatch, a firmware
compile error in `src/main.cpp`, and a tracked `*.pem`.
- Coverage: n/a for shell scripts; >90% applies only to any Python added (`tools/hw_probe.sh`
is shell, not Python).
- `.agents/validate.sh` must run the **full** suite — a partial gate defeats the
"no phase commits unless 100% passing" guarantee.
## Completion Criteria
- [ ] `bash .agents/validate.sh` exits 0 on the untouched tree and prints, per check: the
suite results (`RESULT: all suites passed`), the two build results, and the hygiene verdict.
- [ ] `SANITIZE=1` is exercised by the gate (or by a documented, gate-called second pass) and
passes.
- [ ] The gate contains no `pio … -t upload`, no `curl` to a device, no network fetch.
- [ ] Each of the four injected faults makes the gate exit non-zero (demonstrated in the
task's report, then reverted).
- [ ] `.agents/HW_VERIFICATION.md` exists and names, for every device-only claim in this
roadmap, the command that observes it (`tools/watch_target.py`, the serial console,
`curl -k https://keyrelay.local/status`).
- [ ] Full test suite green; `git status` shows only planning artifacts.
@@ -0,0 +1,65 @@
# Task 01 — Author the gate: `.agents/validate.sh`
**Phase:** `01_verification_gate` · **Source:** `TODO.md` — (foundation: no TODO item; the
gate the other four items need)
**Story:** `n/a`
## Objective
`.agents/validate.sh` becomes the authoritative pass/fail gate for this firmware project:
the host suites (normal and sanitised), both PlatformIO environments, and the repo-hygiene
checks. Nothing in it may need a device or the network.
## Work
1. `.agents/validate.sh` — if the pipeline already installed its generic template, adapt it
in place; otherwise write it, keeping the template's three hygiene gates verbatim
(tracked-but-ignored files, key-material shapes `.pem/.key/.crt/…`, `PII_PATTERNS`).
Add, in this order, each one printing its own header and accumulating `rc`:
- `bash tests/run_tests.sh` — the C++ suites, `test_relayctl.py` and `test_page.js`.
- `SANITIZE=1 bash tests/run_tests.sh` — ASan/UBSan, the run that found 5 of the 10 bugs.
- `uv run --python 3.14 --with platformio python -m platformio run -e bridge -e console`
— the exact invocation from `AGENTS.md`. **Never** `-t upload`. If `uv` is missing, the
gate **fails** with the message from `AGENTS.md` (`pio` on PATH is broken; the fallback
probe is `find / -maxdepth 6 -name platformio -type d -path '*site-packages*'`) — a
silent skip here would let a firmware that does not compile reach `complete/`.
- A page-size sanity check that the served page's byte length equals
`kIndexHtmlLen`-by-construction: `python3` (or `grep`) counts the bytes of the raw
string between `R"HTML(` and `)HTML"` in `src/web_page.h` and prints it; the phase
final passes compare it to the README claim. No device.
2. `.gitignore` — add `.agents/phase-sessions/` and `.agents/pipeline.log` if the pipeline
has not already; never ignore `.agents/` itself.
3. Run `bash .agents/validate.sh` and record the output in the task report, including the
bridge `RAM:`/`Flash:` lines, so later phases can quote a delta instead of a guess.
4. Prove the gate bites, one fault at a time, reverting immediately after each and
re-running to confirm it goes green again:
(a) invert one assertion in `tests/test_keystate.cpp`;
(b) rename an element id in `src/web_page.h` that the script reaches for;
(c) introduce a syntax error in `src/main.cpp`;
(d) `touch certs/test.pem && git add certs/test.pem` (then `git rm --cached`).
`AGENTS.md` warns that a multi-line regex rewrite across a source file has destroyed
`main.cpp` before — make these four edits with exact-string edits only.
- ASSUMPTION: the gate is allowed to take ~30 s per task (measured: suites ≈2 s with g++
inside `localhost/ai-sandbox:ubuntu-9a08b1d500b0`, builds ≈2 s incremental). The full
recompile of `bridge` from clean is the worst case and is acceptable.
- ASSUMPTION: a missing `uv`/PlatformIO is a gate failure rather than a skip, because every
phase in this roadmap touches firmware and a skipped build check is the exact failure
`AGENTS.md` records ("`pio run -t upload` reports SUCCESS on a stale binary").
## Testing & Quality
- The four injected faults each make the gate exit non-zero; the untouched tree exits 0.
- The gate is idempotent and safe to run from any cwd (`cd` to the repo root by
`BASH_SOURCE` like `tests/run_tests.sh` does).
- No `set -e` swallowing: use the `rc` accumulation pattern so every check reports, not
just the first failure.
## Completion Criteria
- [ ] `bash .agents/validate.sh` → exit 0, printing suite results, both build results, page
byte count, and the hygiene verdict.
- [ ] Each of the four injected faults produced a non-zero exit (paste the four lines into
the report), and the tree is reverted (`git diff` clean apart from `.agents/`).
- [ ] No `upload`, no `curl`, no `podman pull`, no network access in the gate.
- [ ] Full test suite green.
@@ -0,0 +1,68 @@
# Task 02 — The hardware contract: what the gate cannot see
**Phase:** `01_verification_gate` · **Source:** `TODO.md` — (foundation; `AGENTS.md`
"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."*)
**Story:** `n/a`
## Objective
Make the device-only half of this roadmap explicit, runnable and non-blocking: a probe that
reports what hardware is reachable right now, and a file that pairs every device-only claim
with the exact command that would confirm it — so a unit running unattended never hangs on
a board and never claims a device result it did not observe.
## Work
1. `.agents/HW_VERIFICATION.md` — three sections:
- **Baseline** (measured, from task 01's gate run): `bridge RAM 26.7% (87 592 B) /
Flash 36.1% (1 135 293 B)`, `console RAM 16.8% (55 200 B) / Flash 33.6% (1 056 081 B)`,
`3406 host checks`. Later phases quote a delta against these numbers.
- **Claims that need hardware**, each with its command and its expected observation, at
least: keystroke/chord delivery and *which* keys a chord really was
(`python3 tools/watch_target.py` on the target); served-page byte agreement
(`curl -sk https://keyrelay.local/ | wc -c` vs the gate's page count — `http` until
phase 02 lands, `https -k` after); TLS handshake + the SHA-256 fingerprint printed once
on the serial console; takeover actually releasing the incumbent's held keys
(`watch_target.py` must show key-up *before* the new session's first key); the mobile
soft keyboard and touch pad (a phone on the board's AP, `watch_target.py` for both the
characters and the pointer deltas).
- **The blocking rule**: a unit attempts a device step **at most once**, wrapped in
`timeout`, and writes the outcome — `ok`, `skipped (no device)`, or `failed: …` — with
the command it ran into the section's table. It never loops on a port, never re-flashes
to "see if it works", and never leaves the board in ROM download mode without saying so
in the report (`tools/to_bootloader.py` drops it there deliberately; a power cycle is
the only way back and nobody may be there to do it).
2. `tools/hw_probe.sh` (executable, `set -uo pipefail`, no side effects) — prints one line
per fact and **always exits 0** (it informs, it does not gate):
`/dev/ttyACM*`/`/dev/ttyUSB*` present?; `keyrelay.local` resolves?; `https` reachable
(`curl -sk -m 2 -o /dev/null -w '%{http_code}'`)?; `http` reachable and does it redirect?;
`uv` on PATH?; `node` on PATH?; is a `watch_target.py`-capable evdev device visible
(informational only). Nothing is opened on a serial port except a `test -e`/`test -r`
check — opening it toggles DTR/RTS and resets the board, so the probe must not.
3. `.agents/validate.sh` — append `bash tools/hw_probe.sh` as the **last, non-affecting**
line (its exit status is deliberately not folded into `rc`), with a comment saying why:
it is context for whoever reads the gate output, not a check.
4. `README.md` — no behaviour change yet; only if the "End-to-end verification" section
contradicts what the probe just printed, fix the stale line. Do not restructure it here.
- ASSUMPTION: at execution time a board *may* be attached and reachable but nobody is
watching it, so "board available" is treated as best-effort. Any phase that needs an
on-target second machine (`watch_target.py`) records `skipped (no device)` rather than
inventing a result. This is the user-facing cost of running the roadmap unattended; the
file is where they pick those checks up.
## Testing & Quality
- `bash tools/hw_probe.sh` exits 0 with and without a board, with and without `uv`, from any
cwd; it never blocks (every probe has a `-m`/`timeout`).
- The gate still passes with the probe line added, and its output cannot flip `rc`.
- `shellcheck`-clean if `shellcheck` is installed; otherwise `-n` syntax check in the report.
## Completion Criteria
- [ ] `bash tools/hw_probe.sh` prints the six facts and exits 0 (paste its output).
- [ ] `.agents/HW_VERIFICATION.md` covers every phase 02–05 device claim listed above, each
with command + expected observation + the rule about one attempt.
- [ ] `bash .agents/validate.sh` still exits 0.
- [ ] Full test suite green; no firmware source touched.
@@ -0,0 +1,95 @@
# Phase 02 — Encrypted control channel: HTTPS + `wss://`
**Source:** `TODO.md` L3 — "Some encryption for password/key input. Doesn't need to be
proper x509, but something"
**Story:** `n/a`
**Context:** `PLAN.md` anchors "Crypto/TLS stack", "TLS transport shape", "TLS identity",
"Plaintext rollback"; `src/control_server.{h,cpp}`, `src/config.h`, `src/main.cpp`,
`src/web_page.h`, `tools/relayctl.py`, README "Security model and trust boundaries"
## Objective
Encrypt the control channel end to end: the board serves the page and the WebSocket over
TLS on 443 with a self-signed ECDSA certificate it generated itself and keeps in NVS, the
browser pins its SHA-256 fingerprint once, and the passphrase never crosses the network in
cleartext again. `:80` stops carrying anything except a redirect, so an on-path attacker can
no longer replace the page or read the keystrokes.
**Why not "just encrypt the passphrase".** A challenge–response would hide the passphrase
and nothing else: the keystrokes, the pasted text and the pointer stay readable on a network
the user does not control, and a page served over `http://` can be replaced on-path by
anybody who can see the traffic — at which point the attacker does not need the passphrase,
they need one keystroke from a page they wrote. The user asked for TLS rather than the
lighter option, and the trust-boundary table in the README only closes under TLS.
## Safety position (unchanged, and load-bearing for every task here)
TLS is **transport**, never an arming input. Arming still needs the passphrase, the board
button or the serial console; a TLS session that is authenticated but not armed types
nothing; every fail-safe in `AGENTS.md` keeps working when TLS drops, because a dropped
session is a lost link, which runs the release funnel and disarms. The rollback `-D
KR_TLS=0` restores today's behaviour and must stay buildable.
## Dependencies
- `01_verification_gate` (todo) — the gate must be running the real checks before a change
this central can be trusted; the hardware contract tells this phase how to observe the
board without blocking on it.
## Tasks
1. `01_tls_identity_module.md` — `src/tls.h`: on-board ECDSA secp256r1 keypair + self-signed
cert via mbedTLS, with the pure PEM/fingerprint helpers host-tested in
`tests/test_tls.cpp`.
2. `02_identity_persistence.md` — NVS storage of the PEM pair, generate-once, serial `cert`
command printing the fingerprint and never the key, `StatusSnapshot` visibility.
3. `03_tls_listener.md` — the 443 listener, non-blocking mbedTLS session over the accepted
socket, session lifetime, heap cost measured against the phase 01 baseline.
4. `04_transport_abstraction.md` — `ControlServer` routes through one transport interface so
`serveReadonly()` stays single-sourced; `:80` becomes redirect-only and plaintext `/ws`
is refused rather than downgraded.
5. `05_page_client_fingerprint.md` — the page's `wss://` path and pinning banner,
`relayctl.py` over stdlib `ssl` (`--ca`, `--insecure`), and the cross-checks that keep
page, tool and firmware agreeing.
6. `06_tls_docs_rollback.md` — README security model/wire table/quick start/backlog,
`AGENTS.md` stale statements, and the `-D KR_TLS=0` rollback build proven to compile.
## Testing & Quality
- Unit/integration: `tests/test_tls_pem.cpp` (new suite registered in `tests/run_tests.sh`
`suites=()`) covering the pure half only — PEM armour round-trip, fingerprint formatting,
SAN/subject string building, PEM truncation and CRLF handling, rejecting a key/cert pair
that does not match. The mbedTLS-bound half is exercised by the firmware build plus the
device check (`openssl s_client`, browser, `watch_target.py`).
- `tests/test_proto.cpp`: nothing on the wire changes shape, so its 2 327 checks must stay
green untouched — that is the point: **this phase does not change the protocol.**
- `tests/test_relayctl.py`: TLS connection setup, refusal to fall back to plaintext
silently, `--insecure` opt-in is loud, and the existing frame/table pins unaffected.
- `tests/test_page.js`: the connect path still builds the right URL for both `http:` and
`https:` origins, the fingerprint banner renders only when the board reports TLS, and
every element id still exists in the markup.
- Coverage: **>90%** on new/modified code (the pure module is fully walked; the socket layer
is measured by the device checks and stated, not faked).
- `.agents/validate.sh` runs the suites, the sanitiser run and **both** firmware environments
after every task; `bridge` must still link, and `console` (`KR_HAVE_HID == 0`) too.
## Completion Criteria
- [ ] `bash .agents/validate.sh` green, and the report quotes `bridge` `RAM:`/`Flash:`
against the phase 01 baseline (`RAM 26.7% / Flash 36.1%`) plus the measured heap
cost of one and two live TLS sessions.
- [ ] A fresh board (or `nvs erase`) generates an identity at first boot and prints its
SHA-256 fingerprint **once**, on the serial console; reflashing keeps the same
fingerprint (NVS survives), and nothing about the key ever appears in a log.
- [ ] `curl -sk https://keyrelay.local/ | wc -c` equals the page byte count the gate prints,
and `curl -s http://keyrelay.local/` no longer serves the page (it redirects).
- [ ] The browser page over `https://` reaches `linked`, arms, types, and `watch_target.py`
on the target sees the keystrokes; `relayctl.py --ca <fingerprint's pem>` and
`--insecure` both connect and type; a plaintext `ws://` upgrade on :80 is refused.
- [ ] `grep -rn "\.pem\|\.key" --include=* .` finds no key material in tracked files; the
hygiene gate in the gate script is still enabled.
- [ ] `-D KR_TLS=0` still builds `bridge` (the rollback path), and README says how to use it.
- [ ] README (intro check count, Safety model, Security model table + the paragraph that
currently *admits* the token is visible in cleartext, Wire protocol, Controller page,
Architecture file map, Tests table, Quick start, Troubleshooting, Design decisions,
Backlog item 2) and `AGENTS.md` stale statements updated in this phase's commit.
@@ -0,0 +1,87 @@
# Task 01 — Device identity: keypair + self-signed certificate on the board
**Phase:** `02_tls_control_channel` · **Source:** `TODO.md:3` — "Some encryption for
password/key input. Doesn't need to be proper x509, but something"
**Story:** `n/a`
## Objective
`kr::tls` can, on the board, produce an ECDSA secp256r1 keypair and a self-signed X.509
certificate for `keyrelay.local`, as PEM strings, plus a SHA-256 fingerprint of the
certificate — with the string-handling half of that testable on a host.
## Work
1. `src/tls_pem.h` — **new, pure** module (no Arduino, no IDF headers; the rule in
`AGENTS.md` exists so this compiles and runs under sanitisers on the host):
- `wrapPem(const char *label, const uint8_t *der, size_t derLen, char *out, size_t cap)`
→ `-----BEGIN <label>-----\n` + base64 in 64-char lines + `\n-----END <label>-----\n`;
returns the length, `0` when `cap` is too small (never truncates silently — invariant 5).
- `fingerprintHex(const uint8_t *digest, size_t digestLen, char *out, size_t cap)` →
lowercase hex, `out` sized by the caller; refuses a `cap` that cannot hold it.
- `subjectLine()` / `sanList()` builders producing
`CN=keyrelay.local`, `DNS:keyrelay.local, DNS:keyrelay, IP:192.168.4.1` —
the names in `src/config.h` (`kHostname`, `kApGw`), not literals invented here.
- `pemFind(label, haystack, out, cap)` → extracts the DER back out of an armour block
(tolerant of CRLF and of a missing trailing newline), `false` on a malformed block.
2. `src/tls.h` (+ `src/tls.cpp` if it needs a translation unit) — the mbedTLS half:
- `bool generateIdentity(PemPair &out, char *fpHex, size_t fpCap)`: `mbedtls_entropy_init`
+ `mbedtls_ctr_drbg_seed` (personalisation string `"keyrelay"`) →
`mbedtls_pk_setup` + `mbedtls_ecp_gen_key(MBEDTLS_ECP_DP_SECP256R1, …)` →
`mbedtls_x509write_crt` (subject == issuer, serial from the DRBG, validity from build
time to +10 years, `MBEDTLS_MD_SHA256`, SAN extension) →
`mbedtls_x509write_crt_der` → `wrapPem("CERTIFICATE")`; key via
`mbedtls_pk_write_key_pem`. Free everything on every exit path, including the error
paths; no early `return` that leaks a context.
- `bool selfCheck(const PemPair &)` — reparse cert+key and prove they match; used at boot
so a half-written NVS record is reported instead of producing a listener that fails
every handshake.
- Header comment must record *how this was verified linkable*: `flags/ld_libs` of
`framework-arduinoespressif32-esp32s3-libs` carries `-lmbedtls_2 -lmbedcrypto
-lmbedx509 -lesp-tls`, and sdkconfig enables `MBEDTLS_ECP_C`, `MBEDTLS_ECDSA_C`,
`ECP_DP_SECP256R1_ENABLED`, `KEY_EXCHANGE_ECDHE_ECDSA`.
3. `src/config.h` — `kCertCommonName[]`, `kCertValidityYears`, `kPemMax` (sized from the
measured PEM lengths plus headroom, with the numbers in a comment), and
`#ifndef KR_TLS / #define KR_TLS 1` so the rollback flag has one home.
4. `tests/test_tls_pem.cpp` — **new suite**, registered in `tests/run_tests.sh` in
`suites=()`. It includes `src/tls_pem.h` **only** — it must never include `src/tls.h`
or anything pulling in `mbedtls`, or the host build breaks and the pure-module rule is
gone:
PEM round-trip (`wrapPem` → `pemFind` → same bytes) for a DER blob of 0/1/45/46/47/n bytes
around the 64-char line boundary, CRLF and no-trailing-newline tolerance, refusal when
`cap` is one byte short, `fingerprintHex` against the known SHA-256 of `"abc"`, SAN list
containing every name the README tells the operator to expect, and ASan-clean runs of all
of it under `SANITIZE=1`.
5. `platformio.ini` — `-D KR_TLS=1` for `bridge` and `console`, in the existing
`build_flags` blocks, with a comment naming the rollback value.
- ASSUMPTION: **on-board generation rather than a keypair built on the host and flashed.**
The alternatives were rejected on record: committing a PEM would put every board behind
one key *and* trip the pipeline's key-material hygiene gate; a host-generated per-user PEM
makes the first build fail for anybody who has not run a script. Nothing in `src/` may
contain a key.
- ASSUMPTION: the certificate is generated at boot when NVS holds none, and regenerated only
when NVS is erased — see task 02. A changed fingerprint after an erase is expected and
documented, not a fault.
- ASSUMPTION: ECDSA secp256r1 rather than RSA 2048 — this board has no PSRAM, and the EC
pair is a fraction of the RAM and flash cost of RSA while being the stronger key. Browser
support for self-signed EC certs is universal on the platforms the README targets.
## Testing & Quality
- `bash tests/run_tests.sh` → the new suite passes and is listed; `SANITIZE=1` variant clean.
- `uv run --python 3.14 --with platformio python -m platformio run -e bridge -e console`
→ both link (this is the task that proves the mbedTLS symbols resolve in a real image).
- The pure module includes no Arduino/IDF header: `grep -n "Arduino.h\|esp_\|mbedtls"
src/tls_pem.h` finds nothing.
- Coverage **>90%** on `src/tls_pem.h` (walked exhaustively), stated for `src/tls.*`.
## Completion Criteria
- [ ] `tests/test_tls_pem.cpp` runs in the suite and the PEM boundary cases (0/45/46/47 bytes,
one byte short of `cap`) each have a named check.
- [ ] `generateIdentity()` compiles and links into `bridge` **and** `console`; the report
quotes the new `RAM:`/`Flash:` pair against `bridge 26.7% / 36.1%`.
- [ ] `tests/test_proto.cpp` untouched and still at its previous check count — the wire
format did not change in this task.
- [ ] Full test suite green; no key material anywhere in the tree (`git status`, `grep`).
@@ -0,0 +1,73 @@
# Task 02 — Keep the identity across reflashes: NVS, first boot, `cert`
**Phase:** `02_tls_control_channel` · **Source:** `TODO.md:3` — "Some encryption for
password/key input…" (the identity half: an encryption key has to live somewhere that is not
a file in this repo)
**Story:** `n/a`
## Objective
The keypair and certificate are generated once, stored in NVS, reused after a reflash, and
made observable to the operator as a SHA-256 fingerprint — on the serial console and in the
status frame — without the key ever being printed, echoed, or written to a file.
## Work
1. `src/main.cpp` — the NVS half (follow the existing shape exactly: `Preferences prefs;`,
`prefs.begin(kNvsNamespace, true/false)` / `end()`, `constexpr char kKey…[]` next to
`kKeyToken`):
- `kKeyTlsCert[] = "tlscrt"`, `kKeyTlsKey[] = "tlskey"`, `kKeyTlsFp[] = "tlscrtfp"`.
- `loadTlsIdentity()` at boot: load cert+key+fingerprint; if any is missing **or**
`kr::tls::selfCheck()` fails, generate, store, and print the fingerprint **once**
(`[tls] identity generated — pin this certificate:` then the hex, wrapped 2-by-2).
Never `Serial.printf` the key, the cert body, or a length that identifies the text.
- A corrupt/half-written record regenerates rather than booting a listener that cannot
complete a handshake — and says so, once, loudly.
2. `src/main.cpp`, serial console — a `cert` command next to `token` in the help text
(`~main.cpp:893` block) and the dispatch chain (`~main.cpp:1010`): prints the
fingerprint, the certificate's notBefore/notAfter and the SAN list. **No** argument form
that writes a key, **no** subcommand that dumps the PEM. Update the help line and the
`usage` output.
3. `src/control_server.h` / `.cpp` — `StatusSnapshot` gains `bool tls` and
`const char *fp` (the first 8 bytes of the fingerprint as 16 hex chars, sized by a named
constant); `statusJson()` emits `"tls":u,"fp":"…"`; `kStatusJsonCap` grows accordingly
with the arithmetic in the comment (the reason that constant exists is that a truncated
status lies about state).
4. `src/main.cpp` — fill both fields where `StatusSnapshot` is populated (`~main.cpp:522`,
next to `s.defaultToken`).
5. `tests/test_proto.cpp` — extend the status-frame assertions: the new fields render, a
`tls:0` snapshot renders `fp:""`, and the rendered frame still fits
`kStatusJsonCap` for the longest legal SSID (that test exists; make sure the new fields
do not push it over).
6. `README.md` — "First bring-up" / Quick start: the fingerprint appears once at first boot,
`cert` reprints it, an `nvs erase` changes it, and the browser asks you to accept the
certificate once per browser. Security-model wording is task 06's job; do not restate the
whole model here.
- ASSUMPTION: the fingerprint lives in NVS next to the cert rather than being recomputed at
boot, so `cert` costs no hashing and the page can show it from the status frame; it is
public data, so storing it is not a secret-at-rest question.
- ASSUMPTION: `Preferences.putString` is fine for ~1 KB PEM blobs in the existing namespace
(`huge_app.csv` gives NVS 20 KB, already holding SSID/pass/token) — verify with the boot
log and the "nvs: " lines; if it does not fit, the fallback is a dedicated small partition
and that is a stop-and-report, not a silent change of layout.
## Testing & Quality
- `tests/test_proto.cpp`: new status fields pinned, cap arithmetic pinned.
- `bash .agents/validate.sh` green, including `console` (`KR_HAVE_HID == 0`) — the console
environment is what proves the TLS/identity path does not depend on USB HID.
- Device (best effort, one attempt, per `.agents/HW_VERIFICATION.md`): first boot prints a
fingerprint; reflash prints the same one; `cert` prints it again; `grep` over the serial
transcript for `PRIVATE KEY` finds nothing. Record `ok`/`skipped (no device)`.
## Completion Criteria
- [ ] `statusJson()` output contains `"tls":1` and a 16-hex `fp` on a TLS build; the frame
length is asserted `<= kStatusJsonCap`.
- [ ] `cert` on the serial console prints fingerprint + validity + SANs and no PEM body.
- [ ] Fingerprint survives a reflash (or the report says `skipped (no device)` and the exact
commands to confirm it).
- [ ] No private key material in any file, log, or `Serial.printf` (`git grep -i "private
key" src/` finds only the mbedTLS API call names).
- [ ] Full test suite green.
@@ -0,0 +1,106 @@
# Task 03 — The TLS listener: one session at a time, never blocking the loop
**Phase:** `02_tls_control_channel` · **Source:** `TODO.md:3` — "Some encryption for
password/key input. Doesn't need to be proper x509, but something"
**Story:** `n/a`
## Objective
A second listener on 443 accepts a socket, drives an mbedTLS **server** handshake from
`tick()` without ever blocking `loop()`, and hands the control server a byte pipe that looks
like the plain socket it already reads. The keyboard path must not wait on the network stack
— that is the reason `tick()` is non-blocking today, and TLS must not break it.
## Work
1. `src/tls_session.h` (+ `.cpp`) — `kr::tls::Session`, one TLS connection over an already
accepted fd:
- `bool open(int fd, const PemPair &id)` — `mbedtls_ssl_init` +
`mbedtls_ssl_config_init` + `mbedtls_ssl_config_defaults(...,
MBEDTLS_SSL_IS_SERVER, MBEDTLS_SSL_TRANSPORT_STREAM, MBEDTLS_SSL_PRESET_DEFAULT)`,
`mbedtls_ssl_conf_endpoint(MBEDTLS_SSL_IS_SERVER)`, `mbedtls_x509_crt_parse` +
`mbedtls_pk_parse_key` + `mbedtls_ssl_conf_own_cert`, and
`mbedtls_ssl_set_bio(&fd, recvCb, sendCb, NULL)` with `recvCb`/`sendCb` wrapping
`::recv`/`::send` on the fd and mapping `EWOULDBLOCK` to
`MBEDTLS_ERR_SSL_WANT_READ`/`WANT_WRITE`. Set the fd non-blocking
(`fcntl(fd, F_SETFL, O_NONBLOCK)`) before the handshake. Client certification is **not**
requested (`MBEDTLS_SSL_VERIFY_NONE`) — the passphrase is still the controller's
credential, not the certificate.
- `Handshake poll()` → `InProgress | Ready | Failed`, calling `mbedtls_ssl_handshake()`
once per `tick()` and returning on `WANT_READ`/`WANT_WRITE`. A session that has not
completed within `kTlsHandshakeMs` is closed — a socket that opens and never finishes a
handshake is the same kind of scan as one that opens and never sends `T<token>`.
- `size_t read(uint8_t *dst, size_t cap)` / `size_t write(const uint8_t *src, size_t len)`
— `mbedtls_ssl_read`/`mbedtls_ssl_write`, returning 0 on `WANT_READ`/`WANT_WRITE` and
never spinning; a short write is returned as a short write (the caller keeps its
framing state, exactly as with `WiFiClient`).
- `bool alive()`, `void close()` — `mbedtls_ssl_session_reset` + `mbedtls_ssl_free` +
`mbedtls_x509_crt_free` + `mbedtls_pk_free` + `::close(fd)` on **every** path,
including the error paths out of `open()`.
- Do **not** use `esp_tls` for this: `esp_tls_server_session_create()` owns the socket
lifetime and its handshake is not driven the way `tick()` needs. Note that in the
header comment along with what was checked in `esp_tls.h`, so the next reader does not
"simplify" it back.
2. `src/control_server.h` / `.cpp` — a second `WiFiServer _tlsServer(kHttpsPort)`;
`acceptNew()` accepts from both listeners; `tick()` gives the TLS path the same shape as
the existing one: `acceptNew → serveHttp/serveWebSocket → timeout checks`. Keep the
controller slot in exactly **one** place: a socket is either HTTP, WebSocket or none, no
matter which listener it came from, so a TLS controller and a plaintext controller can
never both hold the keyboard.
- At most **two** live sessions at once (`kTlsMaxSessions`): the controller's, and one
more — a short-lived read-only request destroyed the moment its response is flushed,
or (phase 03) a pending takeover claim. A third connection is refused **explicitly**
(TLS alert then close, plus a counter), never queued and never half-opened: the
read-only path must fail visibly rather than push the board out of memory. This is a
RAM decision, not a policy one — say so in the comment, and keep the refusal
independent of which listener the socket came from.
3. `src/config.h` — `kHttpsPort = 443`, `kTlsHandshakeMs = 10000`,
`kTlsMaxSessions = 2`, and the RAM note: `CONFIG_MBEDTLS_SSL_MAX_CONTENT_LEN` is 16384 in
this core, so a session's I/O buffers are ~2×16 KB. Try
`mbedtls_ssl_conf_max_frag_len(&ssl, MBEDTLS_SSL_MAX_FRAG_LEN_4096)` **only** if the
measured heap says it is needed, and record the measurement either way.
4. Measure, in the report and in `.agents/HW_VERIFICATION.md`: `ESP.getFreeHeap()` at boot,
with one session open, with a second (viewer) session open, and after both close —
against the phase 01 baseline (`bridge RAM 26.7% / 87 592 B`). Print the numbers in the
boot log behind `KR_DEBUG` if the project has a debug switch, otherwise log once at
startup and once per session close *as numbers only* (invariant 4: nothing about content).
5. `tools/hw_probe.sh` (from phase 01) — extend with a `openssl s_client -connect
<ip>:443 -servername keyrelay.local` probe wrapped in `timeout 5` reporting protocol and
cipher, informational, exit status unchanged.
- ASSUMPTION: raw mbedTLS rather than `esp_tls` (rationale above). If raw mbedTLS turns out
unable to complete a handshake non-blockingly on this core, the fallback is `esp_tls` in
async mode on the same fd — but that is a design change to be recorded in the task report
and the README's Design decisions, not a quiet swap.
- ASSUMPTION: TLS on 443 only, no plaintext WebSocket on 8443, no dual-stack port list. The
rollback is `-D KR_TLS=0` (task 06), not a second open port.
- ASSUMPTION: 2 concurrent sessions is the ceiling. Today's server already holds at most one
controller plus one one-shot viewer, so the policy is unchanged; only the cost per socket
went up.
## Testing & Quality
- Host: the parsing/timeout arithmetic that can be isolated (handshake-deadline comparison,
"a session that never completes is dropped", "read returns 0 rather than spinning") goes
into a pure helper in `src/tls_session.h` guarded the same way `output.h` is, and is
checked in `tests/test_tls_pem.cpp` or a new `tests/test_tls_session.cpp` if the helper is
big enough — no mbedTLS includes in either.
- Firmware: `bridge` and `console` both build; the report quotes the four heap numbers.
- Device (best effort, one attempt): `openssl s_client` completes a handshake and negotiates
`TLSv1.2`/`TLSv1.3` with `ECDHE-ECDSA-*`; a second concurrent connection is accepted while
the first is open; a connection that never completes the handshake is dropped after
`kTlsHandshakeMs` and the loop keeps typing (verify by watching the LED heartbeat / a
`status` poll during the stalled attempt).
- The keystroke path's latency is unchanged while a *second* client connects — that is the
regression this task can introduce, so it gets named in the report even when the answer is
"not measured".
## Completion Criteria
- [ ] A TLS connection to :443 completes a handshake and survives `kTlsHandshakeMs`-bound
teardown when it does not.
- [ ] `loop()` never blocks on the TLS path: the report states how that was shown (code walk
of every `mbedtls_ssl_*` call site plus the device observation), not asserted.
- [ ] Heap numbers recorded for boot / 1 session / 2 sessions / after close, and no leak over
20 connect/disconnect cycles.
- [ ] `bash .agents/validate.sh` green; full test suite green; `test_proto.cpp` untouched.
@@ -0,0 +1,89 @@
# Task 04 — One routing path, two transports: TLS is not a second server
**Phase:** `02_tls_control_channel` · **Source:** `TODO.md:3` — "Some encryption for
password/key input…" (the routing half: the routes must not fork into a secure one and an
insecure one that can disagree)
**Story:** `n/a`
## Objective
Put the transport underneath `ControlServer` instead of beside it: every route — page,
`/status`, `/ws`, the read-only viewer — is served through `serveReadonly()`/`serveHttp()`
once, over either a plain socket or a TLS session, and `:80` stops carrying content: it
redirects and otherwise tells the client to use `https://`. Invariant 8 explicitly requires
that both routes keep going through `serveReadonly()` "so they cannot drift apart"; a
half-encrypted server is the shape that drifts.
## Work
1. `src/conn.h` — a small transport view used by the routing code: `read(dst,cap)`,
`write(src,len)` (may be partial — returns what went), `alive()`, `stop()`, and
`writeProgmem(src,len)` for the page. Implemented by `PlainConn` (wraps `WiFiClient`) and
`TlsConn` (wraps `kr::tls::Session` from task 03). No virtual calls in the keystroke path
if a tagged union is cheaper — measure, then choose, and say which in the comment.
2. `src/control_server.cpp`:
- `readSome()`, `writeTextFrame()`, `httpResponse()`, `httpError()`, `sendStatus()`,
`sendError()` go through the conn. **`readSome()` keeps its rule**: only bytes from an
authenticated controller on the WebSocket refresh `_lastInboundMs`. A TLS session must
not change that — a handshake, a `/status` poll or a viewer refresh is not the operator
being alive (that comment in `readSome()` explains a real bug; carry it over).
- `serveReadonly()` and `serveViewer()` keep serving *both* listeners; a read-only
request over TLS is the same code with a different conn.
- The page write becomes **resumable**: `c.write_P(kIndexHtml, kIndexHtmlLen)` works for a
`Print`, not for a socket. Copy PROGMEM into a bounded RAM chunk (name the size) and
carry a per-connection send cursor (`sent`, `total`) so a 35 KB page drains across
several `tick()`s under a per-tick byte budget. If the client stalls, the existing 10 s
stall rule drops it. **Never** block in `loop()` waiting for a slow page consumer, and
never queue the page in RAM beyond the chunk.
- `closeLink()` stays the single funnel: whichever transport it is handed, it releases on
link change (`wasLinked` semantics, bug 10) and destroys the TLS session. A TLS teardown
failure must not skip the release.
3. `:80` behaviour (`KR_TLS == 1`):
- `GET /` and `/index.html` → `301` with `Location: https://<host>/…`.
- A request carrying a `token` query parameter (i.e. `/status?token=…`) → **`426 Upgrade
Required`** with a one-line body telling the operator to use `https://`, and **no
redirect** — a redirect that echoes `?token=` would expose the passphrase twice instead
of once. `AGENTS.md` says the token stays a query parameter on `/status` because `curl`
is the point of that endpoint; this keeps `curl` working (over TLS) without making the
plaintext path leakier.
- A `/ws` upgrade on :80 → `426`, plaintext controller refused rather than silently
downgraded (invariant 5: never quietly accept the weaker thing).
- All of the above is compiled out by `-D KR_TLS=0`, which restores today's `:80` server
exactly.
4. `src/config.h` — `kHttpRedirectPort`/`kHttpsPort` naming, `kPageChunkBytes`, and the
per-tick page budget, each with the measured numbers that chose it.
5. `tests/test_relayctl.py` + `tests/test_page.js` — assert the *refusal* shapes that can be
checked from the client side: the tool's error text distinguishes "board wants TLS" from
"wrong passphrase", and the page's URL builder picks `wss://` from an `https:` origin and
`ws://` only on a `-D KR_TLS=0` build (pin it by asserting the served page contains no
plaintext `ws://` literal when TLS is on).
- ASSUMPTION: 426 (Upgrade Required) for token-carrying and WebSocket requests on the
plaintext port, 301 for everything else. The alternative — redirect everything including
`?token=` — was rejected for the reason above.
- ASSUMPTION: mDNS stays one service on port 80 plus a second `_https._tcp` advertisement;
`keyrelay.local` must keep resolving, which is the whole point of the README's "IP if the
name fails" note.
## Testing & Quality
- Host: no protocol change, so `tests/test_proto.cpp` (2 327 checks) and `test_output.cpp`
must stay green untouched — if either needs editing, the task has changed the wire or the
funnel and must stop and say what it changed and why.
- The `readSome()` rule gets a **source pin** in `tests/test_relayctl.py` alongside the
existing three: the TLS conn's bytes reach `_lastInboundMs` only when
`_phase == WebSocket && _authed`. (Pinning source sentences is how bug 10 stays dead.)
- Device (best effort, one attempt): `curl -sk https://keyrelay.local/ | wc -c` equals the
gate's page count; `curl -sI http://keyrelay.local/` is a 301; `curl -s
'http://keyrelay.local/status?token=x'` is 426 and its body contains no token; the page
still reaches `linked` over `wss://` while a second tab watches `/status`.
- RAM: the chunk size + one page cursor, quoted in the report.
## Completion Criteria
- [ ] One `serveReadonly()` implementation reachable from both listeners (a `grep` for a
second HTTP response writer in `src/` comes up empty).
- [ ] Page delivered intact over TLS (byte-for-byte, `wc -c` and a `sha256sum` against the
PROGMEM count) and the send is resumable, not blocking.
- [ ] `:80` serves only the redirect and the two 426s; no keystroke-bearing path listens on 80.
- [ ] `bash .agents/validate.sh` green; the new source pin present; full test suite green.
@@ -0,0 +1,86 @@
# Task 05 — The client side of pinning: page and `relayctl.py`
**Phase:** `02_tls_control_channel` · **Source:** `TODO.md:3` — "Some encryption for
password/key input…" (the half the operator actually touches: a browser that must trust the
board, and the terminal controller)
**Story:** `n/a`
## Objective
The page talks `wss://` when it was served over `https://`, shows the board's certificate
fingerprint so the operator can confirm it against the serial console, and refuses to accept
a passphrase when it was loaded over plaintext; `relayctl.py` speaks TLS over stdlib `ssl`
with **fingerprint pinning** as the default posture, `--ca` for a real CA, and `--insecure`
only as a loud, explicit opt-in.
## Work
1. `src/web_page.h`:
- `connect()` already picks `wss://` from `location.protocol === "https:"` — keep it, and
add the guard the other way round: when the page was loaded over `http:` while the board
reports `tls:1`, do **not** open a socket and do **not** accept the passphrase input;
say why ("this page arrived in the clear; reload over https"). Do not silently fall
back to `ws://`.
- A fingerprint line in the Connection card, driven by the status frame: `certificate
SHA-256 aabbccdd… — confirm it against the `cert` command on the board's serial
console`. Rendered only from `s.fp` (task 02), never assembled from anything the page
invents, and the pill says `tls` vs `plaintext` so the state is visible without reading.
- Keep every element id the script reaches for intact (`tests/test_page.js` checks that
coverage) and add the new ids to the markup in the same change.
2. `tools/relayctl.py`:
- `Relay.__init__`: build the socket through `ssl` when TLS is on — default port 443,
`WS_PATH` unchanged.
- Pinning: `--fingerprint <sha256>` (also honoured as `$KEYRELAY_TLS_FP`) connects with
certificate verification off, then takes `getpeercert(binary_form=True)`, hashes it and
**aborts on mismatch** before a single frame is sent. That is real pinning rather than
TOFU, and it needs no CA distribution for a self-signed device certificate.
- `--ca <pem>` uses `ssl.create_default_context()` with hostname checking on.
- `--insecure` sets `CERT_NONE` and prints a warning line to stderr on every run.
If none of the three is given, refuse to connect and say what to pass — the tool must
not be the component that quietly downgrades (`AGENTS.md` invariant 5).
- Keep `--token` / `$KEYRELAY_TOKEN` behaviour, including the pre-flight check that does
not require a reachable board, and never print the token.
- `-D KR_TLS=0` boards: `--plain` (explicit) selects the old `ws://` on port 80, so the
rollback path stays scriptable and remains opt-in.
3. `tests/test_relayctl.py`: URL/port selection per flag combination; the "no TLS option
given → refuse" rule; a fingerprint mismatch aborts **before** `T<token>` is sent (assert
on the recorded frame list, which the suite already has); `--insecure` emits the warning;
`--plain` is the only path to port 80; and all existing frame/table pins unchanged.
4. `tests/test_page.js`: the `https:` origin builds `wss://`, the `http:` origin refuses and
never sends a frame; the fingerprint line renders exactly the `fp` the status frame
carried and renders nothing when `tls:0`; every id the script touches exists in the markup.
5. `README.md` — Quick start: accept the certificate warning once per browser, or pin;
`curl -sk https://keyrelay.local/status?token=…`; `relayctl.py --fingerprint`; the
fingerprint from `cert`. Troubleshooting: `SSL_ERROR_R…`/`certificate verify failed` means
the board regenerated its identity (`nvs erase`) or you are on the wrong network — which
is exactly the attack the fingerprint is for, so say it in those words.
- ASSUMPTION: fingerprint pinning is the default posture rather than shipping a CA. A CA
distributed with the repo would be one shared key for every board (rejected in task 01);
per-board CA installation on every controller device is more ceremony than a keyboard
bridge is worth. `--ca` remains for the operator who wants a real chain.
- ASSUMPTION: the passphrase stays in `sessionStorage` (as today) and stays a `/status` query
parameter (per `AGENTS.md`). Over TLS both are encrypted in transit; the remaining exposure
is the local browser and shell history, which the README already names.
## Testing & Quality
- Both host suites extended as above; `bash tests/run_tests.sh` and the `SANITIZE=1` variant green.
- `uv … run -e bridge -e console` green, and `curl -sk https://keyrelay.local/ | wc -c` still
equals the gate's page count after the markup change (the page is served from flash — an
unflashed board shows the old page, so say which you observed).
- Device (best effort, one attempt): phone or laptop reaches the page over `https://`,
`linked` appears, the fingerprint shown matches `cert` on the console, and `relayctl.py
--fingerprint …` types while `watch_target.py` on the target confirms it.
- Coverage **>90%** for the changed `relayctl.py` paths (the suite drives the connection
object against a fake socket, as it already does for frames).
## Completion Criteria
- [ ] An `http:`-loaded page refuses to connect and to accept a passphrase (shown by
`test_page.js`, and by the served bytes when the board is reachable).
- [ ] `relayctl.py` with no TLS argument refuses; with a wrong fingerprint it aborts before
sending `T<token>`; with the right one it arms and types.
- [ ] The page displays the board's fingerprint from the status frame, and the pill says
`tls`.
- [ ] Full test suite green; `test_proto.cpp` untouched.
@@ -0,0 +1,92 @@
# Task 06 — Tell the truth about it: docs, backlog, and the rollback build
**Phase:** `02_tls_control_channel` · **Source:** `TODO.md:3` — "Some encryption for
password/key input. Doesn't need to be proper x509, but something" (the documentation half
`AGENTS.md` requires in the same change: *"Docs move with the code."*)
**Story:** `n/a`
## Objective
The README stops describing a plaintext control channel — including the paragraph that
currently *apologises* for it — and Backlog item 2 ("TLS with a pinned certificate") is
closed. The `-D KR_TLS=0` rollback is built, documented and named, so the encryption is a
decision an operator can reverse rather than a dependency they are stuck with.
## Work
1. `README.md`, in place, keeping the reasoning and not just the behaviour:
- **Intro bullets + the check count**: update `**3406 checks**` to the new total and add
one bullet for what is now encrypted in transit.
- **"Security model and trust boundaries"**: the trust table — "Anyone on the board's
network" can no longer read keystrokes or the passphrase; add/adjust rows for
"Anyone who can see the traffic" (sees a TLS handshake to an unnamed host and nothing
else) and "Anyone with a copy of this firmware" (gets no key: the identity is generated
on each board). The paragraph beginning **"What the passphrase does and does not do"**
currently states that the page is plain HTTP, the socket is `ws://` and "the token is
visible at first contact"; rewrite it in place, keeping its structure — what TLS now
buys, what it does **not** buy (an unpinned connection is still an on-path risk until you
confirm the fingerprint; the target still sees whatever you type on it; the local
machine is still not trusted), and the **Practical rule** paragraph adjusted to
"the certificate you did not pin is the one thing to check".
- **Safety model**: one sentence locating TLS where it belongs — transport, not an arming
input. A TLS session that is not armed types nothing, and a dropped TLS session is a
lost link, which runs the funnel and disarms.
- **Wire protocol**: the table is unchanged (same frames), but add the line that the
transport is `wss://` and that `T<token>` no longer crosses the network in the clear;
`426` on the plaintext port documented with the error-code list.
- **Quick start**: first boot prints the fingerprint once, `cert` reprints it, the browser
asks once per browser, `curl -sk https://keyrelay.local/status?token=…`, `relayctl.py
--fingerprint`. Note the `-D KR_TLS=0` rollback right where a stuck operator looks for it.
- **Controller page**: the `tls` pill and the fingerprint line; keep the existing element
table current rather than appending a new section.
- **Architecture** file map: `src/tls_pem.h` (pure), `src/tls.h` (identity),
`src/tls_session.h` (session), `src/conn.h` (transport view), and what each owns.
- **Tests**: new `test_tls_pem` row with its real check count; the totals in the intro and
the table made to agree.
- **Measured behaviour**: the TLS numbers that task 03 measured — handshake time, heap
with 1 and 2 sessions, flash growth — as measurements, with the command that produced them.
- **Backlog**: remove item 2 and renumber the rest; if a narrower remainder exists (e.g.
"a real CA so browsers stop warning", or session-resumption/ticket tuning), that becomes
an item — do not leave a closed item in place.
- **Design decisions**: why TLS instead of a challenge–response over the passphrase; why
the identity is generated on the board rather than shipped as a PEM; why raw mbedTLS
rather than `esp_tls`; why a `token`-carrying request on `:80` is refused with 426
instead of redirected.
2. `AGENTS.md` — **fix stale statements only, do not expand**: the page-size check
`curl -s http://keyrelay.local/ | wc -c` becomes `curl -sk https://keyrelay.local/ |
wc -c` (the page is still served from flash, so the "reflash before you believe the
network" warning stays); "Current defaults worth knowing" gains the TLS identity fact in
the same one-line style (generated on board, in NVS, survives reflashing, `cert` prints
the fingerprint); the "Secrets" bullet keeps its rule and, if it does not already cover
it, the concrete instance that the private key lives in NVS and never in a file. Nothing
else in this file: phase narrative belongs in `.agents/phases/`.
3. `src/tls.h`, `src/tls_pem.h`, `src/tls_session.h`, `src/conn.h`, `tools/relayctl.py` —
the docstring at the top of each module describes *why*, in the house style of
`proto.h`/`web_page.h`/`control_server.h`: what it protects, what it does not, and the
decisions a later editor must not reverse by accident.
4. Build the rollback and prove it exists: `uv run --python 3.14 --with platformio python
-m platformio run -e bridge -e bridge --upload-port "" --build-flags "-D KR_TLS=0"` or
the equivalent temporary env (document the exact working command in the README rather
than inventing prose around it), and record the resulting `RAM:`/`Flash:` pair.
5. `.agents/HW_VERIFICATION.md` — fill in the device rows this phase touched with what was
actually observed, or `skipped (no device)` plus the command. Never a claim that was not
made.
## Testing & Quality
- `bash .agents/validate.sh` green, including both firmware environments and the rollback build.
- Every number quoted in the README matches a command in this repository (check count from
the suite, page bytes from the gate, heap/RAM/flash from the build and the boot log).
- A reader who has never seen the code can bring the board up over TLS from the README alone.
## Completion Criteria
- [ ] `grep -n "plain HTTP\|ws://" README.md` returns only statements about the rollback
build, not about the default.
- [ ] Backlog has no TLS item and the numbering is contiguous.
- [ ] The trust-boundary table reflects the encrypted channel, and the "does and does not"
paragraph names what TLS still does not protect.
- [ ] `-D KR_TLS=0` builds, and the README states the exact command and the trade.
- [ ] README check count and Tests table agree with the suite's own output; `AGENTS.md`
carries no new prose, only corrected facts.
- [ ] Full test suite green.
@@ -0,0 +1,75 @@
# Phase 03 — Take the bridge: session takeover that cannot arm itself
**Source:** `TODO.md` L6 — "Need to be able to take control if another session is connected.
Sometimes I accidentally leave my laptop connected."
**Story:** `n/a`
**Context:** `PLAN.md` "Safety position"; `src/control_server.{h,cpp}` (`acceptNew()`,
`serveViewer()`, `closeLink()`), `src/proto.h`, `src/main.cpp` (`releaseEverything()`,
`onMessage()`, `onLinkChange()`), `src/web_page.h`, `tools/relayctl.py`
## Objective
When a controller left connected on the laptop is holding the bridge, the operator on the
phone can take it: the newcomer authenticates on a *pending* claim, deliberately asks for the
bridge, the incumbent is told and dropped, **the release funnel runs**, and the board comes
back **disarmed** so the new session has to arm again on purpose.
## Why this shape
`E3` exists because two controllers typing into one machine is the ambiguity a keyboard must
not have — that stays. What changes is that "no room" was the *only* answer, which turns the
most ordinary accident (a laptop tab that kept the socket open) into a dead board until you
find the other tab or reboot the board. A claim is therefore not a new privilege: it is the
**same** privilege (the passphrase) exercised from a different socket, plus one deliberate
act, plus a guarantee that the handover cannot inherit anything.
The load-bearing rule for every task here: **a takeover can never produce an armed board.**
The incumbent's keys go up before the newcomer is adopted, `A1` from the newcomer is an
ordinary authenticated arm, and nothing in the handover path may call `arm()`.
## Dependencies
- `02_tls_control_channel` (todo) — the claim travels over the encrypted channel, and the
pending slot reuses the *one* extra socket the server already budgets (controller + one
more), so the TLS-session ceiling set in phase 02 (`kTlsMaxSessions = 2`) must exist. When
phase 02 is rolled back with `-D KR_TLS=0` the claim travels over plaintext `ws://`, exactly
like the controller does in that build — the claim adds no new security dependency.
## Tasks
1. `01_pending_claim_socket.md` — the second socket may handshakes and authenticate as a
*pending claim* instead of being refused; its own header buffer, bounded reader and
command whitelist; nothing else changes.
2. `02_takeover_funnel.md` — `X` claims the bridge: `E12` to the incumbent, the funnel, the
disarm, then adoption; silence-clock and `controllerSeen()` semantics kept honest.
3. `03_status_page_takeover_ui.md` — `busy` in the status frame, the page's TAKE OVER affordance,
`E12` handled, and the incumbent visibly disarmed with the reason in its log.
4. `04_takeover_tests_docs.md` — `relayctl.py --take-over`, the frame/pin tests that keep the
safety sentences true, README Safety/Security/Wire/Controller pages.
## Testing & Quality
- `tests/test_proto.cpp`: `X` parses (and refuses trailing garbage), `E12` renders, the
round-trip and bounds checks extended, the *existing* chord-label assertions untouched.
- `tests/test_relayctl.py`: the `--take-over` frame sequence (`T` then `X` then `H`) and the
default refusal; plus three **source pins** in the style of the existing three —
(a) the takeover path calls the release funnel before it adopts the newcomer,
(b) no code reachable from the `X` handler calls `arm()`/sets `armed`,
(c) a pending claim cannot exist without `_authed`.
These are the sentences a later edit can undo silently; they are the point of this phase.
- `tests/test_page.js`: refused-then-offered flow (a refused upgrade shows the affordance, it
does not retry-loop), `E12` disarms locally, the takeover button sends nothing until clicked.
- Coverage **>90%** on new/modified logic.
- Device (best effort, one attempt, per `.agents/HW_VERIFICATION.md`): two controllers, one
arm, one held key, one takeover — `watch_target.py` must show the key **up** before the new
session's first key, and the board must report `armed:0` across the handover.
## Completion Criteria
- [ ] A second controller can authenticate and take the bridge; a third gets `E3`.
- [ ] The handover releases every key and every mouse button and leaves the board disarmed;
demonstrated on hardware or recorded as `skipped (no device)` with the commands.
- [ ] The incumbent's page says it was taken over, disarmingly and in its log.
- [ ] The three source pins exist and fail when the corresponding code is inverted (shown in
the report, then reverted).
- [ ] `bash .agents/validate.sh` green; README safety/security text says takeover cannot arm.
@@ -0,0 +1,80 @@
# Task 01 — A pending claim: the second socket may authenticate
**Phase:** `03_session_takeover` · **Source:** `TODO.md:6` — "Need to be able to take control
if another session is connected. Sometimes I accidentally leave my laptop connected."
**Story:** `n/a`
## Objective
A second socket may complete the WebSocket handshake and authenticate as a **pending claim**
while the incumbent keeps the keyboard — instead of getting a `503` and being locked out of
the board entirely. Nothing can be *done* from a pending claim yet; this task only opens the
door far enough to stand in it.
## Work
1. `src/control_server.h` — the pending slot reuses the socket that today is `_viewer`, on
purpose: one incumbent + one more, never one incumbent + two claimants, because that is
already the server's shape and it is the TLS-session ceiling from phase 02
(`kTlsMaxSessions = 2`). Add, beside `_viewer`/`_viewerHttp`/`_viewerLen`:
- `enum class ViewerRole : uint8_t { Readonly, Claim };`
- `Phase _viewerPhase` (`None`/`Http`/`WebSocket`) and its own `ws::Reader` over a
**small** buffer — `kClaimMsgCap = 64` in `config.h`: `T<token>` is at most
`kTokenMax + 1`, `X` is one byte, `K` is one byte. Anything larger is refused, not
buffered. Add a comment naming the RAM each field costs and why the claim buffer is a
sixteenth of the controller's.
- `bool _claimAuthed`, `uint32_t _claimAtMs`, and (task 02) the claim's conn.
2. `src/control_server.cpp`, `serveViewer()`:
- an `/ws` upgrade from the viewer slot is now accepted into `ViewerRole::Claim` and
`Phase::WebSocket` (the same `ws::acceptFor()` handshake as the controller path); the
`503` path stays for what comes *after* the claim slot is occupied (task 02 wires the
counting; here, keep refusing a *third* socket and make `_rejected++` mean "no room").
- A pending claim authenticates exactly like a controller: `T<token>` within
`kAuthTimeoutMs`, the same constant-time compare, the same `_authFails`/`_lockUntilMs`
counters (one lockout for the board, not one per socket), and the same close-with-nothing
on failure.
- While pending and unauthenticated, **only** `T` is honoured. While pending and
authenticated, only `T`, `K` and (from task 02) `X`; every other frame gets
`E6`-shaped refusal and the claim is dropped. No arm, no state, no text, no mouse —
spell that list out in the code comment, because it is the whole safety argument.
- The claim never touches `_lastInboundMs`: a claim attempt is not the operator being
alive (the comment in `readSome()` explains why that distinction exists). It refreshes
`_claimAtMs` instead, and a claim that goes quiet is dropped.
- The read-only routes still work from the same slot when it is *not* in Claim role —
`serveReadonly()` remains the single implementation (invariant 8).
3. `src/config.h` — `kClaimMsgCap`, `kClaimTimeoutMs` (a pending claim that has not asked for
the bridge this long is dropped; 15 s is plenty for a human click, and an unanswered claim
holding a TLS session open is a nuisance attack), with the reasoning in the comment.
4. `tests/test_proto.cpp` / `tests/test_relayctl.py` — nothing on the wire yet, so both
suites must stay green **untouched**; if either needs a change, this task grew the
protocol and must stop and say so.
- ASSUMPTION: the claim is accepted into a pending state rather than the board keeping
`503`-only and adding an HTTP "request takeover" endpoint. A second control channel would
be a second auth path — exactly the drift `AGENTS.md` warns about — while the pending
WebSocket reuses the authentication code that already exists and is already tested.
- ASSUMPTION: one claim slot at a time; a third socket gets the `503`/`E3` it gets today.
- ASSUMPTION: a claim gets a smaller message cap than the controller. A frame that does not
fit is a refusal, consistent with invariant 7 (one frame is one command) and it keeps a
hostile claimant from allocating board RAM.
## Testing & Quality
- Host: existing suites untouched and green (`test_proto` at 2 327 checks, `test_output`
unchanged). New pure logic is limited to the claim state machine's predicates (which role,
which commands allowed, deadline arithmetic) — those go in a pure helper the tests can call,
in the style of `output.h`.
- Firmware: `bridge` + `console` build; report the RAM delta from the added buffers
(`kClaimMsgCap` + one `ws::Reader` + one header buffer), against `bridge 26.7% / 87 592 B`.
- Device (best effort, one attempt): with the controller connected, a second tab's `/ws`
upgrade is accepted and authenticates; a third socket is refused; the controller keeps
typing throughout.
## Completion Criteria
- [ ] A second `/ws` upgrade while a controller is linked reaches `authenticated` as a claim
(log/observation or `skipped (no device)`), and a third is refused.
- [ ] A pending claim can arm nothing: the whitelist is one visible list in the code and the
report names every entry.
- [ ] Claim timeouts and silence drop the claim and free its session; no leak over 20 attempts.
- [ ] `bash .agents/validate.sh` green; `test_proto.cpp`/`test_relayctl.py` unchanged in this task.
@@ -0,0 +1,78 @@
# Task 02 — `X`: hand the bridge over through the funnel
**Phase:** `03_session_takeover` · **Source:** `TODO.md:6` — "Need to be able to take control
if another session is connected…"
**Story:** `n/a`
## Objective
One authenticated frame from the pending claim — `X` — takes the bridge: the incumbent is
told (`E12`), the release funnel runs, the board ends up **disarmed**, and only then is the
claimant adopted as the controller. The handover is a link change that the rest of the
firmware recognises, not a silent substitution of one socket for another.
## Work
1. `src/proto.h` — `Cmd::Takeover` for a lone `X`, documented in the header comment's
direction table: `c -> d X claim this bridge for myself; requires authentication, and
nothing else is honoured on a claim socket`. `proto::formatError` unchanged; add
`kErrTakenOver = 12` to `control_server.h` ("your session was taken over by another
controller") and name it in the error list comment.
2. `src/control_server.cpp` — the handover, in this order and no other:
1. `sendError(12, "session taken over")` on the **incumbent** socket, so its page can say
why it stopped. It is best-effort: an unreadable incumbent must not block the handover.
2. `closeLink("taken over")` on the incumbent — the existing funnel. `closeLink()` already
disarms and releases for anything that reached the WebSocket phase (that is bug 10's
fix; do not re-narrow it here) and calls `_onLinkChange(false)`, which in `main.cpp`
runs `releaseEverything()`. **This must happen before adoption**, so the newcomer can
never inherit a held key or an armed bridge.
3. Promote the claim: move its conn into the controller slot, `_phase = WebSocket`,
`_authed = true`, `_controllerSeen = true`, `_lastInboundMs = millis()`,
`_connectedAtMs = nowMs`, reset the controller's reader, then `OK` + `sendStatus()` and
`_onLinkChange(true)`.
4. Clear the claim slot completely (`_claimAuthed = false`, reader reset, buffers zeroed)
so a stale claim cannot be promoted twice.
- `arm()` is not called, and nothing on this path can reach it. State that in a comment at
the top of the handover function and make the function's name say it too
(`handOverClaim()` or similar — not `takeover()` which reads like an arm).
- If the incumbent is already gone by the time `X` arrives, the promotion still runs
through the same steps (the funnel is a no-op when disarmed), so there is only one path.
- `_rejected` keeps meaning "a socket was turned away", and a successful takeover gets its
own counter (`_takeovers`), reported in the status frame (task 03) — counters only, never
addresses or identities (invariant 4).
3. `src/main.cpp` — `onMessage()` handles `Cmd::Takeover` for the case where an *existing*
controller sends `X` (a no-op with `OK`, documented: you already hold it) — do not let it
fall through to `E6` — and `fillStatus()` publishes the counter.
4. `src/config.h` — nothing new; `kClaimTimeoutMs` from task 01 bounds the claim's life.
- ASSUMPTION: `X` is honoured **only** on a claim socket (or is a no-op from the controller).
It is not a general command, so a controller cannot use it to reset its own session.
- ASSUMPTION: the incumbent gets no warning window and no veto. Its operator is by
definition absent — that is the situation the TODO describes — and a veto held by an
absent tab is the lockout we are removing. The protection is that the claimant needed the
passphrase, and that the board lands disarmed.
- ASSUMPTION: `E12` is a new code rather than reusing `E3`. `E3` means "you were refused";
`E12` means "you were dropped". One of those should be alarming on the incumbent's screen
and the other should not.
## Testing & Quality
- `tests/test_proto.cpp`: `X` parses to `Cmd::Takeover`; `X` with trailing bytes is `Cmd::Bad`;
`E12` formats; the render/parse round trip covers it; the existing 2 327 checks around chord
labels and state specs stay exactly as they are.
- `tests/test_relayctl.py` — the three source pins named in the phase overview: funnel before
adoption, no `arm()` reachable from the `X` path, no promotion without `_authed`. Written
the way the existing pins are written (a named assertion per sentence, with the sentence in
the output) so the suite says out loud what it checked.
- Device (best effort, one attempt): arm on tab A, hold a key, take over from tab B —
`watch_target.py` shows the key-up and the board reports `armed:0`; tab A shows `E12`.
Record the transcript in `.agents/HW_VERIFICATION.md` or `skipped (no device)`.
## Completion Criteria
- [ ] `X` from an authenticated claim drops the incumbent with `E12` and adopts the claim.
- [ ] The board is disarmed immediately after every takeover, observed or recorded as
unobserved-with-commands — never asserted without a mechanism.
- [ ] The three source pins exist and each one fails when its sentence is broken (shown, then
reverted).
- [ ] `bash .agents/validate.sh` green; `_takeovers` counter visible; full test suite green.
@@ -0,0 +1,76 @@
# Task 03 — Make it visible: `busy` in the status frame, TAKE OVER on the page
**Phase:** `03_session_takeover` · **Source:** `TODO.md:6` — "Need to be able to take control
if another session is connected…" (the operator-facing half: the accident has to be fixable
from the screen you are looking at)
**Story:** `n/a`
## Objective
A controller that finds the bridge already held can see that it is held, can see who is
holding it in the only terms the board is allowed to know (a counter and a link state, never
an address), and can take it with one deliberate click. The incumbent's screen stops being
mysterious: it says it was taken over, disarms, and shows that in its log.
## Work
1. `src/control_server.h` / `.cpp` — `StatusSnapshot` gains `bool busy` (a controller holds
the keyboard: `linked()`) and `uint32_t takeovers`; `statusJson()` emits `"busy":u` and
`"tkov":u`; grow `kStatusJsonCap` with the arithmetic in the comment. **No** client
address, no user agent, no timing that would identify the other session — the README's
"never stored" list is about exactly this temptation.
2. `src/web_page.h`:
- The link pill gets a third honest state: `held by another controller` (from `s.busy`)
alongside `linked` and `link down`.
- A refused upgrade no longer retries in a loop. On close-without-auth, if the board
reports `busy`, show a **take over** button next to the passphrase field. Clicking it:
open the socket, authenticate with the passphrase already in the field, then send `X` —
one click, one intent. Before the click nothing is sent; after it the page behaves like
any other controller, starting **disarmed**, with a log line naming what happened.
- `onFrame()` handles `E12`: `setArmed(false, "another controller took over")`, drop the
mouse (`dropMouse`), abort a typing run, mark the link pill, and stop reconnecting
automatically — a page that immediately re-claims would turn two tabs into a fight.
The existing `code === "3"` branch keeps its meaning for "no room for a claim".
- Keep the arming banner, the safety hints and every id the script reaches for intact —
`tests/test_page.js` checks id coverage, and phase 04 is where the prose gets trimmed.
3. `tools/relayctl.py` — `--take-over`: after authentication, if the board reports a
controller already present, send `X` and continue; without the flag, a `busy` board is a
clear refusal naming the flag. `--take-over` on a free board is a no-op, not an error.
(Task 04 finishes the tool's tests; the flag lands here so the page and the tool agree
from the start.)
4. `README.md` — the **Wire protocol** table gains `X` and `E12` (this is a four-file
protocol change: `proto.h`, `web_page.h`, `relayctl.py`, README — all four move), the
**Controller page** element table gains the take-over affordance, and **Security model**
gets the sentence that matters: someone with the passphrase can take the bridge over, and
cannot arm it by doing so.
- ASSUMPTION: the page reconnect policy after a takeover of *your* session is "stay down until
you click". Auto-reclaim is the fastest way to make two devices fight over a keyboard.
- ASSUMPTION: `busy` is a boolean plus a counter, not "who". The README's trust table already
promises the target's user cannot detect a second controller and the board stores nothing
identifying; `busy` keeps that promise while making the state visible to the person who is
locked out.
## Testing & Quality
- `tests/test_page.js`: `busy` renders the third pill state; a refused upgrade shows the
button and sends nothing until clicked; the click sends `T` then `X` in that order and
nothing else; `E12` disarms, releases the mouse, aborts typing and does not reconnect;
every id the script reaches for exists in the markup (the existing coverage check).
- `tests/test_proto.cpp`: the new status fields render and the frame still fits
`kStatusJsonCap` with the longest legal SSID.
- `tests/test_relayctl.py`: `--take-over` frame ordering; refusal without it; no-op on a free
board; every generated frame parseable by the firmware's own parser (already a suite rule).
- Device (best effort, one attempt): the whole gesture from a phone while a laptop tab holds
the bridge, observed with `watch_target.py` on the target.
## Completion Criteria
- [ ] `s.busy` drives a distinct pill state, and the take-over button appears only when the
board says it is held.
- [ ] One click takes the bridge; the page that lost it says so, disarmed, and stays down.
- [ ] `relayctl.py --take-over` works and the default refuses; both go through the same
frames the page sends.
- [ ] README wire table lists `X`/`E12` and all four protocol locations agree (`test_proto`
and `test_relayctl` green).
- [ ] Full test suite green; no client identity anywhere in the status frame.
@@ -0,0 +1,74 @@
# Task 04 — Pin the safety sentences, the procedure, and what `AGENTS.md` now gets wrong
**Phase:** `03_session_takeover` · **Source:** `TODO.md:6` — "Need to be able to take control
if another session is connected…" (the half that keeps it true next month)
**Story:** `n/a`
## Objective
Make the takeover's safety claims the kind that fail loudly when someone edits them away:
executable checks in the host suites, a written end-to-end procedure that can be run against
a real target, and the two documents that currently state the old rule corrected so they no
longer describe a refusal that no longer happens.
## Work
1. `tests/test_relayctl.py` — the phase's three source pins, final form, in the style of the
existing three ("the suite says so out loud"):
- the takeover path calls the release funnel (`releaseEverything()` / the `onLinkChange`
that runs it) **before** the newcomer is adopted — assert the ordering in
`control_server.cpp`, not the intention;
- no `arm` / `armed = true` is reachable from the `X` handler;
- a claim socket cannot reach `Phase::WebSocket` promotion without `_authed`.
Also: the existing pins (silence disarm off `controllerSeen()`, `acceptNew()` never stamps
the controller clock, `closeLink()` stamps it only for a controller link) must still pass —
the takeover touched all three of those code paths, so re-read each one and confirm the
sentence is still true rather than trusting green.
2. `tests/test_page.js` — one check per safety sentence: takeover disarms the page that lost
the bridge; a lost page does not auto-reclaim; the claim sends nothing before the click;
the mouse is released on `E12`. Reuse the shim's `fire()`; extend the shim only as far as
`WebSocket` refusal needs (it already shims `WebSocket`, `sessionStorage` and `location`).
3. `README.md`:
- **End-to-end verification**: the takeover procedure in the commands-already-in-this-file
style — terminal A `watch_target.py` on the target, terminal B `relayctl.py … key a`
held, laptop tab holds the bridge, phone takes over, and what must appear in what order
(key-up before the newcomer's first key).
- **Safety model**: a bullet in the "disarmed is the only default" list — a takeover
disarms, releases every key and every mouse button, and cannot arm.
- **Tests**: the new check counts, and the suite descriptions updated to name the takeover
pins the way the table already names bug 10's pins.
- If this phase found a real bug — a stale `wasLinked`, a double promotion, a claim that
survived its own timeout — it goes into **"Ten bugs this verification caught"** with its
root cause and how it was proven, and the section's count changes with it. A bug fixed
silently is a bug that comes back.
4. `AGENTS.md` — **stale statements only**. Invariant 8 currently reads "A second `/ws`
upgrade is refused (`E3`)"; that is now wrong. Rewrite that one sentence to say what is
true: the keyboard stays exclusive, a second `/ws` is a pending claim that must
authenticate, and only `X` moves the keyboard — with `E3` now meaning "no room for a
claim". Nothing else in the file: no phase narrative, no new sections.
5. `.agents/HW_VERIFICATION.md` — the takeover row filled with what was observed (or
`skipped (no device)` + the commands). If it was skipped, the phase overview's completion
criteria must still be honest about it — the phase's final pass restates it, it does not
delete it.
- ASSUMPTION: the pins are source-text assertions, not behavioural ones, because the handover
needs two real sockets and there is no socket harness here. That is the same trade the
project already made for bug 10, and the same caveat is written into the test's comment.
## Testing & Quality
- Each new pin is demonstrated to fail when its sentence is inverted, then reverted (report
shows both runs) — a pin that cannot fail is decoration.
- `bash .agents/validate.sh` green: suites, sanitiser run, both firmware environments.
- Coverage **>90%** on the modified logic; `test_proto.cpp` unchanged apart from the status
fields and `X`.
## Completion Criteria
- [ ] Three takeover source pins present, named in the suite output, each proven to fail.
- [ ] Page safety sentences pinned in `test_page.js` (disarm on `E12`, no auto-reclaim,
nothing sent before the click, mouse released).
- [ ] README: safety bullet, end-to-end takeover procedure, Tests table and totals consistent.
- [ ] `AGENTS.md` invariant 8 describes the claim, not the old refusal, and gained no new prose.
- [ ] Full test suite green; no behaviour change in phases 01–02 (spot-check: TLS handshake
and `busy`-free takeover both still observed or recorded as unobserved-with-commands).
@@ -0,0 +1,70 @@
# Phase 04 — The controller page: less to read, works on a phone
**Source:** `TODO.md` L4 — "Clean up keyrelay interface, don't need as much text, make it
responsive"
**Story:** `n/a`
**Context:** `src/web_page.h` (markup + `<style>` + script), `tests/test_page.js`, README
"Controller page"; `AGENTS.md` "The page is served from flash"
## Objective
Reorganise the page so the controls are the thing you see: single column and thumb-sized on a
phone, unchanged on a laptop, and the explanatory prose folded away to where it is asked for
instead of sitting between the operator and the ARM button. **No safety statement gets
weaker or shorter** — they move, they do not shrink, and each one gets a test that fails if it
disappears.
## Why the prose is load-bearing anyway
Every long hint in this page exists because someone got hurt without it: the arming banner
(Enter goes to another machine), the `Esc`-frees-the-pointer note (Pointer Lock is invisible),
the layout caveat (characters are positions), the KDE paragraph (those three buttons are the
laptop's until the board steals them). Trimming them is the right call for usability and the
wrong call for safety *unless* the trimming is pinned. So this phase writes the assertions
before it deletes the sentences.
## Dependencies
- `03_session_takeover` (todo) — its TAKE OVER affordance and the `tls` pill/fingerprint line
are new elements in the same markup; folding prose around them is easier once they exist,
and the id-coverage check has to be updated once.
## Tasks
1. `01_responsive_layout.md` — CSS and viewport only: breakpoints, thumb-sized targets, sticky
ARM bar, safe-area insets, collapsed log; no markup restructure, no id changes.
2. `02_prose_trim_pinned.md` — the safety assertions first, then the fold: long hints into
`<details>`, the footer shortened, the connection/mouse/paste paragraphs cut to one line
each with the reasoning one tap away.
3. `03_suite_size_and_docs.md` — `test_page.js` extended for the new structure, the README's
page-size claim and its duplicated `test_page` row corrected, totals made to agree, and the
`curl` byte check re-derived.
## Testing & Quality
- `tests/test_page.js` is the only thing that can see this page before a browser does
(`AGENTS.md`: a raw string is invisible to every compiler). It must cover, in addition to
what it covers today:
- **safety strings still present**: the arming warning names Enter and "every key"; the
mouse hint names `Esc` and release; the paste hint names "stop typing"/disarm cancelling a
run; the layout note says positions/US layout; the default-passphrase shout still exists.
- every element id the script reaches for exists in the markup (existing rule, re-verified
after the restructure);
- the pinned page constants (`TEXT_FRAME_MAX`, `TEXT_JOB_MAX`) still equal `config.h`;
- the script still behaves identically: arming, state frames, paste chunking, mouse
capture/movement/button ordering, `E12`.
- Coverage **>90%** on the page script's changed branches; CSS is checked by assertions on the
served bytes (breakpoints present, sticky ARM bar) plus a phone observation.
- Device (best effort, one attempt): `curl -sk https://keyrelay.local/ | wc -c` equals the
byte count the gate prints, and a phone shows one column with the ARM bar reachable and the
log not pushing it off screen.
## Completion Criteria
- [ ] The page works at 360 px wide and at 1440 px wide without horizontal scroll, ARM is
reachable with a thumb, and the log is collapsible.
- [ ] Every safety statement above is still on the page **and** asserted by `test_page.js`.
- [ ] The page is smaller in bytes than it is today, and README's size claim matches.
- [ ] README Tests table has one `test_page` row with the real count (the duplicate stale row
at 47 checks is removed), and the intro's total check count matches the suite output.
- [ ] `bash .agents/validate.sh` green; no behaviour change in phases 01–03.
@@ -0,0 +1,66 @@
# Task 01 — Responsive layout: CSS and viewport, nothing else
**Phase:** `04_controller_page_responsive` · **Source:** `TODO.md:4` — "Clean up keyrelay
interface, don't need as much text, make it responsive" (the layout half)
**Story:** `n/a`
## Objective
The page lays out for the screen it is on: one column and thumb-sized controls on a phone, the
current dense grid on a laptop, the ARM bar always reachable, the viewport configured so the
layout is not scaled-bitmap small.
## Work
1. `src/web_page.h`, `<style>`:
- `main` grid: keep `auto-fit`/`minmax(240px,1fr)` above the breakpoint; at `@media (max-width:
720px)` force one column and reduce padding.
- Thumb targets: `button.k`, `input`, `.pill`, checkboxes get `min-height:44px` (and
`min-width` where a target is narrower) on coarse pointers —
`@media (pointer: coarse)` where that reads better than a width query.
- `input[type=password], input[type=text], textarea { font-size:16px }` on small screens:
below 16 px iOS Safari zooms the page on focus and never zooms back out. Name that in the
comment — it is why the size is not "13 px looks nicer".
- ARM bar: `position:sticky; top:0; z-index` so it survives scrolling on a phone; keep the
armed styling exactly as it is (`body.armed` outline is an indicator, not decoration).
- `#log`: `max-height` on small screens, and `overscroll-behavior:contain` so scrolling the
log does not scroll the page.
- `touch-action:manipulation` on the interactive elements (kills the 300 ms double-tap
zoom) and `user-select:none` where a press is a control, not text.
- `padding-bottom: calc(14px + env(safe-area-inset-bottom))` on `main`, plus
`viewport-fit=cover` added to the existing `<meta name="viewport">` (without it `env()`
is always 0). Do **not** add `maximum-scale`/`user-scalable=no` — pinch zoom stays.
2. `tests/test_page.js` — assertions on the served bytes, in the existing style: the viewport
meta contains `viewport-fit=cover` and does not disable scaling; a `max-width:720px`
breakpoint exists; the ARM bar is sticky; the coarse-pointer target floor exists; the
input font-size floor exists. These are layout claims, so they are checked where the page
can be seen without a browser.
3. `src/web_page.h` header comment — one paragraph: the page is for a laptop *and* a phone
held in one hand over a target you are standing at, and the sticky ARM bar exists because
the state must be unmissable at any scroll position.
- ASSUMPTION: breakpoints at 720 px plus a `pointer: coarse` query, rather than a full design
system or container queries. One breakpoint the README can describe beats five nobody can
remember.
- ASSUMPTION: dark theme only, as today. A light theme is not on anyone's list and doubles
every colour assertion.
- ASSUMPTION: the log stays in the page (collapsed by its own height, not hidden behind a tab)
because the log is where `E9`/`E4` explanations appear; hiding it is hiding the diagnostics.
## Testing & Quality
- `node tests/test_page.js` green, including the new byte-level layout assertions; the id
coverage check unchanged (no ids added or renamed by this task).
- `bash .agents/validate.sh` green; the page byte count printed by the gate changes and is
recorded (the README claim is task 03's job).
- Device (best effort, one attempt): a phone at 360–430 px logical width shows one column, the
ARM bar stays visible while scrolling, focusing the passphrase field does not zoom the page.
## Completion Criteria
- [ ] No horizontal scroll at 360 px, 768 px or 1440 px (observed on a phone or recorded as
`skipped (no device)` with the exact check).
- [ ] ARM reachable without scrolling from the top of the page on a phone, and it is still the
first element in `main`.
- [ ] Focus on the passphrase input does not zoom (or the report says unobserved).
- [ ] All four layout assertions present in `test_page.js`; full test suite green.
@@ -0,0 +1,70 @@
# Task 02 — Cut the reading, keep the warnings: assertions first
**Phase:** `04_controller_page_responsive` · **Source:** `TODO.md:4` — "Clean up keyrelay
interface, **don't need as much text**, make it responsive" (the prose half)
**Story:** `n/a`
## Objective
The page stops being a wall of explanation while every safety statement it carries survives
verbatim — each one now guarded by a test that fails if a later edit deletes it. Write the
assertions, then delete the prose.
## Work
1. `tests/test_page.js` — **first**, before touching the markup: add a named check per
statement that must survive, matching on normalised text so a rewrap does not trip it:
- arming forwards **every** key including Enter, and only while armed;
- `Esc` frees the pointer and releasing frees the buttons;
- disarm / stop / focus loss cancels a run of pasted text;
- characters are positions → the target must be on a US layout;
- the default-passphrase shout (driven by `dflt:1`, not static text);
- nothing is stored/scheduled/replayed, in whatever wording survives;
- the takeover cannot arm (added by phase 03 — guard it now so this task cannot lose it).
Run the suite: it must pass *before* the trim (proving the sentences are there) and after
(proving the trim did not remove them).
2. `src/web_page.h` markup:
- Each long paragraph becomes a one-line summary plus `<details><summary>why</summary>…`:
the KDE/snap-left explanation, the mouse "movement is relative" paragraph, the send-text
paragraph, the chords paragraph, and the footer's shortcut list. The **arming banner**
stays visible — it is the one paragraph that must never be behind a tap.
- The Connection card's passphrase paragraph → one line ("the board checks this on every
connection; without it nothing can arm") with the rest in `<details>`.
- Shorten `title` attributes that are currently multi-sentence (they are already duplicated
in the `<details>`); a `title` is a tooltip, not a manual.
- Collapse the Log card's option row and the State card's `kv` rows into a two-line summary
at narrow widths — counters stay visible; they are the "is it typing" evidence.
- Keep the DOM ids and the class hooks the CSS in task 01 targets; keep
`#banner`, `#arm`, `#log`, `#paste`, `#mPad` and the chord buttons' `data-chord`
attributes byte-identical (`test_proto.cpp` asserts every labelled chord against the
combination its label promises).
3. `<details>` in the shim — `tests/test_page.js`'s DOM shim is id-based, so `<details>` needs
nothing to parse, but the safety assertions must read text content across it: make the
matcher pull from the raw markup string (which the suite already has for the id check)
rather than from the shim's element tree.
4. `README.md` — Controller page section: say the hints are folded and that the safety notes
are pinned by `tests/test_page.js` ("the test asserts the warning is still on the page"),
which is the kind of sentence that keeps the next person from tidying them away.
- ASSUMPTION: `<details>` rather than a help modal or a tour: zero script, keyboard
accessible, printable, works with no JS, and it degrades to visible text in a browser that
ignores it — which is the safe direction for a safety note.
- ASSUMPTION: "don't need as much text" means the *explanations*, not the controls. The chord
buttons, the VT keys and the KDE row stay: the operator who does not need them today needs
them the day a window manager steals Alt+Tab.
## Testing & Quality
- The new safety-string assertions pass before and after the trim; both runs in the report.
- Every one of them is demonstrated to fail when the corresponding sentence is deleted (then
restored) — otherwise they are decoration, which is the failure mode `AGENTS.md` calls out.
- `node tests/test_page.js`, `tests/test_proto.cpp` (chord labels!) and the sanitiser run
green; page byte count drops and is recorded for task 03.
## Completion Criteria
- [ ] Safety statements present and pinned; each pin proven to fail on deletion.
- [ ] Visible prose on the laptop view is roughly a third of what it was; every removed
paragraph is reachable in a `<details>` in the served bytes.
- [ ] `data-chord` bytes, `#banner`, `#arm`, `#paste`, `#mPad` and every script-reached id intact.
- [ ] Full test suite green; `test_proto.cpp` untouched.
@@ -0,0 +1,61 @@
# Task 03 — Make the numbers agree again: suite coverage, page size, README
**Phase:** `04_controller_page_responsive` · **Source:** `TODO.md:4` — "Clean up keyrelay
interface, don't need as much text, make it responsive" (the verification half: a page claim
is only true when something measures it)
**Story:** `n/a`
## Objective
The restructure leaves the documentation and the suite telling the truth about the page as it
is now: one `test_page` row with the real count, the size claim matching the served bytes, and
coverage for the branches the restructure touched.
## Work
1. `tests/test_page.js` — coverage for the restructured page:
- id coverage after the restructure (the rule already exists; make sure it still runs
against the **markup** and not the shim, which hands out an element for any id);
- the `<details>` content is still in the served bytes (a folded note is not a deleted one);
- pinned constants: `TEXT_FRAME_MAX`/`TEXT_JOB_MAX` in the page still equal `kTextFrameMax`
/ `kTextJobCap` in `src/config.h` (the suite does this today — re-run it and confirm it
did not quietly stop matching after the edit);
- behaviour unchanged: arming, `S=` state frames, paste chunking and refusal, mouse capture
and button ordering, `E12`, the fingerprint line, `busy`.
2. `README.md`:
- Tests table: **one** `test_page` row, its real count, description covering the mouse, the
takeover and the folded safety notes. Delete the stale duplicate row that still claims 47
checks — the table currently lists the same suite twice with two different numbers, which
is exactly the "two paths that can disagree" shape `AGENTS.md` warns about.
- Controller page: "~35 KB" becomes the measured number, with the sentence that says how it
was measured (`curl -sk https://keyrelay.local/ | wc -c` against the byte count
`.agents/validate.sh` prints).
- Intro bullet `**3406 checks**` → the new total from the suite output, not arithmetic.
- Quick start / Troubleshooting: any sentence describing the old layout ("the page shows a
banner under the ARM bar", pill lists) corrected to what the page does now.
3. `AGENTS.md` — stale statements only: the page-size check line already changes in phase 02
(`https -k`); if the byte count is quoted anywhere, update it or point at the gate that
prints it rather than restating a number that will drift again.
4. `.agents/HW_VERIFICATION.md` — fill the phone row: 360 px, one column, ARM reachable, no
focus zoom, folded notes expandable — or `skipped (no device)` with the commands.
- ASSUMPTION: the totals in README are taken from a captured suite run pasted into the report,
not added by hand. The intro count and the table sum have disagreed before.
## Testing & Quality
- `bash tests/run_tests.sh` and `SANITIZE=1 tests/run_tests.sh` green; `bash
.agents/validate.sh` green including both firmware builds.
- The README's page-size claim and check count are reproducible from commands in this
repository, and the report shows both commands with their output.
- No change to `src/*.h` other than `src/web_page.h` markup/style; no behaviour change in
phases 01–03 (spot-check TLS, `busy`, and the paste path).
## Completion Criteria
- [ ] One `test_page` row in the Tests table with the real count; no duplicate anywhere.
- [ ] README page size == the served page's byte count (or the report says the board was
unreachable and quotes the PROGMEM count the gate prints).
- [ ] Intro check total matches the suite's own output.
- [ ] `test_page.js` covers: id coverage, folded content present, pinned constants, behaviour
unchanged; full test suite green.
@@ -0,0 +1,96 @@
# Phase 05 — The phone is a controller: soft keyboard and touch pointer
**Source:** `TODO.md` L5 — "Needs to be mobile compatible - click arm and then mobile
keyboard pops up. Mobile trackpad for touch trackpad."
**Story:** `n/a`
**Context:** `src/web_page.h` (the mouse block, `takeMouse()`/`flushMouse()`/`dropMouse()`,
`inPageField()`, `setArmed()`), `src/text.h` (`strokeFor()`), `src/proto.h`, `src/mouse.h`,
`src/config.h`, `tests/test_page.js`, README "Controller page" / "Safety model"
## Objective
Arm the bridge on a phone and the soft keyboard appears so you can actually type into the
target; press and hold the pad and the target's pointer moves, let go and every button is up.
Two capabilities the desktop gets from hardware — a physical keyboard and Pointer Lock — get
honest software equivalents, with the same guarantees restated in touch terms rather than
waived.
## The two equivalences, stated up front
| desktop guarantee | touch equivalent |
|---|---|
| Pointer Lock: the cursor cannot escape the tab, and `Esc` always frees it | **finger down on the pad is the capture, finger up is the release** — movement stops and every button lets go the instant the touch ends, and the Release button is 44 px and on screen. The capture cannot outlive the finger that asked for it. |
| Modifiers are physical keys held with a second hand | an **on-screen latch row** whose state is lit, restated in every frame, and cleared by a tap — never a sticky Ctrl that silently modifies the next six keys. |
Both are *more* conservative than the desktop case, not less: a touch capture ends when you
stop touching, which is stronger than a lock you have to remember to escape.
## What must not be traded away
- Movement is **relative**, one report per frame, deltas capped at ±127 and split so the net
displacement survives (invariant 7: splitting is the controller's job).
- No auto-click, no gesture, no macro, nothing scheduled. The button row is held-while-pressed.
- Capture requires armed **and** the board reporting `mouse:1`, and `E10`/`E11` still refuse
rather than ignore.
- Characters are typed by the board's own map. The page must **not** grow a second
character→usage table: the board owns `strokeFor()`, which is why the new `Q=` frame carries
*modifiers and hex bytes*, not usages. (`AGENTS.md`: when you add a table that decodes
something somebody else defined, test it — better still, do not add it.)
- Anything a touch session can leave behind (a latch, a held button, a focused field) dies in
the same funnel: disarm, blur, visibility change, link loss.
## Dependencies
- `04_controller_page_responsive` (todo) — the single-column layout, 44 px targets and
`touch-action:manipulation` are what make a touch pad usable; the folded prose is what makes
room for the latch row and the button row.
- `03_session_takeover` (todo) — `E12` handling and the `busy` pill, because a phone is exactly
the device that finds the laptop holding the bridge.
## Tasks
1. `01_touch_trackpad.md` — the pad: press-and-hold capture, relative movement from
`touchmove`, two-finger drag as the wheel, an explicit held-while-pressed button row, and
release on `touchend`/`touchcancel`/blur/disarm.
2. `02_soft_keyboard_capture.md` — ARM focuses a forwarding field so the soft keyboard pops;
printable characters leave as `P=0:<hex>` one per frame, named keys (Enter, Backspace, Tab,
arrows…) as a press/release pair; the field is cleared as soon as it is read.
3. `03_modifier_latch_q_frame.md` — the latch row and the `Q=<mods>:<hex>` frame that lets a
latched modifier type a character without the page owning a second character map. A real
four-place protocol change: `proto.h`, `web_page.h`, `relayctl.py`, README.
4. `04_mobile_tests_docs.md` — `test_page.js` touch/latch coverage, the named-key table pinned,
README's Controller page and Safety model rewritten for touch, `AGENTS.md`'s "three tables"
row, and the phone observation recorded.
## Testing & Quality
- `tests/test_page.js` — the shim already records document/window listeners and can `fire()`
them; extend it to dispatch `touchstart`/`touchmove`/`touchend`/`touchcancel` with multiple
touch points and `beforeinput` with `inputType`, then assert:
capture requires armed and `mouse:1`; one frame per report with nothing sent when idle; a
400×300-style drag split so no delta exceeds a signed byte and the net displacement is exact;
two-finger drag becomes wheel/pan not movement; a finger up releases every button **and**
stops movement; the button row's bits match `MOUSE_BTN`; the latch row's byte matches the
modifier names; a character becomes exactly one `P=0:` frame and a latched one becomes one
`Q=` frame; `E9`/`E10`/`E11` land in the log and cancel nothing they should not.
Every frame the page emits is parsed by the firmware's own parser — the suite rule that has
already caught mislabelled chords.
- `tests/test_proto.cpp`: `Q=` parses, bounds-checks (mods byte, cap on hex length), refuses
malformed input, round-trips.
- `tests/test_relayctl.py`: the tool builds `Q=` identically, and the button/name tables and
`MOUSE_BUTTONS` agreement extends to whatever the touch rows introduce.
- Coverage **>90%** on the changed page script and the new parser path.
- Device (best effort, one attempt): a phone on the board's AP — arm, keyboard appears, type a
word and `watch_target.py` shows the characters; hold the pad, move, lift, and the target
shows motion that stops and no button stuck.
## Completion Criteria
- [ ] Arm on a phone brings up the soft keyboard and typed characters land on the target.
- [ ] Hold the pad → the pointer moves; lift → motion stops and every button is up, observed
with `watch_target.py` or recorded as `skipped (no device)` with the exact commands.
- [ ] `Ctrl+C`-equivalent reachable with the latch row and lands as Ctrl+C on the target.
- [ ] `Q=` in the README wire table, `proto.h`, `web_page.h` and `relayctl.py`, with the tests
that pin all four agreeing.
- [ ] No second character→usage table anywhere; the named-key table that does exist is pinned.
- [ ] `bash .agents/validate.sh` green; README and `AGENTS.md` describe the touch model.
@@ -0,0 +1,81 @@
# Task 01 — Touch pad: the capture is the finger
**Phase:** `05_mobile_controller` · **Source:** `TODO.md:5` — "Needs to be mobile compatible
— … **Mobile trackpad for touch trackpad.**"
**Story:** `n/a`
## Objective
`#mPad` becomes a touch pad: press and hold it and the target's pointer moves with your
finger, drag two fingers to scroll, and the moment the touch ends the movement stops and every
button lets go. Pointer Lock does not exist for touch, so the capture is the touch itself.
## Work
1. `src/web_page.h`, the mouse block:
- `touchstart` on `#mPad`: requires `armed` and `mouseAvailable()` (`s.mouse === 1`) — the
same two gates as `takeMouse()`, and the same `E10`/`E11` refusal path if the board
disagrees. Sets `mTouchActive`, records the anchor point, and takes **no** button.
- `touchmove`: compute the delta from the previous sample, feed the existing movement
pipeline (the code behind `flushMouse()`) so the ±127 cap and the multi-frame split are
the ones already tested — do not write a second splitter. Coalesce per report the way
`mousemove` does, and drop movement when there is no link rather than queueing it
(the page's existing rule: a queue of pointer motion delivered after you stopped moving
is the pointer version of a stuck key).
- Two-touch drag → wheel/pan through the same quantiser the desktop `wheel` handler uses
(notches with the remainder carried), not movement. `touchmove` with two fingers must not
also emit dx/dy.
- `touchend`/`touchcancel`, and `blur`/`visibilitychange`/disarm/link loss: end the capture,
`dropMouse()` (which releases every button through the funnel), reset the anchor. `touchcancel`
is the browser taking the gesture away — incoming call, notification shade, a palm — and it
must land exactly like lifting the finger.
- A visible button row beside the pad: **Left / Right / Middle**, held while pressed
(`touchstart` down, `touchend` up), carrying the existing `MOUSE_BTN` bits. Tap on the pad
itself never clicks. The row is 44 px and shows which bits are down, so "left is held" is
a thing you can see before you ask what is stuck.
- The pad's label changes with state (`not captured` / `captured — move to move, lift to
release`) since a phone has no cursor to indicate either.
2. `refreshMouseUi()` / `paintMouse()` / `mouseAvailable()` — teach them the touch state so
the pills say what is true. `mCaptured` (Pointer Lock) and `mTouchActive` are different
states that must render distinctly and never both claim the pointer.
3. Keep the desktop path byte-for-byte identical: `mousemove`, the Pointer Lock request,
`Esc`, `wheel` on a mouse — phase 04's assertions and today's `test_page.js` mouse checks
must stay green without edit.
4. `tests/test_page.js` — extend the shim for multi-point touch and add: capture requires
armed + `mouse:1`; a drag produces one `M=` per flush with no idle frames; a large drag
splits with the net displacement exact; two fingers produce wheel/pan and no dx/dy;
`touchend` and `touchcancel` each release every button and stop movement; button-row bits
equal `MOUSE_BTN`; every emitted frame parses with the firmware's own parser.
5. `README.md` — Controller page: the pad row of the element table describes the touch
behaviour; "What capture does and does not do" gains the touch sentence (this is
elaborated properly in task 04 — here, only what this task changes).
- ASSUMPTION: one finger moves, two fingers scroll, and clicking is an explicit held button
rather than a tap. Tap-to-click on a pad whose whole job is movement produces clicks the
operator did not ask for, and on a remote keyboard a click the target did not expect is the
worst class of surprise.
- ASSUMPTION: no "tap-and-hold to drag" (holding a button while moving). It needs a second
simultaneous gesture and any mis-timing strands a button on the target; the button row gives
the same capability with visible state.
- ASSUMPTION: scale of finger-delta to HID delta is 1:1 (one CSS pixel → one report unit) at
first. If it feels slow on hardware, the tuning knob goes in `config.h`-style naming on the
page side with the measured value in the comment — the board must not invent a pointer speed
of its own.
## Testing & Quality
- `node tests/test_page.js`: the new touch checks pass and the existing mouse checks are
unmodified (prove it: the diff of that test file adds, it does not change, mouse assertions).
- `bash .agents/validate.sh` green; `test_proto.cpp`, `test_mouse.cpp`, `test_output.cpp`
untouched.
- Device (best effort, one attempt): a phone on the board's AP — movement lands, lift stops
it, and `watch_target.py` shows no stuck button across 20 press/release cycles.
## Completion Criteria
- [ ] Press-and-hold on the pad moves the target's pointer; lifting stops it and releases
every button (observed, or recorded `skipped (no device)` with the commands).
- [ ] Two-finger drag scrolls and never moves the pointer.
- [ ] Left/Right/Middle are held-while-pressed and visible; tap on the pad clicks nothing.
- [ ] Desktop Pointer Lock path unchanged and still covered by the pre-existing checks.
- [ ] Full test suite green.
@@ -0,0 +1,94 @@
# Task 02 — Arm on a phone and the keyboard appears
**Phase:** `05_mobile_controller` · **Source:** `TODO.md:5` — "Needs to be mobile compatible -
**click arm and then mobile keyboard pops up.**"
**Story:** `n/a`
## Objective
Pressing ARM on a touch device focuses a forwarding field so the soft keyboard opens and stays
open, and everything typed into it becomes keystrokes on the target: printable characters via
one `P=0:<hex>` frame each, named keys as a press/release pair — with nothing held in the page
any longer than the frame that carries it.
## Work
1. `src/web_page.h`:
- A forwarding field: `<input id="keyfield" type="text" autocomplete="off" autocapitalize="none"
autocorrect="off" spellcheck="false" enterkeyhint="done" inputmode="text">`, visible while
armed on a coarse pointer (a visible field is honest: you can see the caret is in it),
collapsed to a one-line "keyboard" strip otherwise.
- `$("arm").addEventListener` → after arming is confirmed by the board (`setArmed(true)`),
`focus()` the field when `matchMedia('(pointer: coarse)').matches` — that is the
"keyboard pops up" the TODO asks for. Disarming blurs it and clears it.
- Read it with `beforeinput`, not `keydown`: soft keyboards are unreliable about `keydown`
with a usable `key`/`code` (Gboard and iOS both deliver `beforeinput` with `inputType`),
and `beforeinput` is the one event that says what the keyboard actually inserted:
- `insertText` → for each character, `send("P=0:" + hex(charBytes))` — the board's own
`strokeFor()` does the mapping and the pacing, so **no second character table on the
page** and no `Shift` guess on the laptop side;
- `deleteContentBackward` → Backspace; `insertLineBreak`/`insertParagraph` → Enter;
`deleteWordBackward` → a few Backspaces, documented, not a clever trick;
- then `field.value = ""` immediately. The field is a courier, not a buffer: invariant 4
says nothing typed may outlive the keystroke, and a DOM node full of the last paragraph
you sent is exactly that.
- Named keys (`keydown`, where `ev.key` is a name and not a character): Enter, Tab,
Backspace, Delete, Escape, the four arrows, Home/End/PageUp/PageDown — a small
`KEYNAME_TO_USAGE` map (≤20 entries, usage ids from `CODE_TO_USAGE`), sending
`S=<mods>:<usage>` then `S=<mods>:` (release) — a press/release pair, not an event, and it
self-heals if one of the two is lost.
- `inPageField()`: the passphrase box, the paste textarea and the log stay exempt from
forwarding; `#keyfield` is the **deliberate exception** and the comment must say so —
otherwise this field is "fixed" back into the exemption in six months and the phone stops
working.
- `Escape` from the soft keyboard is forwarded to the target (there is no Pointer Lock to
free on touch). While a desktop Pointer Lock is held, `Esc` stays the browser's — the
existing rule is not relaxed, it simply does not apply to a device that never took the lock.
- The field is never `preventDefault`ed (a soft keyboard that is cancelled does not type at
all), which is also why the desktop shortcut-swallowing logic must stay scoped to
non-coarse input.
2. `src/config.h` / page constants — nothing new for `P=`; assert the existing
`TEXT_FRAME_MAX` still bounds a single character trivially and that a rapid burst of
characters produces one frame each and is refused with `E8` (log it) if the board's queue
is full, rather than the page buffering them.
3. `tests/test_page.js` — extend the shim with `beforeinput`/`inputType` and a focusable
element, then assert: arming focuses the field on coarse pointers only; each inserted
character produces exactly one `P=0:` frame with the right hex; `field.value` is empty after
each event (nothing accumulates); Backspace/Enter produce the press/release pair; the
passphrase field and paste box still swallow input; disarm blurs and clears the field; a
`P=0:` frame the firmware cannot express yields the `E9` log line and nothing else is typed.
4. `README.md` — Controller page: the keyboard field row and the sentence that characters are
typed by the board's map, which is why the phone needs no layout of its own — and the
consequence that a non-US target still means non-US output (`E9` rather than a guess).
- ASSUMPTION: `beforeinput` as the primary source with `keydown` as the fallback for named
keys. Reading `value` on `input` instead would lose the distinction between "inserted" and
"autocompleted", and iOS predictive replacement arrives as `insertReplacementText` — which
this task deliberately **refuses** (log it, type nothing) because accepting a completion the
operator did not press is the same family of failure as silently sanitising a paste.
- ASSUMPTION: emoji, CJK and anything else `strokeFor()` cannot express produce `E9` per
character. That is the reject-never-guess rule arriving on the phone, and the log line says
what happened so it does not look like the board dropped keys.
- ASSUMPTION: the field is visible while armed on a phone. A hidden focused input that pops a
keyboard with no on-screen indication of *what* is receiving the keys is exactly the kind of
invisible state this project keeps having to fix.
## Testing & Quality
- `node tests/test_page.js` with the new focus/`beforeinput` coverage; every frame the page
emits still parsed by the firmware's parser; existing paste assertions unchanged (the paste
path and the keyboard path both use `P=` and must not interfere — assert that a keyboard
frame during a paste run does not disturb the paste's flow control).
- `bash .agents/validate.sh` green; page byte growth recorded against the gate's count.
- Device (best effort, one attempt): phone, arm, keyboard appears, type `relay` and
`watch_target.py` on the target shows `r e l a y` in order with no duplicates; type an emoji →
one `E9` and nothing on the target.
## Completion Criteria
- [ ] ARM on a phone opens the soft keyboard without a second tap.
- [ ] Typed characters land on the target, one frame each, in order, with no duplicates and no
accumulation in the field.
- [ ] Backspace and Enter work as press/release pairs; autocomplete text is refused, not typed.
- [ ] The passphrase box and paste box still swallow input while armed.
- [ ] Full test suite green; `src/text.h` untouched (the map did not move).
@@ -0,0 +1,92 @@
# Task 03 — Modifiers you can see: the latch row, and `Q=` to carry them
**Phase:** `05_mobile_controller` · **Source:** `TODO.md:5` — "Needs to be mobile compatible…"
(a soft keyboard sends no modifier keys, so without this the phone can type letters and
nothing else — no Ctrl+C, no Alt+Tab, no Ctrl+L)
**Story:** `n/a`
## Objective
An on-screen Ctrl / Alt / Shift / Super latch row whose state is visible and restated on every
frame, and one new frame — `Q=<mods>:<hex>` — that says "type these characters with these
modifiers held", so the **board's** character map stays the only character map. This is a
wire-protocol change and therefore four edits plus tests, exactly as `AGENTS.md` demands.
## Work
1. `src/proto.h` — `Cmd::TypedWithMods` for `Q=<mods>:<hex>` and a documented line in the
header comment's direction table:
`c -> d Q=<mods>:<hex> type these characters with <mods> held in addition to whatever
each character's own stroke needs`. Same bounds as `P=` (`kTextFrameMax` characters per
frame, cap on the hex length), same all-or-nothing acceptance, same `E9` for a character the
map cannot express.
2. `src/text.h` — the stroke path gains the modifier overlay: for each character,
`strokeFor()` + `OR` the frame's mods, then the same pacing and the same report sequence.
Two hard refusals, both `E6`/`E9` per the existing convention and **never** silently
clipped (invariant 5):
- **`Ctrl+Alt+Del` cannot be composed this way.** If the overlay plus the stroke's own mods
produce Ctrl+Alt and the usage is `Del` — or the usage set ever contains `0x4c` with
Ctrl+Alt — refuse the whole frame. Invariant 6 says CAD is fixed on the device and never
assembled from wire bytes; `Q=` must not become the door around that rule. Same for the
VT-switch chords the README documents as dangerous on a Linux VT.
- The overlay never releases a key: `Q=` types and lets go, it does not latch state.
3. `src/main.cpp` / `src/output.h` — wire the new command into the same typed-run machinery
`P=` uses (one run, one funnel: `R`, disarm, disconnect cancel it). If the queue is full the
frame is refused with `E8` like `P=`, and `typing` counts it.
4. `src/web_page.h`:
- A latch row: `Ctrl Alt Shift Super`, sticky toggles, using the existing `MOD_CODE` bits,
lit when active, `44px`, grouped with the existing chord buttons so there is one place
for "keys the phone does not have".
- Printable characters: `P=0:<hex>` when no latch is down, `Q=<mods>:<hex>` when one is.
The latch is cleared by tapping it again, by disarm, by blur/visibility change and by link
loss — never left set between sessions (a sticky Ctrl that silently modifies the next six
characters is this project's stuck-key bug with a new face).
- Named keys keep using `S=<mods>:<usage>` (it already carries the modifier byte) and
include the latches in that byte.
- A "held now" echo of the latches in the existing `#held` readout, so what the board is
being told is on screen.
5. `tools/relayctl.py` — `type --with-mods ctrl+alt TEXT` (or the equivalent subcommand shape
the tool already uses) building `Q=` byte-for-byte like the page; the tool's `MOUSE_BUTTONS`
/ modifier name tables extended and pinned against the page.
6. `README.md` — the **Wire protocol** table gains `Q=` with its modifiers column and refusal
rules; the error-code list gets whatever `Q=` refuses with; the Controller page element
table gains the latch row; **Layout caveats** gains one sentence: the phone borrows the
board's map, so the same US-layout caveat applies to it.
- ASSUMPTION: `Q=` rather than making the page resolve characters to usages. A second
character→usage table would have to be tested against `strokeFor()` forever — the
"three tables that must agree" problem `AGENTS.md` exists to prevent, self-inflicted. One
frame with a modifier byte keeps the map in `text.h`, which is already walked across the
whole printable range by `test_text.cpp`.
- ASSUMPTION: sticky (toggle) latches, not press-and-hold. On a phone there is no second hand
free to hold Ctrl while tapping, and a latch whose state is lit and restated is verifiable
in a way a half-held key is not.
- ASSUMPTION: `Q=` inherits `P=`'s pacing (`0` = the board's default). A phone cannot type
faster than the board's pace anyway, and letting the controller choose the pace for a
latched character buys nothing but a new way to mis-time a target.
## Testing & Quality
- `tests/test_text.cpp`: overlay arithmetic (no mods, Ctrl only, Ctrl+Shift where the stroke
already needs Shift), refusal cases including the composed-CAD attempt and the VT chords, the
exact report sequence for `Q=05:61` (Ctrl+Alt+a) asserted byte by byte, cancellation of a `Q=`
run by `R`/disarm, buffer-full behaviour, and the `Q=` render/parse round trip at the largest
legal frame.
- `tests/test_proto.cpp`: `Q=` parses, malformed variants (`Q=`, `Q=zz:`, `Q=05`, over-long
hex) are `Cmd::Bad`, and the existing `P=`/`S=`/`Y=` assertions unchanged.
- `tests/test_relayctl.py`: the tool's `Q=` frames are byte-identical to the page's, parsed by
the firmware's own parser; modifier-name tables agree across page, tool and `config.h`.
- `tests/test_page.js`: latch toggles produce the right byte; a character with a latch produces
one `Q=` frame; disarm/blur clears the latch; the chord buttons still send their
`data-chord` bytes unchanged (`test_proto.cpp`'s label-vs-bytes assertion must stay green).
- Coverage **>90%** on the parser and stroke-overlay changes.
## Completion Criteria
- [ ] `Q=` present and identical in `proto.h`, `web_page.h`, `relayctl.py` and the README table.
- [ ] Ctrl+C on a phone lands as Ctrl+C on the target (observed with `watch_target.py` or
recorded `skipped (no device)` with the commands).
- [ ] `Q=` cannot compose Ctrl+Alt+Del or the VT-switch chords — proven by a test that fails
when the refusal is removed (show both runs), and the reasoning is in `text.h`.
- [ ] Latches clear on disarm, blur and link loss, and are shown in the held-keys readout.
- [ ] Full test suite green, including the sanitiser run (this task changes buffer arithmetic).
@@ -0,0 +1,78 @@
# Task 04 — Make the touch model legible, and prove the tables
**Phase:** `05_mobile_controller` · **Source:** `TODO.md:5` — "Needs to be mobile compatible -
click arm and then mobile keyboard pops up. Mobile trackpad for touch trackpad." (the half
that keeps it working after this chat is gone)
**Story:** `n/a`
## Objective
The touch equivalences are written down where they will be read before someone "improves"
them, the one new table this phase introduces is pinned against what it maps to, and the phone
observations are recorded as observations.
## Work
1. `tests/test_page.js` — the last touch assertions, added in the suite's existing voice:
every frame the page emits in touch mode parses with the firmware's own parser; the pad's
frames never exceed one signed byte in either axis; `touchcancel` is treated exactly like
`touchend` (a notification shade stealing the gesture must not leave a button down); the
pad capture ending does **not** disarm the keyboard (release and disarm are different acts,
already true for `R`); the mouse capture and the keyboard stay independent — taking the pad
does not arm, and disarming drops the pad.
2. `tests/test_relayctl.py` — the named-key table (`KEYNAME_TO_USAGE`, the ≤20-entry page map
for Enter/Tab/arrows/…) pinned: every entry's usage id checked against the value the
firmware's parser and `CODE_TO_USAGE` agree on, and the set of names checked against a list
in the test — the table decodes something the HID spec defined, so the *table* is tested,
not anybody's confidence in it (`AGENTS.md`, the `REL_*` lesson: codes guessed from field
order were wrong while the feature appeared to work).
3. `README.md`:
- **Controller page**: a short "On a phone" block — arm focuses the forwarding field, the
latch row, the pad's press-and-hold, the button row, `E9` on emoji.
- **Safety model**: the touch equivalences written as rules, not as UI description —
finger-down is the capture and finger-up ends it; the latch is visible and cleared by every
fail-safe; `Esc` cannot be forwarded from a soft keyboard because the phone's panic path is
the Release button, which is 44 px and on screen. Add the sentence that says *why* the
touch capture is stronger than Pointer Lock rather than weaker.
- **Security model**: the trust-boundary row for "Whoever holds the phone" — pointer only
while armed and while the finger is down.
- **Wire protocol**: `Q=` already landed in task 03; here the surrounding prose (the
`<mods>` explanation, the refusal list) is brought in line with what the code actually does.
- **Tests** table: the new counts, and the touch/table pins named the way the mouse pins are
named. Intro total updated from real output.
- **Measured behaviour**: the phone numbers — keyboard latency per character through `P=`,
pad deltas per cm of travel, anything `watch_target.py` showed. Numbers, with the command.
- **Backlog**: anything this phase discovered and deliberately did not do (e.g. gesture-free
right-click on a tablet with a hover-capable pointer, a `beforeinput` path for
autocompletion, a non-US soft keyboard), so the discovery is not lost.
4. `AGENTS.md` — the "Three tables that must agree" table gains the one new row this phase
created (`KEYNAME_TO_USAGE` in `src/web_page.h`, pinned by `tests/test_relayctl.py`), in the
existing format. Nothing else: the file is the operating manual, not a changelog.
5. `.agents/HW_VERIFICATION.md` — the phone rows: keyboard pops on ARM, characters in order,
latch combo on the target, pad move/stop/no stuck button, `E9` on emoji — each `ok` with the
command and what was seen, or `skipped (no device)` with the command to run.
6. `.agents/PLAN.md` — no edits (it is locked); if this phase revealed that an anchor was
wrong, that goes in the phase report and the final pass raises it, not into the file.
- ASSUMPTION: the named-key map stays page-only (the terminal tool does not need a soft
keyboard). The pin exists so the page's map cannot drift from the HID ids it names, not to
synchronise two copies.
## Testing & Quality
- Full gate green: suites, sanitiser run, `bridge` + `console` builds.
- Each new pin demonstrated to fail on an injected break (a wrong usage id in
`KEYNAME_TO_USAGE`, a `touchcancel` handler removed), then reverted.
- README internally consistent: intro counts, Tests table, Controller page, Safety model and
Backlog all describe the same page the board serves (`curl -sk … | wc -c` agrees with the
gate's number).
## Completion Criteria
- [ ] `KEYNAME_TO_USAGE` pinned by name **and** by usage id, with the failure demonstration.
- [ ] Touch safety rules in the README as rules, including why finger-up is stronger than Esc.
- [ ] `AGENTS.md` table row added, nothing else in that file changed.
- [ ] Phone observations recorded honestly (`ok` or `skipped (no device)` + command) — no claim
in that file that nobody made.
- [ ] `bash .agents/validate.sh` green; full test suite green; no regression in phases 01–04
(TLS still terminates, takeover still disarms, layout still single-column).
+2
View File
@@ -19,3 +19,5 @@ __pycache__/
token
*.capture
*.log
.agents/phase-sessions/
.agents/pipeline.log
+59 -12
View File
@@ -10,7 +10,17 @@ 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").
## Phased execution
This repository runs work through `.agents/phases/`:
1. Always read `.agents/PLAN.md` first to understand the project context and goals.
2. Follow the phased execution protocol in `.agents/phases/`.
3. Never modify `.agents/PLAN.md` or any files in `.agents/phases/complete/`.
4. If you need to update any file in `.agents/phases/todo/`, you must ask the user for permission first.
5. Strictly adhere to the **LOCKED DECISIONS** listed in `.agents/PLAN.md`.
## The invariants
@@ -20,9 +30,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 +68,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 +87,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 +115,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 +166,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.
+304 -54
View File
@@ -1,19 +1,22 @@
# keyrelay — a WiFi-remote USB keyboard on the M5Stack AtomS3 Lite
# keyrelay — a WiFi-remote USB keyboard and mouse on the M5Stack AtomS3 Lite
Type on your laptop; the keystrokes land on a *different* machine, over the air, as
genuine USB HID input — including Enter and Ctrl+Alt+Del. The board enumerates on the
target as a plain boot-protocol keyboard, so the target needs no software, no driver
and no network. The controller is a web page served by the board itself.
genuine USB HID input — including Enter and Ctrl+Alt+Del. Capture the pointer and your
mouse moves the other machine's cursor. The board enumerates on the target as a
boot-protocol keyboard with a relative mouse on the same HID interface, so the target
needs no software, no driver and no network. The controller is a web page served by the
board itself.
```
your laptop target machine
┌──────────────────┐ ┌──────────────────┐
│ browser tab │ WiFi │ │
│ keydown/keyup ├────WebSocket──►│ AtomS3 Lite │
│ of the page │ (token) │ keyrelay │
└──────────────────┘ │ │
└────────┬─────────┘
│ USB HID boot keyboard
│ mousemove │ (token) │ keyrelay │
│ (Pointer Lock) │ │ │
└──────────────────┘ └────────┬─────────┘
│ USB HID: boot keyboard
│ + relative mouse
▼
whatever has keyboard focus:
a login prompt, a BIOS setup,
@@ -21,21 +24,33 @@ and no network. The controller is a web page served by the board itself.
```
```
disarmed LED dark nothing you type goes anywhere
disarmed LED dark nothing you type or move goes anywhere
armed LED solid red every keystroke in the tab goes to the target
```
The mouse is opt-in twice over: the bridge has to be armed *and* the page has to be
handed the pointer, which the browser only does when you click. `Esc` gives it back —
that one is the browser's own guarantee, not something this firmware has to honour.
* **Nothing types until you arm it.** Boot, reload, reconnect, focus loss, socket loss
and 8 s of silence all disarm, and disarming sends "all keys up".
and 8 s of silence all disarm, and disarming sends "all keys up" **and "all mouse
buttons up"** — to the target, as reports, not just to the board's own bookkeeping.
* **Arming needs a passphrase** (or a thumb on the board's button). The LED is readable
from across the room, so the state is not a secret between you and the screen.
* **The mouse is captured, not grabbed.** The page asks the browser for Pointer Lock —
the same thing a web game asks for — so the cursor cannot escape the tab while it is
being forwarded, and `Esc` always returns it. Losing the lock, the tab or the link
releases every button. Movement is relative: the board says how far, never where, so
there is no position for the two machines to disagree about.
* **The keystrokes are not stored, logged or replayable.** The board keeps counters and
the live key state, and nothing else — no sequence storage, no macro, no script, nothing
it can type later. Pasted text is typed from RAM and forgotten; the serial console has no
command that types anything at all. It is a keyboard, not a payload dropper.
* **955 checks** cover the key-state machine, the wire protocol, the text→keystroke map,
SHA-1/base64, the WebSocket codec and the controller page's own script before anything is
flashed, and the target-side verifier proves what actually arrived.
the live key and button state, and nothing else — no sequence storage, no macro, no
script, nothing it can type later, no gesture it can replay. Pasted text is typed from
RAM and forgotten; the serial console has no command that types or clicks anything at
all. It is a keyboard and a mouse, not a payload dropper.
* **3406 checks** cover the key-state machine, the mouse state, the report queues and the
release funnel, the wire protocol, the text→keystroke map, SHA-1/base64, the WebSocket
codec, the controller page's own script, the CLI's frames and the target-side verifier
itself — all before anything is flashed.
---
@@ -73,13 +88,21 @@ armed LED solid red every keystroke in the tab goes to the target
whose keyboard you would rather not wear out.
- Keeping an agent session live on a machine you are not typing into (that is the
sibling project [`../jiggler`](../jiggler), which is a simpler build of the same board).
- Anything where a KVM-over-IP would be right and you do not need video, mouse or
audio, and you have 8 dollars and a USB port to spare.
- Pointing at something on a machine you cannot reach — a BIOS menu item, a dialog in a
installer, a button in a kiosk app — with the cursor you can already see on that
machine's own monitor.
- Anything where a KVM-over-IP would be right and you do not need video or audio, and you
have 8 dollars and a USB port to spare.
**Bad fits — use something else**
- **You need the screen.** There is no video path. PiKVM or TinyPilot
do video + keyboard + mouse; this is keyboard only.
- **You need the screen.** There is no video path. You steer the target's cursor without
being able to see it, which is fine for a monitor you are looking at and hopeless for one
you are not. PiKVM or TinyPilot do video + keyboard + mouse.
- **You need to know where the cursor is.** Movement is relative — see
[Layout caveats](#layout-caveats). There is no absolute pointer, so "click the OK
button" is not a thing this can express, deliberately: an absolute pointer would give
the board a position to be wrong about.
- **You want unattended automation.** Deliberately impossible here (see above). If you
want scripted input on a machine you own, `xdotool`/`ydotool`/`uinput` on that machine
is the honest tool, and a Rubber Ducky is what this design is *not*.
@@ -105,6 +128,16 @@ focus loss, a silent controller or a detached target cancels the rest of a run m
What stays absent is the part that would change what this device *is* — something the
board remembers, assembles on its own, or fires without a human in the loop.
**The mouse is the same line, drawn in the same place.** There is no auto-click, no
double-click macro, no drag recorded and replayed, no "move to (x, y)", no idle wiggle
that keeps a screen unlocked, and nothing that moves the pointer because a timer said so.
`relayctl mouse click` and `mouse drag` exist because verifying that the button bits
arrive is a thing the operator has to be able to do, and each needs `--yes`; a movement
(`mouse move`) does not, because moving a cursor is reversible by definition. If nobody is
moving a mouse, nothing moves. The pointer is captured with the browser's own Pointer Lock,
so `Esc` — which the browser keeps for itself and never forwards — is an escape hatch that
does not depend on the firmware, the network or this page being correct.
Two consequences hold either way. The CLI's ordinary commands still press one key per
invocation, which is all verification needs; scripting 30 of them is possible and would
still be 30 deliberate actions with the LED on. And a captured board is a keyboard, not a
@@ -120,7 +153,7 @@ connector it already uses.
```bash
cd keyrelay
tests/run_tests.sh # 955 checks, no hardware needed
tests/run_tests.sh # 3406 checks, no hardware needed
pio run -e bridge # ~1.1 MB image, ~35% of the 3 MB app slot
python3 tools/to_bootloader.py # 1200-baud touch -> ROM download mode
pio run -e bridge -t upload # flash
@@ -194,34 +227,63 @@ Enforced in this order, each independent of the others:
3. **Armed is displayed on the device.** Solid red LED while armed and attached. If the
target is not attached the LED blinks red instead, because "armed" without a target
means keystrokes are being dropped, and that deserves to look wrong.
4. **Silence disarms.** No inbound frame for `kNoClientTimeoutMs` (8 s) → all keys up,
disarmed. Covers the laptop sleeping, the browser being killed, the WiFi dying, the
network going down — every way a controller can vanish without saying goodbye. The
browser sends a keepalive every 2 s and answers the board's own 15 s WebSocket pings,
4. **Silence disarms.** No inbound frame for `kNoClientTimeoutMs` (8 s) → all keys up and
all buttons up, disarmed. Covers the laptop sleeping, the browser being killed, the WiFi
dying, the network going down — every way a controller can vanish without saying goodbye.
The browser sends a keepalive every 2 s and answers the board's own 15 s WebSocket pings,
so a live tab holds it open indefinitely; `relayctl` does the same while it types. This
is checked in `loop()` against `ControlServer::lastInboundMs()`, and it is checked
*before* the typewriter is driven: it is the one rule that stops a run of pasted text
when the controller dies without a parting packet.
is checked in `loop()`, and it is checked *before* the typewriter is driven: it is the
one rule that stops a run of pasted text when the controller dies without a parting
packet.
**What counts as silence, and what does not.** The clock is moved only by the
*authenticated controller*: by its authentication, and by bytes on its WebSocket. An
accepted TCP connection is not a live operator, and neither is anything reading the
board — a monitoring script, a health check, a second tab pointed at `/status`, or
`relayctl listen` used to hold this timer at zero and keep the bridge armed
indefinitely with the last key still pressed (bug 10). A board armed by its own button
with no controller at all is deliberately left running: it has no controller to go
quiet. This backstop is not gated on the link still existing, and must never be — see
[Ten bugs](#ten-bugs-this-verification-caught-all-fixed).
5. **Focus loss releases every key.** The tab blurring or being hidden sends "all keys
up" and disarms (unless you tick *stay armed when this tab loses focus*, which still
releases the keys — you cannot keep a held Ctrl alive by tabbing away).
6. **The target taking the bus away disarms.** USB detach or host suspend clears the
report queue, releases the keys and disarms; `ARDUINO_USB_*` events drive it.
7. **A full queue is a stop, not a drop.** 48 queued state changes without a drain means
something is outrunning the USB path, so the bridge releases everything, reports `E7`
and disarms rather than typing out of step with what the operator sees.
7. **A full queue is a stop, not a drop.** 48 queued key states or 32 queued mouse
reports without a drain means something is outrunning the USB path, so the bridge
releases everything, reports `E7` and disarms rather than typing out of step with what
the operator sees. The queue is not a buffer to absorb loss: 255 queued reports once
sat through a disconnect, which is ~2 s of keystrokes delivered after the operator had
stopped asking.
8. **State, not events.** The controller restates the *whole* key state on every event
(see [Wire protocol](#wire-protocol)), so a lost frame cannot leave a modifier stuck
on the target. This is the single most important design decision in the project: every
"stuck Ctrl" horror story in HID injection tooling is an event-protocol bug.
"stuck Ctrl" horror story in HID injection tooling is an event-protocol bug. Mouse
buttons follow the same rule — every `M=` frame carries the complete button byte — and
movement is the one thing that is *not* state because a delta has no state to lose: a
dropped movement frame moves the cursor slightly less, never leaves anything stuck.
9. **A paste cancels; it never pauses.** `R` means "stop typing", not "hold on". The run
is dropped on release-all, disarm, target detach or suspend, queue overflow, focus
loss and silence — every fail-safe above routes through the same `releaseEverything()`
that clears the queue, raises the keys and discards what was still to type. The board
never holds a run that outlives the decision to send it.
that clears the queue, raises the keys, lets go of the mouse buttons and discards what
was still to type. The board never holds a run that outlives the decision to send it.
10. **Losing the pointer releases the pointer.** Pointer Lock going away, the tab
blurring, the tab being hidden, disarm, `R`, silence and target detach all send an
all-buttons-up mouse report. *stay armed when this tab loses focus* exempts the
keyboard only — a held mouse button is never something to keep across a tab switch.
The page stops capturing rather than queueing movement to send later.
The physical kill switch is the button: press it and the keys come up, no network, no
passphrase, no software required.
The physical kill switch is the button: press it and the keys come up and the buttons come
up, no network, no passphrase, no software required. `Esc` is a second one for the pointer
specifically, and it is the browser's, not ours.
**One release funnel, and it has to actually send.** Every item above ends in
`releaseEverything()`, which now queues an all-keys-up *and* an all-buttons-up report to
the target rather than only clearing the board's own idea of what is down. That is the
fixed ninth bug: a held key stayed held on the target after silence-disarm, target loss or
a reload, because the report that would have released it was never queued (see
[Measured behaviour](#ten-bugs-this-verification-caught-all-fixed)).
---
@@ -229,8 +291,9 @@ passphrase, no software required.
| actor | can | cannot |
|---|---|---|
| Anyone on the target (its own OS) | see a Logitech G815 keyboard in its device list; read the CDC console; type on itself | arm or drive the bridge remotely, detect that a second controller exists |
| Anyone on the board's network | fetch the page; open a socket; learn that a keyrelay exists | arm, send a key, send a chord, type a run of text, read `/status` — all need the passphrase |
| Anyone on the target (its own OS) | see a Logitech G815 keyboard and a pointer in its device list; read the CDC console; type on itself | arm or drive the bridge remotely, detect that a second controller exists |
| Anyone on the board's network | fetch the page; open a socket; learn that a keyrelay exists | arm, send a key, send a chord, type a run of text, move the pointer, click, read `/status` — all need the passphrase, and all but `/status` need the bridge armed |
| Whoever holds the laptop's mouse | the browser tab's pointer, only after a click on **Capture pointer**, and only while armed | move the target's pointer without the LED being red, or keep moving it after `Esc` — Pointer Lock is revoked by the browser and cannot be retaken without another click |
| Someone guessing the passphrase | 5 attempts before a 30 s lockout during which the token is not evaluated at all | learn anything from the failure: same error, same close, no timing oracle (constant-time compare) |
| The owner of the WiFi | join the network, then everything above | — the network *is* the perimeter; see below |
| You, holding the board | arm/disarm (button), reconfigure or reflash (console), stop everything (unplug) | — |
@@ -260,6 +323,15 @@ gated by what it reveals — `/status` needs the passphrase, the page needs it b
arm or type. If that ever goes too far, the `ap off`/network config is the blunt instrument;
there is no per-client ACL.
**The mouse widens what a compromised controller can do, so it is gated the same twice
over.** Arm plus Pointer Lock. It is also the reason there is a `mouse off` switch: the
composite descriptor is fixed when USB starts, so turning the pointer interface off is a
reboot, not a setting change — and a board that advertises no pointer cannot be made to
move one. When a target selects HID **boot protocol** (some BIOS setups, some KVMs) the
mouse is switched off by itself, because a 5-byte mouse report on a boot-protocol
interface would be parsed as keyboard data: the keyboard keeps working, the pointer stops,
and `E11` says why rather than the command silently doing nothing.
**Identity.** The board reports itself as `046d:c33f` "Logitech Gaming Keyboard G815",
matching the sibling `jiggler` firmware so the two are indistinguishable from the host
side and `/dev/input/by-id` names stay stable across reflashes. This makes the device
@@ -312,6 +384,19 @@ by the compositor and never reach the page. Three answers, in order of preferenc
not close the tab, `Ctrl+R` will not reload it and F11 will not fullscreen it. It also
means you cannot type into the page's own fields while armed — disarm first.
**While the pointer is captured, `Esc` belongs to the browser.** That is the point: it
revokes Pointer Lock, the page sees `pointerlockchange`, and every button goes up. What it
cannot do is disarm the bridge — the keyboard is still armed and still typing, so the status
line says so and you can press `R` or the board's button. Nothing else the laptop's
compositor grabs takes the pointer away: `Super` and `Alt+Tab` are inert while locked,
which is another reason capture is the design and a plain `mousemove` relay would not be.
**The laptop's own pointer settings apply to what gets forwarded.** Acceleration,
`libinput Accel Speed` and a high pointer speed on the laptop mean the same hand movement
sends different deltas going out, and the target then accelerates them again. Set the
laptop's pointer speed to the middle and disable acceleration while steering something you
cannot see the response curve of; the target's speed is what it is.
---
## Layout caveats
@@ -326,6 +411,22 @@ This is identical to what a physical KVM does, and the fixes are the same:
literal; `tools/relayctl.py` carries the same table and
`tests/test_relayctl.py` fails if the two drift).
**The pointer is relative, on purpose.** The board reports a boot mouse: buttons, ±127 per
axis per report, wheel and horizontal pan. It does not know where the target's cursor is,
and the target will not say, so there is no "move to" and no way to desynchronise — the
same property that makes `S=` the right model for keys. Two consequences to know before you
depend on it:
- **Acceleration is applied twice**, once by the laptop's driver before the page sees the
movement and once by the target's driver after the board sends it. The deltas that leave
the page are the laptop's, not your hand's, and the cursor moves by the target's idea of
those. Fine for steering; not fine for a pixel-exact drag.
- **The target clamps at its screen edges**, and the board cannot see that happen. Moving
5000 px right from the far right of the screen does nothing, and the board has no way to
know. This is why `mouse move` exists as a *verifiable* primitive and no macro of
movements is ever stored: relative motion is only meaningful while a human is watching
the screen it lands on.
ISO keyboards: `IntlBackslash` (0x32) is the extra key next to Left Shift on ISO and
`Backslash` (0x31) is the ANSI one; both are in the table. `AltRight` is `AltGr` and
travels as its own modifier bit, which is what layout engines on the target expect.
@@ -350,7 +451,7 @@ invisible to every compiler and a typo there fails in a browser, with a target l
|---|---|
| big ARM/DISARM bar | the state must be unmissable; it only changes when the board confirms |
| red page border + red LED while armed | two independent indicators of the same fact |
| status pills: network / target / link / armed / caps-num | "will my keystrokes land, and where" |
| status pills: network / target / link / armed / caps-num / **mouse** | "will my keystrokes land, and where", and whether the pointer is yours right now |
| **Ctrl+Alt+Del** with two-press confirm | the OS eats the real one; on Linux it reboots |
| VT1–VT7, Alt+Tab, Win, PrtSc, Menu | the chords the laptop's compositor steals |
| **⇦ snap left / snap right ⇨ / ⛶ fullscreen** | Super+Left, Super+Right, Super+PageUp — KDE window management, sent by the board so your laptop does not answer it with its own windows |
@@ -358,12 +459,25 @@ invisible to every compiler and a typo there fails in a browser, with a target l
| *stay armed if this tab loses focus* | off by default; for the one workflow that needs it |
| **Send text** box, its button and its *stop typing* | paste something and have it typed as keystrokes; the button says how many characters, refuses to be armed-into accidentally, and is the only path text takes |
| *drop characters I cannot type* | off by default: a paste that quietly loses a character is worse than one that refuses |
| held-keys readout, keys/chords/text/chords counters | so "it is typing" is a fact you can see |
| **Capture pointer / Release pointer** and the mouse pad | the pointer half of the bridge. Capture asks the browser for Pointer Lock, so the cursor cannot escape the tab while forwarded; the pad says what a relative mouse means before you discover it mid-drag |
| **Release buttons** under the pad | panic for the pointer, separate from the keyboard's because the two fail independently — held-button-then-lost-link is the mouse's stuck-key, and it is the same one release funnel |
| held-keys readout and the mouse line under it, keys/chords/text/chords/mouse counters | so "it is typing" and "it is moving" are facts you can see; the mouse line names the buttons held and the button byte, because "left is down" and "the target has 0x01" are different claims and only the second one is on the wire |
| log, *log each key sent*, **clear** | the board's errors verbatim plus one line per key pressed — never one per release, never a blank line, and a repeating line is counted rather than stacked |
The caps/num indicators are read *from the target* (its HID output reports drive them),
not guessed — a caps-lock desync between controller and target is a real failure mode of
remote keyboards and this design cannot have it.
remote keyboards and this design cannot have it. The mouse counters are read from the board
for the same reason: `mframes` counts the reports the board accepted, which is the number
`watch_target.py` on the target can be compared against.
**What capture does and does not do.** The pointer is captured only while the bridge is
armed, and only after a click; `Esc`, blur, a hidden tab, disarm, `R` and the board
reporting it cannot do mouse reports (boot protocol, or `mouse off`) all end it, each
releasing the buttons. The page never accumulates movement to send later: if the link is
down, movement is dropped, because a queue of pointer motion delivered after you stopped
moving is the pointer version of a stuck key. `mouse frames 0` in the status line while you
are moving means the frames are not getting there — that is a fault to look at, not noise,
so it is shown rather than smoothed.
---
@@ -381,7 +495,8 @@ and impossible for one malformed field to desynchronise what follows it.
| c → d | `C` | Ctrl+Alt+Del (fixed in firmware, not read off the wire) |
| c → d | `Y=<mods>:<u>,<u>…` | one synthetic chord, `kChordHoldMs` (40 ms) |
| c → d | `P=<ms>:<hex>` | type pasted text, `<ms>` ms per character (0 = board default) |
| c → d | `R` | release every key, stay armed — **and drop a text run in progress** |
| c → d | `M=<btn>:<dx>:<dy>:<wheel>:<pan>` | **the complete mouse button state** plus this report's relative movement |
| c → d | `R` | release every key *and every mouse button*, stay armed — **and drop a text run in progress** |
| c → d | `K` | keepalive |
| d → c | `OK` | accepted |
| d → c | `E<code>:<text>` | error (codes below) |
@@ -396,6 +511,21 @@ release are two frames and therefore two HID reports, so a fast tap cannot vanis
identical consecutive reports collapse, which is what keeps the queue short. A single
message resynchronises the target completely, which is why no key can get stuck.
**`M=` restates the buttons and carries one report's movement.** `<btn>` is the button byte
as two hex digits — `01` left, `02` right, `04` middle, `08` back, `10` forward, and any
combination — and is the *whole* button state every time, exactly like `S=`. A frame that
lifts one button restates the ones still held; there is no button-up event to lose, so no
button can get stuck on the target. The four signed decimal fields are this report's
movement, each `±127` (`kMouseDeltaMax`, the boot-mouse byte), and all five fields are
required — `M=01` is `E6`, not "left, no movement", because a controller that forgot three
fields is a controller whose next frame is also wrong. Movement is the one field that is
not state: losing a frame moves the cursor slightly less and leaves nothing behind.
The page chunks movement to 127 per axis and keeps the diagonal shape (a 200×200 move
leaves as 127,127 then 73,73, never as two axis-parallel legs), so a fast flick cannot turn
into a silently clamped one, and it flushes the pending movement under the *old* button
state before restating a new one — that ordering is what makes a drag a drag.
**`P=` is text, typed by the board.** `<hex>` is the bytes to type, so one frame carries
at most `kTextFrameMax` (120) characters and a longer paste is several frames; `<ms>` is
the pace asked for, and the board keeps it — expansion and timing live on the USB side
@@ -410,7 +540,13 @@ not a guess at timing.
Error codes: `1` not authenticated · `2` locked out · `3` another controller holds the
bridge · `4` not armed · `5` no USB host attached · `6` bad message · `7` queue overflow
(released and disarmed) · `8` text buffer full · `9` a character with no US-ANSI keystroke.
(released and disarmed) · `8` text buffer full · `9` a character with no US-ANSI keystroke ·
`10` the mouse interface is off on this board · `11` the target chose HID boot protocol, so
no mouse report can be sent safely.
`10` and `11` are two digits, which changed the encoder: `formatError` used to emit
`E` + `code % 10` and would have rendered both of them as `E0:` — a refusal that names
nothing. Codes now render in full, every single-digit code byte-identical to before.
The same protocol is what a future OS-wide capture client would speak — see
[Backlog](#backlog) — which is why it lives in `src/proto.h` instead of inside the server.
@@ -435,10 +571,17 @@ python3 tools/console.py /dev/ttyACM0 status # one command, print the reply
| `ap auto\|force\|off` | AP policy: when the station is down / always / never |
| `token <passphrase>` | control passphrase, 6–32 chars, never echoed back |
| `arm` / `disarm` | same as the button |
| `mouse on\|off` | whether the bridge enumerates a pointer interface at all. Stored, and it needs a **reboot**: the composite descriptor is fixed when USB starts, so this cannot change under a live target. Off means the pointer cannot be moved by anything, ever, until it is turned back on |
| `reboot`, `help` | |
There is deliberately **no command that types anything** — the console is configuration,
not an input path. Replies are printed with a blocking write (the global serial timeout is
`status` also says which of the two the running bridge is, and why: a line like
`mouse interface=yes target=report buttons=00 reports=41 queued=0`, where
`target=boot` means the target asked for boot protocol and the pointer is disabled by
itself until it re-selects report protocol.
There is deliberately **no command that types or clicks anything** — the console is
configuration, not an input path. That is why `mouse on|off` is the only mouse command and
it changes a capability rather than performing an action. Replies are printed with a blocking write (the global serial timeout is
0 so logging into a port nobody reads can never stall `loop()`); without that, multi-line
replies truncate at the 128-byte CDC TX ring, which is exactly what happened on the first
build and is why `pollConsole()` bumps the timeout around each reply.
@@ -451,22 +594,29 @@ build and is why `pollConsole()` bumps the timeout around each reply.
src/
config.h defaults: network, timeouts, caps, buffers (NVS overrides)
keystate.h pure modifier byte + pressed-set -> 8-byte HID report, rollover
mouse.h pure button byte + signed deltas -> 5-byte relative report; the
buttons are restated, not evented, exactly like keystate
text.h pure pasted text -> US-ANSI keystrokes, paced; run buffer and cancel
output.h pure the one place keystrokes, pasted runs, chords and mouse reports
become two ordered queues, and the one place anything is released
proto.h pure the wire protocol: parse/render commands, constant-time compare
sha1.h pure SHA-1 + base64 for the WebSocket handshake
ws_codec.h pure handshake request parsing + RFC 6455 frame codec
web_page.h the controller page, in PROGMEM
web_page.h the controller page, in PROGMEM (~48 KB now, with Pointer Lock)
control_server.{h,cpp} sockets: HTTP routes, WebSocket lifecycle, auth, heartbeat
main.cpp glue: USB identity/HID, WiFi, report queue, chords, text run,
main.cpp glue: USB identity/HID composite, WiFi, chords, text run,
LED, button, watchdogs, serial console
tools/
to_bootloader.py 1200-baud touch into ROM download mode (stdlib termios)
console.py serial console, one command or interactive (stdlib)
relayctl.py controller from a terminal: status/arm/disarm/key/chord/listen/paste
watch_target.py on the target: decode the board's evdev events, prove delivery
relayctl.py controller from a terminal: status/arm/disarm/key/chord/listen/
paste/mouse
watch_target.py on the target: decode the board's evdev events (keyboard *and*
mouse), prove delivery
tests/
run_tests.sh builds and runs everything (host g++ or a local container)
test_keystate.cpp test_proto.cpp test_text.cpp test_ws_codec.cpp
test_keystate.cpp test_mouse.cpp test_output.cpp test_proto.cpp test_text.cpp
test_ws_codec.cpp
test_relayctl.py test_page.js (node, skipped when absent)
AGENTS.md operating manual for whoever edits this next: the invariants, the three
tables that must agree, the build traps, the verification standard
@@ -476,7 +626,7 @@ TODO.md open items, kept with their root cause rather than deleted
If you are an agent or a new contributor, read `AGENTS.md` before changing anything: several
rules here are invisible to a compiler and to the test suite at the same time.
The five modules marked *pure* have no Arduino and no ESP-IDF include, so the identical
The seven modules marked *pure* have no Arduino and no ESP-IDF include, so the identical
code compiles and runs on a host under `-fsanitize=address,undefined`. That is not
convenience for its own sake: the sanitiser caught five real defects in the frame decoder
before the first flash (a `reset()` that left the reader latched on the previous frame, an
@@ -493,11 +643,31 @@ keydown/keyup -> full state S=.. -> Reader (RFC6455) -> proto::parse -> auth gat
-> ReportQueue (dedupe identical, overflow = release + disarm)
-> drainReports: tud_hid_n_ready(0) ? USBHIDKeyboard::sendReport : keep queued
-> HID interrupt endpoint, boot protocol, 8-byte report -> target
pointer move -> M=btn:dx:dy:wheel:pan -> same gate (armed + attached + mouse allowed)
-> MouseState::apply (button byte restated; idle frames produce no report at all)
-> a second ReportQueue, same dedupe and the same overflow rule
-> drainReports, after the keyboard queue -> report #2 on the *same* HID interface
fail-safe (any of: disarm, R, blur, silence, detach, suspend, overflow)
-> releaseEverything() -> OutputStage::releaseAll()
clears both queues, both trackers, the chord and the text run, THEN queues
all-keys-up and (if the target can take it) all-buttons-up
```
`USBHID::SendReport` in the core is synchronous (it waits on a semaphore for the transfer
to complete, up to 100 ms), so `drainReports()` takes at most `kMaxReportsPerLoop` = 8
reports per pass — bounded time in the USB path, and the control plane never waits on it.
reports per pass across **both** devices — bounded time in the USB path, and the control
plane never waits on it.
**Both devices live on one HID interface, which is a constraint with three consequences.**
The keyboard's `USBHIDKeyboard` is the first HID instance, so it claims the boot keyboard
subclass; the mouse is added as the second report on that same instance, which is why
`mouseDev` is created after `kbd` and never before. Because the descriptor is written when
`USB.begin()` runs, `mouse off` cannot take effect until a reboot. And because a
boot-protocol host parses every report on that interface as an 8-byte keyboard report, a
target that selects boot protocol gets the pointer switched off under it (`E11`) rather than
sending 5-byte mouse reports that would arrive as garbage keystrokes.
Chords (Ctrl+Alt+Del, VT switches) are synthetic: the press is pushed, and after the hold
the *tracked* state is pushed back, so keys that were genuinely held before a chord
survive it and the tracker is never corrupted by something the operator did not type.
@@ -529,12 +699,29 @@ SANITIZE=1 tests/run_tests.sh # + ASan/UBSan (recommended, and what found 5 b
| suite | checks | what it pins down |
|---|---|---|
| `test_keystate` | 40 | idempotence, transition preservation (a fast tap is two reports), 6-key limit, ErrorRollOver and recovery from it, dedupe, `clear()`, the exact Ctrl+Alt+Del bytes `05 00 4c 00 00 00 00 00` |
| `test_mouse` | 48 | the 5-byte report layout, every button bit where the HID spec says it is, idle frames producing **no** report, a button change with no movement still producing one, the button byte being restated rather than evented, `releaseAll` being idempotent, and that no frame can put a value outside one signed byte on the wire |
| `test_output` | 153 | **the release funnel.** Queues hold their order and collapse duplicates; releasing with keys held queues an all-keys-up report *when there is a target to receive it*; releasing drops a press that was queued but never sent; releasing with a mouse button held queues all-buttons-up; boot protocol or `mouse off` means no mouse report is queued **but the key release still is**; a pasted run and an in-flight chord are both cancelled; and overflow is detected on both queues. Regression-proven: reinstating the old `releaseEverything()` fails 23 of these |
| `test_proto` | 193 | every command form, strict rejection of malformed state specs, bounds (cap on keys, token length and text frames), render/parse round-trip, error framing, constant-time compare, **every chord button's bytes matched against the label printed on it**, and the page's text constants pinned to `config.h` |
| `test_text` | 459 | the whole printable-ASCII map walked (nothing may be unmapped, nothing may claim a key it has no glyph for), shifted glyphs and the digit row, what is refused rather than guessed at, all-or-nothing frame acceptance, pace arithmetic and clamping, the exact press/release report sequence for two characters, overlay on the operator's held keys, ErrorRollOver propagation, cancel, buffer-full, `P=` render/parse round-trip including the largest legal frame, and one end-to-end case that takes the exact frame the page sends and asserts the HID report sequence the target ends up seeing |
| `test_ws_codec` | 135 | SHA-1 against published vectors including the million-`a` case, base64 against RFC 4648, the RFC 6455 handshake example, frame encode/decode at the 125/126 and 64-bit boundaries, byte-at-a-time delivery, and rejection of unmasked, fragmented, reserved-bit, binary, over-long and oversized frames |
| `test_relayctl` | 81 | the Python client's codec, that its key table has not drifted from the page's, that its text validator agrees with the firmware's character map on all 128 ASCII codes, CRLF normalisation, and `TEXT_FRAME_MAX`/`MAX_PASTE_CHARS` pinned to `config.h` |
| `test_relayctl` | 160 | the Python client's codec, that its key table has not drifted from the page's, that its text validator agrees with the firmware's character map on all 128 ASCII codes, CRLF normalisation, `TEXT_FRAME_MAX`/`MAX_PASTE_CHARS` pinned to `config.h`, the mouse frame builder refusing a sixth button and any movement that does not fit one report, movement splitting that reproduces the original displacement exactly, click/drag/wheel frame *ordering*, every generated frame parseable by the firmware's own parser, and the button bits and frame pace agreeing between page, tool and `config.h` |
| `test_page` | 84 | the page's script, executed: blank and whitespace-only log lines never render and a repeating line is counted not stacked, character validation, CRLF, hex encoding, one frame for a short paste and exactly the right bytes, a busy board holding the next frame back, a 300-character paste reassembling to the text that went in, refusal of untypeable text and the opt-in drop, cancellation by stop/disarm, **the mouse: capture requires the bridge armed, movement becomes exactly one `M=` per report with nothing sent when idle, a 400×300 flick split so no frame exceeds one signed byte while the diagonal shape survives, the click ordering that makes a drag a drag, wheel pixels quantised to notches with the remainder carried, `Esc`/blur/disarm each releasing the buttons, `stay armed on blur` exempting the keyboard only, a board that reports `mouse:0` refusing capture and dropping one in progress — and every mouse frame the page emits parsed by the firmware's own parser** |
| `test_page` | 47 | the page's script, executed: blank and whitespace-only log lines never render and a repeating line is counted not stacked, character validation, CRLF, hex encoding, one frame for a short paste and exactly the right bytes, a busy board holding the next frame back, a 300-character paste reassembling to the text that went in, refusal of untypeable text and the opt-in drop, and cancellation by stop/disarm, plus every element id the script reaches for checked against the markup |
The verifier is verified too, because twice now it was the verifier that was wrong: node
classification (EV_REL means a pointing device — not the device's name, not the order the
glob found them in), movement coalescing one report into one line, net displacement
arithmetic, hi-res wheel events not counted as extra notches, and a stuck button being
called out in those words.
The last six checks in `test_relayctl` are **source pins**, and the suite says so out loud:
they assert that `loop()`'s silence disarm runs off `controllerSeen()` rather than
`linked()`, that `acceptNew()` never stamps the controller clock, and that `closeLink()`
stamps it only for a controller link. They cannot execute — that code needs a socket and a
board — but each one is a sentence a later edit can undo with no compiler error and no
behavioural test failing on the host, which is exactly how bug 10 survived. Reinstating
either half of it fails 3 of them.
Gaps, stated plainly: nothing here exercises real sockets, real WiFi associations, or real
USB transactions — those are the [end-to-end procedure](#end-to-end-verification) below.
The HTTP routing and auth-lifecycle code in `control_server.cpp` is the least-tested part
@@ -563,8 +750,20 @@ printf 'Hello from keyrelay\n' | \
python3 tools/relayctl.py --host keyrelay.local paste --yes # 19 characters
python3 tools/relayctl.py --host keyrelay.local listen 20 # heartbeats, live
python3 tools/relayctl.py --host keyrelay.local disarm
# 3. the mouse, same two terminals
python3 tools/relayctl.py --host keyrelay.local mouse move 120 -40
python3 tools/relayctl.py --host keyrelay.local mouse click --yes
python3 tools/relayctl.py --host keyrelay.local mouse wheel -3
python3 tools/relayctl.py --host keyrelay.local mouse drag 200 0 --button right --yes
python3 tools/relayctl.py --host keyrelay.local mouse release
```
And in the browser, which is where the pointer is actually usable: open the page, arm it,
press **Capture pointer**, move the mouse (the target's cursor moves, the laptop's is
hidden), hold left and move for a drag, press `Esc`. Then arm it, capture, hold a button,
and **close the tab** — the target must stop clicking within 8 s of the silence timeout.
What a correct run proves, and how to tell:
| claim | evidence |
@@ -580,6 +779,13 @@ What a correct run proves, and how to tell:
| target LEDs are read back | `LED CapsLock on` on the target, caps indicator on the page |
| silence disarms | arm it, close the tab, wait 8 s, `status` says `armed: 0` |
| auth is real | `relayctl` with a wrong token fails; the board logs `authfails=1`; 5 tries → `E2` |
| **movement reaches the target as movement** | `watch_target.py` prints a `movement dx … dy …` line per report and the summary's `net position` matches the direction and rough distance you moved — the keyboard half of the watcher cannot show this, which is why the mouse node is watched separately |
| **the button bits are the buttons they claim to be** | `mouse click --yes` gives `BTN Left press/release`; `--button right` gives `BTN Right`. A button that arrives as the wrong one is a bit-order bug and looks identical from the page |
| **a drag is down, move, up in that order** | `BTN Left press`, then `movement` lines, then `BTN Left release` — any other order is a drag that clicks at the start and drags nothing |
| **no stuck buttons** | `still held` absent from the mouse summary at the end. Check it the hard way: hold a button and close the tab |
| **the wheel is notches, not pixels** | `mouse wheel -3` produces `REL_WHEEL -3` three times, and hi-res events do not inflate the count |
| **boot protocol refuses the pointer, not the keyboard** | on a BIOS/KVM that selects boot protocol, keys keep working and `M=` answers `E11`; `console.py status` says `target=boot` |
| **`mouse off` really removes the capability** | `console.py mouse off` + reboot → `lsusb`/evdev show no pointing device, `watch_target.py` notes the missing interface, the page's Capture button is disabled, and keys still work |
`watch_target.py` finds the board by USB id, not device name, so it keeps working on a
different port or a renamed input node, and it accepts both the Logitech identity and the
@@ -635,10 +841,17 @@ AtomS3 Lite → Linux laptop (US layout), board joined the LAN, firmware 1.0.0.
| LED read-back | `LED CapsLock on` then `off`, coming from the *target's* HID output reports |
| **silence disarms** | controller process killed → `armed:0` within 11 s, keys released. **Worth knowing:** that run passed *before* the silence timer existed — killing a process closes the socket, and the socket-close path disarmed. The FIN-less death (laptop slept, network vanished) is what the timer covers, and it is wired now. A check that passes for the wrong reason is still a check that passes, which is exactly the problem |
| single controller | a second client got `E3` / `503` and the board logged `rejected=1` — no takeover |
| **mouse enumeration** | the target created **two** input devices from one HID interface — `keyboard=/dev/input/event19`, `mouse=/dev/input/event20` — with `caps rel: 1943`, i.e. REL_X, REL_Y, REL_WHEEL, REL_HWHEEL and both hi-res axes |
| **mouse movement** | `mouse move 120 -40` → `movement dx +120 dy -40` on the target, exact, one report |
| **mouse buttons** | `click` → `BTN Left press` + `BTN Left release`; `--button right` → `BTN Right`. The bit order the page claims is the bit order the target sees |
| **the wheel** | `mouse wheel -3` → `REL_WHEEL -3`. It took a wrong axis table in the *verifier* to prove this worked — see the note under Tests |
| **drag ordering** | `drag 200 0 --button right` → `BTN Right press`, `movement dx +127`, `movement dx +73`, `BTN Right release`. Both halves of the property at once: the ordering is down-then-move-then-up, and the 200 px left as two reports that stay inside one signed byte |
| **keyboard and mouse together** | keys, chords, a 14-character paste and mouse reports in one session, `drop=0 roll=0`, `keys=3` and `mframes` advancing independently |
| **a controller that dies holding a button** | `BTN Left press` → client `os._exit()` (no close, no disarm) → **`BTN Left release` 9.4 s later**, and a live controller holding the same link with keepalives stayed armed for the 24 s it was tested. This is bug 10, and it is the property the whole release funnel exists to provide |
| lockout | 5 wrong passphrases → `E2`, for 30 s, from the page's own retry loop |
| queues | `drop=0 roll=0` throughout; `queue 0 pending, 0 held, ok` at rest |
### Nine bugs this verification caught (all fixed)
### Ten bugs this verification caught (all fixed)
Worth listing, because each one is a class of bug that HID-over-network tools ship with:
@@ -689,6 +902,43 @@ Worth listing, because each one is a class of bug that HID-over-network tools sh
type. The lesson is the one the whole project keeps relearning: a safety property that
is only written down is not a safety property.
10. **A held mouse button stayed held, forever, and anything on the network could keep it
that way.** Found while verifying the mouse, with the mouse innocent: `releaseEverything()`
queued the release reports (bug 9's fix), and they still did not go out.
Three things stacked up. `closeLink()` ran the release callback only `if (linked())`,
and `linked()` is `_phase == WebSocket && _authed` — so any path that cleared `_authed`
on its way to closing the socket closed it with the keys down and nothing downstream
able to notice. The board then sat `armed=1 mbtn=01` with `phase=none`. And the
backstop that should have caught it, `loop()`'s silence timer, was gated on
`control.linked()` — **a controller that has gone away is precisely the case in which
`linked()` is false**, so the fail-safe designed for a vanished controller could only
ever run while the controller was there. Measured: `BTN Left press`, controller killed,
target still holding the button **26 s later**, and the armed state never expired.
The second half was worse. `acceptNew()` stamped `_lastInboundMs` for *any* incoming
TCP connection, so a read-only `GET /status` — which is served by design while a
controller holds the bridge — reset the silence clock. Any authenticated monitor,
second tab or health probe kept a dead controller's armed state open indefinitely. The
first fix traded that for stamping in `closeLink()`, which moved the reset from
*accept* to *every close* and changed nothing observable; `wasLinked` is the honest
test, because it is true exactly when the thing being closed can leave key state
behind.
Fixed as: `wasLinked = (_phase == Phase::WebSocket)`; the silence clock moved only by
an authenticated controller's own traffic or by that link closing; `controllerSeen()`
distinguishes "no controller since boot" (button-armed, leave it alone) from "one came
and went" (never stay armed on its strength). Verified on hardware with the monitor
running the whole time: `BTN Left press` → `BTN Left release` at 9.4 s, armed 0 and
staying 0 for 37 s of continued polling, while a live controller holding the link with
keepalives stayed armed through the same test.
What to take from it: **never gate a fail-safe on the absence of the thing its
condition detects.** And a backstop needs its own test, because this one passed every
keyboard suite for exactly the reason bug 9 did — the socket-close path happened to
cover the case being checked. `tests/test_relayctl.py` now pins all three sentences in
source, and reinstating either half of the bug fails 3 checks.
### The hazard nobody warns you about
**When the target is the machine you are working on, your keystrokes land on your own
+21
View File
@@ -84,6 +84,22 @@ constexpr uint16_t kTextPeriodMsDefault = 12; // ms per character when asked fo
constexpr uint16_t kTextPeriodMaxMs = 250; // slowest pace a controller may ask for
constexpr uint16_t kTextHoldMaxMs = 6; // how long one character is held down
// ---------------------------------------------------------------------------
// Mouse
// ---------------------------------------------------------------------------
// The controller captures the pointer with the browser's own Pointer Lock and
// forwards it as relative HID mouse reports. One M= frame is one report: the button
// byte is restated every frame, and a movement delta is self-contained, so a frame
// that never arrives moves the cursor a little less and strands nothing. See mouse.h.
//
// The deltas are capped at one report's worth (a signed byte) because one frame is
// one command: splitting a large movement into several frames is the controller's
// job, exactly like splitting a long paste. The board does not split -- it would turn
// one command into a queue depth nobody asked for.
constexpr int8_t kMouseDeltaMax = 127; // one signed byte, the report's range
constexpr uint8_t kMouseButtonMask = 0x1F; // 5 buttons: all the report can carry
constexpr size_t kMouseReportQueueDepth = 32; // movement bursts, same fail-safe
// Safety: a bridge that has heard nothing from its controller for this long is
// a bridge that should not be typing. Arming never survives it (laptop slept,
// cable unplugged, browser killed).
@@ -95,6 +111,11 @@ constexpr uint32_t kStatusIntervalMs = 1000;
// ---------------------------------------------------------------------------
// Control-server buffers
// ---------------------------------------------------------------------------
// The status JSON, sized for every field in StatusSnapshot plus headroom. Sized in
// one place because three call sites build it and a truncated status lies about state,
// which is worse than no status.
constexpr size_t kStatusJsonCap = 384;
constexpr size_t kHttpHeaderCap = 1536; // reject a request line bigger than this
constexpr size_t kWsMessageCap = 256; // one controller frame is a command, not a payload
constexpr uint32_t kAuthTimeoutMs = 5000; // a new socket must authenticate this fast
+45 -9
View File
@@ -60,12 +60,14 @@ size_t statusJson(const StatusSnapshot &s, char *out, size_t cap) {
out, cap,
"{\"v\":\"%s\",\"up\":%lu,\"usb\":%u,\"armed\":%u,\"net\":\"%s\","
"\"ssid\":\"%s\",\"ip\":\"%s\",\"keys\":%lu,\"chords\":%lu,\"drop\":%lu,"
"\"roll\":%u,\"leds\":%u,\"dflt\":%u,\"ap\":%u,\"typed\":%lu,\"typing\":%u}",
"\"roll\":%u,\"leds\":%u,\"dflt\":%u,\"ap\":%u,\"typed\":%lu,\"typing\":%u,"
"\"mouse\":%u,\"mbtn\":%u,\"mframes\":%lu}",
s.version, (unsigned long)s.uptimeS, s.usbHost ? 1u : 0u, s.armed ? 1u : 0u,
s.net, ssid, s.ip, (unsigned long)s.keysSent, (unsigned long)s.chordsSent,
(unsigned long)s.dropped, s.rollover ? 1u : 0u, (unsigned)s.ledState,
s.defaultToken ? 1u : 0u, s.apForced ? 1u : 0u,
(unsigned long)s.typedChars, (unsigned)s.typing);
(unsigned long)s.typedChars, (unsigned)s.typing,
s.mouse ? 1u : 0u, (unsigned)s.mouseButtons, (unsigned long)s.mouseFrames);
return (n < 0 || (size_t)n >= cap) ? 0 : (size_t)n;
}
@@ -118,6 +120,14 @@ void ControlServer::acceptNew(uint32_t nowMs) {
WiFiClient fresh = _server.accept();
if (!fresh) return;
if (_phase != Phase::None && _client && !_client.connected()) {
// The incumbent controller is gone and its socket says so. Taking over without
// settling it would inherit its armed state and whatever keys it left down: the
// release funnel runs on a link *change*, and a silent handover to an unrelated
// socket is not one the operator would recognise. Say goodbye, then take the call.
closeLink("controller socket closed");
}
if (_phase != Phase::None && _client && _client.connected()) {
// The bridge is driven by somebody. The incumbent keeps the *keyboard*; this socket
// is still allowed to look at the board. Only a second /ws upgrade -- a controller
@@ -136,7 +146,11 @@ void ControlServer::acceptNew(uint32_t nowMs) {
_httpLen = 0;
_authed = false;
_connectedAtMs = nowMs;
_lastInboundMs = nowMs;
// Deliberately NOT _lastInboundMs = nowMs. Someone connecting is not the operator being
// alive: a monitoring script, a health check, a second tab pointed at /status or a
// port-scan used to keep the silence timer at zero forever, which held the bridge armed
// -- with the last key the dead controller left still pressed -- for as long as anything
// kept dialling in. Only authenticated controller traffic counts (see readSome).
_reader.reset();
}
@@ -144,7 +158,11 @@ size_t ControlServer::readSome(uint8_t *dst, size_t cap) {
if (!_client || !_client.available()) return 0;
const int n = _client.read(dst, cap);
if (n > 0) {
_lastInboundMs = millis();
// "The controller is alive" means the *authenticated controller on the WebSocket* is
// sending. Bytes from a half-read HTTP request, an unauthenticated socket or a
// read-only /status poll must not count, or the fail-safe that is supposed to fire 8 s
// after a controller vanishes can be held open by anything else on the network.
if (_phase == Phase::WebSocket && _authed) _lastInboundMs = millis();
return (size_t)n;
}
return 0;
@@ -253,7 +271,7 @@ void ControlServer::serveReadonly(WiFiClient &c, const ws::Request &req) {
}
StatusSnapshot s;
if (_fillStatus) _fillStatus(_ctx, s);
char json[320];
char json[kStatusJsonCap];
const size_t len = statusJson(s, json, sizeof(json));
httpResponse(c, "200 OK", "application/json", json, len);
return;
@@ -399,6 +417,8 @@ bool ControlServer::handleWsFrame(uint32_t nowMs) {
_authed = true;
_authFails = 0;
_lockUntilMs = 0;
_controllerSeen = true;
_lastInboundMs = millis(); // the controller exists from here, and only from here
writeTextFrame("OK", 2);
sendStatus();
if (_onLinkChange) _onLinkChange(_ctx, true);
@@ -428,10 +448,10 @@ void ControlServer::sendStatus() {
if (_phase != Phase::WebSocket || !_authed) return;
StatusSnapshot s;
if (_fillStatus) _fillStatus(_ctx, s);
char json[320];
char json[kStatusJsonCap];
const size_t len = statusJson(s, json, sizeof(json));
if (len == 0) return;
char framed[340];
char framed[kStatusJsonCap + 8];
framed[0] = 'S'; // 'S' prefix, then the JSON body
memcpy(framed + 1, json, len);
writeTextFrame(framed, len + 1);
@@ -445,8 +465,15 @@ void ControlServer::sendError(uint8_t code, const char *text) {
}
void ControlServer::closeLink(const char *why) {
(void)why;
const bool wasLinked = linked();
(void)why; // named for the readers of this file, not printed: the CDC ring truncates
// "A controller could have left keys down on the target" is the question here, and the
// answer is yes for anything that reached the WebSocket phase -- not only for what is
// still marked authenticated. Checking linked() instead meant a path that cleared
// _authed before getting here closed the socket with the keys still held and nothing
// downstream able to notice: the silence timer, the backstop, asks control.linked(),
// and linked() is exactly what stops being true when a controller goes away.
// disarm() is a no-op when already disarmed, so this cannot over-release.
const bool wasLinked = _phase == Phase::WebSocket;
if (_client && _client.connected()) {
uint8_t frame[16];
const size_t n = ws::encodeFrame(ws::kOpClose, nullptr, 0, frame, sizeof(frame));
@@ -460,6 +487,15 @@ void ControlServer::closeLink(const char *why) {
_authed = false;
_httpLen = 0;
_reader.reset();
// The last instant at which we knew a controller existed. The silence timer counts from
// here once the link is gone, so a controller that vanishes disarms the board whether
// or not the socket close is ever noticed.
// Stamped only when it is a *controller* link being closed. Doing this on every close
// (the first attempt) reset the silence clock on each read-only /status request, so
// anything polling the board held a dead controller's armed state open indefinitely --
// which is the same bug wearing a different hat. `wasLinked` is the honest test: it is
// true exactly when this close can leave key state behind.
if (wasLinked) _lastInboundMs = millis();
if (wasLinked && _onLinkChange) _onLinkChange(_ctx, false);
}
+31
View File
@@ -45,11 +45,33 @@ struct StatusSnapshot {
uint32_t typedChars = 0; // characters sent by pasted text (a count, never the text)
uint16_t typing = 0; // characters still queued in the paste in progress
bool rollover = false;
// The mouse: whether it can report at all, which buttons the board believes are
// down, and how many reports have gone out. `mouse` is false when the interface is
// switched off on the board or when the target has forced boot protocol -- in both
// cases the keyboard is untouched and M= frames are refused, not ignored.
bool mouse = false;
uint8_t mouseButtons = 0;
uint32_t mouseFrames = 0;
uint8_t ledState = 0; // the target's caps/num/scroll LEDs, per its HID reports
bool defaultToken = false;
bool apForced = false;
};
// Error codes, named. The wire form is `E<code>:<text>`; see proto.h for the list.
enum ErrorCode : uint8_t {
kErrNotAuthed = 1,
kErrLockedOut = 2,
kErrBusy = 3,
kErrNotArmed = 4,
kErrNoTarget = 5,
kErrBadMessage = 6,
kErrQueueFull = 7,
kErrTextFull = 8,
kErrUntypeable = 9,
kErrMouseDisabled = 10,
kErrMouseBootProtocol = 11,
};
/**
* Renders the status frame. Split out of the class so it is callable from the serial
* console too, and so the field names stay in one place.
@@ -75,6 +97,14 @@ class ControlServer {
void sendError(uint8_t code, const char *text);
bool linked() const { return _phase == Phase::WebSocket && _authed; }
/**
* True from the first successful authentication until reboot. The silence timer needs
* this as well as lastInboundMs(): an armed board with no controller at all (armed by
* its own button, or by the serial console) has nothing to hear from, and must not be
* disarmed for it -- but a board whose controller came and went must never stay armed
* on the strength of a link that no longer exists.
*/
bool controllerSeen() const { return _controllerSeen; }
uint32_t lastInboundMs() const { return _lastInboundMs; }
uint32_t rejected() const { return _rejected; }
uint32_t authFails() const { return _authFails; }
@@ -111,6 +141,7 @@ class ControlServer {
ws::Reader _reader{_wsBuf, sizeof(_wsBuf)};
bool _authed = false;
bool _controllerSeen = false;
uint32_t _connectedAtMs = 0;
uint32_t _lastInboundMs = 0;
uint32_t _lastStatusMs = 0;
+351 -136
View File
@@ -3,11 +3,19 @@
*
* laptop (browser tab) --WiFi--> AtomS3 Lite --USB HID--> target machine
*
* The board enumerates on the target as a plain boot-protocol HID keyboard (and a
* CDC console), so the target needs no software, no network and no driver -- it
* thinks a keyboard is attached, because one is. The laptop is the controller: a
* page served by the board itself, which restates the complete key state on every
* keystroke, over a passphrase-authenticated WebSocket.
* The board enumerates on the target as a boot-protocol HID keyboard with a relative
* mouse on the same interface (plus a CDC console), so the target needs no software,
* no network and no driver -- it thinks a keyboard and a mouse are attached, because
* they are. The laptop is the controller: a page served by the board itself, which
* restates the complete key state on every keystroke and the complete mouse-button
* state on every mouse frame, over a passphrase-authenticated WebSocket.
*
* The keyboard and the mouse are two collections inside ONE TinyUSB HID interface:
* the Arduino core concatenates their report descriptors and keys them by report id,
* and every report leaves on the same endpoint. Two consequences are load-bearing and
* both are handled below -- the keyboard's constructor has to be the one that fixes
* the interface as a boot keyboard, and a target that puts the interface into BOOT
* protocol cannot be sent a mouse report at all (see onHidEvent).
*
* press button -> arm / disarm locally (works with no network at all)
* hold 3 s at power-on -> ROM USB download mode, so the board can always be
@@ -22,8 +30,11 @@
* 3. Armed is displayed on the device: solid red LED, and it blinks red instead if
* there is no target attached to type into.
* 4. Silence disarms. If the controller stops talking for kNoClientTimeoutMs (laptop
* slept, browser killed, network gone) the keys go up and the bridge disarms.
* Typing that continues after you stopped typing is the failure this prevents.
* slept, browser killed, network gone) the keys and the mouse buttons go up and the
* bridge disarms. Typing that continues after you stopped typing is the failure this
* prevents. Mouse capture is the controller's own extra gate: the browser releases
* the pointer on Escape, on blur and on tab hide, and each of those releases every
* button on the target before anything else is considered.
* 5. The target taking the bus away (unplug, suspend) disarms and clears the queue.
* A pasted run cancels on every one of these: "release every key" means "and stop
* typing", not "pause". The characters live in RAM until they go out and are never
@@ -65,6 +76,7 @@
#include "USBCDC.h"
#include "esp32-hal-tinyusb.h" // usb_persist_restart()
#include "USBHIDKeyboard.h"
#include "USBHIDMouse.h"
#define KR_HAVE_HID 1
// This environment builds with ARDUINO_USB_CDC_ON_BOOT=0, so HardwareSerial.h has
// mapped `Serial` to UART0 -- dead pins on this board, which has no USB-to-UART
@@ -77,9 +89,13 @@
#include <ESPmDNS.h>
#include <Preferences.h>
#include <cstddef> // offsetof, for the report layout assertions below
#include "config.h"
#include "control_server.h"
#include "keystate.h"
#include "mouse.h"
#include "output.h"
#include "proto.h"
#include "text.h"
@@ -89,7 +105,15 @@
// hid_device.h needs tinyusb's internal device struct visible, which drags the whole
// private header set in for one boolean. At file scope, before the anonymous
// namespace, so the code below can call it.
//
// Instance 0 is the one HID interface this device enumerates -- the Arduino core puts
// the keyboard and the mouse in the same interface, so this one boolean gates both.
extern "C" bool tud_hid_n_ready(uint8_t instance);
// hid_protocol_t from TinyUSB's hid_types.h, without including it: the values are part
// of the USB HID specification, not of the library's internals.
constexpr uint8_t kHidProtocolBoot = 0;
constexpr uint8_t kHidProtocolReport = 1;
#endif
namespace {
@@ -115,10 +139,18 @@ constexpr uint32_t kSampleIntervalMs = 5; // button integrator
constexpr uint8_t kDebounceSamples = 4;
constexpr uint32_t kRecoveryHoldMs = 3000; // hold at boot -> ROM download mode
constexpr uint32_t kLedBlinkPeriodMs = 300; // "armed but no target" urgency blink
constexpr size_t kMaxReportsPerLoop = 8; // bound the time in the USB send path
constexpr size_t kMaxReportsPerLoop = 8; // bound the time in the USB send path,
// keyboard and mouse sharing the budget
constexpr size_t kConsoleLineCap = 96;
#if KR_HAVE_HID
// Declaration order is not a style choice here. The first USBHID constructed is the one
// that fixes the shared HID interface's protocol, and the keyboard's constructor is the
// only one that claims HID_ITF_PROTOCOL_KEYBOARD -- the boot-protocol subclass the
// README measures as `class=03 sub=01 proto=01` and what Windows needs for secure
// attention. So the keyboard is a file-scope object (constructed before anything in
// setup() runs) and the mouse is deliberately NOT: it is built in setup(), which is the
// structural guarantee that it can never get in front of the keyboard.
USBHIDKeyboard Keyboard; // ctor registers the HID interface with TinyUSB
USBCDC Serial(0); // ctor registers the CDC interface, after Keyboard
volatile bool usbStarted = false;
@@ -127,8 +159,7 @@ uint8_t targetLeds = 0; // caps/num/scroll as the TARGET reports them
Preferences prefs;
ControlServer control;
kr::KeyboardState keys;
kr::text::Typewriter typer; // the paste in flight, paced here (see text.h)
kr::OutputStage stage; // both report queues, both device states, one fail-safe
// ---------------------------------------------------------------------------
// Persistent settings (NVS). Build-time defaults from config.h are only the
@@ -139,18 +170,27 @@ constexpr char kKeyStaSsid[] = "sta_ssid";
constexpr char kKeyStaPass[] = "sta_pass";
constexpr char kKeyToken[] = "token";
constexpr char kKeyApMode[] = "ap_mode"; // 0 auto, 1 force on, 2 off
constexpr char kKeyMouse[] = "mouse"; // 1 = advertise the mouse collection
char staSsid[33] = "";
char staPass[65] = "";
char token[kr::kTokenMax + 1] = ""; // filled from NVS, or kDefaultToken, in loadSettings
uint8_t apMode = 0;
// Whether the target is offered a mouse at all. It decides what the target
// enumerates, so it is read before the USB descriptors are fixed (setup()) and a
// change only takes effect on the next boot -- which is also what makes it a
// rollback: `mouse off`, `reboot`, and the board is the keyboard-only device it used
// to be, with no reflash and the same Logitech identity.
bool mouseEnabled = true;
void loadSettings() {
prefs.begin(kNvsNamespace, true);
const String nvSsid = prefs.getString(kKeyStaSsid, "");
const String nvPass = prefs.getString(kKeyStaPass, "");
const String nvToken = prefs.getString(kKeyToken, "");
apMode = (uint8_t)prefs.getInt(kKeyApMode, 0);
mouseEnabled = prefs.getInt(kKeyMouse, 1) != 0;
prefs.end();
if (nvSsid.length()) {
@@ -188,64 +228,84 @@ void saveApMode(uint8_t mode) {
apMode = mode;
}
void saveMouseEnabled(bool on) {
prefs.begin(kNvsNamespace, false);
prefs.putInt(kKeyMouse, on ? 1 : 0);
prefs.end();
mouseEnabled = on;
}
// ---------------------------------------------------------------------------
// Report queue
// The emit side
// ---------------------------------------------------------------------------
// The controller can burst faster than the target's 1 ms poll interval, and every
// report the core sends is synchronous (USBHID::SendReport waits for the transfer
// to complete), so reports are queued here and drained a few per loop pass.
struct ReportQueue {
kr::KeyboardReport items[kr::kReportQueueDepth];
size_t head = 0;
size_t count = 0;
// Both report queues, the keyboard and mouse device states, the pasted run and the
// chord in flight live in kr::OutputStage (src/output.h), because they all have to be
// cancelled together and that is a thing worth being unable to forget. The queues
// exist because the controller can burst faster than the target's 1 ms poll interval
// and every report the core sends is synchronous (USBHID::SendReport waits for the
// transfer to complete), so reports are queued and drained a few per loop pass.
bool push(const kr::KeyboardReport &r) {
if (count > 0) {
const size_t last = (head + count - 1) % kr::kReportQueueDepth;
// Two identical reports in a row are one report. Distinct transitions are
// always kept -- collapsing those is how a fast tap would vanish.
if (kr::reportsEqual(items[last], r)) return true;
}
if (count >= kr::kReportQueueDepth) return false;
items[(head + count) % kr::kReportQueueDepth] = r;
count++;
return true;
}
const kr::KeyboardReport *peek() const { return count ? &items[head] : nullptr; }
void pop() {
if (!count) return;
head = (head + 1) % kr::kReportQueueDepth;
count--;
}
void clear() { head = 0; count = 0; }
};
#if KR_HAVE_HID
// The mouse object, built in setup() only when the board is configured to have one.
// The Arduino wrapper registers its report descriptor from its constructor and TinyUSB
// freezes the descriptors at the first tinyusb_init(), so "no mouse" has to mean
// "never constructed" -- a file-scope object could not be un-advertised, and `mouse
// off` would change nothing about what the target enumerates.
USBHIDRelativeMouse *mouseDev = nullptr;
ReportQueue queue;
// A chord (Ctrl+Alt+Del, Ctrl+Alt+F3, ...) is a press that has to be followed by a
// release after a fixed hold, without disturbing the tracked key state -- the keys
// that were genuinely held before the chord must come back afterwards.
struct ChordInFlight {
bool active = false;
uint32_t releaseAtMs = 0;
};
ChordInFlight chord;
// The report this firmware builds has to be the report TinyUSB's hid_mouse_report_t
// describes, byte for byte: buttons, then four signed bytes. Asserted rather than
// trusted, because a mismatch would not fail to build, it would move the target's
// cursor with the wheel byte.
static_assert(sizeof(kr::mouse::MouseReport) == 5, "5-byte mouse report");
static_assert(offsetof(kr::mouse::MouseReport, dx) == 1 &&
offsetof(kr::mouse::MouseReport, dy) == 2 &&
offsetof(kr::mouse::MouseReport, wheel) == 3 &&
offsetof(kr::mouse::MouseReport, pan) == 4, "mouse report layout");
#endif
bool armed = false;
uint32_t keysSent = 0;
uint32_t chordsSent = 0;
uint32_t dropped = 0;
uint32_t typedChars = 0; // a count only: the characters themselves are never kept
uint32_t mouseFrames = 0; // mouse reports accepted -- movement counts, never content
uint32_t prevHeld = 0;
#if KR_HAVE_HID
// True when a target is attached and the HID interface is live. Nothing can be
// armed without it -- arming against nothing would only blink.
#if KR_HAVE_HID
bool canType() { return usbStarted; }
bool mousePresent() { return mouseDev != nullptr; }
#else
bool canType() { return false; }
bool mousePresent() { return false; }
#endif
// True unless the target has put the shared HID interface into BOOT protocol. TinyUSB's
// own default at attach is report protocol, and it only changes when the host says so
// (onHidEvent), so this starts true and is a fact about the target, not a guess.
bool hostWantsReportProtocol = true;
// Whether a mouse report can actually be delivered: the interface has to exist, and
// the target must not be reading boot reports (see onHidEvent). When it is false the
// keyboard is unaffected -- it is what the device was before the mouse existed, and it
// carries on being exactly that.
bool mouseCanReport() { return mousePresent() && hostWantsReportProtocol; }
void releaseEverything();
void setArmed(bool on, const char *why);
// Every producer of reports ends up here, so "the queue would not take it" is one
// behaviour instead of four copies of it that can drift apart.
void notePush(kr::OutputStage::Pushed p) {
if (p != kr::OutputStage::Pushed::Overflow) return;
dropped++;
releaseEverything();
control.sendError(kr::kErrQueueFull, "report queue overflow -- released and disarmed");
setArmed(false, "queue overflow");
}
// ---------------------------------------------------------------------------
// LED
// ---------------------------------------------------------------------------
@@ -294,25 +354,30 @@ void updateLed(uint32_t now) {
// ---------------------------------------------------------------------------
// Arming
// ---------------------------------------------------------------------------
/**
* The one funnel every fail-safe goes through: `R`, disarm, the board's button, the
* controller vanishing, the target taking the bus away, silence, a queue that filled.
*
* It clears what has not gone out, raises the keys and the buttons, ends a chord
* mid-hold and cancels a pasted run -- and then queues the reports that say so, which
* is the part that used to be missing. Clearing the tracker is not the same statement
* to the target as releasing the keys, and disarm is exactly the moment the controller
* stops talking: no further frame was coming to carry the release, so whatever was held
* stayed held. src/output.h pins the behaviour in tests.
*/
void releaseEverything() {
#if KR_HAVE_HID
queue.clear(); // drop any press still waiting to go out
keys.clear(); // all keys up, modifiers included
chord.active = false;
// "Release every key" has to mean "and stop typing", not "pause". Every fail-safe
// route (R, disarm, target detached, queue overflow) comes through here, so a
// pasted run cannot outlive any of them.
typer.cancel();
#endif
// Which reports the target could receive is not the same question for the two
// devices: the keyboard needs a target attached, the mouse also needs report protocol
// (see onHidEvent). Passing both keeps the fail-safe from queueing a report that would
// arrive as corruption.
stage.releaseAll(canType(), mouseCanReport());
}
void setArmed(bool on, const char *why);
void disarm(const char *why) { setArmed(false, why); }
void setArmed(bool on, const char *why) {
if (on && !canType()) {
control.sendError(5, "no USB host attached -- nothing to type into");
control.sendError(kr::kErrNoTarget, "no USB host attached -- nothing to type into");
Serial.println(F("[arm] refused: no USB host"));
return;
}
@@ -449,8 +514,11 @@ void fillStatus(void *, kr::StatusSnapshot &s) {
s.chordsSent = chordsSent;
s.dropped = dropped;
s.typedChars = typedChars;
s.typing = (uint16_t)typer.pending(); // flow control: how much is left to type
s.rollover = keys.rollover();
s.typing = (uint16_t)stage.typer.pending(); // flow control: how much is left to type
s.rollover = stage.keys.rollover();
s.mouse = mouseCanReport();
s.mouseButtons = stage.mouseState.buttons();
s.mouseFrames = mouseFrames;
s.defaultToken = strcmp(token, kr::kDefaultToken) == 0;
s.apForced = apMode == 1;
}
@@ -459,47 +527,73 @@ void linkChanged(void *, bool linked) {
Serial.printf("[link] controller %s\n", linked ? "authenticated" : "gone");
flash(linked ? kColorBlue : kColorAmber, 180);
// A controller that vanished must not leave its last key state latched on the
// target: whoever picks up the keyboard next starts from nothing pressed.
// target: whoever picks up the keyboard next starts from nothing pressed. This is
// the *fast* path; loop()'s silence timer is the backstop for the case where the
// socket close is never noticed, and it no longer asks whether a controller is
// linked -- see the comment there.
if (!linked) disarm("controller disconnected");
}
#if KR_HAVE_HID
// The controller's key state, queued if it changed. Overflow is a fail-safe, not a
// dropped key: stop typing rather than type out of step with what the operator sees.
void pushTrackedState() {
if (!keys.dirty()) return;
keys.ackDirty();
if (!armed) return;
if (!queue.push(keys.report())) {
// Fail safe, not a dropped key: something is outrunning the queue, so stop
// typing rather than type out of step with what the operator sees.
dropped++;
releaseEverything();
control.sendError(7, "report queue overflow -- released and disarmed");
setArmed(false, "queue overflow");
}
notePush(stage.pushKeys());
}
#endif
/**
* The target choosing HID protocol for the shared interface.
*
* There is one HID interface and it carries both collections, so a BIOS setup or a KVM
* that issues SET_PROTOCOL(BOOT) will parse *every* report on it as an 8-byte boot
* keyboard report -- including a 5-byte mouse report, which would arrive as four bytes
* of garbage keystrokes followed by a truncated report. The keyboard cannot help that;
* the mouse can, by not sending. hostWantsReportProtocol is therefore a fact about the
* target, and mouseCanReport() is the only gate that consults it.
*/
void onHidProtocol(void *, esp_event_base_t, int32_t, void *event_data) {
auto *data = static_cast<arduino_usb_hid_event_data_t *>(event_data);
const bool report = data->set_protocol.protocol != kHidProtocolBoot;
if (report == hostWantsReportProtocol) return;
hostWantsReportProtocol = report;
Serial.printf("[usb] target selected %s protocol%s\n",
report ? "report" : "BOOT",
report ? "" : " -- mouse reports refused, keyboard unaffected");
if (!report) {
// Anything queued would be delivered as corruption; the buttons themselves are
// released on the way through, because "we cannot tell the target to let go" is
// not the same as "the target may still be holding one".
stage.mouseQ.clear();
stage.mouseState.releaseAll();
}
control.sendStatus();
}
#endif // KR_HAVE_HID
void startChord(uint8_t mods, const uint8_t *usages, size_t n, uint32_t holdMs,
const char *label) {
#if KR_HAVE_HID
if (!armed) { control.sendError(4, "not armed"); return; }
if (!canType()) { control.sendError(5, "no USB host attached"); return; }
if (chord.active) { control.sendError(6, "a chord is still being held"); return; }
const kr::KeyboardReport press = kr::makeReport(mods, usages, n);
if (!queue.push(press)) {
dropped++;
releaseEverything();
setArmed(false, "queue overflow");
if (!armed) { control.sendError(kr::kErrNotArmed, "not armed"); return; }
if (!canType()) { control.sendError(kr::kErrNoTarget, "no USB host attached"); return; }
if (stage.chord.active) {
control.sendError(kr::kErrBadMessage, "a chord is still being held");
return;
}
chord.active = true;
chord.releaseAtMs = millis() + holdMs;
const kr::KeyboardReport press = kr::makeReport(mods, usages, n);
const kr::OutputStage::Pushed p = stage.pushKeyboardReport(press);
if (p == kr::OutputStage::Pushed::Overflow) {
notePush(p); // release, disarm, and say so
return;
}
stage.chord.active = true;
stage.chord.releaseAtMs = millis() + holdMs;
chordsSent++;
Serial.printf("[hid] chord %s (%u ms)\n", label, (unsigned)holdMs);
#else
(void)mods; (void)usages; (void)n; (void)holdMs; (void)label;
control.sendError(5, "this build has no HID interface");
control.sendError(kr::kErrNoTarget, "this build has no HID interface");
#endif
}
@@ -521,16 +615,16 @@ void onMessage(void *, const proto::Message &msg) {
return;
case proto::Cmd::ReleaseAll:
#if KR_HAVE_HID
// Keys up AND buttons up, plus the queue, the chord and a pasted run in
// flight. One funnel, so `R` cannot mean something different per device.
releaseEverything();
#endif
return;
case proto::Cmd::State: {
if (!armed) { control.sendError(4, "not armed"); return; }
if (!armed) { control.sendError(kr::kErrNotArmed, "not armed"); return; }
#if KR_HAVE_HID
keys.apply(msg.mods, msg.usages, msg.nUsages);
const uint32_t held = (uint32_t)keys.held();
stage.keys.apply(msg.mods, msg.usages, msg.nUsages);
const uint32_t held = (uint32_t)stage.keys.held();
if (held > prevHeld) keysSent += held - prevHeld; // count presses, not frames
prevHeld = held;
pushTrackedState();
@@ -538,6 +632,37 @@ void onMessage(void *, const proto::Message &msg) {
return;
}
case proto::Cmd::Mouse: {
// `M=` is the mouse's `S=`: the button byte is restated in full every frame, so
// a lost frame cannot leave a button stuck, and the movement fields are the one
// report's worth of relative motion -- which has no state to lose.
if (!armed) { control.sendError(kr::kErrNotArmed, "not armed"); return; }
if (!canType()) { control.sendError(kr::kErrNoTarget, "no USB host attached"); return; }
if (!mousePresent()) {
control.sendError(kr::kErrMouseDisabled,
"this board has no mouse interface (`mouse on`, then reboot)");
return;
}
if (!hostWantsReportProtocol) {
// The honest refusal, and the reason for it: in boot protocol the target would
// read these five bytes as keyboard data. Keys keep working regardless.
control.sendError(kr::kErrMouseBootProtocol,
"target is in HID boot protocol, so a mouse report would "
"arrive as keystrokes -- keys still work");
return;
}
#if KR_HAVE_HID
const kr::mouse::Frame f{msg.buttons, msg.dx, msg.dy, msg.wheel, msg.pan};
const kr::OutputStage::Pushed p = stage.pushMouse(f);
// Nothing queued means the frame said nothing at all -- same buttons, no
// movement. That is a success, not an error: the controller is allowed to send
// an idle frame and the target simply does not see it.
if (p == kr::OutputStage::Pushed::Ok) mouseFrames++;
notePush(p);
#endif
return;
}
case proto::Cmd::SecureAttention: {
// Fixed on the device side, not taken from the wire: the combination that
// reboots a Linux VT or unlocks a Windows box is not something a malformed
@@ -555,14 +680,14 @@ void onMessage(void *, const proto::Message &msg) {
// A pasted run is still gated by everything else: armed, and a target to type
// into. What it is NOT is stored -- the characters live in RAM until they have
// gone out, and cancel() is reachable from every fail-safe.
if (!armed) { control.sendError(4, "not armed"); return; }
if (!armed) { control.sendError(kr::kErrNotArmed, "not armed"); return; }
#if KR_HAVE_HID
if (!canType()) { control.sendError(5, "no USB host attached"); return; }
if (!canType()) { control.sendError(kr::kErrNoTarget, "no USB host attached"); return; }
// Accepted as a unit: a frame that cannot be taken contributes nothing, so the
// controller can retry exactly the same characters.
const kr::text::Outcome o = typer.feed(msg.payload, msg.nPayload);
const kr::text::Outcome o = stage.typer.feed(msg.payload, msg.nPayload);
if (o.result == kr::text::Result::Full) {
control.sendError(8, "text buffer full -- wait for the run to drain");
control.sendError(kr::kErrTextFull, "text buffer full -- wait for the run to drain");
return;
}
if (o.result == kr::text::Result::Untypeable) {
@@ -572,22 +697,22 @@ void onMessage(void *, const proto::Message &msg) {
snprintf(why, sizeof(why),
"0x%02X at offset %u has no US-ANSI keystroke -- nothing typed",
o.byte, (unsigned)o.index);
control.sendError(9, why);
control.sendError(kr::kErrUntypeable, why);
return;
}
typer.start(millis(), msg.periodMs);
stage.typer.start(millis(), msg.periodMs);
// Length and pace only. Nothing here records what the characters were.
Serial.printf("[text] %u chars queued, %u ms per character\n",
(unsigned)typer.pending(), (unsigned)typer.periodMs());
(unsigned)stage.typer.pending(), (unsigned)stage.typer.periodMs());
control.sendStatus();
#else
control.sendError(5, "this build has no HID interface");
control.sendError(kr::kErrNoTarget, "this build has no HID interface");
#endif
return;
}
default:
control.sendError(6, "unrecognised command");
control.sendError(kr::kErrBadMessage, "unrecognised command");
return;
}
}
@@ -654,22 +779,50 @@ void onKeyboardLed(void *, esp_event_base_t, int32_t, void *event_data) {
targetLeds = data->leds;
}
/**
* Both queues, one endpoint, keyboard first.
*
* The order is a decision, not an accident: a key release that is waiting behind a
* burst of pointer movement is a key that stays down longer than the operator's
* finger did. The budget is shared, so one pass cannot spend all its time on the
* mouse and starve the keyboard.
*
* This runs whether or not the bridge is armed, and it has to: the release reports
* that disarm just queued are the whole point of it, and after a fail-safe there is
* no further command coming to carry them.
*/
void drainReports() {
for (size_t i = 0; i < kMaxReportsPerLoop; i++) {
const kr::KeyboardReport *r = queue.peek();
if (!r) return;
if (stage.keyQ.empty()) break;
if (!usbStarted) { // nobody to receive them
dropped += (uint32_t)queue.count;
queue.clear();
return;
dropped += (uint32_t)stage.keyQ.count();
stage.keyQ.clear();
break;
}
if (!tud_hid_n_ready(0)) return; // endpoint still busy; try next loop
if (!tud_hid_n_ready(0)) return; // endpoint busy; the mouse waits too
const kr::KeyboardReport *r = &stage.keyQ.front();
KeyReport wire;
wire.modifiers = r->modifiers;
wire.reserved = 0;
memcpy(wire.keys, r->keys, sizeof(wire.keys));
Keyboard.sendReport(&wire); // synchronous: returns once transmitted
queue.pop();
stage.keyQ.pop();
}
for (size_t i = 0; i < kMaxReportsPerLoop; i++) {
if (stage.mouseQ.empty()) break;
// Same rule as the keyboard, plus the two reasons a mouse report cannot go out at
// all. Dropping them is correct here: they are movement and button state that
// nobody can receive, and holding them until protocol changes would deliver a
// stale burst the operator never asked for.
if (!usbStarted || !mouseDev || !hostWantsReportProtocol) {
dropped += (uint32_t)stage.mouseQ.count();
stage.mouseQ.clear();
break;
}
if (!tud_hid_n_ready(0)) break; // endpoint busy; next loop
mouseDev->sendReport(stage.mouseQ.front());
stage.mouseQ.pop();
}
}
#endif // KR_HAVE_HID
@@ -742,25 +895,35 @@ void printHelp() {
" ap auto|force|off access point policy (auto = when station is down)\n"
" token <passphrase> control passphrase, max 32 chars\n"
" arm | disarm same as the button, from the console\n"
" mouse on|off offer the target a pointer (needs a reboot)\n"
" reboot\n"
"There is deliberately no command that types anything. Keystrokes come from\n"
"a human at a keyboard, through the controller page. Pasted text is a controller\n"
"action too: it needs the bridge armed, and nothing is remembered by the board.\n"));
"There is deliberately no command that types or clicks anything. Keystrokes come\n"
"from a human at a keyboard, through the controller page. Pasted text is a\n"
"controller action too: it needs the bridge armed, and nothing is remembered by\n"
"the board. `mouse` changes what the device IS, never what it does.\n"));
}
void printStatus() {
kr::StatusSnapshot s;
fillStatus(nullptr, s);
char json[320];
char json[kr::kStatusJsonCap];
kr::statusJson(s, json, sizeof(json));
Serial.printf("status %s\n", json);
Serial.printf("link controller=%s rejected=%u authfails=%u\n",
control.linked() ? "yes" : "no", (unsigned)control.rejected(),
(unsigned)control.authFails());
Serial.printf("queue %u pending, %u held, %s\n", (unsigned)queue.count,
(unsigned)keys.held(), keys.rollover() ? "ROLLOVER" : "ok");
Serial.printf("queue %u keys pending, %u held, %s\n",
(unsigned)stage.keyQ.count(), (unsigned)stage.keys.held(),
stage.keys.rollover() ? "ROLLOVER" : "ok");
Serial.printf("text %u chars typed, %u still to go\n", (unsigned)typedChars,
(unsigned)typer.pending());
(unsigned)stage.typer.pending());
// `target=BOOT protocol (refused)` is the line to look for when the page says the
// pointer is captured and nothing moves: it is the target's choice, not a bug here.
Serial.printf("mouse interface=%s target=%s buttons=0x%02X %u reports, %u queued\n",
mousePresent() ? "present" : "off",
hostWantsReportProtocol ? "report protocol" : "BOOT protocol (refused)",
(unsigned)stage.mouseState.buttons(), (unsigned)mouseFrames,
(unsigned)stage.mouseQ.count());
}
// Split a line into `verb` and the rest of the line, trimmed. Returns false if the
@@ -822,6 +985,28 @@ void handleConsoleLine(char *buf) {
return;
}
if (strcmp(verb, "mouse") == 0) {
if (!rest) {
Serial.printf("mouse interface: %s (target currently sees %s)\n",
mousePresent() ? "on" : "off",
hostWantsReportProtocol ? "report protocol" : "BOOT protocol");
return;
}
const bool on = strcmp(rest, "on") == 0;
if (!on && strcmp(rest, "off") != 0) {
Serial.println(F("usage: mouse on|off"));
return;
}
saveMouseEnabled(on);
// Configuration, not an action: it changes whether the target is offered a pointer
// at all, and the composite descriptor is frozen when USB starts. Saying "reboot"
// rather than doing it keeps the surprise out of a console session that is halfway
// through something.
Serial.printf("[mouse] %s -- reboot to apply (the USB descriptor is fixed at start)."
" While off, nothing can move the pointer.\n", on ? "on" : "OFF");
return;
}
if (strcmp(verb, "token") == 0) {
if (!rest) {
Serial.printf("token: %s\n", strcmp(token, kr::kDefaultToken) == 0
@@ -885,11 +1070,32 @@ void setup() {
Serial.begin(115200);
Serial.setTxTimeoutMs(0); // never stall the loop because nobody is reading
// Before USB.begin(), because `mouse off` has to change what the descriptors say and
// the first tinyusb_init() freezes them. Reading NVS has no USB dependency at all, so
// nothing is lost by doing it here rather than after the recovery hold.
loadSettings();
#if KR_HAVE_HID
USB.onEvent(onUsbEvent);
setUsbIdentity(); // must precede USB.begin()
Keyboard.begin(); // descriptors freeze at USB.begin()
Keyboard.begin(); // ctor registered the boot keyboard; descriptors
// freeze at USB.begin() below
Keyboard.onEvent(onKeyboardLed);
// The protocol event lives on USBHID's own event base, and USBHIDKeyboard only
// re-exports its keyboard subset of it -- so it is registered on the base directly,
// which is what the core posts to. Must be before USB.begin(): the host can issue
// SET_PROTOCOL the instant it enumerates, and a target that boots straight into boot
// protocol would otherwise get one mouse report before the gate exists.
esp_event_handler_register(ARDUINO_USB_HID_EVENTS,
ARDUINO_USB_HID_SET_PROTOCOL_EVENT, onHidProtocol, nullptr);
if (mouseEnabled) {
// Constructed here and not at file scope on purpose: the constructor is what
// registers the mouse report descriptor, so "no mouse" has to mean "never
// constructed". After Keyboard, always, or the interface stops being a boot
// keyboard and Windows stops honouring Ctrl+Alt+Del.
mouseDev = new USBHIDRelativeMouse();
mouseDev->begin();
}
if (!USB.begin()) Serial.println(F("[usb] FAILED to start the TinyUSB device stack"));
#endif
@@ -899,7 +1105,6 @@ void setup() {
for (uint32_t t = millis(); !usbStarted && millis() - t < 1000; delay(10)) {}
#endif
loadSettings();
ledSelfTest();
Serial.println();
@@ -918,6 +1123,11 @@ void setup() {
Serial.println(WiFi.status() == WL_CONNECTED ? WiFi.localIP().toString()
: String(kr::kApGw));
Serial.println(F("button short press = arm / disarm. Console: `help`"));
if (mousePresent()) {
// Short on purpose: the CDC TX ring truncates a long line (README bug 1).
Serial.println(F("mouse: capture it in the page; Esc frees the cursor and releases "
"every button."));
}
}
void loop() {
@@ -933,8 +1143,18 @@ void loop() {
// so this only ever fires when the controller is gone without having said goodbye:
// laptop slept, browser killed, WiFi died without a FIN. That is exactly the moment
// a pasted run must stop typing, which is why this is checked before the typewriter
// is driven rather than after.
if (armed && control.linked() &&
// is driven rather than after. Captured mouse movement refreshes the same clock -- it
// is inbound traffic like anything else -- so holding a pointer still alive at 125 Hz
// counts as being there, and losing it counts as leaving.
// Note what this does NOT condition on: control.linked(). A controller that has gone
// away is precisely the case where linked() is false, so gating the backstop on it made
// the backstop uncallable in the one situation it exists for -- the board sat armed with
// a key or a mouse button still held, forever, whenever the link teardown failed to run
// the release itself. It now runs off "we have had an authenticated controller, and this
// is how long since we heard from it or watched it leave" (ControlServer::closeLink
// stamps the clock on the way out). A board armed by its own button with no controller
// at all is still left alone, because it has never had one to go quiet.
if (armed && control.controllerSeen() &&
static_cast<int32_t>(now - control.lastInboundMs()) >
static_cast<int32_t>(kr::kNoClientTimeoutMs)) {
disarm("controller silent");
@@ -943,34 +1163,29 @@ void loop() {
#if KR_HAVE_HID
// Release the synthetic chord after its hold, back to exactly what is tracked.
// Nothing else touches the tracker, so the operator's held keys survive a chord.
if (chord.active && now >= chord.releaseAtMs) {
chord.active = false;
if (armed && usbStarted) {
if (!queue.push(keys.report())) {
dropped++;
releaseEverything();
setArmed(false, "queue overflow");
}
}
if (stage.chord.active && now >= stage.chord.releaseAtMs) {
stage.chord.active = false;
if (armed && usbStarted) notePush(stage.pushKeyboardReport(stage.keys.report()));
}
// A pasted run, typed on this clock and no other. text.h emits at most one report
// transition per call, so a fast pace cannot deepen the queue, and the queue's own
// overflow path releases and disarms if the target stops taking reports.
for (size_t i = 0; i < kMaxReportsPerLoop && typer.active(); i++) {
if (!armed || !usbStarted) { typer.cancel(); break; }
for (size_t i = 0; i < kMaxReportsPerLoop && stage.typer.active(); i++) {
if (!armed || !usbStarted) { stage.typer.cancel(); break; }
kr::KeyboardReport r;
bool pressed = false;
if (!typer.step(now, keys, r, pressed)) break;
if (!queue.push(r)) {
dropped++;
releaseEverything();
control.sendError(7, "report queue overflow -- released and disarmed");
setArmed(false, "queue overflow");
if (!stage.typer.step(now, stage.keys, r, pressed)) break;
const kr::OutputStage::Pushed p = stage.pushKeyboardReport(r);
if (p == kr::OutputStage::Pushed::Overflow) {
notePush(p);
break;
}
if (pressed) typedChars++;
}
// Both queues, one endpoint, keyboard first. This runs whether or not the bridge is
// armed, and it has to: the release reports that disarm just queued are the whole
// point of it, and there will be no further command to send them.
drainReports();
#endif
+145
View File
@@ -0,0 +1,145 @@
/**
* keyrelay -- the emulated mouse. Pure logic: no Arduino, no TinyUSB, so the exact
* code the board runs is compiled and unit-tested on a host (tests/test_mouse.cpp).
*
* Why a movement is not a violation of "state, not events"
* --------------------------------------------------------
* Invariant 1 says the controller restates the whole desired state and never sends
* key up/down events, because one lost release is a key stuck forever. A mouse has
* two halves, and they behave differently:
*
* - **Buttons are state.** Every `M=` frame carries the complete button byte, not
* a press or a release, so a frame that never arrives did not click, and any one
* frame afterwards restates the truth. Same argument, same guarantee as `S=`.
* - **Movement is a delta, and a delta is self-contained.** There is no position
* for the board or the controller to be wrong about: a lost frame means the
* cursor moved a little less, which is what a relative mouse does when a real
* one is picked up and moved back down. Nothing is stranded, nothing needs to
* be reconciled, and no resynchronisation timer is needed because there is
* nothing to be out of step with.
*
* That is also why the pointer is relative and not absolute. An absolute pointer
* would give the board a position to disagree with the target about -- the target
* clips it at the screen edge, snaps it between monitors, and the next frame's
* "where the cursor is" would be a guess. Relative movement cannot be wrong, only
* incomplete.
*
* One frame is one report
* -----------------------
* The report is five bytes: a button byte and four signed bytes of movement. A
* frame's deltas are therefore capped at one report's worth (config.h
* kMouseDeltaMax), and splitting a larger movement across frames is the
* controller's job, exactly as splitting a long paste is. The board does not
* split: it would turn one command into a queue depth nobody asked for, and a
* queue that fills is a fail-safe event.
*
* What the board never does
* -------------------------
* No click automation, no double-click, no drag recorded and replayed, no
* "move to the OK button", nothing scheduled. Like the text run, this exists
* because a human is moving a mouse right now; the moment it could act on its own
* it would be a different and worse device. See README "What this deliberately is
* not".
*/
#pragma once
#include <stdint.h>
#include <stddef.h>
#include <string.h>
namespace kr {
namespace mouse {
// USB HID Usage Tables, Generic Desktop: the pointer page's button bits, in the
// order the report descriptor lays them out (see TinyUSB's TUD_HID_REPORT_DESC_MOUSE,
// which is what this firmware enumerates). Five buttons and three bits of padding --
// a sixth would have nowhere to go, so the parser rejects one rather than dropping it.
enum Button : uint8_t {
kBtnNone = 0x00,
kBtnLeft = 0x01,
kBtnRight = 0x02,
kBtnMiddle = 0x04,
kBtnBackward = 0x08,
kBtnForward = 0x10,
kBtnAll = 0x1F,
};
/**
* The report on the wire: byte-identical to TinyUSB's `hid_mouse_report_t`
* (buttons, X delta, Y delta, wheel, horizontal pan), five bytes, no padding.
*
* `pan` is Consumer-page AC Pan, which is why it is in the descriptor at all: a
* trackpad's horizontal scroll has no other way out.
*/
struct MouseReport {
uint8_t buttons;
int8_t dx;
int8_t dy;
int8_t wheel;
int8_t pan;
};
static_assert(sizeof(MouseReport) == 5, "a relative mouse report is 5 bytes");
// "Nothing held, nothing moved." The report a fail-safe sends, and the one a real mouse
// sends whenever it presses a button without moving.
constexpr MouseReport kReportClear{0, 0, 0, 0, 0};
// One controller frame, before it becomes a report: the button byte restated, plus
// this frame's movement. Same five values the wire carries.
struct Frame {
uint8_t buttons = 0;
int8_t dx = 0;
int8_t dy = 0;
int8_t wheel = 0;
int8_t pan = 0;
};
inline bool reportsEqual(const MouseReport &a, const MouseReport &b) {
// No padding in a 5-byte struct of 1-byte members, so a byte compare is the
// whole comparison -- and it is the comparison the host itself makes.
return memcmp(&a, &b, sizeof(MouseReport)) == 0;
}
inline bool moved(const Frame &f) {
return f.dx != 0 || f.dy != 0 || f.wheel != 0 || f.pan != 0;
}
/**
* The mouse's tracked state, which is exactly one byte: which buttons are down.
*
* Deliberately small. Movement is not accumulated here -- a frame that reports 30
* pixels of movement is a report, not a change in a value that has to stay in step
* with anything -- so there is no state on the board for a lost frame to corrupt.
*/
class MouseState {
public:
/**
* Turn one frame into the report to send. Returns false when the frame carries
* nothing at all: no button change and no movement. An idle report is not free
* (it is the whole endpoint for that frame, and the target has to parse it) and
* it says nothing, so it is dropped rather than sent.
*/
bool apply(const Frame &f, MouseReport &out) {
const uint8_t b = static_cast<uint8_t>(f.buttons & kBtnAll);
if (!moved(f) && b == _buttons) return false;
_buttons = b;
out = MouseReport{b, f.dx, f.dy, f.wheel, f.pan};
return true;
}
// Every button up. The tracked byte is the only state there is, so this is the whole
// of it: the report that says so is the caller's to build (see OutputStage::releaseAll,
// which queues one whether or not anything was held -- a fail-safe should not depend
// on bookkeeping being right).
void releaseAll() { _buttons = kBtnNone; }
uint8_t buttons() const { return _buttons; }
bool down() const { return _buttons != kBtnNone; }
private:
uint8_t _buttons = kBtnNone;
};
} // namespace mouse
} // namespace kr
+185
View File
@@ -0,0 +1,185 @@
/**
* keyrelay -- the emit side of the bridge: what the target is told, in what order,
* and what happens when it cannot be told. Pure logic, host-tested
* (tests/test_output.cpp): no Arduino, no TinyUSB.
*
* Why the queues and the devices live together
* --------------------------------------------
* Before the mouse there was one queue, one device and one fail-safe, and the
* fail-safe was a function in main.cpp that knew about all three by name. That made
* the bug this module exists to prevent possible: `releaseEverything()` cleared the
* queue and the tracker and never queued the report that tells the target the keys
* went up -- and disarm is exactly the moment the controller stops talking, so no
* later frame was coming to carry it. A key stayed down on the target until the next
* keystroke, and after a disarm there need never be one.
*
* So the state and the funnel are one object now, and the funnel is the only way to
* reach it:
*
* - clearing means queueing the release. Both devices, in the same call.
* - a queue that cannot take a report is reported to the caller as an overflow,
* which the caller treats the way it always has: release, disarm, say so. There
* is no "drop the mouse frame, it is only a movement" special case, because the
* frame that does not fit may be the one releasing a held button, and a mouse
* button stuck down on a target is the same class of failure as a stuck key.
* - the pasted run is a member, so cancelling work in flight is not something a
* caller can forget to do.
*
* Order of consequence
* --------------------
* Keyboard and mouse reports go out on one shared TinyUSB interface and one shared
* endpoint, so there is one queue per device and one drain. The keyboard queue is served
* first when both have something: it is what the device is for, and a burst of movement
* must not delay a key release.
*/
#pragma once
#include <stdint.h>
#include <stddef.h>
#include "config.h"
#include "keystate.h"
#include "mouse.h"
#include "text.h"
namespace kr {
/**
* A ring of reports waiting for the target's next 1 ms poll.
*
* Two identical reports in a row are one report: distinct transitions are always
* kept, and collapsing those is how a fast tap would vanish.
*/
template <typename T, size_t DEPTH>
class ReportQueue {
public:
bool push(const T &r) {
if (_count > 0) {
const size_t last = (_head + _count - 1) % DEPTH;
if (reportsEqual(_items[last], r)) return true;
}
if (_count >= DEPTH) return false;
_items[(_head + _count) % DEPTH] = r;
_count++;
return true;
}
const T &front() const { return _items[_head]; }
void pop() {
if (!_count) return;
_head = (_head + 1) % DEPTH;
_count--;
}
void clear() { _head = 0; _count = 0; }
size_t count() const { return _count; }
size_t capacity() const { return DEPTH; }
bool empty() const { return _count == 0; }
private:
T _items[DEPTH];
size_t _head = 0;
size_t _count = 0;
};
using KeyboardQueue = ReportQueue<KeyboardReport, kReportQueueDepth>;
using MouseQueue = ReportQueue<mouse::MouseReport, kMouseReportQueueDepth>;
/**
* A synthetic chord (Ctrl+Alt+Del, Super+Left, ...): a press that has to be followed
* by a release after a fixed hold without disturbing the tracked key state, so the
* keys that were genuinely held before the chord come back afterwards.
*/
struct ChordState {
bool active = false;
uint32_t releaseAtMs = 0;
};
/**
* Everything the bridge can put on the USB wire, and the one place that decides it.
*
* `canType` is the caller's answer to "is a target attached and is the HID interface
* live": the release reports are only queued when somebody could receive them, so a
* fail-safe that fires while detached does not fill a queue that the next drain will
* count as dropped.
*/
class OutputStage {
public:
enum class Pushed : uint8_t { Nothing, Ok, Overflow };
// ---- tracked state -------------------------------------------------------
KeyboardState keys;
mouse::MouseState mouseState;
text::Typewriter typer;
ChordState chord;
// ---- the queues ----------------------------------------------------------
// Names, not `keyboard` / `mouse`: a member called `mouse` would shadow the
// kr::mouse namespace for every type name written inside this class.
KeyboardQueue keyQ;
MouseQueue mouseQ;
/**
* Queue the tracked key state, if it changed since the last time. The dirty flag
* is cleared either way: an overflow is a fail-safe, not a report to retry.
*/
Pushed pushKeys() {
if (!keys.dirty()) return Pushed::Nothing;
keys.ackDirty();
return keyQ.push(keys.report()) ? Pushed::Ok : Pushed::Overflow;
}
Pushed pushKeyboardReport(const KeyboardReport &r) {
return keyQ.push(r) ? Pushed::Ok : Pushed::Overflow;
}
/**
* One mouse frame: restated buttons plus this frame's movement. Nothing is queued
* when the frame said nothing (no button change, no movement) -- see mouse.h.
*/
Pushed pushMouse(const mouse::Frame &f) {
mouse::MouseReport r;
if (!mouseState.apply(f, r)) return Pushed::Nothing;
return mouseQ.push(r) ? Pushed::Ok : Pushed::Overflow;
}
/**
* The funnel. Every fail-safe arrives here: `R`, disarm, the board's button, the
* controller vanishing, the target taking the bus away, silence, a queue that
* filled up.
*
* It does four things, and the fourth is the one that was missing once:
* 1. drop what has not gone out yet, for both devices;
* 2. raise the keys and the buttons, and end a chord mid-hold;
* 3. cancel a pasted run -- "release every key" means "and stop typing";
* 4. queue the all-up reports, because a tracker with nothing pressed is not the
* same statement as a target with nothing pressed.
*
* The two arguments say which reports the target could actually receive: the keyboard
* when a target is attached at all, the mouse only when the interface exists AND the
* target is in report protocol. A report nobody can receive is not queued, because the
* drain counts whatever is left as dropped -- and in boot protocol a 5-byte mouse
* report is exactly the corruption the mouse refuses to send in the first place.
*
* The tracked state is cleared either way. "Nothing is held" has to be true of the
* board after a fail-safe whether or not anybody was told, or the next controller
* inherits a button its operator let go of.
*
* The reports themselves are unconditional rather than conditional on something having
* been held: a panic path should not depend on bookkeeping being right, and a real
* mouse sends an all-zero report every time it presses a button without moving.
*/
bool releaseAll(bool keysOut, bool mouseOut) {
keyQ.clear();
mouseQ.clear();
keys.clear();
chord.active = false;
typer.cancel();
mouseState.releaseAll(); // the byte, whatever the target is told about it
bool queued = false;
if (keysOut && keyQ.push(keys.report())) queued = true;
if (mouseOut && mouseQ.push(mouse::kReportClear)) queued = true;
return queued;
}
};
} // namespace kr
+145 -7
View File
@@ -17,7 +17,10 @@
* c -> d Y=<mods>:<u1>,<u2>... one synthetic burst of a chord the laptop's
* own OS steals (VT switch, Alt+Tab, PrtSc)
* c -> d P=<ms>:<hex> type pasted text, <ms> ms per character
* c -> d R release every key (kept armed)
* c -> d M=<btn>:<dx>:<dy>:<wh>:<pan> one mouse report: the whole button
* byte plus this frame's movement
* c -> d R release every key AND every mouse button
* (kept armed)
* c -> d K keepalive
* d -> c OK accepted, or the reply to H
* d -> c E<code>:<text> error, e.g. E4:arm required
@@ -27,6 +30,13 @@
* hex digits, lowercase or uppercase. Empty key lists are legal (`S=00:` means
* "nothing pressed"), which is what makes the protocol self-healing.
*
* `M=` carries `<btn>` as two hex digits (bits 0..4: left, right, middle, backward,
* forward -- five buttons is all a mouse report has, so a sixth is rejected rather
* than dropped) and the four movements as signed decimal, each within one report's
* worth of +/-127. All five fields are always present. Buttons are restated on every
* frame, exactly like `S=`; the movement is a delta and belongs to this frame only.
* See mouse.h for why that is as safe as a state restatement.
*
* `P=` is the one command whose payload is text rather than a key state: `<hex>`
* is the bytes to type (so at most kTextFrameMax characters per frame) and `<ms>`
* is the pace in milliseconds per character, 0 for the board's default. The board
@@ -57,6 +67,7 @@ enum class Cmd : uint8_t {
SecureAttention, // Ctrl+Alt+Del
Chord, // one-shot burst of an arbitrary chord
Text, // pasted text to type, paced on the board
Mouse, // one mouse report: button byte plus movement
ReleaseAll,
KeepAlive,
Bad,
@@ -71,6 +82,8 @@ struct Message {
uint8_t payload[kTextFrameMax]; // Text: the characters to type
uint8_t nPayload;
uint16_t periodMs; // Text: ms per character, 0 = board default
uint8_t buttons; // Mouse: the whole button byte, restated
int8_t dx, dy, wheel, pan; // Mouse: this frame's movement
};
inline void initMessage(Message &m) {
@@ -82,6 +95,8 @@ inline void initMessage(Message &m) {
m.nPayload = 0;
memset(m.payload, 0, sizeof(m.payload));
m.periodMs = 0;
m.buttons = 0;
m.dx = m.dy = m.wheel = m.pan = 0;
}
inline const char *name(Cmd c) {
@@ -94,6 +109,7 @@ inline const char *name(Cmd c) {
case Cmd::SecureAttention: return "secure-attention";
case Cmd::Chord: return "chord";
case Cmd::Text: return "text";
case Cmd::Mouse: return "mouse";
case Cmd::ReleaseAll: return "release-all";
case Cmd::KeepAlive: return "keepalive";
default: return "bad";
@@ -189,6 +205,64 @@ inline Cmd parseText(const char *data, size_t len, Message &out) {
return Cmd::Text;
}
/**
* Parse one signed decimal movement: optional '-', one to three digits, and at most
* one report's worth (kMouseDeltaMax). Nothing else is legal -- no '+', no spaces,
* no value that would need splitting -- so a frame either describes one report
* exactly or is refused and changes nothing on the target.
*
* `-0` is accepted as 0; it is a hand-typed oddity, not an ambiguity.
*/
inline bool parseSignedDelta(const char *s, size_t len, int8_t &out) {
if (len == 0 || len > 4) return false;
size_t i = 0;
const bool neg = (s[0] == '-');
if (neg) i = 1;
if (i >= len) return false; // '-' with nothing after it
int v = 0;
for (; i < len; i++) {
if (s[i] < '0' || s[i] > '9') return false;
v = v * 10 + (s[i] - '0');
if (v > kMouseDeltaMax) return false; // more than one report is a typo
}
out = static_cast<int8_t>(neg ? -v : v);
return true;
}
/**
* Parse `M=<btn>:<dx>:<dy>:<wheel>:<pan>` -- five fields, always all five.
*
* The button byte is hex and must fit the five bits the report has; the movements are
* signed decimal. A sixth button bit is refused rather than quietly masked: the
* controller believed it pressed something this report cannot carry, and a click that
* silently never happens is the failure mode this protocol keeps refusing to have.
*/
inline Cmd parseMouse(const char *data, size_t len, Message &out) {
// `data` starts at the '=', so "M=00:0:0:0:0" arrives here as 11 bytes.
if (len < 11 || data[0] != '=') return Cmd::Bad;
const char *field[5];
size_t flen[5];
size_t n = 0, start = 1;
for (size_t i = 1; i <= len; i++) {
if (i != len && data[i] != ':') continue;
if (n >= 5) return Cmd::Bad; // a sixth field
field[n] = data + start;
flen[n] = i - start;
n++;
start = i + 1;
}
if (n != 5) return Cmd::Bad; // missing field, or a trailing ':'
if (!parseByte(field[0], flen[0], out.buttons)) return Cmd::Bad;
if (out.buttons & ~kMouseButtonMask) return Cmd::Bad;
if (!parseSignedDelta(field[1], flen[1], out.dx)) return Cmd::Bad;
if (!parseSignedDelta(field[2], flen[2], out.dy)) return Cmd::Bad;
if (!parseSignedDelta(field[3], flen[3], out.wheel)) return Cmd::Bad;
if (!parseSignedDelta(field[4], flen[4], out.pan)) return Cmd::Bad;
return Cmd::Mouse;
}
/**
* Parse one command frame. `len` must be the frame's payload length; a trailing
* CR or LF is tolerated (hand-typed test clients) but nothing else after the
@@ -259,6 +333,9 @@ inline Cmd parse(const char *data, size_t len, Message &out) {
case 'P':
return out.cmd = parseText(data + 1, len - 1, out);
case 'M':
return out.cmd = parseMouse(data + 1, len - 1, out);
default:
return out.cmd = Cmd::Bad;
}
@@ -301,6 +378,55 @@ inline size_t formatChord(char *out, size_t cap, uint8_t mods, const uint8_t *us
return r;
}
/**
* Render a signed movement the way the parser reads it: no '+', no padding.
* Returns the new offset, or 0 if `cap` would be exceeded.
*/
inline size_t putSigned(char *out, size_t cap, size_t o, int8_t v) {
char tmp[8];
size_t n = 0;
const bool neg = (v < 0);
unsigned a = neg ? static_cast<unsigned>(-(v + 1)) + 1u : static_cast<unsigned>(v);
if (a == 0) tmp[n++] = '0';
while (a) { tmp[n++] = static_cast<char>('0' + (a % 10u)); a /= 10u; }
if (neg) tmp[n++] = '-';
if (o + n > cap) return 0;
for (size_t i = 0; i < n; i++) out[o++] = tmp[n - 1 - i];
return o;
}
/**
* Render a mouse message, so the page, relayctl and the host tests all produce
* byte-identical frames for the same movement. Returns 0 and leaves the buffer empty
* rather than sending half a report; a movement larger than one report is the
* caller's to split before it gets here.
*/
inline size_t formatMouse(char *out, size_t cap, uint8_t buttons, int8_t dx, int8_t dy,
int8_t wheel, int8_t pan) {
static const char kHex[] = "0123456789abcdef";
if (buttons & ~kMouseButtonMask) { out[0] = '\0'; return 0; }
if (dx > kMouseDeltaMax || dx < -kMouseDeltaMax) { out[0] = '\0'; return 0; }
if (dy > kMouseDeltaMax || dy < -kMouseDeltaMax) { out[0] = '\0'; return 0; }
if (wheel > kMouseDeltaMax || wheel < -kMouseDeltaMax) { out[0] = '\0'; return 0; }
if (pan > kMouseDeltaMax || pan < -kMouseDeltaMax) { out[0] = '\0'; return 0; }
if (cap < 12) { out[0] = '\0'; return 0; } // "M=00:0:0:0:0" plus its NUL
size_t o = 0;
out[o++] = 'M';
out[o++] = '=';
out[o++] = kHex[(buttons >> 4) & 0xF];
out[o++] = kHex[buttons & 0xF];
const int8_t v[4] = {dx, dy, wheel, pan};
for (int i = 0; i < 4; i++) {
out[o++] = ':';
const size_t next = putSigned(out, cap - 1, o, v[i]); // -1: room for the NUL
if (next == 0) { out[0] = '\0'; return 0; }
o = next;
}
out[o] = '\0';
return o;
}
/**
* Render a text message, so that every client -- the page, relayctl, the host
* tests -- produces byte-identical frames for the same paste. `periodMs` 0 means
@@ -347,15 +473,27 @@ inline size_t formatText(char *out, size_t cap, uint16_t periodMs,
// 4 not armed 5 no USB host attached
// 6 bad message 7 too many keys for one report
// 8 text buffer full 9 a character this keyboard map cannot type
// 10 the mouse interface is off on this board
// 11 the target asked for boot protocol, so a mouse report cannot be sent
//
// The code is written in decimal at full width -- `code % 10` truncated 10 to `E0`,
// which is a code no client would recognise. Codes below 10 render byte-identically
// to the single-digit form, so every existing client keeps working unchanged; both
// clients read the code up to the first ':' rather than assuming one character.
inline size_t formatError(char *out, size_t cap, uint8_t code, const char *text) {
char digits[4];
size_t dn = 0;
if (code == 0) digits[dn++] = '0';
for (uint8_t v = code; v; v /= 10) digits[dn++] = static_cast<char>('0' + (v % 10));
const size_t n = strlen(text);
if (cap < 4 || n + 4 > cap) return 0; // never a truncated frame
if (2 + dn + n >= cap) return 0; // never a truncated frame
out[0] = 'E';
out[1] = static_cast<char>('0' + (code % 10));
out[2] = ':';
memcpy(out + 3, text, n);
out[3 + n] = '\0';
return 3 + n;
for (size_t i = 0; i < dn; i++) out[1 + i] = digits[dn - 1 - i];
out[1 + dn] = ':';
memcpy(out + 2 + dn, text, n);
out[2 + dn + n] = '\0';
return 2 + dn + n;
}
/**
+290 -5
View File
@@ -22,6 +22,14 @@
* and cancels a run of pasted text that is mid-flight. A keyboard that keeps
* typing after you tabbed away is the failure mode this design cannot be allowed
* to have.
* - The mouse is captured, never grabbed. "Capture the pointer" asks the browser for
* Pointer Lock -- the same thing a web game asks for -- so the cursor cannot escape
* the tab while the board is forwarding it, and Escape always gives it back. Capture
* is a second deliberate act on top of arming and it is the only way a mouse report
* ever leaves this tab. Every report restates the whole button byte, so a frame lost
* in the WiFi does not leave a button held: the next one says the truth. Losing the
* lock -- Escape, blur, tab hidden, the browser refusing -- releases every button on
* the target before anything else is considered.
* - Text goes to the target from one place: the box below, by pressing its button,
* while armed. It is not a macro facility -- there is no history, nothing is
* scheduled or repeated, and the board holds the characters only until it has
@@ -116,6 +124,12 @@ p.hint{color:var(--dim);font-size:11.5px;margin:8px 0 0}
footer{padding:0 14px 18px;color:#5c6672;font-size:11.5px;max-width:980px}
kbd{border:1px solid var(--line);border-bottom-width:2px;border-radius:4px;padding:0 5px;
font-size:11.5px;background:#0b0f14}
.mousepad{height:118px;border:1px dashed var(--line);border-radius:10px;background:#0b0f14;
display:flex;align-items:center;justify-content:center;text-align:center;padding:0 14px;
color:var(--dim);font-size:12.5px;cursor:crosshair;user-select:none}
.mousepad:hover{border-color:var(--accent);color:var(--fg)}
.mousepad.captured{border-style:solid;border-color:var(--hot);color:var(--hot);
background:#1d0d10;cursor:none}
body.armed{outline:3px solid var(--hot);outline-offset:-3px}
body.off{opacity:.92}
</style>
@@ -129,6 +143,7 @@ body.off{opacity:.92}
<span class="pill" id="pLink">link</span>
<span class="pill" id="pArmed">disarmed</span>
<span class="pill" id="pLeds" title="reported by the target">caps num</span>
<span class="pill off" id="pMouse" title="is this tab holding your mouse pointer?">mouse free</span>
</div>
</header>
@@ -183,6 +198,7 @@ body.off{opacity:.92}
<div>keys sent</div><div id="sKeys">0</div>
<div>text sent</div><div id="sTyped">0</div>
<div>chords</div><div id="sChords">0</div>
<div>mouse reports</div><div id="sMouse">0</div>
<div>dropped</div><div id="sDrop">0</div>
</div>
<h2 style="margin-top:10px">Held now</h2>
@@ -206,6 +222,33 @@ body.off{opacity:.92}
</div>
</div>
<div class="card">
<div class="rowhead">
<h2>Mouse</h2>
<button class="k" id="mCapture" title="Asks the browser for Pointer Lock, the same thing a web game does: your cursor cannot escape this tab while the board is forwarding it. Escape gives it back.">capture the pointer</button>
<button class="k danger" id="mRelease" title="Every mouse button up. The cursor stays captured.">release buttons</button>
<span class="pill off" id="mStat">not captured</span>
</div>
<div class="mousepad" id="mPad" tabindex="-1">
<span id="mPadText">click here to hand this tab the mouse</span>
</div>
<div class="row" style="margin-top:8px">
<span style="color:var(--dim);font-size:12.5px">buttons held</span>
<div class="keys" id="mHeld"><span style="color:var(--dim)">none</span></div>
<span class="pill off" id="mFrames">0 reports</span>
</div>
<p class="hint">
Movement is <b>relative</b>, like a real mouse: the board says how far the pointer
moved, never where it is, so the target's own pointer speed and acceleration decide
where that lands. Buttons are restated on every frame, which is why a frame lost in
the WiFi cannot leave one held down. <kbd>Esc</kbd> frees the cursor and lets go of
every button &mdash; the browser keeps that key for itself, so it never reaches the
target, and losing the tab does the same. No auto-click, no macro, no saved gesture,
nothing scheduled: if nobody is moving a mouse, nothing moves. Capture needs the
bridge armed and this board's mouse interface on (<code>mouse on</code> over serial).
</p>
</div>
<div class="card">
<h2>Send text</h2>
<textarea id="paste" rows="3" spellcheck="false" autocomplete="off"
@@ -241,9 +284,10 @@ body.off{opacity:.92}
</main>
<footer>
Shortcuts while armed: <kbd>Ctrl+Alt+Esc</kbd> release every key and disarm &middot;
<kbd>Ctrl+Alt+d</kbd> the Ctrl+Alt+Del confirm &middot; <kbd>Esc</kbd> and every other
key are forwarded to the target untouched.
Shortcuts while armed: <kbd>Ctrl+Alt+Esc</kbd> release every key and every mouse button
and disarm &middot; <kbd>Ctrl+Alt+d</kbd> the Ctrl+Alt+Del confirm &middot; while the
pointer is captured <kbd>Esc</kbd> belongs to the browser and frees the cursor &middot;
every other key is forwarded to the target untouched.
Nothing is forwarded while disarmed; reload, focus loss and socket loss all disarm.
</footer>
@@ -455,6 +499,8 @@ function applyStatus(s) {
$("sKeys").textContent = s.keys || 0;
$("sTyped").textContent = s.typed || 0;
$("sChords").textContent = s.chords || 0;
$("sMouse").textContent = s.mframes || 0;
$("mFrames").textContent = (s.mframes || 0) + " reports";
$("sDrop").textContent = s.drop || 0;
if (typing) $("typeStat").textContent = "typing (" + (s.typing || 0) + " still to go)";
const net = $("pNet");
@@ -467,6 +513,10 @@ function applyStatus(s) {
leds.textContent = ((s.leds & 2) ? "CAPS" : "caps") + " " + ((s.leds & 1) ? "NUM" : "num");
leds.className = "pill " + ((s.leds & 3) ? "on" : "off");
if (s.armed !== armed) setArmed(!!s.armed, "board");
// The board knows whether it can report a mouse; this tab does not decide that for it.
if (mCaptured && s.mouse === 0) dropMouse("the board cannot report a mouse any more");
if (mButtons !== (s.mbtn || 0) && !mCaptured) paintMouse();
refreshMouseUi();
if (s.dflt) {
$("banner").innerHTML = '<b style="color:var(--hot)">The board is still using the '
+ 'default passphrase.</b> Set your own with the serial command <code>token '
@@ -492,9 +542,11 @@ function setArmed(on, why) {
const p = $("pArmed");
p.className = "pill " + (on ? "bad" : "off");
p.textContent = on ? "ARMED" : "disarmed";
if (!on) { abortTyping("disarmed"); resetKeys(); }
if (!on) { abortTyping("disarmed"); resetKeys(); dropMouse("disarmed"); }
if (why !== "local") log(on ? "ARMED -- typing goes to the target" : "disarmed (" + why + ")",
on ? "g" : "w");
refreshSendBox();
refreshMouseUi();
}
$("arm").addEventListener("click", () => {
@@ -560,14 +612,17 @@ document.addEventListener("keyup", ev => {
function panic() {
abortTyping("Ctrl+Alt+Esc");
resetKeys();
dropMouse("Ctrl+Alt+Esc");
send("R");
if (armed) send("A0");
setArmed(false, "panic (Ctrl+Alt+Esc)");
}
function dropEverything(why) {
const wasAnything = armed || mods || keys.length || mButtons || mCaptured;
abortTyping(why);
if (!armed && !mods && !keys.length) return;
dropMouse(why);
if (!wasAnything) return;
resetKeys();
send("R");
if (armed) send("A0");
@@ -575,6 +630,9 @@ function dropEverything(why) {
}
window.addEventListener("blur", () => {
// The mouse never gets the stay-armed exception. A cursor you are not looking at is
// not a cursor you are aiming, and the browser is releasing the lock anyway.
dropMouse("tab lost focus");
if ($("keepArmed").checked) {
// Staying armed across a blur is the operator's choice; a run of text still
// stops, because nothing here continues unwatched.
@@ -632,6 +690,232 @@ function fire(frame) {
log("chord " + frame, "g");
}
/* ------------------------------------------------------------------ *
* Mouse
*
* Capture, not grab. This tab asks the browser for Pointer Lock -- the
* same request a web game makes -- and while it holds it, the cursor
* cannot leave the tab and every movement goes to the board. Escape
* always gives the cursor back: that is the browser's own guarantee and
* nothing here can take it away, which is why the mouse does not need a
* second panic key.
*
* What goes on the wire is one report per frame: the complete button
* byte, then this frame's movement. Buttons are restated every frame like
* the keys are, so a lost frame did not click and the next frame is the
* truth either way. Movement is a delta with no accumulated position
* anywhere, so a lost frame means the pointer travelled a little less --
* which is all a relative mouse ever promises. That asymmetry is why
* dropping the tail of a huge movement is reported rather than fatal, and
* why a queue overflow on a mouse frame still releases and disarms: the
* frame that does not fit may be the one letting go of a button.
*
* The rate is capped in the browser, not on the board: MOUSE_FRAME_MS is
* the shortest interval between frames out of this tab, and MOUSE_MAX_FRAMES
* how many one flush may emit. Beyond that the movement is dropped and
* said out loud -- a cursor that lagged is survivable, a silent divergence
* is not.
* ------------------------------------------------------------------ */
const MOUSE_DELTA_MAX = 127; // one report's worth == config.h kMouseDeltaMax
const MOUSE_BUTTON_MASK = 0x1f; // five buttons == config.h kMouseButtonMask
const MOUSE_FRAME_MS = 8; // 125 Hz: the ceiling this tab sends at
const MOUSE_MAX_FRAMES = 8; // per flush, i.e. ~1000 px of travel per flush
const MOUSE_WHEEL_PX = 100; // ~one wheel detent, for deltaMode 0 (pixels)
// DOM MouseEvent.button -> the report's button bit, in the order the HID report
// descriptor lays them out (left, right, middle, backward, forward).
const MOUSE_BTN = { 0: 0x01, 2: 0x02, 1: 0x04, 3: 0x08, 4: 0x10 };
const MOUSE_BTN_NAME = { 0x01: "Left", 0x02: "Right", 0x04: "Middle",
0x08: "Back", 0x10: "Forward" };
let mButtons = 0; // the byte restated on every frame out
let mCaptured = false; // this tab holds Pointer Lock
let pendX = 0, pendY = 0, pendWheel = 0, pendPan = 0;
const clampDelta = v => Math.max(-MOUSE_DELTA_MAX, Math.min(MOUSE_DELTA_MAX, v));
function mouseFrame(buttons, dx, dy, wheel = 0, pan = 0) {
// Every field is always written: the firmware parses exactly five and refuses anything
// else, so a missing one is a refused frame, not a default. The defaults here are
// defence against a call site typo, not a shorter wire form.
return "M=" + (buttons & MOUSE_BUTTON_MASK).toString(16).padStart(2, "0") +
":" + dx + ":" + dy + ":" + wheel + ":" + pan;
}
function paintMouse() {
const held = $("mHeld");
held.textContent = "";
const bits = [];
for (const bit of [0x01, 0x02, 0x04, 0x08, 0x10]) if (mButtons & bit) bits.push(bit);
if (!bits.length) held.innerHTML = '<span style="color:var(--dim)">none</span>';
for (const bit of bits) {
const s = document.createElement("span");
s.className = "key mod";
s.textContent = MOUSE_BTN_NAME[bit];
held.appendChild(s);
}
const pill = $("pMouse");
const stat = $("mStat");
const pad = $("mPad");
if (mCaptured) {
pill.className = "pill bad"; pill.textContent = "mouse captured";
stat.className = "pill bad"; stat.textContent = "captured -- Esc frees the cursor";
$("mPadText").textContent = "the board has your mouse -- press Esc to give it back";
pad.classList.add("captured");
} else {
pill.className = "pill off";
pill.textContent = mButtons ? "buttons held!" : "mouse free";
stat.className = mButtons ? "pill bad" : "pill off";
stat.textContent = mButtons ? "buttons still held" : "not captured";
$("mPadText").textContent = "click here to hand this tab the mouse";
pad.classList.remove("captured");
}
refreshMouseUi();
}
function mouseAvailable() {
// Unknown until the first heartbeat; a board that says 0 has the interface switched
// off, or the target chose boot protocol and would read a mouse report as keystrokes.
return !status || status.mouse !== 0;
}
function refreshMouseUi() {
const cap = $("mCapture"), rel = $("mRelease");
cap.disabled = !authed || !armed || mCaptured || !mouseAvailable();
rel.disabled = !mCaptured && mButtons === 0;
const hint = $("mPadText");
if (!mCaptured && !mouseAvailable()) {
hint.textContent = "this board has no mouse available -- `mouse on` over serial, "
+ "and the target must not be in boot protocol";
}
}
/* Send whatever has accumulated, as as many reports as it needs. */
function flushMouse() {
for (let n = 0; n < MOUSE_MAX_FRAMES; n++) {
const dx = clampDelta(pendX), dy = clampDelta(pendY);
const wheel = clampDelta(Math.trunc(pendWheel)), pan = clampDelta(Math.trunc(pendPan));
if (!dx && !dy && !wheel && !pan) return;
pendX -= dx; pendY -= dy; pendWheel -= wheel; pendPan -= pan;
if (!send(mouseFrame(mButtons, dx, dy, wheel, pan))) { dropMouse("link lost"); return; }
}
// More than MOUSE_MAX_FRAMES worth of movement in one MOUSE_FRAME_MS window: this tab
// cannot get it out fast enough. Say so -- the pointer moved less than the mouse did,
// which is a lagging cursor and not a stuck one.
if (pendX || pendY || Math.abs(pendWheel) >= 1 || Math.abs(pendPan) >= 1) {
log("dropped " + Math.round(pendX) + "," + Math.round(pendY) +
" px of movement this tab could not send in time", "w");
}
pendX = 0; pendY = 0; pendWheel = 0; pendPan = 0;
}
function dropMouse(why) {
const wasSomething = mCaptured || mButtons;
mCaptured = false;
pendX = pendY = pendWheel = pendPan = 0;
if (mButtons) {
// Restate the whole button byte as "nothing down" -- but only while the bridge is
// still armed. A board that just disarmed released the buttons itself (src/output.h,
// the same funnel that releases the keys), so sending M= there would only earn an E4
// and bury the message that explains why the mouse let go.
if (armed) send(mouseFrame(0, 0, 0, 0, 0));
mButtons = 0;
}
if (document.pointerLockElement && document.exitPointerLock) document.exitPointerLock();
paintMouse();
if (wasSomething && why) log("mouse released (" + why + ")", "w");
}
function takeMouse() {
if (!authed) { log("not linked -- enter the passphrase and press connect", "w"); return; }
if (!armed) { log("arm the bridge first -- the mouse is not a free pass either", "w"); return; }
if (!mouseAvailable()) {
log("this board cannot report a mouse -- `mouse on` over serial and reboot, or the "
+ "target is in boot protocol", "w");
return;
}
const pad = $("mPad");
if (typeof pad.requestPointerLock !== "function") {
log("this browser has no Pointer Lock, so the mouse cannot be forwarded", "e");
return;
}
const p = pad.requestPointerLock();
if (p && p.catch) p.catch(() => log("the browser refused pointer lock -- click the pad "
+ "again (it needs the tab to be focused)", "e"));
}
$("mCapture").addEventListener("click", takeMouse);
$("mPad").addEventListener("click", () => { if (!mCaptured) takeMouse(); });
$("mRelease").addEventListener("click", () => {
if (!mButtons) { log("no mouse buttons are held", "w"); return; }
flushMouse(); // the movement so far belongs to the old button state
mButtons = 0;
send(mouseFrame(0, 0, 0, 0, 0));
log("mouse every button released", "w");
paintMouse();
});
document.addEventListener("pointerlockchange", () => {
if (document.pointerLockElement === $("mPad")) {
mCaptured = true;
mButtons = 0;
pendX = pendY = pendWheel = pendPan = 0;
log("mouse captured -- the board moves your pointer now. Esc gives it back.", "g");
} else {
dropMouse("pointer released");
return;
}
paintMouse();
});
document.addEventListener("pointerlockerror", () => {
log("the browser refused pointer lock", "e");
dropMouse("refused");
});
document.addEventListener("mousemove", ev => {
if (!mCaptured) return;
pendX += ev.movementX || 0;
pendY += ev.movementY || 0;
});
function mouseButtonEvent(ev, down) {
if (!mCaptured) return;
const bit = MOUSE_BTN[ev.button];
if (bit === undefined) return; // a button this report cannot carry
const next = down ? (mButtons | bit) : (mButtons & ~bit);
if (next === mButtons) return;
// Order matters: the movement collected so far happened with the OLD buttons, and a
// drag has to arrive as "button down, then movement", never the other way round.
flushMouse();
mButtons = next;
if (!send(mouseFrame(mButtons, 0, 0, 0, 0))) { dropMouse("link lost"); return; }
log("mouse " + MOUSE_BTN_NAME[bit] + " " + (down ? "down" : "up") + " " +
mouseFrame(mButtons, 0, 0, 0, 0), down ? "g" : undefined);
paintMouse();
}
document.addEventListener("mousedown", ev => { if (mCaptured) { ev.preventDefault(); mouseButtonEvent(ev, true); } },
{passive:false});
document.addEventListener("mouseup", ev => mouseButtonEvent(ev, false));
/* The right button would otherwise open this tab's context menu. */
document.addEventListener("contextmenu", ev => { if (mCaptured) ev.preventDefault(); });
document.addEventListener("wheel", ev => {
if (!mCaptured) return;
ev.preventDefault();
// The report carries wheel and pan in detents, not pixels, and a detent is one notch
// of a wheel: a trackpad's fine-grained scroll is quantised because the byte has no
// other unit. The remainder is carried, so slow scrolling still adds up.
const scale = ev.deltaMode === 1 ? 1 : (ev.deltaMode === 2 ? 10 : 1 / MOUSE_WHEEL_PX);
pendWheel += ev.deltaY * scale;
pendPan += ev.deltaX * scale;
}, {passive:false});
/* The one rate limit between a gaming mouse and a target: nothing leaves this tab more
* often than every MOUSE_FRAME_MS, however fast the browser delivers events. */
setInterval(() => { if (mCaptured) flushMouse(); }, MOUSE_FRAME_MS);
/* ------------------------------------------------------------------ *
* Pasted text
*
@@ -835,6 +1119,7 @@ if (sessionStorage.getItem("keyrelay.token")) connect();
else log("enter the board's passphrase, then press connect");
paintHeld();
refreshSendBox();
paintMouse();
</script>
</body>
</html>
+5 -4
View File
@@ -4,9 +4,10 @@
# tests/run_tests.sh build and run every suite
# tests/run_tests.sh clean remove the build directory
#
# The modules under test (src/keystate.h, src/proto.h, src/text.h, src/ws_codec.h,
# src/sha1.h) have no Arduino or ESP-IDF dependency by design, so the exact code that
# runs on the board is compiled and exercised here before anything is flashed.
# The modules under test (src/keystate.h, src/proto.h, src/text.h, src/mouse.h,
# src/output.h, src/ws_codec.h, src/sha1.h) have no Arduino or ESP-IDF dependency by
# design, so the exact code that runs on the board is compiled and exercised here before
# anything is flashed.
#
# A C++ compiler is not always installed on a workstation, so the runner picks one:
# $CXX if you set it, it wins
@@ -32,7 +33,7 @@ images=(
"docker.io/library/gcc:14"
)
suites=(test_keystate test_proto test_text test_ws_codec)
suites=(test_keystate test_proto test_text test_mouse test_output test_ws_codec)
build_and_run() {
local cxx="$1"
+157
View File
@@ -0,0 +1,157 @@
// tests for the emulated mouse: tests/test_mouse.cpp
//
// The two things worth proving about a relative mouse are that a frame which changes
// nothing sends nothing, and that a button release is never conditional on movement.
// Everything else about it is arithmetic.
#include <cstddef>
#include <cstring>
#include "mouse.h"
#include "test.h"
using kr::mouse::Button;
using kr::mouse::Frame;
using kr::mouse::MouseReport;
using kr::mouse::MouseState;
namespace {
bool allZero(const MouseReport &r) {
const uint8_t *b = reinterpret_cast<const uint8_t *>(&r);
for (size_t i = 0; i < sizeof(MouseReport); i++) {
if (b[i] != 0) return false;
}
return true;
}
} // namespace
int main() {
CASE("the report is the five bytes the descriptor promises");
{
CHECK_EQ(sizeof(MouseReport), 5);
// The layout is asserted, not assumed: buttons, X, Y, wheel, pan. A struct that
// reordered itself would still compile and would move the target's cursor with the
// wheel byte.
CHECK_EQ(offsetof(MouseReport, buttons), 0);
CHECK_EQ(offsetof(MouseReport, dx), 1);
CHECK_EQ(offsetof(MouseReport, dy), 2);
CHECK_EQ(offsetof(MouseReport, wheel), 3);
CHECK_EQ(offsetof(MouseReport, pan), 4);
}
CASE("button bits are the report's order, not the DOM's");
{
CHECK_EQ(kr::mouse::kBtnLeft, 0x01);
CHECK_EQ(kr::mouse::kBtnRight, 0x02);
CHECK_EQ(kr::mouse::kBtnMiddle, 0x04);
CHECK_EQ(kr::mouse::kBtnBackward, 0x08);
CHECK_EQ(kr::mouse::kBtnForward, 0x10);
CHECK_EQ(kr::mouse::kBtnAll, 0x1F);
}
CASE("movement produces a report, nothing produces nothing");
{
MouseState m;
MouseReport r;
CHECK(m.apply(Frame{0, 30, -4, 0, 0}, r));
CHECK_EQ(r.buttons, 0);
CHECK_EQ(r.dx, 30);
CHECK_EQ(r.dy, -4);
CHECK_EQ(r.wheel, 0);
CHECK_EQ(r.pan, 0);
// An idle frame is the whole endpoint for that frame and says nothing, so it is
// dropped rather than sent -- this is what stops a chatty controller keeping a
// target busy at 125 Hz with a pointer that is not moving.
CHECK(!m.apply(Frame{0, 0, 0, 0, 0}, r));
CHECK(!m.apply(Frame{0, 0, 0, 0, 0}, r));
}
CASE("a button change is a report even with no movement at all");
{
MouseState m;
MouseReport r;
CHECK(m.apply(Frame{kr::mouse::kBtnLeft, 0, 0, 0, 0}, r));
CHECK_EQ(r.buttons, kr::mouse::kBtnLeft);
CHECK(allZero(kr::mouse::kReportClear)); // the report a fail-safe sends
// Repeating the same button byte with no movement is not a new state.
CHECK(!m.apply(Frame{kr::mouse::kBtnLeft, 0, 0, 0, 0}, r));
CHECK(m.apply(Frame{0, 0, 0, 0, 0}, r)); // ...but letting go is
CHECK_EQ(r.buttons, 0);
}
CASE("buttons are restated, not diffed");
{
MouseState m;
MouseReport r;
m.apply(Frame{kr::mouse::kBtnLeft, 0, 0, 0, 0}, r);
m.apply(Frame{kr::mouse::kBtnLeft | kr::mouse::kBtnRight, 0, 0, 0, 0}, r);
CHECK_EQ(r.buttons, kr::mouse::kBtnLeft | kr::mouse::kBtnRight);
// Letting go of one reports the state that remains, which is the whole reason a
// frame lost in the network cannot strand a button: the next frame is the truth.
m.apply(Frame{kr::mouse::kBtnRight, 5, 0, 0, 0}, r);
CHECK_EQ(r.buttons, kr::mouse::kBtnRight);
CHECK_EQ(r.dx, 5);
CHECK_EQ(m.buttons(), kr::mouse::kBtnRight);
CHECK(m.down());
}
CASE("only the five buttons the report has");
{
MouseState m;
MouseReport r;
// The parser refuses a sixth bit, so this is a second line of defence rather than
// the behaviour anyone relies on: whatever arrives cannot set a padding bit.
m.apply(Frame{0xFF, 1, 0, 0, 0}, r);
CHECK_EQ(r.buttons, 0x1F);
CHECK_EQ(m.buttons(), 0x1F);
}
CASE("releaseAll lets go, from any state, and is idempotent");
{
MouseState m;
MouseReport r;
// Nothing was ever down; letting go is still well-defined and leaves the same state.
m.releaseAll();
CHECK_EQ(m.buttons(), 0);
CHECK(!m.down());
m.apply(Frame{kr::mouse::kBtnMiddle, 0, 0, 0, 0}, r);
m.releaseAll();
CHECK_EQ(m.buttons(), 0);
CHECK(!m.down());
m.releaseAll();
CHECK_EQ(m.buttons(), 0);
// And after letting go, the next movement frame restates the empty button byte -- so
// a release that never reached the target is corrected by the next one regardless.
CHECK(m.apply(Frame{0, 4, 0, 0, 0}, r));
CHECK_EQ(r.buttons, 0);
}
CASE("the full range of one signed byte survives");
{
MouseState m;
MouseReport r;
CHECK(m.apply(Frame{0, 127, -127, 127, -127}, r));
CHECK_EQ(r.dx, 127);
CHECK_EQ(r.dy, -127);
CHECK_EQ(r.wheel, 127);
CHECK_EQ(r.pan, -127);
}
CASE("report equality is byte equality");
{
const MouseReport a{1, 2, 3, 4, 5};
const MouseReport b{1, 2, 3, 4, 5};
const MouseReport c{1, 2, 3, 4, 6};
const MouseReport d{0, 2, 3, 4, 5};
CHECK(kr::mouse::reportsEqual(a, b));
CHECK(!kr::mouse::reportsEqual(a, c)); // pan differs: still a different report
CHECK(!kr::mouse::reportsEqual(a, d));
}
return report("mouse");
}
+282
View File
@@ -0,0 +1,282 @@
// tests for the emit side of the bridge: tests/test_output.cpp
//
// This suite exists mainly for one property, and it is the property the project got
// wrong once: releasing everything has to TELL the target. Clearing the tracker and the
// queue is not a release, because the last report the target received still says keys are
// down -- and disarm is exactly the moment the controller stops talking, so no later
// frame is coming to carry it. With a mouse added, the same shape appears a second time:
// a button held at disarm time is a drag nobody is holding.
#include <cstring>
#include "config.h"
#include "keystate.h"
#include "mouse.h"
#include "output.h"
#include "test.h"
using kr::KeyboardReport;
using kr::mouse::Frame;
using kr::mouse::kBtnLeft;
using kr::mouse::kBtnRight;
using kr::mouse::MouseReport;
using kr::OutputStage;
namespace {
bool keysClear(const KeyboardReport &r) {
return r.modifiers == 0 && r.reserved == 0 && r.keys[0] == 0 && r.keys[1] == 0 &&
r.keys[2] == 0 && r.keys[3] == 0 && r.keys[4] == 0 && r.keys[5] == 0;
}
bool mouseClear(const MouseReport &r) {
return r.buttons == 0 && r.dx == 0 && r.dy == 0 && r.wheel == 0 && r.pan == 0;
}
// Drain a queue into a flat list so "what would the target have received" is a fact a
// test can count, not an inference about the firmware's loop.
size_t drainKeyboard(OutputStage &s, KeyboardReport *out, size_t cap) {
size_t n = 0;
while (!s.keyQ.empty() && n < cap) {
out[n++] = s.keyQ.front();
s.keyQ.pop();
}
return n;
}
size_t drainMouse(OutputStage &s, MouseReport *out, size_t cap) {
size_t n = 0;
while (!s.mouseQ.empty() && n < cap) {
out[n++] = s.mouseQ.front();
s.mouseQ.pop();
}
return n;
}
} // namespace
int main() {
CASE("a queue keeps distinct transitions and collapses identical ones");
{
OutputStage s;
s.keys.apply(0, nullptr, 0);
CHECK(s.pushKeys() == OutputStage::Pushed::Nothing); // nothing changed yet
const uint8_t a = 0x04;
s.keys.apply(0, &a, 1);
CHECK(s.pushKeys() == OutputStage::Pushed::Ok);
CHECK(s.pushKeys() == OutputStage::Pushed::Nothing); // restating is a no-op
// A fast press and release is two reports. Collapsing those is how a tap vanishes,
// so only *identical* neighbours may collapse.
s.keys.apply(0, nullptr, 0);
CHECK(s.pushKeys() == OutputStage::Pushed::Ok);
KeyboardReport got[8];
CHECK_EQ(drainKeyboard(s, got, 8), 2);
CHECK_EQ(got[0].keys[0], 0x04);
CHECK(keysClear(got[1]));
}
CASE("release with keys held queues the all-up report <-- the bug this guards");
{
OutputStage s;
const uint8_t k = 0x04;
s.keys.apply(kr::kModLshift, &k, 1);
CHECK(s.pushKeys() == OutputStage::Pushed::Ok);
CHECK(s.releaseAll(true, true));
KeyboardReport got[8];
const size_t n = drainKeyboard(s, got, 8);
CHECK_EQ(n, 1); // exactly one report: the release
CHECK(keysClear(got[0])); // and it says nothing is pressed
CHECK_EQ(s.keys.held(), 0);
}
CASE("release drops presses that had not gone out yet");
{
OutputStage s;
const uint8_t k = 0x04;
s.keys.apply(0, &k, 1);
s.pushKeys(); // queued, never sent
s.releaseAll(true, true);
KeyboardReport got[8];
CHECK_EQ(drainKeyboard(s, got, 8), 1);
CHECK(keysClear(got[0])); // the press is gone; only the release is left
}
CASE("release with buttons held queues the all-up mouse report");
{
OutputStage s;
CHECK(s.pushMouse(Frame{kBtnLeft, 0, 0, 0, 0}) == OutputStage::Pushed::Ok);
CHECK(s.releaseAll(true, true));
MouseReport got[8];
CHECK_EQ(drainMouse(s, got, 8), 1);
CHECK(mouseClear(got[0]));
CHECK_EQ(s.mouseState.buttons(), 0);
}
CASE("a mouse the target cannot receive is never queued, even by a fail-safe");
{
OutputStage s;
// The boot-protocol case. A 5-byte report reaching a target that is reading an
// 8-byte boot keyboard report is not "the mouse is unavailable", it is keystrokes
// nobody pressed -- so the release must not sneak one out on the way past.
s.pushMouse(Frame{kBtnLeft, 0, 0, 0, 0});
CHECK_EQ(s.mouseQ.count(), 1u);
s.releaseAll(true, false);
CHECK_EQ(s.mouseQ.count(), 0u);
KeyboardReport got[8];
CHECK_EQ(drainKeyboard(s, got, 8), 1); // the keyboard is unaffected ...
CHECK(keysClear(got[0]));
// ... and the button byte is cleared regardless, because what the board believes must
// not outlive the fail-safe just because the target could not be told.
CHECK_EQ(s.mouseState.buttons(), 0);
}
CASE("release covers both devices in one call");
{
OutputStage s;
const uint8_t k = 0x04;
s.keys.apply(0, &k, 1);
s.pushKeys();
s.pushMouse(Frame{kBtnLeft | kBtnRight, 3, 4, 0, 0});
CHECK_EQ(s.keyQ.count(), 1u);
CHECK_EQ(s.mouseQ.count(), 1u);
CHECK(s.releaseAll(true, true));
KeyboardReport kr8[8];
MouseReport mr8[8];
CHECK_EQ(drainKeyboard(s, kr8, 8), 1);
CHECK_EQ(drainMouse(s, mr8, 8), 1);
CHECK(keysClear(kr8[0]));
CHECK(mouseClear(mr8[0]));
}
CASE("release with nothing held still says so, and that is the trade");
{
OutputStage s;
// The reports are unconditional on purpose: a panic path must not hinge on the
// tracker's bookkeeping being right, and "no buttons, no movement" is what a real
// mouse sends every time it presses a button without moving. Two extra reports on a
// 1 ms bus, and no branch in the fail-safe.
CHECK(s.releaseAll(true, true));
KeyboardReport kr8[8];
MouseReport mr8[8];
CHECK_EQ(drainKeyboard(s, kr8, 8), 1);
CHECK_EQ(drainMouse(s, mr8, 8), 1);
CHECK(keysClear(kr8[0]));
CHECK(mouseClear(mr8[0]));
CHECK_EQ(s.keys.held(), 0);
CHECK_EQ(s.mouseState.buttons(), 0);
}
CASE("release with no target attached queues nothing to be counted as dropped");
{
OutputStage s;
const uint8_t k = 0x04;
s.keys.apply(0, &k, 1);
s.pushKeys();
s.pushMouse(Frame{kBtnLeft, 0, 0, 0, 0});
// Neither device reachable: the drain counts whatever is left as dropped, so the
// fail-safe must not add release reports nobody can receive. The state is still
// cleared, which is the part that matters for whatever comes next.
CHECK(!s.releaseAll(false, false));
CHECK_EQ(s.keyQ.count(), 0u);
CHECK_EQ(s.mouseQ.count(), 0u);
CHECK_EQ(s.keys.held(), 0);
CHECK_EQ(s.mouseState.buttons(), 0);
}
CASE("release cancels a pasted run -- clearing means stop, not pause");
{
OutputStage s;
const uint8_t text[] = {'h', 'e', 'l', 'l', 'o'};
CHECK(s.typer.feed(text, sizeof(text)).result == kr::text::Result::Ok);
s.typer.start(1000, 12);
CHECK(s.typer.active());
CHECK_EQ(s.typer.pending(), 5u);
s.releaseAll(true, true);
CHECK(!s.typer.active());
CHECK_EQ(s.typer.pending(), 0u);
}
CASE("release ends a chord mid-hold");
{
OutputStage s;
s.chord.active = true;
s.chord.releaseAtMs = 999999;
s.releaseAll(true, true);
CHECK(!s.chord.active);
}
CASE("an idle mouse frame costs nothing and is not queued");
{
OutputStage s;
CHECK(s.pushMouse(Frame{0, 0, 0, 0, 0}) == OutputStage::Pushed::Nothing);
CHECK_EQ(s.mouseQ.count(), 0u);
CHECK(s.pushMouse(Frame{kBtnLeft, 0, 0, 0, 0}) == OutputStage::Pushed::Ok);
CHECK(s.pushMouse(Frame{kBtnLeft, 0, 0, 0, 0}) == OutputStage::Pushed::Nothing);
CHECK_EQ(s.mouseQ.count(), 1u);
}
CASE("a full queue is refused, and says so");
{
OutputStage s;
// Every report differs, so nothing collapses and the queue really fills.
for (size_t i = 0; i < kr::kMouseReportQueueDepth; i++) {
CHECK(s.pushMouse(Frame{0, (int8_t)(i + 1), 0, 0, 0}) == OutputStage::Pushed::Ok);
}
CHECK_EQ(s.mouseQ.count(), (unsigned)kr::kMouseReportQueueDepth);
// Not "drop the movement, it is only a movement": the frame that does not fit may be
// the one releasing a held button, and that is a stuck-mouse failure.
CHECK(s.pushMouse(Frame{kBtnLeft, 0, 0, 0, 0}) == OutputStage::Pushed::Overflow);
const uint8_t lo = 0x04, hi = 0x05;
for (size_t i = 0; i < kr::kReportQueueDepth; i++) {
const uint8_t k = (i & 1) ? lo : hi; // alternating: no two neighbours equal
CHECK(s.pushKeyboardReport(kr::makeReport(0, &k, 1)) == OutputStage::Pushed::Ok);
}
CHECK_EQ(s.keyQ.count(), (unsigned)kr::kReportQueueDepth);
const uint8_t other = 0x06;
CHECK(s.pushKeyboardReport(kr::makeReport(0, &other, 1)) ==
OutputStage::Pushed::Overflow);
// Overflow leaves the queue untouched: the caller's response is releaseEverything(),
// and half-dropping a state transition is the thing that must not happen.
CHECK_EQ(s.keyQ.count(), (unsigned)kr::kReportQueueDepth);
CHECK(s.releaseAll(true, true));
CHECK_EQ(s.keyQ.count(), 1u); // ...and the release still gets out
CHECK_EQ(s.mouseQ.count(), 1u);
}
CASE("queue depths are the ones config.h declares");
{
OutputStage s;
CHECK_EQ(s.keyQ.capacity(), (unsigned)kr::kReportQueueDepth);
CHECK_EQ(s.mouseQ.capacity(), (unsigned)kr::kMouseReportQueueDepth);
CHECK(kr::kMouseReportQueueDepth >= 8); // a flick is tens of reports
CHECK(kr::kMouseReportQueueDepth <= 64); // and it is RAM on an 8 MB board
}
CASE("order is preserved across a movement burst under a held button");
{
OutputStage s;
s.pushMouse(Frame{kBtnLeft, 0, 0, 0, 0});
s.pushMouse(Frame{kBtnLeft, 10, 0, 0, 0});
s.pushMouse(Frame{kBtnLeft, 10, 0, 0, 0}); // identical neighbour: collapses
s.pushMouse(Frame{kBtnLeft, 7, 0, 0, 0});
s.pushMouse(Frame{0, 0, 0, 0, 0});
MouseReport got[8];
CHECK_EQ(drainMouse(s, got, 8), 4); // 5 frames in, one identical pair out
CHECK_EQ(got[0].buttons, kBtnLeft);
CHECK_EQ(got[0].dx, 0);
CHECK_EQ(got[1].dx, 10);
CHECK_EQ(got[2].dx, 7); // the repeated +10 never arrived twice
CHECK_EQ(got[2].buttons, kBtnLeft);
CHECK(mouseClear(got[3])); // and the release is last
}
return report("output");
}
+227 -9
View File
@@ -37,11 +37,33 @@ function makeEl(tagName) {
appendChild(c) { this.children.push(c); return c; },
removeChild(c) { this.children = this.children.filter(x => x !== c); return c; },
addEventListener(t, fn) { (this.listeners[t] = this.listeners[t] || []).push(fn); },
// Element handlers are reachable too: the mouse pad is clicked, and a real click is
// what the browser turns into a Pointer Lock request.
fire(t, ev) {
for (const fn of (this.listeners[t] || [])) fn(ev || { preventDefault() {} });
},
getAttribute() { return null; }, setAttribute() {}, focus() {},
};
return el;
}
// Document- and window-level listeners are recorded rather than dropped. Every
// fail-safe this page has is triggered by something the browser does to the document --
// blur, visibilitychange, pointerlockchange -- so a shim that swallows them cannot
// prove them.
function makeListeners() {
const map = {};
return {
map,
add(t, fn) { (map[t] = map[t] || []).push(fn); },
fire(t, ev) {
for (const fn of (map[t] || [])) fn(ev || { preventDefault() {}, target: null });
},
};
}
const docListeners = makeListeners();
const winListeners = makeListeners();
// The shim hands out an element for any id, which is far less work than a real DOM.
// The weakness in that is obvious, so it is compensated for: every id the script asks
// for is recorded and checked against the ids that are actually in the markup at the
@@ -70,16 +92,26 @@ class FakeWebSocket {
FakeWebSocket.OPEN = 1;
FakeWebSocket.instances = [];
const sandbox = {
document: {
getElementById: byId,
createElement: t => makeEl(t),
querySelectorAll: () => [],
addEventListener() {},
body: makeEl("body"),
hidden: false,
const doc = {
getElementById: byId,
createElement: t => makeEl(t),
querySelectorAll: () => [],
addEventListener: docListeners.add,
body: makeEl("body"),
hidden: false,
// Pointer Lock, as much of it as the page touches. exitPointerLock fires the change
// event because a real browser does, which is what makes the release path testable.
pointerLockElement: null,
exitPointerLock() {
if (!doc.pointerLockElement) return;
doc.pointerLockElement = null;
docListeners.fire("pointerlockchange", {});
},
window: { addEventListener() {} },
};
const sandbox = {
document: doc,
window: { addEventListener: winListeners.add },
location: { protocol: "http:", host: "keyrelay.local" },
sessionStorage: { getItem: () => null, setItem() {} },
WebSocket: FakeWebSocket,
@@ -95,6 +127,11 @@ const expose = `
log, logEl, untypeableChars, hexify, describePress, inPageField, pasteText,
sendPaste, refreshSendBox, setArmed, abortTyping, onFrame, connect,
TEXT_FRAME_MAX, TEXT_JOB_MAX, TEXT_PACE_MS,
takeMouse, dropMouse, flushMouse, mouseFrame, paintMouse, refreshMouseUi,
MOUSE_DELTA_MAX, MOUSE_FRAME_MS, MOUSE_MAX_FRAMES, MOUSE_WHEEL_PX,
get mCaptured() { return mCaptured; }, set mCaptured(v) { mCaptured = v; },
get mButtons() { return mButtons; },
get pendX() { return pendX; }, get pendY() { return pendY; },
get ws() { return ws; },
get armed() { return armed; }, set armed(v) { armed = v; },
get authed() { return authed; }, set authed(v) { authed = v; },
@@ -131,7 +168,29 @@ const checkEq = (a, b, label) => {
}
};
const frames = () => sentFrames.filter(f => f.startsWith("P="));
const mframes = () => sentFrames.filter(f => f.startsWith("M="));
const textOf = f => Buffer.from(f.slice(f.indexOf(":") + 1), "hex").toString();
// The pad the page asks for Pointer Lock, wired the way a browser wires it: the request
// sets pointerLockElement and fires pointerlockchange.
function givePointerLock() {
const pad = byId("mPad");
pad.requestPointerLock = () => {
doc.pointerLockElement = pad;
docListeners.fire("pointerlockchange", {});
return Promise.resolve();
};
return pad;
}
function takePointerLockAway() {
doc.pointerLockElement = null;
docListeners.fire("pointerlockchange", {});
}
const move = (dx, dy) => docListeners.fire("mousemove", { movementX: dx, movementY: dy });
const btn = (b, down) => docListeners.fire(down ? "mousedown" : "mouseup",
{ button: b, preventDefault() {} });
const scroll = (dy, dx, mode) => docListeners.fire("wheel",
{ deltaY: dy, deltaX: dx || 0, deltaMode: mode || 0, preventDefault() {} });
const wait = ms => new Promise(r => setTimeout(r, ms));
console.log("the log stays readable");
@@ -264,6 +323,165 @@ checkEq(!!x.inPageField({ target: null }), false, "a missing target does not thr
await p;
checkEq(frames().length, 1, "a disarm mid-run stops the run");
console.log("the mouse");
givePointerLock();
x.setArmed(true, "harness: armed");
x.authed = true;
// A capture with nothing behind it must not produce a single frame.
x.setArmed(false, "harness: disarmed");
sentFrames.length = 0;
x.takeMouse();
await wait(2);
checkEq(mframes().length, 0, "a disarmed bridge is not offered the mouse");
checkEq(x.mCaptured, false, "and the pointer is not taken");
x.setArmed(true, "harness: armed");
sentFrames.length = 0;
x.takeMouse();
await wait(2);
checkEq(x.mCaptured, true, "capture takes the pointer when the bridge is armed");
checkEq(mframes().length, 0, "taking the pointer sends no movement nobody made");
move(30, -4);
sentFrames.length = 0;
x.flushMouse();
checkEq(mframes().length, 1, "one flush of one small movement is one frame");
checkEq(mframes()[0], "M=00:30:-4:0:0", "the button byte first, then this frame's movement");
sentFrames.length = 0;
x.flushMouse();
checkEq(mframes().length, 0, "a frame that says nothing is not sent at all");
// A fast flick: the report carries one signed byte per axis, so the tab splits it.
move(300, -260);
sentFrames.length = 0;
x.flushMouse();
const big = mframes();
const sum = i => big.reduce((a, f) => a + Number(f.split(":")[i]), 0);
checkEq(sum(1), 300, "300 px of X arrives as 300 px, split across reports");
checkEq(sum(2), -260, "and so does the Y");
check(big.every(f => [1, 2, 3, 4].every(i => Math.abs(Number(f.split(":")[i]))
<= x.MOUSE_DELTA_MAX)),
"and no single report asks for more than one signed byte");
// Order is the whole difference between a click and a drag.
move(12, 0);
sentFrames.length = 0;
btn(0, true);
const order = sentFrames.filter(f => f.startsWith("M="));
checkEq(order.length, 2, "a press with movement behind it is two frames");
checkEq(order[0], "M=00:12:0:0:0", "the movement first, under the old button state");
checkEq(order[1], "M=01:0:0:0:0", "then the button, so the target sees press then move");
checkEq(x.mButtons, 0x01, "left is held");
sentFrames.length = 0;
btn(0, false);
checkEq(mframes()[0], "M=00:0:0:0:0", "release restates the whole button byte as nothing");
checkEq(x.mButtons, 0, "and the tab agrees");
// Two buttons at once, and letting go of one: the byte that goes out is the state,
// not the change. (DOM button 0 = left -> 0x01, 2 = right -> 0x02, 1 = middle -> 0x04.)
btn(0, true);
btn(2, true);
sentFrames.length = 0;
btn(0, false);
checkEq(mframes()[0], "M=02:0:0:0:0", "right stays down when the left button goes up");
sentFrames.length = 0;
scroll(100, 0); // one detent's worth of pixels
scroll(100, 0);
scroll(25, 0); // not yet a detent: carried, not dropped
x.flushMouse();
checkEq(mframes()[0], "M=02:0:0:2:0", "wheel travels in detents and the remainder waits");
sentFrames.length = 0;
scroll(3, 0, 1); // deltaMode 1: lines, one detent each
x.flushMouse();
checkEq(mframes()[0], "M=02:0:0:3:0", "a line is a detent, whatever the pixel density");
// The fail-safes. Each one has to let go of the buttons, whatever else it does.
btn(2, false);
btn(0, true);
checkEq(x.mButtons, 0x01, "holding left again for the release tests");
sentFrames.length = 0;
takePointerLockAway();
checkEq(mframes()[0], "M=00:0:0:0:0",
"losing the pointer lock (Esc, focus, the browser) releases every button");
checkEq(x.mCaptured, false, "and the tab stops capturing");
x.takeMouse();
await wait(2);
btn(1, true);
sentFrames.length = 0;
doc.pointerLockElement = null; // the browser already dropped it; blur follows
winListeners.fire("blur", {});
check(mframes().includes("M=00:0:0:0:0"),
"a blur releases the buttons too");
checkEq(x.mCaptured, false, "and ends the capture");
// The stay-armed-on-blur option is an exception for the keyboard and must not become
// one for the cursor: a pointer nobody is looking at is not a pointer being aimed.
byId("keepArmed").checked = true;
x.setArmed(true, "harness: armed");
x.takeMouse();
await wait(2);
btn(0, true);
sentFrames.length = 0;
winListeners.fire("blur", {});
check(mframes().includes("M=00:0:0:0:0"),
"stay-armed-on-blur keeps the keyboard armed and still releases the mouse");
checkEq(x.mCaptured, false, "the capture does not survive it either");
byId("keepArmed").checked = false;
x.setArmed(true, "harness: armed");
x.takeMouse();
await wait(2);
btn(0, true);
sentFrames.length = 0;
x.setArmed(false, "harness: disarmed with a button held");
checkEq(x.mCaptured, false, "disarm ends the capture");
checkEq(x.mButtons, 0, "and the tab's own button state");
// The board releases the buttons itself when it disarms (src/output.h), so the tab
// sends an M= frame to a bridge that has already answered: silence here is correct.
checkEq(mframes().length, 0,
"disarm does not beg a bridge that has already let go");
// A board that cannot report a mouse: refused up front, and a running capture ended.
x.setArmed(true, "harness: armed");
sentFrames.length = 0;
x.onFrame('S{"armed":1,"usb":1,"typing":0,"mouse":0}');
x.takeMouse();
await wait(2);
checkEq(x.mCaptured, false, "a board with no mouse interface does not take the pointer");
checkEq(mframes().length, 0, "and gets no frames");
checkEq(byId("mCapture").disabled, true, "the button says so by being disabled");
x.onFrame('S{"armed":1,"usb":1,"typing":0,"mouse":1}');
checkEq(byId("mCapture").disabled, false,
"the button comes back when the board says it can report a mouse");
x.takeMouse();
await wait(2);
checkEq(x.mCaptured, true, "and it captures again once the board can report one");
sentFrames.length = 0;
x.onFrame('S{"armed":1,"usb":1,"typing":0,"mouse":0}');
checkEq(x.mCaptured, false,
"a capture that the board can no longer honour is ended, not left hanging");
// Every frame the tab can emit has to be one the firmware parses: five fields, a hex
// button byte, signed decimal movements, and short enough for one WebSocket message.
const shape = /^M=[0-9a-f]{2}:-?\d{1,3}:-?\d{1,3}:-?\d{1,3}:-?\d{1,3}$/;
let malformed = 0, tooLong = 0;
for (const f of mframes()) {
if (!shape.test(f)) malformed++;
if (f.length > 256) tooLong++;
}
checkEq(malformed, 0, "every mouse frame this tab emitted parses");
checkEq(tooLong, 0, "and every one fits in one WebSocket message");
x.mCaptured = false;
x.setArmed(false, "harness: done");
console.log("element ids");
const markup = fs.readFileSync(headerPath, "utf8");
const present = new Set();
+172
View File
@@ -343,6 +343,135 @@ int main() {
CHECK(html.find("data-chord=\"05:04\"") == std::string::npos);
}
CASE("mouse message: the button byte restated, plus one report's movement");
{
Message m;
CHECK(parse("M=00:0:0:0:0", m) == Cmd::Mouse);
CHECK_EQ(m.buttons, 0);
CHECK_EQ(m.dx, 0);
CHECK(parse("M=01:30:-4:0:0", m) == Cmd::Mouse);
CHECK_EQ(m.buttons, 1);
CHECK_EQ(m.dx, 30);
CHECK_EQ(m.dy, -4);
// Every field, at both ends of the range a signed byte carries.
CHECK(parse("M=1f:127:-127:127:-127", m) == Cmd::Mouse);
CHECK_EQ(m.buttons, 0x1f);
CHECK_EQ(m.dx, 127);
CHECK_EQ(m.dy, -127);
CHECK_EQ(m.wheel, 127);
CHECK_EQ(m.pan, -127);
CHECK(parse("M=15:0:0:0:0", m) == Cmd::Mouse);
CHECK_EQ(m.buttons, 0x15);
}
CASE("a mouse frame that is not exactly one report is refused");
{
Message m;
// Rejecting means nothing moves, which for a movement is harmless and for a button
// byte is the only safe answer. Guessing -- clamping, or splitting a big delta into
// several reports -- would make the board responsible for a number the controller
// got wrong.
CHECK(parse("M") == Cmd::Bad);
CHECK(parse("M=") == Cmd::Bad);
CHECK(parse("M=00") == Cmd::Bad);
CHECK(parse("M=00:") == Cmd::Bad);
CHECK(parse("M=00:0") == Cmd::Bad);
CHECK(parse("M=00:0:0:0") == Cmd::Bad); // four fields is not five
CHECK(parse("M=00:0:0:0:0:") == Cmd::Bad); // trailing delimiter
CHECK(parse("M=00:0:0:0:0:0") == Cmd::Bad); // ...nor a sixth one
CHECK(parse("M=0:0:0:0:0") == Cmd::Bad); // the button byte is two hex digits
CHECK(parse("M=20:0:0:0:0") == Cmd::Bad); // a sixth button has nowhere to go
CHECK(parse("M=ff:0:0:0:0") == Cmd::Bad);
CHECK(parse("M=00:128:0:0:0") == Cmd::Bad); // more than one report's worth
CHECK(parse("M=00:-128:0:0:0") == Cmd::Bad);
CHECK(parse("M=00:0:128:0:0") == Cmd::Bad);
CHECK(parse("M=00:+5:0:0:0") == Cmd::Bad); // no plus sign, whatever JS emits
CHECK(parse("M=00:5 :0:0:0") == Cmd::Bad); // no spaces
CHECK(parse("M=00:5x:0:0:0") == Cmd::Bad);
CHECK(parse("M=00::0:0:0") == Cmd::Bad); // an empty field is not a zero
CHECK(parse("m=00:0:0:0:0") == Cmd::Bad); // commands are uppercase
CHECK(parse("M=00:0:0:0:0 ") == Cmd::Bad); // nothing trails a command
CHECK(parse("M=00:0:0:0:0\n") == Cmd::Mouse); // ...except a hand-typed newline
// -0 is a hand-typed oddity, not an ambiguity.
CHECK(parse("M=00:-0:0:0:0", m) == Cmd::Mouse);
CHECK_EQ(m.dx, 0);
}
CASE("the board refuses to render a mouse frame it would not parse");
{
char buf[64];
CHECK_EQ(kr::proto::formatMouse(buf, sizeof(buf), 0, 0, 0, 0, 0), 12u);
CHECK_STR(buf, "M=00:0:0:0:0");
CHECK(kr::proto::formatMouse(buf, sizeof(buf), 0x01, 30, -4, 0, 0) > 0);
CHECK_STR(buf, "M=01:30:-4:0:0");
CHECK(kr::proto::formatMouse(buf, sizeof(buf), 0x1f, -127, 127, -127, 127) > 0);
CHECK_STR(buf, "M=1f:-127:127:-127:127");
CHECK_EQ(kr::proto::formatMouse(buf, sizeof(buf), 0x20, 0, 0, 0, 0), 0u);
CHECK_STR(buf, ""); // nothing half-written
CHECK_EQ(kr::proto::formatMouse(buf, sizeof(buf), 0, 128, 0, 0, 0), 0u);
CHECK_EQ(kr::proto::formatMouse(buf, sizeof(buf), 0, 0, 0, 0, -128), 0u);
// A buffer that is too small yields 0 and an empty string, never a truncated frame:
// a frame cut in half is a command the board did not receive, on a target that is
// waiting for the rest of it.
char tiny[12];
CHECK_EQ(kr::proto::formatMouse(tiny, sizeof(tiny), 0, 127, 127, 127, 127), 0u);
CHECK_STR(tiny, "");
}
CASE("a rendered mouse frame parses back to the same report");
{
const uint8_t bs[] = {0x00, 0x01, 0x02, 0x04, 0x08, 0x1f};
const int8_t vs[] = {0, 1, -1, 127, -127, 42, -42};
for (size_t b = 0; b < sizeof(bs); b++) {
for (size_t i = 0; i < sizeof(vs); i++) {
for (size_t j = 0; j < sizeof(vs); j++) {
char buf[64];
const size_t n = kr::proto::formatMouse(buf, sizeof(buf), bs[b], vs[i], vs[j],
(int8_t)(-(int)vs[i]), vs[j]);
if (n == 0) { CHECK(false); continue; }
Message m;
CHECK(kr::proto::parse(buf, n, m) == Cmd::Mouse);
CHECK_EQ(m.buttons, bs[b]);
CHECK_EQ(m.dx, vs[i]);
CHECK_EQ(m.dy, vs[j]);
CHECK_EQ(m.wheel, -vs[i]);
CHECK_EQ(m.pan, vs[j]);
CHECK(n <= kr::kWsMessageCap);
}
}
}
}
CASE("error codes above 9 are not truncated to their last digit");
{
char buf[96];
// The single-digit codes must render exactly as they always did, or every client
// that branches on them breaks.
CHECK_EQ(kr::proto::formatError(buf, sizeof(buf), 1, "x"), 4u);
CHECK_STR(buf, "E1:x");
CHECK_EQ(kr::proto::formatError(buf, sizeof(buf), 9, "no key"), 9u);
CHECK_STR(buf, "E9:no key");
// 10 and 11 are the mouse's, and `code % 10` used to turn them into E0 -- a code no
// client would recognise, on the one path where a client needs to know.
CHECK_EQ(kr::proto::formatError(buf, sizeof(buf), 10, "off"), 7u);
CHECK_STR(buf, "E10:off");
CHECK_EQ(kr::proto::formatError(buf, sizeof(buf), 11, "boot"), 8u);
CHECK_STR(buf, "E11:boot");
CHECK_EQ(kr::proto::formatError(buf, sizeof(buf), 0, "z"), 4u);
CHECK_STR(buf, "E0:z");
// Never a truncated frame: a client cannot parse half of one, and cannot tell an
// over-long error from a real one.
CHECK_EQ(kr::proto::formatError(buf, 7, 10, "too long"), 0u);
CHECK_EQ(kr::proto::formatError(buf, sizeof(buf), 10, ""), 4u);
CHECK_STR(buf, "E10:");
}
CASE("the page's text constants are the board's text constants");
{
// The page splits a paste by kTextFrameMax and refuses more than kTextJobCap,
@@ -363,5 +492,48 @@ int main() {
CHECK(html.find("\"P=\" + TEXT_PACE_MS") != std::string::npos);
}
CASE("the page's mouse constants are the board's mouse constants");
{
// Same hand-copied-number hazard as the text ones: if the page thinks one report
// carries 255 of something, the board refuses every frame at 128.
std::string html = readFile("src/web_page.h");
if (html.empty()) html = readFile("../src/web_page.h");
CHECK(!html.empty());
char needle[80];
snprintf(needle, sizeof(needle), "const MOUSE_DELTA_MAX = %d;", (int)kr::kMouseDeltaMax);
CHECK(html.find(needle) != std::string::npos);
snprintf(needle, sizeof(needle), "const MOUSE_BUTTON_MASK = 0x%x;",
(unsigned)kr::kMouseButtonMask);
CHECK(html.find(needle) != std::string::npos);
// The page must build the frame the firmware parses, with all five fields -- a
// missing one is a refused frame, and the page would look like it was moving the
// mouse while nothing arrived.
CHECK(html.find("function mouseFrame(buttons, dx, dy, wheel = 0, pan = 0)") !=
std::string::npos);
CHECK(html.find("\":\" + dx + \":\" + dy + \":\" + wheel + \":\" + pan") !=
std::string::npos);
// DOM MouseEvent.button -> report bit. The DOM numbers are not in HID order: 1 is
// the middle button and 2 is the right one. A page that got these the wrong way round
// would send a right-click as a middle-click, parse cleanly, and be wrong -- the
// Alt+Tab failure all over again, so the mapping is pinned here and in relayctl.
CHECK(html.find("0: 0x01, 2: 0x02, 1: 0x04, 3: 0x08, 4: 0x10") != std::string::npos);
// The mouse has to be released by the same fail-safes the keys are.
CHECK(html.find("dropMouse(\"disarmed\")") != std::string::npos);
CHECK(html.find("dropMouse(\"tab lost focus\")") != std::string::npos);
CHECK(html.find("dropEverything(\"tab hidden\")") != std::string::npos);
// ...and the board's own release funnel must be the one that releases the buttons
// when the page could not.
std::string out = readFile("src/output.h");
if (out.empty()) out = readFile("../src/output.h");
CHECK(!out.empty());
CHECK(out.find("mouseState.releaseAll()") != std::string::npos);
CHECK(out.find("mouse::kReportClear") != std::string::npos);
CHECK(out.find("typer.cancel()") != std::string::npos);
}
return report("proto");
}
+206 -3
View File
@@ -201,8 +201,10 @@ config_src = (root / "src" / "config.h").read_text()
def config_value(name):
found = re.search(rf"\b{name}\s*=\s*(\d+)\s*;", config_src)
return int(found.group(1)) if found else None
# Decimal or hex: kMouseDeltaMax is 127 and kMouseButtonMask is 0x1F, and both are
# numbers a client has to agree with the firmware about.
found = re.search(rf"\b{name}\s*=\s*(0[xX][0-9a-fA-F]+|\d+)\s*;", config_src)
return int(found.group(1), 0) if found else None
check_eq(relayctl.TEXT_FRAME_MAX, config_value("kTextFrameMax"),
@@ -228,6 +230,89 @@ src_lines = inspect.getsource(drained)
check("OP_PONG" in src_lines, "_drain answers a ping")
check("raise RelayError" in src_lines, "_drain still surfaces an error frame")
print("mouse")
check_eq(relayctl.mouse_frame(0x01, 30, -4), "M=01:30:-4:0:0",
"a mouse frame is the button byte then four movements")
check_eq(relayctl.mouse_frame(0), "M=00:0:0:0:0",
"and all five fields are always written -- the firmware parses exactly five")
check_eq(relayctl.mouse_frame(0x1f, -127, 127, -127, 127), "M=1f:-127:127:-127:127",
"the full signed range")
for bad, why in (((0x20, 0, 0, 0, 0), "a sixth button bit has nowhere to go"),
((0, 128, 0, 0, 0), "128 is more than one report"),
((0, -128, 0, 0, 0), "and so is -128"),
((0, 0, 0, 128, 0), "the wheel is one byte too")):
try:
relayctl.mouse_frame(*bad)
check(False, f"mouse_frame should refuse: {why}")
except relayctl.RelayError:
check(True, f"mouse_frame refuses: {why}")
# A movement bigger than one report is split here, exactly as the page splits it, and the
# split is exact -- clamping it would move the cursor half as far as was asked and say
# nothing about it.
for dx, dy in ((300, 0), (0, -260), (300, -260), (127, 127), (1, -1), (-500, 500)):
parts = relayctl.split_moves(dx, dy)
check_eq(sum(p[0] for p in parts), dx, f"the X split of {dx},{dy} adds up exactly")
check_eq(sum(p[1] for p in parts), dy, f"and the Y of {dx},{dy}")
check(all(abs(p[0]) <= relayctl.MOUSE_DELTA_MAX and abs(p[1]) <= relayctl.MOUSE_DELTA_MAX
for p in parts), f"and no part of {dx},{dy} exceeds one report")
check_eq(relayctl.mouse_frames("click", [], "left"),
[(0x01, 0, 0, 0, 0), (0, 0, 0, 0, 0)],
"a click is the button restated down then restated up")
check_eq(relayctl.mouse_frames("release", []), [(0, 0, 0, 0, 0)],
"release restates the whole button byte as nothing")
drag = relayctl.mouse_frames("drag", ["300", "0"], "right")
check_eq(drag[0], (0x02, 0, 0, 0, 0), "a drag presses first")
check(drag[-1] == (0, 0, 0, 0, 0), "and releases last")
check(all(f[0] == 0x02 for f in drag[1:-1]),
"and every movement in between carries the button, so it is a drag and not a move")
check_eq(sum(f[1] for f in drag), 300, "and the drag travels the whole distance")
check_eq(relayctl.mouse_frames("wheel", ["-3"]), [(0, 0, 0, -3, 0)],
"wheel notches go where the report's wheel byte is")
for bad_args, why in ((("move", ["1"], "left"), "move needs dx and dy"),
(("wheel", [], "left"), "wheel needs a count"),
(("move", ["1", "2"], "thumb"), "an unknown button is refused"),
(("wiggle", [], "left"), "an unknown action is refused")):
try:
relayctl.mouse_frames(*bad_args)
check(False, f"mouse_frames should refuse: {why}")
except (relayctl.RelayError, ValueError):
check(True, f"mouse_frames refuses: {why}")
# Every frame this tool can build must be one the firmware parses. The shape is asserted
# rather than a reimplementation of the parser, because the parser itself is tested
# against the same frames in tests/test_proto.cpp.
mframe = re.compile(r"^M=[0-9a-f]{2}:-?\d{1,3}:-?\d{1,3}:-?\d{1,3}:-?\d{1,3}$")
built = [relayctl.mouse_frame(*f)
for f in relayctl.mouse_frames("drag", ["500", "-500", "4", "-4"], "middle")]
check(all(mframe.match(f) for f in built), f"every built frame parses ({len(built)})")
check(all(len(f) <= config_value("kWsMessageCap") for f in built),
"and every one fits in one WebSocket message")
# The numbers are the board's. If they drift, the board starts answering E6 to frames
# this tool believed were legal.
check_eq(relayctl.MOUSE_DELTA_MAX, config_value("kMouseDeltaMax"),
"relayctl MOUSE_DELTA_MAX == kMouseDeltaMax")
check_eq(relayctl.MOUSE_BUTTON_MASK, config_value("kMouseButtonMask"),
"relayctl MOUSE_BUTTON_MASK == kMouseButtonMask")
check_eq(int(re.search(r"const MOUSE_DELTA_MAX = (\d+);", page_src).group(1)),
config_value("kMouseDeltaMax"), "page MOUSE_DELTA_MAX == kMouseDeltaMax")
check_eq(int(re.search(r"const MOUSE_BUTTON_MASK = (0x[0-9a-f]+);", page_src).group(1), 16),
config_value("kMouseButtonMask"), "page MOUSE_BUTTON_MASK == kMouseButtonMask")
check_eq(int(re.search(r"const MOUSE_FRAME_MS = (\d+);", page_src).group(1)),
relayctl.MOUSE_FRAME_MS, "the page and the CLI pace mouse frames identically")
# Button bits, agreed between the page and this tool even though the page keys them by
# DOM number and this keys them by name. Getting one wrong is a clean frame that is the
# wrong button -- the Alt+Tab failure again, one layer down.
check_eq({name: bits for name, bits in relayctl.MOUSE_BUTTONS.items()},
{"left": 0x01, "right": 0x02, "middle": 0x04, "back": 0x08, "forward": 0x10},
"the button bits are the report descriptor's order")
check("0: 0x01, 2: 0x02, 1: 0x04, 3: 0x08, 4: 0x10" in page_src,
"the page maps DOM button numbers to those same bits (DOM 1 is middle, 2 is right)")
print("protocol strings")
check_eq("S=%02x:%s" % (0x0A, "04,28"), "S=0a:04,28", "state frame format")
@@ -239,10 +324,128 @@ check_eq("T" + "hunter2", "Thunter2", "auth frame format")
# has to be a deliberate edit in two places.
for command in ("A1", "A0", "R", "H", "K"):
check(f'"{command}"' in relay_src, f'relayctl sends "{command}"')
for command in ("'A'", "'R'", "'H'", "'K'", "'T'", "'S'", "'Y'", "'C'", "'P'"):
for command in ("'A'", "'R'", "'H'", "'K'", "'T'", "'S'", "'Y'", "'C'", "'P'", "'M'"):
check(command in proto_src, f"proto.h parses {command}")
check("case 'M':" in proto_src, "proto.h routes M= to the mouse parser")
# The two mouse error codes have to be in the README's list. A client that cannot look up
# what E11 means cannot tell the operator why the pointer is not moving, and this suite is
# the only place that notices when the code and the document stop agreeing.
readme_src = (root / "README.md").read_text()
for code, where in (("10 the mouse interface is off", "proto.h"),
("11 the target asked for boot protocol", "proto.h")):
check(code in proto_src, f"proto.h's error list documents {code}")
check(code.split()[0] in readme_src,
f"README's error list has code {code.split()[0]}")
check("mouse interface is off" in readme_src and "boot protocol" in readme_src,
"README explains what both mouse refusals mean, not just their numbers")
check("`M=<btn>`" in readme_src or "M=<btn>" in readme_src or "`M=`" in readme_src,
"README's wire protocol table lists the M= command")
check("if (c == 'Y' && n == 0 && out.mods == 0)" in proto_src,
"proto.h still requires a chord to press something")
print("the fail-safe wiring")
# Source pins, and deliberately honest about being source pins: control_server.cpp and
# main.cpp need a socket and a board, so nothing here runs them. What they were caught
# doing is invisible to a compiler and to every behavioural test on the host, and each of
# these three is a sentence that a later edit can quietly undo.
main_src = (root / "src" / "main.cpp").read_text()
server_src = (root / "src" / "control_server.cpp").read_text()
# The backstop must not ask whether a controller is linked: a controller that has gone
# away is exactly the case where linked() is false, which made the silence timer
# uncallable in the one situation it exists for -- the board sat armed with a key held
# forever, and read-only /status traffic kept it that way.
check("if (armed && control.controllerSeen() &&" in main_src,
"loop()'s silence disarm runs off controllerSeen(), not linked()")
check("if (armed && control.linked()" not in main_src,
"nothing gates an armed-and-silent disarm on the link still being there")
# Accepting a TCP connection is not the operator being alive.
accept = server_src[server_src.index("void ControlServer::acceptNew"):
server_src.index("size_t ControlServer::readSome")]
# Code lines only -- the function carries a comment explaining why it does NOT stamp the
# clock, and a check that flagged the comment would be worse than no check at all.
accept_code = "\n".join(l for l in accept.splitlines()
if not l.strip().startswith(("//", "*", "/*")))
check("_lastInboundMs" not in accept_code,
"acceptNew() does not refresh the controller-silence clock")
# And closing a read-only /status request must not refresh it either -- the first fix
# stamped it on every close, which held a dead controller's armed state open exactly as
# effectively as the original bug did.
close_fn = server_src[server_src.index("void ControlServer::closeLink"):
server_src.index("}", server_src.index("_onLinkChange(_ctx, false)"))]
check("if (wasLinked) _lastInboundMs = millis();" in close_fn,
"closeLink() stamps the silence clock only when a controller link is what closed")
check("const bool wasLinked = _phase == Phase::WebSocket;" in close_fn,
"closeLink() treats anything that reached the WebSocket phase as able to leave "
"keys down, not only what is still marked authenticated")
print("the target-side verifier")
# Twice now the verifier itself was the bug -- mislabelled LEDs, unnamed key codes that
# looked like lost keystrokes -- so the thing that decides what "it arrived" means gets
# tested too, and the mouse half of it especially: it is new, and a watcher that misread
# REL_X would either cry wolf or miss a mouse that worked.
import watch_target as wt # noqa: E402
# EV_REL is what makes a node a pointing device. Nothing here trusts a device name.
check_eq(wt.classify(1 << wt.EV_REL | 1 << wt.EV_KEY), "mouse", "EV_REL means mouse")
check_eq(wt.classify(1 << wt.EV_KEY | 1 << wt.EV_LED), "keyboard",
"EV_KEY without EV_REL means keyboard")
check_eq(wt.classify(1 << wt.EV_ABS), None,
"an absolute-only device is neither, and is not watched as a mouse")
check_eq(wt.capability_bits("/nonexistent/does/not/exist"), 0,
"an unreadable sysfs file is 0 rather than an exception")
m = wt.MouseWatch(False)
check(m.event(0, 0, 20) is None and m.event(0, 1, -6) is None,
"a raw axis event is not a line -- one report is one line")
check(m.event(0, 0, 15) is None, "and more movement in the same report accumulates")
line = m.flush(0.1)
check(line is not None and "+35" in line and "-6" in line,
f"the report comes out as the sum of its axes ({line!r})")
check(m.flush(0.2) is None, "a report with no movement prints nothing")
check_eq(m.x, 35, "net X is the exact sum of what the target received")
check_eq(m.y, -6, "and so is net Y")
check_eq(m.travel, 41, "and the path length counts both, not the diagonal")
# REL_WHEEL_HI_RES is 1/240 of a notch. Counting it as a notch would report 240x the
# scroll that happened, which is the same class of error as the mislabelled LEDs.
# The axis numbers, pinned to the kernel's, not to the HID report's byte order. Getting
# this wrong made a working wheel look dead -- see the comment on REL_NAMES.
check_eq((wt.REL_X, wt.REL_Y, wt.REL_WHEEL, wt.REL_HWHEEL), (0, 1, 8, 6),
"axis codes are the kernel's: REL_WHEEL is 8, not 2")
check_eq((wt.REL_WHEEL_HI_RES, wt.REL_HWHEEL_HI_RES), (11, 12),
"and the hi-res wheel codes are 11/12, not 6/7")
m2 = wt.MouseWatch(False)
check(m2.event(0, wt.REL_WHEEL, -3) is not None,
"a wheel notch is printed as it happens, not coalesced with movement")
m2.event(0, wt.REL_WHEEL_HI_RES, 720) # the same notches again, finer
check_eq(m2.wheel, -3, "hi-res wheel events are not counted as extra notches")
check("REL_WHEEL" in "\n".join(m2.report()),
"and the summary names the axis it saw, so a wheel that arrived is visible")
m3 = wt.MouseWatch(False)
m3.event(0, 40, 5) # an axis this tool does not know
check(any("rel40" in l for l in m3.report()),
"an unnamed axis code is counted and shown, never dropped in silence")
m2.event(0, 272, 1)
check("still held" in "\n".join(m2.report()),
"a button the target still believes is down is called out, in those words")
m2.event(0, 272, 0)
check("still held" not in "\n".join(m2.report()),
"and the warning goes away when it is released")
k = wt.KeyboardWatch(False)
k.event(0, 30, 1)
k.event(0, 30, 0)
check("a" in "".join(k.typed), "the keyboard watcher still reassembles typed text")
kc = wt.KeyboardWatch(False)
for code in (29, 56, 111):
kc.event(0, code, 1)
check(any("Ctrl+Alt+Del" in l for l in kc.report()),
"and still names Ctrl+Alt+Del as one combination, not three keys")
print(f"relayctl {checks} checks, {failures} failures")
sys.exit(1 if failures else 0)
+12 -2
View File
@@ -83,8 +83,18 @@ def main() -> int:
sys.stdout.flush()
return 0
finally:
termios.tcsetattr(fd, termios.TCSAFLUSH, saved)
os.close(fd)
# `reboot` makes the device disappear underneath us: the CDC node is gone the
# instant the board resets, so restoring the port settings can legitimately fail.
# The command already reached the board -- do not turn a successful `reboot` into
# a Python traceback and a non-zero exit.
try:
termios.tcsetattr(fd, termios.TCSAFLUSH, saved)
except (OSError, termios.error): # termios.error is NOT an OSError here
pass
try:
os.close(fd)
except OSError:
pass
if __name__ == "__main__":
+189
View File
@@ -8,6 +8,9 @@ installed to be useful.
relayctl.py --host keyrelay.local status
relayctl.py --host 192.168.4.1 arm
relayctl.py --host 10.2.1.99 key Enter # one key, press and release
relayctl.py --host 10.2.1.99 mouse move 120 -40 # relative movement, split to fit
relayctl.py --host 10.2.1.99 mouse click right # press and release
relayctl.py --host 10.2.1.99 mouse drag 200 0 # press, move, release, in order
relayctl.py --host 10.2.1.99 cad # Ctrl+Alt+Del, with --yes
relayctl.py --host keyrelay.local listen # print every status heartbeat
relayctl.py --host keyrelay.local disarm
@@ -24,6 +27,14 @@ a controller, so no separate `arm` command could have done it), the text is neve
echoed back, and the run disarms again afterwards. What is still absent is a script or
macro facility: the board cannot store a sequence, cannot be told to type later, and
nothing here replays one. See README "What this deliberately is not".
`mouse` is the same argument with a pointer. Movement is relative -- the board reports how
far, never where -- so the target's own pointer speed decides where a given `move` lands,
and there is no position for this tool to be wrong about. `click` and `drag` press real
buttons and need --yes for that reason; a movement does not, because moving a cursor is
reversible by definition. There is no gesture recording, no auto-click and no "move to the
OK button", because the moment this tool could actuate a target on its own it would be a
different and worse device than a keyboard.
"""
from __future__ import annotations
@@ -49,6 +60,22 @@ MAX_SESSION_KEYS = 16
TEXT_FRAME_MAX = 120 # characters per P= frame
MAX_PASTE_CHARS = 2048 # characters one run holds on the board
DEFAULT_TEXT_PACE_MS = 12 # what the board uses when asked for 0
# Mouse movement. Both numbers are the board's -- keep them in step with src/config.h
# (kMouseDeltaMax, kMouseButtonMask) or this tool builds frames the board refuses.
# One M= frame is one HID report, and a report carries one signed byte per axis, so a
# larger movement is split into several frames here rather than guessed at there.
MOUSE_DELTA_MAX = 127 # one signed byte, per axis, per frame
MOUSE_BUTTON_MASK = 0x1f # five buttons: all the report can carry
MOUSE_FRAME_MS = 8 # the pace the controller page sends at, matched here
# Button name -> the report's bit, in the order the HID report descriptor lays them out.
# NOTE the DOM's MouseEvent.button numbers are NOT this order (there 1 is middle and 2 is
# right), which is why src/web_page.h carries its own mapping and both are pinned by
# tests: get it wrong and a right-click arrives as a middle-click, cleanly parsed.
MOUSE_BUTTONS = {
"left": 0x01, "right": 0x02, "middle": 0x04, "back": 0x08, "forward": 0x10,
}
GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
OP_TEXT, OP_PING, OP_PONG, OP_CLOSE = 0x1, 0x9, 0xA, 0x8
@@ -117,6 +144,87 @@ def untypeable(text: str) -> list[str]:
if not (ord(ch) == 9 or ord(ch) == 10 or 32 <= ord(ch) <= 126)]
def mouse_frame(buttons: int, dx: int = 0, dy: int = 0, wheel: int = 0,
pan: int = 0) -> str:
"""Render `M=<btn>:<dx>:<dy>:<wheel>:<pan>` -- one mouse report, five fields.
Strict in the same direction the firmware parses: the button byte must fit the five
bits the report has and every movement must fit one signed byte, or this raises. A
frame the board would refuse is not sent, and nothing is clamped to make it fit --
clamp a 300 and the cursor quietly travels half as far as was asked.
"""
if buttons & ~MOUSE_BUTTON_MASK:
raise RelayError(f"buttons {buttons:#02x} does not fit a five-button report "
f"(have: {', '.join(MOUSE_BUTTONS)})")
for name, v in (("dx", dx), ("dy", dy), ("wheel", wheel), ("pan", pan)):
if not -MOUSE_DELTA_MAX <= v <= MOUSE_DELTA_MAX:
raise RelayError(f"{name}={v} is more than one report's worth "
f"(+/-{MOUSE_DELTA_MAX}); split it")
return f"M={buttons:02x}:{dx}:{dy}:{wheel}:{pan}"
def split_moves(dx: int, dy: int, wheel: int = 0, pan: int = 0) -> list[tuple]:
"""Greedy split of a movement into one-report chunks, same shape as the page's.
Both axes travel together in each chunk, so a diagonal keeps its shape rather than
arriving as an L, and the sums are exact -- 300 px out means 300 px on the target.
"""
out: list[tuple] = []
while dx or dy or wheel or pan:
step = [max(-MOUSE_DELTA_MAX, min(MOUSE_DELTA_MAX, v)) for v in (dx, dy, wheel, pan)]
out.append(tuple(step))
dx -= step[0]
dy -= step[1]
wheel -= step[2]
pan -= step[3]
return out or [(0, 0, 0, 0)]
def mouse_frames(action: str, values: list[str], button: str = "left") -> list[tuple]:
"""Turn a `mouse` subcommand into the frames to send, as (buttons, dx, dy, wheel, pan).
A click is two frames -- the button restated down, then restated up -- with the
movement in between, because the byte on the wire is the whole button state and the
order is the entire difference between a click and a drag.
"""
if button not in MOUSE_BUTTONS:
# Checked for every action, not only the ones that use it: `--button thumb` is a
# typo, and a command that quietly ignores a nonsense option is one that will be
# typed again.
raise RelayError(f"unknown button {button!r} (have: {', '.join(MOUSE_BUTTONS)})")
if button != "left" and action not in ("click", "drag"):
# `mouse move 100 0 --button right` would otherwise move the cursor and pretend the
# button was involved. It is not: only the verbs that press something take a
# button, and saying so beats inventing a meaning for it later.
raise RelayError(f"`{action}` does not press a button, so --button has no "
f"meaning here (click and drag do take one)")
if action == "release":
return [(0, 0, 0, 0, 0)]
if action == "move":
if len(values) not in (2, 3, 4):
raise RelayError("usage: mouse move DX DY [WHEEL] [PAN]")
nums = [int(v) for v in values] + [0] * (4 - len(values))
return [(0, *rest) for rest in split_moves(*nums)]
if action == "wheel":
if len(values) != 1:
raise RelayError("usage: mouse wheel NOTCHES (negative scrolls up)")
# split_moves returns (dx, dy, wheel, pan); the wheel belongs in the third slot.
return [(0, 0, 0, part[2], part[3])
for part in split_moves(0, 0, int(values[0]))]
bit = MOUSE_BUTTONS[button]
if action == "click":
return [(bit, 0, 0, 0, 0), (0, 0, 0, 0, 0)]
if action == "drag":
if len(values) not in (2, 3, 4):
raise RelayError("usage: mouse drag DX DY [WHEEL] [PAN]")
nums = [int(v) for v in values] + [0] * (4 - len(values))
return [(bit, 0, 0, 0, 0)] + [(bit, *rest) for rest in split_moves(*nums)] \
+ [(0, 0, 0, 0, 0)]
raise RelayError(f"unknown mouse action {action!r} "
"(try move, click, drag, wheel or release)")
def text_frame(delay_ms: int, chunk: str) -> str:
"""One `P=<ms>:<hex>` command, byte-identical to proto.h's formatText()."""
return f"P={delay_ms}:{chunk.encode('utf-8').hex()}"
@@ -416,6 +524,25 @@ class Relay:
body = ",".join(f"{u:02x}" for u in usages)
self._send(f"Y={mods:02x}:{body}")
def mouse(self, frames, gap_ms: float = MOUSE_FRAME_MS) -> int:
"""Send mouse reports, paced so they arrive like a mouse and not a flood.
No waiting for acknowledgement between frames -- a movement is not a state the
board has to confirm, and the board's own queue is what absorbs a burst. But the
gap is real: sent as fast as a socket will take them, a few hundred reports would
pile into the board's queue and trip the overflow fail-safe, which releases
everything and disarms. An error frame (E10 no mouse interface, E11 boot
protocol) raises out of the next read rather than being swallowed.
"""
sent = 0
for buttons, dx, dy, wheel, pan in frames:
self._send(mouse_frame(buttons, dx, dy, wheel, pan))
sent += 1
if gap_ms:
time.sleep(gap_ms / 1000.0)
self._drain(0.0) # pick up anything the board said about them
return sent
def listen(self, seconds: float) -> None:
"""Print heartbeats. Sends keepalives: a silent client is one the board disarms
after kNoClientTimeoutMs, and watching must not change what is watched."""
@@ -436,6 +563,8 @@ class Relay:
print(f" net={s.get('net'):4s} ip={s.get('ip'):15s} "
f"usb={s.get('usb')} armed={s.get('armed')} "
f"keys={s.get('keys')} chords={s.get('chords')} "
f"mframes={s.get('mframes', 0)} "
f"mbtn=0x{s.get('mbtn', 0):02x} "
f"drop={s.get('drop')} leds=0x{s.get('leds'):02x}")
else:
print(f" {text}")
@@ -478,6 +607,8 @@ def describe(status: dict) -> str:
f" armed {'YES -- keystrokes are being forwarded' if status.get('armed') else 'no'}",
f" counters keys={status.get('keys')} chords={status.get('chords')} "
f"dropped={status.get('drop')}",
f" mouse {'can report' if status.get('mouse') else 'CANNOT REPORT'} "
f"buttons=0x{status.get('mbtn', 0):02x} reports={status.get('mframes', 0)}",
f" text {status.get('typed', 0)} characters typed"
+ (f", {status['typing']} still to go" if status.get('typing') else ""),
f" leds caps={bool(status.get('leds', 0) & 2)} "
@@ -485,6 +616,10 @@ def describe(status: dict) -> str:
]
if status.get("dflt"):
lines.append(" WARNING the board still uses the default passphrase")
if status.get("usb") and not status.get("mouse"):
lines.append(" NOTE no mouse reports: `mouse on` over serial and reboot, or"
"\n the target chose boot protocol (it would read a mouse"
"\n report as keystrokes, so the board refuses them)")
if status.get("roll"):
lines.append(" NOTE more than six keys are down: ErrorRollOver is out")
return "\n".join(lines)
@@ -534,6 +669,26 @@ def main() -> int:
help="leave the bridge armed afterwards")
p_paste.add_argument("--yes", action="store_true",
help="acknowledge that this types into the target")
p_mouse = sub.add_parser(
"mouse", help="move the target's pointer, or press one of its buttons")
p_mouse.add_argument("action", choices=["move", "click", "drag", "wheel", "release"],
help="move: relative dx dy (and optional wheel, pan); "
"click: press and release one button; drag: press, move, "
"release; wheel: NOTCHES (negative scrolls up); "
"release: every button up")
p_mouse.add_argument("values", nargs="*", metavar="N",
help="numbers for move / drag (px, px [wheel] [pan]) or "
"wheel (notches)")
p_mouse.add_argument("--button", default="left",
help="left, right, middle, back or forward (default left)")
p_mouse.add_argument("--gap", type=int, default=MOUSE_FRAME_MS, metavar="MS",
help=f"milliseconds between reports (default {MOUSE_FRAME_MS}, "
"the rate the controller page uses)")
p_mouse.add_argument("--keep-armed", action="store_true",
help="stay armed afterwards")
p_mouse.add_argument("--yes", action="store_true",
help="required for click and drag, which can actuate something")
p_keys = sub.add_parser(
"keys",
help="verification session: arm, press these keys, disarm again")
@@ -646,6 +801,40 @@ def main() -> int:
return 3
print("sending Ctrl+Alt+Del ...")
relay.chord(0x05, [0x4C])
elif args.cmd == "mouse":
# A click can actuate whatever is under the pointer, so it is gated like the
# other things that have consequences beyond a character appearing. A
# movement is not: it moves a cursor, which is reversible by definition.
if args.action in ("click", "drag") and not args.yes:
print(f"`{args.action}` presses a real mouse button on the machine the "
"board is plugged into, wherever its pointer happens to be.\n"
"Re-run with --yes if a human is looking at it.", file=sys.stderr)
return 4
try:
frames = mouse_frames(args.action, args.values, args.button)
except (RelayError, ValueError) as exc:
print(f"{exc}", file=sys.stderr)
return 2
# Armed inside this one connection, like `keys` and `paste`: the board
# disarms the moment a controller drops, so "arm in one invocation and use it
# in the next" is not a thing that can work -- and the LED is red for exactly
# as long as this session lasts.
status = relay.status()
if not status.get("mouse", 1):
print("this board cannot report a mouse: `mouse on` over serial and "
"reboot, or the target chose boot protocol (it would read a "
"mouse report as keystrokes, so the board refuses them). Nothing "
"was armed and nothing was sent.", file=sys.stderr)
return 3
relay.arm()
print("armed -- LED should be solid red on the board")
n = relay.mouse(frames, args.gap)
print(f"{n} mouse report(s) sent ({args.action}"
+ (f", {args.button}" if args.button != "left" else "") + ")")
if not args.keep_armed:
relay.disarm()
print("disarmed, every key and button released")
print(describe(relay.status()))
elif args.cmd == "keys":
# One deliberate session in one connection: the board disarms the moment
# a controller drops (that is the safety property that makes the rest of
+318 -83
View File
@@ -1,25 +1,35 @@
#!/usr/bin/env python3
"""watch_target.py -- prove what the target actually received, on the target.
Run this on the machine the keyrelay board is plugged into. It opens the evdev node
of the board's keyboard interface and decodes every event, so verification never
depends on what happens to have focus: no terminal, no editor, no guessed timing.
Run this on the machine the keyrelay board is plugged into. It opens the evdev nodes of
the board's HID interfaces and decodes every event, so verification never depends on what
happens to have focus: no terminal, no editor, no guessed timing.
python3 watch_target.py # 20 s, then a summary
python3 watch_target.py # 20 s, then a summary
python3 watch_target.py --seconds 60
python3 watch_target.py --show-all # include unknown key codes
python3 watch_target.py --show-all # include unknown key codes
python3 watch_target.py --keyboard-only # ignore the mouse interface
It reads, it does not write: EV_KEY and EV_LED only, and nothing is injected.
The board enumerates as a keyboard and a relative mouse on one HID interface, which the
kernel splits into two input devices, so both are watched at once. "The mouse works" means
*the target received REL_X*, and a keyboard watcher cannot see that. Movement is coalesced
into one line per report -- a captured pointer is 125 reports a second, and a stream
nobody can read proves nothing -- while buttons and wheel are printed as they happen. The
summary reports distance travelled, not event counts, so it can be compared against a
movement you actually made.
It reads, it does not write: EV_KEY, EV_REL and EV_LED only, and nothing is injected.
Membership of the `input` group is enough to open /dev/input/event*. The board is
found by USB id rather than by a device-name path, so a rename or a different port
cannot break it (the default id is the Logitech G815 identity the firmware uses,
see src/main.cpp; the Espressif default is also accepted for older builds).
cannot break it (the default id is the Logitech G815 identity the firmware uses, see
src/main.cpp; the Espressif default is also accepted for older builds).
"""
from __future__ import annotations
import argparse
import glob
import pathlib
import select
import struct
import sys
@@ -28,7 +38,7 @@ import time
EVENT_FORMAT = "llHHi" # struct input_event on 64-bit Linux
EVENT_SIZE = struct.calcsize(EVENT_FORMAT)
EV_SYN, EV_KEY, EV_LED = 0x00, 0x01, 0x11
EV_SYN, EV_KEY, EV_REL, EV_ABS, EV_LED = 0x00, 0x01, 0x02, 0x03, 0x11
IDENTITIES = {("046d", "c33f"), ("303a", "1001")}
@@ -59,6 +69,27 @@ KEY_NAMES = {
189: "F19", 190: "F20", 191: "F21", 192: "F22", 193: "F23", 194: "F24",
}
# Mouse button codes. A separate number space from the keyboard's EV_KEY codes above --
# which is exactly why the two interfaces are decoded separately instead of through one
# table: BTN_LEFT is 272 and no key code is anywhere near it.
BTN_NAMES = {272: "Left", 273: "Right", 274: "Middle", 275: "Back", 276: "Forward",
277: "Task", 278: "WheelLeft", 279: "WheelRight"}
# Input-event axis codes. The first version of this table had the wheel at code 2, guessed
# from the order the HID report lays its bytes out -- and REL_WHEEL is 8. It was caught by
# nothing on the host and by everything on the target: `mouse wheel -3` scrolled the
# machine, the watcher printed nothing, and the summary said no wheel happened. So the
# numbers are pinned to <linux/input-event-codes.h> by name and the unknown-code path is
# loud, because a verifier that quietly drops a code is worse than one that is wrong.
REL_X, REL_Y, REL_Z = 0, 1, 2
REL_HWHEEL, REL_DIAL, REL_WHEEL, REL_MISC = 6, 7, 8, 9
REL_WHEEL_HI_RES, REL_HWHEEL_HI_RES = 11, 12
REL_NAMES = {REL_X: "REL_X", REL_Y: "REL_Y", REL_Z: "REL_Z",
REL_HWHEEL: "REL_HWHEEL", REL_DIAL: "REL_DIAL",
REL_WHEEL: "REL_WHEEL", REL_MISC: "REL_MISC",
REL_WHEEL_HI_RES: "REL_WHEEL_HI_RES",
REL_HWHEEL_HI_RES: "REL_HWHEEL_HI_RES"}
# code -> (unshifted, shifted), for reassembling what was actually typed. Anything
# not listed still shows up as a named key press, just not as a character.
TYPED = {
@@ -106,9 +137,42 @@ def read_sysfs(path: str) -> str:
return ""
def keyboard_nodes() -> list[str]:
"""evdev nodes of every USB device in IDENTITIES."""
nodes: list[str] = []
def capability_bits(path: str) -> int:
"""The EV_* bitmask a device supports, from sysfs. 0 when it cannot be read."""
value = 0
for word in read_sysfs(path).split():
try:
value = (value << 32) | int(word, 16)
except ValueError:
return 0
return value
def classify(ev_bits: int) -> str | None:
"""Which of the two devices a node is, from its EV_* capability bits.
Split out of target_nodes() so the rule itself can be tested: a relative pointing
device is the one that advertises EV_REL, and the keyboard is everything else that
advertises EV_KEY. Nothing here looks at a device *name*, because the name is what
changes when a different driver binds to it.
"""
if ev_bits & (1 << EV_REL):
return "mouse"
if ev_bits & (1 << EV_KEY):
return "keyboard"
return None
def target_nodes(want_mouse: bool = True) -> dict[str, str]:
"""evdev nodes of the board's interfaces, keyed "keyboard" / "mouse".
Classified by what the kernel says the device emits, not by its name or by the order
the glob found them in. Watching "the first matching node" would have watched the
mouse and reported the keyboard's keystrokes as missing, or the other way round,
depending on which interface the kernel probed first -- which is not a fact to build a
verification on.
"""
out: dict[str, str] = {}
for device in glob.glob("/sys/bus/usb/devices/*"):
if (read_sysfs(f"{device}/idVendor"),
read_sysfs(f"{device}/idProduct")) not in IDENTITIES:
@@ -116,8 +180,183 @@ def keyboard_nodes() -> list[str]:
# Depth is spelled out on purpose: a recursive ** glob over sysfs stalls.
found = glob.glob(f"{device}/*/input/input*/event*")
found += glob.glob(f"{device}/*/*/input/input*/event*")
nodes += ["/dev/input/" + n.rsplit("/", 1)[-1] for n in sorted(found)]
return sorted(set(nodes))
for path in sorted(found):
# .../input/inputN/eventM -> .../input/inputN/capabilities/ev
caps = pathlib.Path(path).parent / "capabilities" / "ev"
kind = classify(capability_bits(str(caps)))
if kind is None or (kind == "mouse" and not want_mouse):
continue
out.setdefault(kind, "/dev/input/" + path.rsplit("/", 1)[-1])
return out
class KeyboardWatch:
"""Key presses, chords, LEDs, and the text that came out of them."""
kind = "keyboard"
def __init__(self, show_all: bool) -> None:
self.show_all = show_all
self.held: set[int] = set()
self.typed: list[str] = []
self.presses = 0
self.chords: set[str] = set()
self.reported: set[str] = set()
self.shift = False
def event(self, stamp: float, code: int, value: int) -> str | None:
if code not in KEY_NAMES and not self.show_all:
return None # unknown codes are counted, never dropped:
if code not in KEY_NAMES: # they still show up in --show-all totals
name = f"key{code}"
else:
name = KEY_NAMES[code]
if value == 1: # press
self.presses += 1
self.held.add(code)
if code in SHIFTS:
self.shift = True
current = frozenset(self.held)
for combo, label in CHORDS.items():
if combo <= current and label not in self.reported:
self.reported.add(label)
self.chords.add(label)
print(f" t={stamp:6.2f}s >>> {label} <<<")
if code in TYPED and not (current & CONTROLS):
self.typed.append(TYPED[code][1 if self.shift else 0])
elif code in TYPED and code == 28:
self.typed.append("<Enter-with-modifiers>")
state = "press"
elif value == 0: # release
self.held.discard(code)
if code in SHIFTS and not (self.held & SHIFTS):
self.shift = False
state = "release"
else: # autorepeat
state = "repeat"
return f"{name:>16} {state}"
def led(self, stamp: float, code: int, value: int) -> str:
return f"LED {LED_NAMES.get(code, f'LED{code}')} {'on' if value else 'off'}"
def flush(self, stamp: float) -> str | None:
"""Keyboards have nothing to coalesce; the interface is here so the loop is."""
return None
def report(self) -> list[str]:
lines = [f" key presses {self.presses}"]
if self.chords:
lines.append(f" chords {', '.join(sorted(self.chords))}")
if self.typed:
text = "".join(self.typed)
preview = text if len(text) <= 240 else text[:240] + "..."
lines.append(f" typed {preview!r}")
else:
lines.append(" typed (nothing plain-text -- modifiers or chords "
"only)")
if self.held:
lines.append(" still held "
+ ", ".join(KEY_NAMES.get(c, str(c)) for c in self.held)
+ " <-- a stuck key: check the controller released it")
return lines
class MouseWatch:
"""Relative movement, coalesced per report, plus buttons and wheel."""
kind = "mouse"
def __init__(self, show_all: bool) -> None:
self.show_all = show_all
self.reports = 0 # reports that carried movement
self.x = 0 # net travel, the thing you can compare with a ruler
self.y = 0
self.travel = 0 # total path length in device units
self.wheel = 0
self.hpan = 0
self.held: set[int] = set()
self.clicks = 0
self.seen_buttons: set[str] = set()
self.axes: set[str] = set()
self._dx = 0 # the report being accumulated
self._dy = 0
self._hw = 0
def event(self, stamp: float, code: int, value: int) -> str | None:
if code in REL_NAMES:
name = REL_NAMES[code]
self.axes.add(name)
if code == REL_X:
self._dx += value
elif code == REL_Y:
self._dy += value
elif code == REL_WHEEL:
self.wheel += value
return f"{name:>16} {value:+d}"
elif code == REL_HWHEEL:
self.hpan += value
return f"{name:>16} {value:+d}"
# Hi-res wheel events are 1/240 of a detent and repeat the notch the plain
# event already reported; counting them would multiply the scroll by 240.
return None
if code in BTN_NAMES:
name = BTN_NAMES[code]
self.seen_buttons.add(name)
if value == 1:
self.held.add(code)
self.clicks += 1
return f"BTN {name:>12} press"
if value == 0:
self.held.discard(code)
return f"BTN {name:>12} release"
return None
# An axis this tool has no name for is still counted and still shown -- silently
# dropping it is how a working wheel looked like a dead one.
self.axes.add(f"rel{code}")
if not self.show_all:
return None
return f"{'rel' + str(code):>16} {value:+d}"
def flush(self, stamp: float) -> str | None:
"""One line per report that moved: 125 reports a second is a blur otherwise."""
if not (self._dx or self._dy):
return None
self.reports += 1
self.x += self._dx
self.y += self._dy
self.travel += abs(self._dx) + abs(self._dy)
line = f"{'movement':>16} dx {self._dx:+5d} dy {self._dy:+5d}"
self._dx = self._dy = 0
return line
def led(self, stamp: float, code: int, value: int) -> str | None:
return None # pointing devices have no LEDs
def report(self) -> list[str]:
lines = [f" reports moved {self.reports}"]
if self.reports:
lines.append(f" net position dx {self.x:+d} dy {self.y:+d} "
f"(path length {self.travel})")
else:
lines.append(" net position (no movement reached the target)")
if self.axes:
lines.append(f" axes seen {', '.join(sorted(self.axes))}")
lines.append(f" wheel {self.wheel:+d} notches, {self.hpan:+d} horizontal")
if self.seen_buttons:
lines.append(f" buttons {', '.join(sorted(self.seen_buttons))} "
f"({self.clicks} press(es))")
else:
lines.append(" buttons (none -- try `relayctl mouse click --yes`)")
if self.held:
lines.append(" still held "
+ ", ".join(BTN_NAMES.get(c, str(c)) for c in self.held)
+ " <-- a stuck button: the controller let go of it and the "
"board did not")
return lines
WATCHERS = {"keyboard": KeyboardWatch, "mouse": MouseWatch}
def main() -> int:
@@ -127,94 +366,90 @@ def main() -> int:
ap.add_argument("--show-all", action="store_true",
help="also print key codes this tool cannot name (unknown codes "
"are counted, never dropped silently: they show in totals)")
ap.add_argument("--keyboard-only", action="store_true",
help="ignore the mouse interface -- for a board flashed with "
"`mouse off`, or to read the keyboard without the pointer "
"filling the screen")
args = ap.parse_args()
nodes = keyboard_nodes()
nodes = target_nodes(want_mouse=not args.keyboard_only)
if not nodes:
ids = " or ".join(":".join(i) for i in IDENTITIES)
print(f"no keyboard for {ids} under /sys/bus/usb -- is the board plugged in "
f"and enumerated here?", file=sys.stderr)
return 1
if len(nodes) > 1:
print(f"note: several matching nodes {nodes}; watching the first")
missing = [k for k in ("keyboard", "mouse")
if k not in nodes and not (k == "mouse" and args.keyboard_only)]
for kind in missing:
print(f"note: no {kind} interface found -- the board enumerated without it "
f"(`mouse off` over serial does that deliberately)", file=sys.stderr)
path = nodes[0]
print(f"watching {path} for {args.seconds:.0f} s -- press keys on the controller")
handles = {}
for kind, path in sorted(nodes.items()):
try:
handles[kind] = open(path, "rb", buffering=0)
except OSError as exc:
print(f"cannot open {path} for the {kind}: {exc} -- are you in the `input` "
f"group?", file=sys.stderr)
return 1
watchers = {kind: WATCHERS[kind](args.show_all) for kind in handles}
print(f"watching {', '.join(f'{k}={v}' for k, v in sorted(nodes.items()))} "
f"for {args.seconds:.0f} s")
print("press keys, or capture the pointer in the controller page and move it")
print(f"{'t':>9} event")
held: set[int] = set()
typed: list[str] = []
presses = 0
chords_seen: set[str] = set()
reported_chords: set[str] = set()
shift = False
start: float | None = None
errors = 0
start: float | None = None
with open(path, "rb", buffering=0) as handle:
try:
deadline = time.time() + args.seconds
while time.time() < deadline:
remaining = max(0.0, deadline - time.time())
if not select.select([handle], [], [], remaining)[0]:
ready, _, _ = select.select(list(handles.values()), [], [], remaining)
if not ready:
continue
raw = handle.read(EVENT_SIZE)
if raw is None or len(raw) != EVENT_SIZE:
errors += 1
continue
sec, usec, event_type, code, value = struct.unpack(EVENT_FORMAT, raw)
if start is None:
start = sec + usec / 1e6
stamp = (sec + usec / 1e6) - start
for kind, handle in handles.items():
if handle not in ready:
continue
raw = handle.read(EVENT_SIZE)
if raw is None or len(raw) != EVENT_SIZE:
errors += 1
continue
sec, usec, event_type, code, value = struct.unpack(EVENT_FORMAT, raw)
if start is None:
start = sec + usec / 1e6
stamp = (sec + usec / 1e6) - start
watcher = watchers[kind]
if event_type == EV_LED:
name = LED_NAMES.get(code, f"LED{code}")
print(f" t={stamp:6.2f}s LED {name} {'on' if value else 'off'}")
continue
if event_type != EV_KEY:
continue
name = KEY_NAMES.get(code, f"key{code}")
if name.startswith("key") and not args.show_all:
continue
if value == 1: # press
presses += 1
held.add(code)
if code in SHIFTS:
shift = True
current = frozenset(held)
for combo, label in CHORDS.items():
if combo <= current and label not in reported_chords:
reported_chords.add(label)
chords_seen.add(label)
print(f" t={stamp:6.2f}s >>> {label} <<<")
if code in TYPED and not (current & CONTROLS):
typed.append(TYPED[code][1 if shift else 0])
elif code in TYPED and code == 28:
typed.append("<Enter-with-modifiers>")
state = "press"
elif value == 0: # release
held.discard(code)
if code in SHIFTS and not (held & SHIFTS):
shift = False
state = "release"
else: # autorepeat
state = "repeat"
print(f" t={stamp:6.2f}s {name:>16} {state}", flush=True)
if event_type == EV_SYN:
line = watcher.flush(stamp)
if line:
print(f" t={stamp:6.2f}s {line}", flush=True)
continue
if event_type == EV_LED:
line = watcher.led(stamp, code, value)
if line:
print(f" t={stamp:6.2f}s {line}", flush=True)
continue
if event_type == EV_KEY:
line = watcher.event(stamp, code, value)
elif event_type == EV_REL:
line = watcher.event(stamp, code, value)
else:
continue
if line:
print(f" t={stamp:6.2f}s {line}", flush=True)
finally:
for handle in handles.values():
handle.close()
print()
print(f" key presses {presses}")
if chords_seen:
print(f" chords {', '.join(sorted(chords_seen))}")
if typed:
text = "".join(typed)
preview = text if len(text) <= 240 else text[:240] + "..."
print(f" typed {preview!r}")
else:
print(" typed (nothing plain-text -- modifiers or chords only)")
if held:
print(f" still held {', '.join(KEY_NAMES.get(c, str(c)) for c in held)}"
" <-- a stuck key: check the controller released it")
for kind in ("keyboard", "mouse"):
if kind in watchers:
print(f" --- {kind} ---")
for line in watchers[kind].report():
print(line)
if errors:
print(f" short reads {errors}")
return 0