Compare commits
2
Commits
9bc8d5213c
...
56aa97d60d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56aa97d60d | ||
|
|
ece0313d02 |
+121
@@ -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.
|
||||
@@ -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).
|
||||
@@ -19,3 +19,5 @@ __pycache__/
|
||||
token
|
||||
*.capture
|
||||
*.log
|
||||
.agents/phase-sessions/
|
||||
.agents/pipeline.log
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 — 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 ·
|
||||
<kbd>Ctrl+Alt+d</kbd> the Ctrl+Alt+Del confirm · <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 · <kbd>Ctrl+Alt+d</kbd> the Ctrl+Alt+Del confirm · while the
|
||||
pointer is captured <kbd>Esc</kbd> belongs to the browser and frees the cursor ·
|
||||
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
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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__":
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user