Initial import: keyrelay, a WiFi-controlled USB HID keyboard

Browser or terminal controller drives an ESP32-S3 that acts as a Logitech
G815 into a target machine running no software. Disarmed by default; arming
requires the passphrase, the board button, or the serial console, and every
fail-safe path cancels in-flight work.

- src/: firmware with pure, host-testable modules (keystate, proto, text,
  ws_codec, sha1), the WebSocket control server, and the served page
- tests/: host suites for the pure modules, relayctl parity, and page JS
- tools/: relayctl terminal controller, serial console, bootloader helper,
  and an evdev target watcher
- platformio.ini: bridge (USB HID) and console (no-HID) environments
- README.md: single source of truth for behaviour, protocol and safety

LLM-Generated: true
This commit is contained in:
2026-11-08 00:20:33 -05:00
commit 9bc8d5213c
27 changed files with 8243 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
# PlatformIO
.pio/
.pioenvs
.piopm
# host test build output (tests/run_tests.sh recreates it)
tests/build/
# python
__pycache__/
*.py[cod]
# editors
*.swp
.DS_Store
# never commit a passphrase, a capture, or a recording of what was typed
*.env
token
*.capture
*.log
+132
View File
@@ -0,0 +1,132 @@
# AGENTS.md — working on keyrelay
A WiFi-controlled USB HID keyboard: laptop browser → ESP32-S3 board → USB into the target.
The target runs no software and sees a Logitech G815. Everything the board does is gated on
one idea: **the difference between a remote keyboard and an unattended injection device is a
human deciding, live, with the arming light on.** Changes that quietly move that line are the
ones to catch here.
The README is the single source of truth for behaviour, protocol, safety and measurement.
This file is the operating manual: what must not break, how to build and verify, and where
the traps are. **Do not fork the protocol or the safety model into `docs/`** — a second copy
drifts, and two paths that can disagree about arming is the exact failure this project keeps
having to fix (see README, "Nine bugs").
## The invariants
1. **State, not events.** `S=<mods>:<usages>` restates the *entire* desired key state. Applying
the same state twice is a no-op; any one message resynchronises the target completely. Never
introduce key-up/key-down events, sequence numbers, or a resynchronisation timer. Every
stuck-key horror story in HID injection tooling is an event-protocol bug.
2. **Disarmed is the only default.** Boot, reload, reconnect, focus loss, socket loss, target
detach, bus suspend, queue overflow and 8 s of controller silence all disarm, and disarming
releases every key. 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
`main.cpp` is the single funnel: it clears the report queue, raises the keys, ends the chord
and discards a pasted run. Any new state that can outlive a disconnect (a queue, a timer, a
run) must be cancelled there too, or it will keep acting after the operator stopped asking.
4. **Nothing is stored.** Counters only. No keystrokes, characters, key names or text in NVS,
logs, `Serial.printf`, error messages or status frames — `src/main.cpp` prints *lengths* for
text runs, never content. No macro store, no schedule, no replay, no auto-arm.
5. **Reject, never guess.** A malformed frame leaves the previous state in force. A character
the map cannot express is `E9` and *none* of that frame is typed — a paste that quietly loses
a character is worse than one that refuses. Silent sanitising is never the default.
6. **`Ctrl+Alt+Del` is fixed on the device.** It is never composed from wire bytes; on a Linux
VT it reboots the target. Same reasoning for the VT-switch chords.
7. **One frame is one command.** ASCII, no JSON inbound, ≤ `kWsMessageCap`. Long payloads are
the *controller's* problem to split, with the board reporting how much it still holds.
8. **The keyboard is exclusive; the board is not.** A second `/ws` upgrade is refused (`E3`);
read-only HTTP is served even while a controller holds the bridge. Keep both routes going
through `serveReadonly()` so they cannot drift apart.
## Three tables that must agree
| table | where | names | pinned by |
|---|---|---|---|
| `CODE_TO_USAGE` | `src/web_page.h` | physical position → usage | `tests/test_relayctl.py` |
| `CODE_TO_USAGE` | `tools/relayctl.py` | same, for the terminal | `tests/test_relayctl.py` |
| `strokeFor()` | `src/text.h` | character → modifier + usage | `tests/test_text.cpp` (whole printable range walked) + tool/board agreement on all 128 ASCII codes |
Chord buttons carry their **wire bytes** in `data-chord`, not a name — which is why
`tests/test_proto.cpp` asserts every labelled chord against the combination its label promises.
A mislabelled button parses cleanly, is correctly gated, arrives intact and is the wrong key;
Alt+Tab was `05:04` (Ctrl+Alt+A) until that test existed. `TEXT_FRAME_MAX` / `TEXT_JOB_MAX` in
the page and `TEXT_FRAME_MAX` / `MAX_PASTE_CHARS` in `relayctl.py` are hand-copied from
`config.h` and are asserted equal in tests — change one, the suite fails, that is the point.
## Build, test, flash
```bash
tests/run_tests.sh # 955 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
python3 tools/to_bootloader.py # 1200-baud touch → ROM download mode
/tmp/piovenv/bin/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:
`find / -maxdepth 6 -name platformio -type d -path '*site-packages*'`.
- **No `g++` on the host.** `run_tests.sh` falls back to a local podman image listed in its
`images[]`. The page suite deliberately runs on the *host* so it works without node in there.
- **Build `console` too.** `ARDUINO_USB_MODE=1` compiles `KR_HAVE_HID == 0`, which is the only
thing that checks the no-HID `#else` branches.
- **The page is served from flash.** A change to `src/web_page.h` is invisible on the network
until the board is reflashed — and a tab that is already open keeps the old page in memory
until it is reloaded. Confirm with `curl -s http://keyrelay.local/ | wc -c` against the size
of the raw string in `src/web_page.h`, and grep the served bytes for the element you added.
- This directory is **not a git repository**, so there is no diff or revert here. Back up
before sweeping edits.
## Verification standard
A feature that lives on the device is not done when the source builds — it is done when the
device is running it and something observed it. Concretely:
- Protocol/page claims → the host suites, plus `curl` against the served page.
- Keystroke delivery → `tools/watch_target.py` on the target (evdev, focus-independent); it is
the only thing that says what the target actually received, including *which* keys a chord
really was.
- Text runs → check the `typed:` summary and press count, not the scrolling stream.
When you write a check, name the mechanism it exercises. "Silence disarms" sat in the README's
safety list while nothing compared `lastInboundMs()` to `kNoClientTimeoutMs`; the documented
verification passed because killing the controller process closes the socket, and the
socket-close path disarms. The check proved a different property from the one it was named after
and nobody noticed, until a run that keeps typing on its own made the difference matter. It is
wired now — but ask, of every green check, *what would still pass if this were broken?*
## Editing rules
- **Pure modules** (`keystate.h`, `proto.h`, `text.h`, `ws_codec.h`, `sha1.h`) include no
Arduino or ESP-IDF headers. That is what lets the identical code run on a host under
sanitisers. Keep it.
- **`web_page.h`** is a C++ raw string: no compiler looks at the HTML or JS. Run
`tests/test_page.js` after touching it (it executes the page against a DOM shim and checks
that every element id the script reaches for exists in the markup).
- **A protocol change is four edits, minimum**: `proto.h` (parser + `format*` + its header
comment), `web_page.h`, `relayctl.py`, and the README Wire protocol table + error-code list.
Tests that pin the wire format are `test_proto.cpp` and `test_relayctl.py`.
- **Docs move with the code.** Update in the same change: README intro bullets and check count,
Safety model, Security model trust-boundary table, Controller page, Wire protocol,
Architecture file map, Tests table, Backlog, and the docstring at the top of whichever module
you changed. This README documents *why* decisions were made — keep the reasoning, don't just
restate the behaviour.
- Secrets: never in a file, a URL, a log or a shell history. The serial `token` command does not
echo it; keep it that way. `/status` takes the token as a query parameter because `curl` is
the point of that endpoint — do not add the token to any other URL.
- `TODO.md` holds **open items only**; clear it when they are done. The durable record is the
README: a fixed bug belongs in "Nine bugs this verification caught" with its root cause and
how it was proven, a new capability in the section that describes it, and a rule worth
repeating here. A TODO that has accumulated resolved items tells the next reader nothing about
what is left to do.
## Current defaults worth knowing
Default passphrase `changeme` (the page and `status` shout about it via `dflt:1`); AP
`keyrelay-ctl` / `keyrelay-ctl-pass`; `http://keyrelay.local` or `192.168.4.1`; paste pace
12 ms/character, 120 characters per frame, 2048 per run; silence disarm 8 s; NVS holds the
station credentials and passphrase and survives reflashing.
+836
View File
@@ -0,0 +1,836 @@
# keyrelay — a WiFi-remote USB keyboard 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.
```
your laptop target machine
┌──────────────────┐ ┌──────────────────┐
│ browser tab │ WiFi │ │
│ keydown/keyup ├────WebSocket──►│ AtomS3 Lite │
│ of the page │ (token) │ keyrelay │
└──────────────────┘ │ │
└────────┬─────────┘
│ USB HID boot keyboard
▼
whatever has keyboard focus:
a login prompt, a BIOS setup,
a VT, a text editor
```
```
disarmed LED dark nothing you type goes anywhere
armed LED solid red every keystroke in the tab goes to the target
```
* **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".
* **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 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.
---
## Contents
- [Is this the right tool](#is-this-the-right-tool)
- [Quick start](#quick-start)
- [The one USB port, and why the control path is WiFi](#the-one-usb-port-and-why-the-control-path-is-wifi)
- [Safety model](#safety-model)
- [Security model and trust boundaries](#security-model-and-trust-boundaries)
- [Linux notes (the target and the laptop)](#linux-notes-the-target-and-the-laptop)
- [Layout caveats](#layout-caveats)
- [Controller page](#controller-page)
- [Wire protocol](#wire-protocol)
- [Serial console](#serial-console)
- [Architecture](#architecture)
- [Tests](#tests)
- [End-to-end verification](#end-to-end-verification)
- [Measured behaviour](#measured-behaviour)
- [Troubleshooting](#troubleshooting)
- [Design decisions](#design-decisions)
- [Backlog](#backlog)
- [Restoring the sibling firmware](#restoring-the-sibling-firmware)
---
## Is this the right tool
**Good fits**
- Driving a machine that has no keyboard and should not get one: a headless box, a
rack server at a console, a kiosk, an instrument, a second machine mid-install.
- Working on a machine you are allowed to touch but not sit at — BIOS/UEFI setup,
bootloader and recovery screens, a login prompt, a full-screen game on a laptop
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.
**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 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*.
- **A machine you are not authorised to input to.** Physical access is the whole
security model of every USB HID tool, and "I could plug it in" is not authorisation.
Use it on your own hardware or where you have explicit permission.
### What this deliberately is not
There is no macro store, no auto-arm at boot, no stored or scheduled sequence, no replay,
and no key logging — in firmware or in the tools. Not because it is hard (it is about ten
lines of `Keyboard.print()`), but because the difference between "a remote keyboard" and
"an unattended injection device" is exactly whether a human is deciding, live, with the
arming light on.
**Where the line sits.** Text you have just pasted and then deliberately sent is a
keyboard doing its job, so there is a command for it: the page's **Send text** box, or
`wl-paste | relayctl paste --yes`. It is gated like everything else — armed, a target
attached, one deliberate press of a button that says how many characters it will type —
and it cannot outlive that decision. The characters sit in RAM until they have gone out
and then they are gone: nothing is stored, nothing is scheduled, and release-all, disarm,
focus loss, a silent controller or a detached target cancels the rest of a run mid-word.
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.
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
recording — pull it out and there is nothing in it to find.
---
## Quick start
Needs [PlatformIO](https://platformio.org/) and the board's source on this machine.
The board is an ESP32-S3 with no USB-UART bridge, so all flashing is over the USB-C
connector it already uses.
```bash
cd keyrelay
tests/run_tests.sh # 955 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
```
Build `console` as well as `bridge` before flashing: it compiles with `ARDUINO_USB_MODE=1`,
which is the only thing that checks the no-HID `#else` branches. And if `pio` on this machine is
a broken launcher rather than a working command (`ModuleNotFoundError: platformio`),
`python3 -m platformio` from an interpreter that has the package works instead — see
[`AGENTS.md`](AGENTS.md), which lists this and the other environment traps.
Then, with the board plugged into the **target**:
```bash
python3 tools/console.py wifi "My SSID" "the wifi passphrase" # optional: join a LAN
python3 tools/console.py token "a-long-passphrase-you-pick" # do this one, always
python3 tools/console.py status
```
Now, on the **laptop**:
| the board is on | open in a browser | because |
|---|---|---|
| your WiFi network | `http://keyrelay.local` (or the `ip=` from `status`) | mDNS, then direct IP |
| its own access point | `http://192.168.4.1` after joining `keyrelay-ctl` | no infrastructure needed |
Enter the passphrase, press **connect**, press **arm** — the LED goes solid red — and
type in that tab. Enter goes. Ctrl+Alt+Del is the button (twice), because your laptop's
own OS swallows the real combination before a browser can see it.
To send text instead of pressing keys, paste it into **Send text** and press its button:
it says how many characters it will type, the board types them as keystrokes at a pace the
board keeps, and **stop typing** abandons the rest. From a terminal it is
`wl-paste | relayctl.py paste --yes` (or `xclip -o -selection clipboard`, `pbpaste`).
To put the sibling project back on the board:
`cd ../jiggler && python3 .../to_bootloader.py && pio run -t upload`.
---
## The one USB port, and why the control path is WiFi
The AtomS3 Lite's USB-C is wired straight to the ESP32-S3's native USB pads (GPIO19/20),
and those pads belong to exactly one peripheral and exactly one host at a time. A board
that is a keyboard to machine A therefore cannot simultaneously be a serial modem to your
laptop. That single hardware fact decides the architecture:
| option | works? | why this project chose as it did |
|---|---|---|
| control over USB, HID to the same laptop | yes, pointless | the target *is* the laptop; that is the `jiggler` use case |
| second USB port / USB host shield | no | the board has one connector, and ESP32-S3 USB-host + device at once is not a thing |
| USB gadget on a Linux SBC (PiKVM-style) | yes | real answer for video + mouse; needs a Pi, a PoE HAT, and 10× the cost |
| **WiFi control, USB HID out** | **yes** | one connector stays free for the target, no host software at all |
The consequence worth stating plainly: the board draws its power and its USB identity
from the target, and its *instructions* from your network. Pulling it out stops everything
immediately — that is a feature.
---
## Safety model
Enforced in this order, each independent of the others:
1. **Disarmed is the only default.** Boot, page reload, WebSocket reconnect and page load
all begin disarmed. ` armed` is a state the board holds, not something the page
assumes — the page's own arming only becomes visible when the board confirms it.
2. **Arming requires authority**: the passphrase over the network, the button on the
board, or the serial console. Nothing else can arm it, and nothing at all reaches the
target while disarmed — not one key, not one chord, not even a "hello".
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,
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.
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.
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.
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.
The physical kill switch is the button: press it and the keys come up, no network, no
passphrase, no software required.
---
## Security model and trust boundaries
| 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 |
| 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) | — |
| The target's user | notice a red LED next to their monitor, and pull it out | — |
**What the passphrase does and does not do.** It authenticates the *control* channel; it
is not confidentiality for keystrokes. The page is served over plain HTTP and the
WebSocket is `ws://`, so on a network you do not control, keystrokes are readable by
anyone who can see the traffic and the token is visible at first contact. This is a
deliberate v1 trade: WSS would need either a trusted certificate (no way to get one for
`keyrelay.local`) or certificate-pinning ceremony in a browser (which the target may not
even have). Documented in [Backlog](#backlog) with the two honest fixes.
Practical rule: **use it on a network you trust**, or on the board's own access point
whose WPA2 passphrase is a separate secret (`kr::kApPass`, change it in `src/config.h`).
Both are better than either alone: the AP means you never have to put the board on
someone else's LAN.
**Single controller, by design.** A second WebSocket upgrade is refused with `503` / `E3`
rather than queued, swapped or shared. Two controllers typing into one machine is precisely
the ambiguity a keyboard must not have, and "who owns this bridge" should never be a tie.
What is exclusive is the **keyboard**, not the board: `GET /` and `/status?token=` are
served even while a controller holds the bridge, so a page can be reloaded, a second window
can watch the counters, and `curl` keeps working mid-session. Read-only access is still
gated by what it reveals — `/status` needs the passphrase, the page needs it before it can
arm or type. If that ever goes too far, the `ap off`/network config is the blunt instrument;
there is no per-client ACL.
**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
unremarkable in `lsusb`; it does not hide it from anyone who looks for a keyboard that
appeared at 14:32. It is your hardware and a real Logitech id, so: personal use only.
To make it honest instead, change the five `kUsb*` constants in `src/main.cpp` — nothing
else depends on them.
**What is never stored:** keystrokes, characters, key names, SSIDs beyond the configured
one, client addresses, or anything else about what was typed. Counters only (`keys`,
`chords`, `dropped`, and `typed`/`typing` for the paste path), which is what the status
display needs to be useful. A run of pasted text exists in RAM from the moment a frame is
accepted until the last character is typed or the run is cancelled, and nothing about it
survives that — no queue across a disconnect, no place to write it. The board has no clock
it trusts and no filesystem it writes to. Turning this into a logger would be a fork, not
a feature flag.
---
## Linux notes (the target and the laptop)
**Ctrl+Alt+Del on a Linux target can reboot it.** On the graphical session it is usually
inert; on a virtual terminal `systemd` maps it to `ctrl-alt-del.target`, which by default
**reboots the machine** (seven CAD presses in two seconds force it). This is why the
combination is a button with a two-press confirm and a 3-second window, never a forwarded
keypress, and why `relayctl cad` refuses to run without `--yes`.
**Ctrl+Alt+F1…F7 works** — which is a bigger deal than CAD: it switches the target's
virtual terminal, and a getty on an unused VT with **autologin** enabled will hand you a
shell as that user. This project puts the VT switches on the special-keys pad so they are
deliberate clicks rather than a chord you can fat-finger.
**Alt+SysRq+b etc.** are reachable too (the magic SysRq key is off by default on most
distros). The pad does not offer them; the keyboard table can produce them, which is what
a keyboard is.
**Keys the laptop's OS steals** (this is the laptop side, and it is the browser-capture
limitation, not a bug): `Ctrl+Alt+Del`, `Ctrl+Alt+F1…F7`, `Alt+Tab`, `Super`,
`PrintScreen`, and on GNOME/KDE a growing list of media and tiling shortcuts are grabbed
by the compositor and never reach the page. Three answers, in order of preference:
1. the **special-keys pad** on the page — each of those is one deliberate click, and the
board synthesises the chord so the laptop never sees it;
2. run the laptop's compositor in a mode with fewer grabs while you work;
3. the **OS-wide capture client** (see [Backlog](#backlog)) — an evdev reader on the laptop
that forwards everything before the compositor gets it. The protocol is already
designed for it; it is a client-side addition, not a firmware change.
**While armed, the page swallows everything** with `preventDefault()`, so `Ctrl+W` will
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.
---
## Layout caveats
`KeyboardEvent.code` names a *physical position* on a US-ANSI keyboard, and those
positions are what travel as HID usage ids. If the **target** is set to a non-US layout,
`KeyQ` still means "the key left of A" and the target maps it to whatever its layout says.
This is identical to what a physical KVM does, and the fixes are the same:
- set the target's layout to English (US) — the one-line fix;
- or remap `CODE_TO_USAGE` in `src/web_page.h` for your laptop's board (it is one object
literal; `tools/relayctl.py` carries the same table and
`tests/test_relayctl.py` fails if the two drift).
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.
**Pasted text goes through a third table**, `strokeFor()` in `src/text.h`: character →
modifier + usage, US-ANSI, and deliberately not position-based, because what is being
pasted is characters. Same caveat, same fix — the target must be on a US layout. A
character with no entry is refused (`E9`) rather than approximated, and `test_text` walks
the entire printable range so an omission cannot hide, while `test_relayctl` proves the
tool's validator and the board's map accept exactly the same 128 codes.
---
## Controller page
Served by the board from flash (`src/web_page.h`, ~35 KB, no CDN, no fonts, no
framework, nothing external — a keyboard bridge must work on an airplane and on a network
that is down). Its script is executed by `tests/test_page.js`, because a C++ raw string is
invisible to every compiler and a typo there fails in a browser, with a target live.
| element | why it is there |
|---|---|
| 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" |
| **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 |
| **Release keys** and `Ctrl+Alt+Esc` | panic: every key up, disarm |
| *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 |
| 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.
---
## Wire protocol
One WebSocket text frame = one command. ASCII, no JSON inbound, trivially hand-typed,
and impossible for one malformed field to desynchronise what follows it.
| dir | message | meaning |
|---|---|---|
| c → d | `T<token>` | authenticate; must be first (`kAuthTimeoutMs` = 5 s) |
| c → d | `H` | hello: status now |
| c → d | `A1` / `A0` | arm / disarm |
| c → d | `S=<mods>:<u>,<u>…` | **the complete key state** |
| 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 | `K` | keepalive |
| d → c | `OK` | accepted |
| d → c | `E<code>:<text>` | error (codes below) |
| d → c | `S{json}` | status, 1 Hz and on every change, incl. `typing` = characters of the run still to type |
`<mods>` is the modifier byte as two hex digits, `<u>` a HID usage id as two hex digits.
Both are strict: exactly two hex digits, no spaces, no trailing comma, usage `00` is not
a key, and a rejected message leaves the previous state in force — the safe outcome.
**`S=` carries state, not events.** `S=00:` means "nothing is pressed". A press and its
release are two frames and therefore two HID reports, so a fast tap cannot vanish;
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.
**`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
because a controller's clock, arriving over WiFi in whatever order the network delivers
it, is not a keyboard timing source. Each character is pressed and released on top of
whatever the operator is physically holding, and released back to it, so a paste can
neither let go of a held key nor strand one. The board answers `E9` for a character the
US-ANSI map cannot express and types none of the frame, rather than sending a paste that
quietly lost one; `E8` means it already holds `kTextJobCap` (2048) characters. A
controller sends the next frame when the status says `typing: 0` — the board's own count,
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.
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.
---
## Serial console
`/dev/ttyACM0` is the CDC half of the same composite USB device, so the console appears
on whatever machine the board is plugged into:
```bash
python3 tools/console.py # interactive, Ctrl-] to quit
python3 tools/console.py /dev/ttyACM0 status # one command, print the reply
```
| command | effect |
|---|---|
| `status` | the same JSON the controller gets, plus link, queue and text-run state (`text N chars typed, M still to go`) |
| `wifi <ssid> [passphrase]` | station network to join (stored in NVS, reconnects) |
| `wifi clear` | forget it; the access point becomes the only way back |
| `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 |
| `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
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.
---
## Architecture
```
src/
config.h defaults: network, timeouts, caps, buffers (NVS overrides)
keystate.h pure modifier byte + pressed-set -> 8-byte HID report, rollover
text.h pure pasted text -> US-ANSI keystrokes, paced; run buffer and cancel
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
control_server.{h,cpp} sockets: HTTP routes, WebSocket lifecycle, auth, heartbeat
main.cpp glue: USB identity/HID, WiFi, report queue, 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
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_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
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
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
unsequenced `_buf[_got++] = b ^ _mask[_got & 3]`, a missing FIN check that silently
accepted fragmentation, a mask key skipped on zero-length frames, and a
`"sec-websocket-key:"` prefix compared at the wrong length so the accept hash was
computed from `"ey: …"`).
The keystroke path, end to end:
```
keydown/keyup -> full state S=.. -> Reader (RFC6455) -> proto::parse -> auth gate
-> KeyboardState::apply (dirty only if the report changed)
-> 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
```
`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.
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.
The USB interface is confirmed boot-protocol on real hardware, which is the precondition
for Windows accepting Ctrl+Alt+Del as the secure attention sequence:
```
$ for i in /sys/bus/usb/devices/*:1.*/; do echo "$(basename $i) class=$(cat $i/bInterfaceClass) sub=$(cat $i/bInterfaceSubClass) proto=$(cat $i/bInterfaceProtocol)"; done
7-1:1.0 class=03 sub=01 proto=01 <- HID, boot subclass, keyboard protocol
7-1:1.1 class=02 sub=02 proto=00 <- CDC-ACM control
7-1:1.2 class=0a sub=00 proto=00 <- CDC-ACM data
```
Two build environments: `bridge` (the real thing, USB-OTG + TinyUSB) and `console`
(`ARDUINO_USB_MODE=1`: no HID, so it cannot type, but WiFi/page/WebSocket/LED/button all
live — for separating "the network side is broken" from "the HID side is broken" without
rebuilding. Arming refuses with `E5` there, on purpose).
---
## Tests
```bash
tests/run_tests.sh # uses g++ if present, else a local container image
SANITIZE=1 tests/run_tests.sh # + ASan/UBSan (recommended, and what found 5 bugs)
```
| 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_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_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 |
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
of the project; it is deliberately the thinnest layer, but it is not covered by a unit
test.
---
## End-to-end verification
Three machines' worth of roles on two boxes: the laptop is the controller, the board is
the bridge, and any Linux box (including the laptop itself) is the target.
```bash
# 1. on the target: what actually arrived, decoded from evdev, focus-independent
python3 tools/watch_target.py --seconds 60
# 2. on the laptop: drive it from the terminal
export KEYRELAY_TOKEN='the-passphrase-you-set'
python3 tools/relayctl.py --host keyrelay.local status
python3 tools/relayctl.py --host keyrelay.local arm
python3 tools/relayctl.py --host keyrelay.local key Enter
python3 tools/relayctl.py --host keyrelay.local chord 04 2b # Alt+Tab, by hand
python3 tools/relayctl.py --host keyrelay.local cad --yes
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
```
What a correct run proves, and how to tell:
| claim | evidence |
|---|---|
| HID reaches the host | `watch_target.py` prints every press/release with a timestamp |
| text is correct | its `typed` line, with case from the shift state |
| Enter works | `Enter press/release` and `<Enter>` in the typed text |
| Ctrl+Alt+Del works as a *combination* | `>>> Ctrl+Alt+Del <<<`, one line, not three |
| no stuck keys | `still held` is absent at the end of the run |
| **pasted text is keystrokes, not a shortcut** | the run's `typed` line reproduces the text, and the press count is roughly twice the character count — a paste that arrived as nothing, or as a single odd chord, means the character map or the pace, not the target |
| **Alt+Tab is Alt+Tab** | `LAlt press` then `Tab press` — and *not* `LCtrl`. This combination was `Ctrl+Alt+A` on the wire until it was caught by the label-vs-bytes test; `watch_target.py` is what settles any doubt about which keys the board actually sent |
| a paste stops | start a long one, press **stop typing** (or blur the tab, or press the board's button): the `typed` text stops mid-sentence and nothing further appears |
| 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` |
`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
Espressif default so it works on older builds.
A pasted run prints one line per press *and* release, so a few hundred characters is a
lot of scrolling: watch the `key presses` and `typed` summary lines at the end rather than
the stream, and give `--seconds` enough room for the pace (`2048` characters at the default
12 ms is about 25 s of typing, plus the run's own margins).
---
## Measured behaviour
_On real hardware: AtomS3 Lite → this laptop (Linux, US layout), board on the same WiFi,
firmware 1.0.0, `bridge` environment._ Fill in / re-run after any change to the keystroke
path; the numbers below are the shape a correct run has.
```
$ python3 tools/console.py status
status {"v":"1.0.0","up":7,"usb":1,"armed":0,"net":"ap","ssid":"keyrelay-ctl",
"ip":"192.168.4.1","keys":0,"chords":0,"drop":0,"roll":0,"leds":0,
"dflt":1,"ap":0}
link controller=no rejected=0 authfails=0
queue 0 pending, 0 held, ok
$ python3 tools/relayctl.py --host <ip> status # armed, typing, then disarmed
armed YES -- keystrokes are being forwarded
counters keys=… chords=0 dropped=0
$ python3 tools/watch_target.py --seconds 30 # on the same laptop
t= 0.42s A press
t= 0.48s A release
...
key presses …
typed '...'
```
### Confirmed on hardware
AtomS3 Lite → Linux laptop (US layout), board joined the LAN, firmware 1.0.0.
| what | result |
|---|---|
| flash | 1 134 768 bytes written (706 661 compressed) in 8.1 s at 1 116 kbit/s, hash verified |
| footprint | RAM 26.7 % (87 400 / 327 680); Flash 35.5 % of the 3 MB app slot, measured with the paste path in |
| enumeration | `class=03 sub=01 proto=01` — boot-protocol keyboard, the Windows secure-attention precondition |
| reflashing | 1200-baud touch into ROM download mode works with this firmware running, so no button and no GPIO0 |
| page delivery | `GET /` → 200, `Content-Length: 35164`, zero external references; 404 on unknown paths; 401 on `/status` without the passphrase; `keyrelay.local` resolves over mDNS |
| arming from the browser | the page armed the board over `ws://` on first try (observed as `controller=yes armed=1`) |
| keystroke delivery | 6/6 inert keys arrived on the target with matched press **and** release: F13, F15, F17, ScrollLock, CapsLock ×2 |
| chord path | `chords=1` counted and delivered (Shift+F15) |
| 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 |
| 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)
Worth listing, because each one is a class of bug that HID-over-network tools ship with:
1. **CDC replies truncated at 128 bytes** — `setTxTimeoutMs(0)` meant "drop what does not
fit", and `serial status` came back cut mid-JSON. Replies now temporarily switch to a
blocking write and flush. A diagnostic tool that lies about state is worse than none.
2. **The CLI lowercased the whole handshake header block** — base64 is case-sensitive, so
every `Sec-WebSocket-Accept` compared wrong and the board looked broken while answering
correctly. Header *names* are case-insensitive; values are not.
3. **The CLI reported stale status** — the board heartbeats at 1 Hz and the key helpers do
not read while typing, so `disarm` printed `armed YES` from a heartbeat sent *before*
the command. `arm`/`disarm` now drain, then poll until the board actually reports the
state asked for — confirm, don't assume.
4. **The target-side verifier mislabelled every LED** — its table was copied from the HID
bit numbering, but Linux `EV_LED` indices are zero-based (`LED_CAPSL` is 1, not 2).
Verified against real events.
5. **Unnamed key codes looked like lost keystrokes** — F13–F24 had no names in the
verifier's table, so a perfectly delivered run appeared to drop its first three keys.
Now named, and unknown codes are counted rather than silently skipped.
6. **The single-controller gate was applied at `accept()`**, before the request was even
read — so while a controller was connected, *anything* else that touched the board got
a 503: `curl` of the page, a second browser, and worst of all **pressing reload on the
page that already held the bridge**, since the board could still be holding the slot the
instant the new request arrives. The fix moves the gate to what it actually protects: a
second `/ws` upgrade is still refused (that is a controller claim), while read-only HTTP
is served on a separate short-lived socket through the same routing code (`serveReadonly`)
so the two paths cannot drift. Verified live: with your browser holding the bridge, the
page returns 200 and `/status` returns 401 without the token, 200 with it, and a second
upgrade still gets 503 while the incumbent stays `controller=yes` and healthy.
7. **The Alt+Tab button sent Ctrl+Alt+A.** Its `data-chord` was `05:04` — LCtrl|LAlt with
usage `0x04`, which is the A key. It parsed cleanly, it was correctly gated by arming,
it arrived intact and it was the wrong combination: Alt+Tab is `04:2b`. Reported by a
user who could not work out why the Pi ignored it, and provable because the page stores
the chord bytes on the button instead of a name to look up — so nothing on the browser
side could ever disagree with a label. Every labelled chord is now pinned to the
combination its label promises, by modifier byte and usage id.
8. **`relayctl`'s drain called two methods that do not exist** (`_send_frame`,
`_check_error`) while answering a ping, so any session outliving the board's 15 s ping
died on an `AttributeError` instead of reading its next frame. Only reachable on long
sessions, which is exactly what typing text is; it answered pings the way `_poll` does
and raises on error frames now.
9. **"Silence disarms" was documented and not implemented.** `kNoClientTimeoutMs` existed,
`ControlServer::lastInboundMs()` existed, and nothing ever compared them. Harmless while
the only input was a human keystroke (a vanished controller sends no more keystrokes),
and not harmless once a run can type for twenty seconds on its own: a laptop that slept
mid-paste, with a link that never got a FIN, would keep typing into the target.
`loop()` now checks it, before driving the typewriter, and clients keep-alive while they
type. The lesson is the one the whole project keeps relearning: a safety property that
is only written down is not a safety property.
### The hazard nobody warns you about
**When the target is the machine you are working on, your keystrokes land on your own
focused window.** The first live test sent `Escape`, and the target was the laptop running
this agent — so the keystroke interrupted the very session that sent it. That is not a bug
in the bridge; it is exactly the transparency the thing is designed for, pointed the wrong
way. So:
- verify with keys that nothing reacts to — **F13–F24 and the Lock keys** are inert almost
everywhere, and they exercise the same code path as Enter;
- before arming against your own machine, decide what has focus; a terminal will *execute*
what you send;
- the page releases keys and disarms when its tab loses focus, which is also how you get
control back: click anything else, then click back.
---
## Troubleshooting
| symptom | cause, and what to do |
|---|---|
| the page does not have a feature the README describes | **the page is served from the board's flash**, so the image you built is not the image on the device. `curl -s http://keyrelay.local/ \| wc -c` and compare with the page in `src/web_page.h`; if they differ, reflash (`to_bootloader.py` then upload) and *reload the tab* — one already open still holds the old page in memory, passphrase notwithstanding |
| `pio run -t upload` sits at `Connecting…` | the app owns the USB pads. `python3 tools/to_bootloader.py`, or hold the button 3 s while powering up (LED solid red) |
| page will not connect | wrong address: `console.py status` for the real `ip=`; `net:"ap"` means you must join `keyrelay-ctl` |
| `keyrelay.local` does not resolve | mDNS is queried per-process with no cache on glibc (`mdns4_minimal [NOTFOUND=return]`), so a cold probe that misses returns "not known" immediately — **just retry**, or use the IP. Across VLANs/VPN it is often blocked outright |
| `arm` does nothing, LED blinks red | no USB host attached (`usb:0`); the board is not plugged into the target, or the target suspended the bus |
| typing does nothing, `armed` says yes | the tab lost focus: click into the page. Or the target's focus moved — that is the target's answer, not a bug |
| `E4 not armed` | it is not armed. `arm` first; chords and states are never free passes |
| `E3 another controller` | another tab/machine holds it. Close it; there is no takeover, deliberately |
| `E2 locked out` | 5 wrong passphrases; wait 30 s |
| keys arrive out of order or missing | check `dropped` (queue overflow) and that the laptop's signal is good; `WiFi.setSleep(false)` is already set. If a *verifier* says keys are missing, check it names the codes — see [the hazard nobody warns you about](#the-hazard-nobody-warns-you-about) |
| the CLI says `another controller holds this bridge` | your browser page has it, and that is the rule working. Close the tab; there is no takeover |
| pasted text arrives as different characters | the **target** is not on a US layout — characters travel as positions, exactly like the live keys. Or the run was sent with *drop characters I cannot type* / `--strip`, which reported how many it removed |
| a paste stops before the end | something cancelled it, on purpose: the tab lost focus, the bridge disarmed, the target suspended the bus, `E7`/`E8`, or 8 s with nothing inbound from the controller. The page's log says which, and how far it got |
| a chord button does something other than its label | the bytes on the button are wrong — the page carries the wire form itself, so nothing browser-side can catch it. `curl` the page and read `data-chord`; this was the case for Alt+Tab (`05:04`, Ctrl+Alt+A) until the label-vs-bytes test was written |
| a key is stuck on the target | `R` / **Release keys** / the button. Then check whether the tab lost focus without the page noticing (a hidden tab still gets blur on most browsers) |
| Ctrl+Alt+Del does nothing on the target | on Linux this is normal on a graphical session and *fatal* on a VT; on Windows the interface must be boot protocol (confirmed above) |
| target keeps suspending the bus | the target suspends, which disarms by design; disable suspend or plug into a port that stays powered |
---
## Design decisions
**Why a web page and not a host daemon.** Because "install something on the laptop" is
the difference between a tool you can use in ten minutes on a machine you are borrowing
and a project you set up once at your own desk. The page is served by the board, so the
only software that has to exist is the browser you already opened.
**Why full state instead of key events.** Covered under [Safety
model](#safety-model) point 8, and it is the decision that makes every other guarantee
here cheap: a lost message is corrected by the next one, so there is no resynchronisation
protocol, no sequence numbers, no stuck-key reconciliation timer.
**Why the board expands and paces text, not the browser.** It is tempting to turn a paste
into existing `S=` frames client-side and change no firmware at all. Timing is the reason
not to: a controller reaches the board over WiFi, in frames, with whatever gaps the network
inserts, and a character is a press and a release with a hold either side of it on a target
that samples every millisecond. A browser tab that stalls for a second would produce either
a burst of keys or a run that outran the 48-deep report queue and tripped the
overflow-disarm. Expanding on the board also means the page and the CLI cannot disagree
about what a character types, which is the same argument that put the protocol in
`proto.h`. Cost: the character map exists twice — positions in `web_page.h`/`relayctl.py`,
characters in `text.h` — and a test pins all three together.
**Why a paste refuses rather than sanitises.** Curly quotes, em dashes and accented
characters have no key on a US-ANSI keyboard. Dropping them quietly would send a passphrase,
a `rm` line or a config value that is *nearly* what was pasted, which is the failure mode no
one notices until it has landed. So the board answers `E9` naming the code point and types
none of the frame, the page says the same before it sends anything, and dropping is an
explicit, per-paste opt-in that says how many characters it removed. Fail-closed, and
loud.
**Why no WebSocket library.** The control channel needs text frames, ping/pong and close.
A library buys fragmentation, extensions, TLS plumbing and many-client handling — attack
surface and heap, none of it wanted. The hand-rolled codec is ~200 lines of pure logic
with 135 host checks behind it, and every deviation from the subset is a hard close.
**Why HTTP/WS without TLS.** See [Security model](#security-model-and-trust-boundaries).
The honest options in the backlog are a user-installed CA (works, annoying) or a
client-side proxy that terminates TLS with a pinned key. Pretending confidentiality here
would be worse than documenting its absence.
**Why NVS for configuration.** So the passphrase and network live in the board rather than
in a header you recompile, and survive reflashing — and so a passphrase never has to enter
a shell history, a git diff or a build log. The `token` command deliberately does not echo
it back.
**Why the sibling project's USB identity.** Two firmwares on one board that are
indistinguishable from the host side, and stable `/dev/input/by-id` names for the tools.
Cost: it is a borrowed VID/PID, so it is documented as personal-use and one edit away
from being honest.
---
## Backlog
Ordered by what would make the biggest difference first.
1. **OS-wide capture client** (`tools/keyrelay-capture.py`, laptop side): grab the
keyboard with evdev, forward full state over the same protocol, `uinput` a shadow if
needed. Solves every compositor-stolen key and is the missing piece for
"transparently drive another machine". Firmware unchanged — the protocol was designed
for it. Needs root or `input` group + a `udev` rule; documented as opt-in because it
swallows the laptop's keyboard while running.
2. **TLS with a pinned certificate** (WSS): either an on-board CA the laptop trusts once,
or a `localhost`-terminating proxy. Until then: trusted network or the board's AP.
3. **A second, independent key path for the panic release**: a GPIO or button
double-click that force-releases without waiting for the loop's disarm path, so the
panic release does not depend on the code that just failed.
4. **Per-connection rate limiting** — a token bucket on state messages, so a wedged or
hostile client cannot pump the queue into the overflow-disarm path as a nuisance
attack (currently it disarms: safe, but noisy), and a cap on page fetches now that the
page is served even while a controller is live (each is ~35 KB, and `no-store` means a
determined client will ask for it again and again).
5. **mDNS also on the AP interface**, verified on each chipset — some builds answer only
on the station interface, which is why the docs say "IP if the name fails".
6. **WPA3/PMF on the SoftAP** and a per-board AP passphrase printed once at first boot,
instead of one shared constant in `config.h`.
7. **Report-rate telemetry**: measure the achieved report interval against the host's
poll rate, so the queue-depth choice is measured rather than reasoned.
8. **Mouse.** The descriptors and the queue would take a second HID interface, and every
safety argument above still holds. It is out because the request was a keyboard, and
half the care in this project went into *not* growing it.
9. **Pastes longer than one run.** `kTextJobCap` is 2048 characters and a controller that
wants to send more has to split the job itself. The flow-control hook already exists —
`typing` in the status frame is the board's own remaining count — so what is missing is
only a client policy that refills as the board drains, plus the `E8` retry that goes
with it. Deliberately not done yet: unbounded buffering on a keyboard bridge buys nothing
and enlarges the window in which text sits in RAM waiting to be typed.
10. **A paste map that is not US-ANSI.** `strokeFor()` in `src/text.h` is the single table
for characters; positions live in `web_page.h`/`relayctl.py`. A second layout means a
second character table *and* a second `CODE_TO_USAGE`, with the existing refusal
semantics intact (no entry → `E9`, never a guess). Until then the answer is "set the
target to US", which is one line on the target and zero bytes on the board.
---
## Restoring the sibling firmware
```bash
cd ../jiggler && pio run -t upload # after tools/to_bootloader.py, if needed
```
The `jiggler` source is untouched by this project; they are separate PlatformIO projects
for one board, and switching is one flash each way.
+1
View File
@@ -0,0 +1 @@
# TODO
+81
View File
@@ -0,0 +1,81 @@
; keyrelay -- WiFi-controlled USB HID keyboard bridge for the M5Stack AtomS3 Lite.
;
; The board is an ESP32-S3FN8 (8 MB flash, no PSRAM) with NO USB-to-UART bridge:
; the USB-C connector is wired straight to the ESP32-S3 native USB (GPIO19/20), and
; those pads belong to ONE peripheral at a time:
;
; ARDUINO_USB_MODE=1 -> built-in USB-Serial/JTAG (console only, cannot type)
; ARDUINO_USB_MODE=0 -> USB-OTG + TinyUSB (HID keyboard + CDC console)
;
; That single-port fact is the whole reason the control path is WiFi: the board can
; be attached to exactly one machine over USB, so the controller on the laptop has to
; reach it over the network instead. See README "Why the control path is WiFi".
;
; Datasheet: https://docs.m5stack.com/en/core/AtomS3-Lite
[platformio]
default_envs = bridge
[usb]
; The core USB library compiles its MSC/FS sources together with the HID ones, and
; those include FS.h / vfs_api.h, which LDF will not resolve by itself (it tries the
; registry and fails). Only the include path is needed: nothing here references the
; MSC/FS symbols, so the linker drops those objects again. Carried over from the
; jiggle firmware on the same board.
fs_include = -I${platformio.packages_dir}/framework-arduinoespressif32/libraries/FS/src
[env]
platform = espressif32@8.0.0
board = esp32-s3-devkitc-1
framework = arduino
; Nominal for USB CDC (the USB link ignores baud); 921600 is what this board takes.
upload_speed = 921600
monitor_speed = 115200
monitor_filters = esp32_exception_decoder, time
; WiFi + TinyUSB + the embedded controller page do not fit the default 1.3 MB app
; slot of the standard two-OTA layout. This board has no OTA path in this project --
; it is reflashed over USB, always -- so one 3 MB app partition is the right trade.
board_build.partitions = huge_app.csv
lib_deps =
adafruit/Adafruit NeoPixel@^1.15.0
; ---------------------------------------------------------------------------
; Default: the full bridge. The board is a composite USB HID keyboard + USB serial
; on the target, a web server and WebSocket endpoint on the network.
; ---------------------------------------------------------------------------
[env:bridge]
build_flags =
${usb.fs_include}
-DARDUINO_USB_MODE=0
; CDC_ON_BOOT=0 is what makes the Logitech identity possible: with 1,
; cores/esp32/main.cpp calls USB.begin() from app_main() before setup() runs, the
; first tinyusb_init() fixes the VID/PID/strings as Espressif's, and every
; USB.VID()/productName() setter afterwards is a no-op. With 0 this environment
; starts the stack itself, after setting the identity (src/main.cpp: kUsb*). The
; console is unaffected: main.cpp owns a USBCDC instance, so /dev/ttyACM0 works.
-DARDUINO_USB_CDC_ON_BOOT=0
; Optional build-time station credentials, so a first bring-up can skip the serial
; console. Quoting is part of the macro: -D KR_STA_SSID='"MyNet"'.
; Prefer the `wifi <ssid> <pass>` console command: it stores into NVS and keeps
; the passphrase out of the image and out of shell history.
; -D KR_STA_SSID='" ssid "'
; -D KR_STA_PASS='" passphrase "'
build_unflags =
; esp32-s3-devkitc-1 pins ARDUINO_USB_MODE=1 in its board JSON; drop it so the
; value above is the only one on the command line.
-DARDUINO_USB_MODE=1
; ---------------------------------------------------------------------------
; Diagnostic: no USB-OTG, so no HID -- the board cannot type, but the whole control
; plane (WiFi, page, passphrase, WebSocket, LED, button) is live. Use it to separate
; "the network side is broken" from "the HID side is broken" without rebuilding.
; Arming refuses with E5 here, on purpose.
; ---------------------------------------------------------------------------
[env:console]
build_flags =
${usb.fs_include}
-DARDUINO_USB_MODE=1
-DARDUINO_USB_CDC_ON_BOOT=1
+102
View File
@@ -0,0 +1,102 @@
/**
* keyrelay -- build-time defaults.
*
* Everything here is a DEFAULT. The values stored in NVS win at runtime, so the
* network and the control passphrase can be changed from the serial console
* without reflashing (`wifi`, `ap`, `token` -- see README "First bring-up").
*
* Nothing secret belongs in this file. The default passphrase is deliberately
* weak and loudly announced as such on the serial console and in the web page,
* because a keyboard that anyone on the LAN can arm is an open door.
*/
#pragma once
#include <stdint.h>
#include <stddef.h>
namespace kr {
constexpr const char kFirmwareName[] = "keyrelay";
constexpr const char kFirmwareVersion[] = "1.0.0";
// ---------------------------------------------------------------------------
// Network
// ---------------------------------------------------------------------------
// Control plane only: the keystrokes travel WiFi -> board -> USB. The target
// machine provides nothing but 5 V and a USB port, and never sees a network.
constexpr uint16_t kHttpPort = 80;
constexpr char kHostname[] = "keyrelay"; // http://keyrelay.local
// Fallback access point, started whenever the station interface is not up, so
// the board is reachable with no infrastructure at all (air-gapped target).
constexpr char kApSsid[] = "keyrelay-ctl";
constexpr char kApPass[] = "keyrelay-ctl-pass"; // >= 8 chars, WPA2. CHANGE IT.
constexpr char kApGw[] = "192.168.4.1";
// Build-time station credentials (optional, still only a default). Override with
// pio run -e bridge -D KR_STA_SSID='"MyNet"' -D KR_STA_PASS='"hunter2"'
// without ever committing them.
#ifndef KR_STA_SSID
#define KR_STA_SSID ""
#endif
#ifndef KR_STA_PASS
#define KR_STA_PASS ""
#endif
constexpr uint32_t kStaTimeoutMs = 8000; // give the association attempt this long at boot
constexpr uint32_t kStaRetryMs = 20000; // ... then retry in the background at this rate
// ---------------------------------------------------------------------------
// Control passphrase
// ---------------------------------------------------------------------------
// Checked on every WebSocket connection and on /status. The page cannot arm the
// bridge without it, and nothing reaches the target until the bridge is armed.
constexpr char kDefaultToken[] = "changeme";
constexpr size_t kTokenMax = 32;
constexpr uint8_t kTokenMaxFails = 5; // then stop evaluating the token ...
constexpr uint32_t kTokenLockMs = 30000; // ... for this long
// ---------------------------------------------------------------------------
// Keystroke path
// ---------------------------------------------------------------------------
constexpr size_t kMaxKeysPerMessage = 8; // protocol cap (HID carries 6)
constexpr uint32_t kCadHoldMs = 60; // how long Ctrl+Alt+Del is held down
constexpr uint32_t kChordHoldMs = 40; // ditto for the other special chords
constexpr uint32_t kMinReportGapUs = 1000; // 1 kHz floor: the host polls at 1 kHz
constexpr size_t kReportQueueDepth = 48; // bursts from a fast typist
// ---------------------------------------------------------------------------
// Pasted text
// ---------------------------------------------------------------------------
// `P=<ms>:<hex>` (src/text.h) types a run of characters that a human pasted and
// then deliberately sent. The board owns the character map and the pacing: a
// controller's clock, arriving over WiFi in whatever order the network delivers
// it, is not a keyboard timing source. The controller asks for a pace; the board
// keeps it.
//
// One controller frame is still one command, so a frame carries at most
// kTextFrameMax characters and a long paste arrives as several frames. The board
// holds whatever it has been given and says so in `typing` in the status frame,
// which is how a controller knows it may send the next piece.
constexpr size_t kTextFrameMax = 120; // characters per P= frame
constexpr size_t kTextJobCap = 2048; // characters held for one paste job
constexpr uint16_t kTextPeriodMsDefault = 12; // ms per character when asked for 0
constexpr uint16_t kTextPeriodMaxMs = 250; // slowest pace a controller may ask for
constexpr uint16_t kTextHoldMaxMs = 6; // how long one character is held down
// 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).
constexpr uint32_t kNoClientTimeoutMs = 8000;
// Status heartbeat to the controller, and the interval of the held-key watchdog.
constexpr uint32_t kStatusIntervalMs = 1000;
// ---------------------------------------------------------------------------
// Control-server buffers
// ---------------------------------------------------------------------------
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
} // namespace kr
+466
View File
@@ -0,0 +1,466 @@
/**
* keyrelay -- control server implementation. See control_server.h for the shape
* and the reasoning; this file is the socket mechanics.
*/
#include "control_server.h"
#include <ESPmDNS.h>
#include "web_page.h"
namespace kr {
namespace {
// HTTP responses are built by hand: one page, one JSON endpoint, one upgrade. A web
// framework would add a request object, a route table and a buffer pool to serve
// four URLs.
void httpResponse(WiFiClient &c, const char *status, const char *contentType,
const char *body, size_t len) {
char head[192];
const int n = snprintf(head, sizeof(head),
"HTTP/1.1 %s\r\n"
"Content-Type: %s\r\n"
"Content-Length: %u\r\n"
"Cache-Control: no-store\r\n"
"X-Content-Type-Options: nosniff\r\n"
"Connection: close\r\n\r\n",
status, contentType, (unsigned)len);
if (n > 0) c.write((const uint8_t *)head, (size_t)n);
if (body && len) c.write((const uint8_t *)body, len);
}
void httpError(WiFiClient &c, const char *status, const char *text) {
httpResponse(c, status, "text/plain; charset=utf-8", text, strlen(text));
}
// SSIDs are user data on a JSON boundary: keep it printable and quote-free rather
// than trusting whatever a neighbouring access point is called.
void jsonSafe(char *dst, size_t cap, const char *src) {
size_t o = 0;
for (; src && *src && o + 1 < cap; src++) {
const char ch = *src;
if (ch == '"' || ch == '\\' || ch < 32 || ch == 127) continue;
dst[o++] = ch;
}
dst[o] = '\0';
}
constexpr char kPathIndex[] = "/";
constexpr char kPathIndexHtml[] = "/index.html";
constexpr char kPathStatus[] = "/status";
constexpr char kPathWs[] = "/ws";
} // namespace
size_t statusJson(const StatusSnapshot &s, char *out, size_t cap) {
char ssid[40];
jsonSafe(ssid, sizeof(ssid), s.ssid);
const int n = snprintf(
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}",
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);
return (n < 0 || (size_t)n >= cap) ? 0 : (size_t)n;
}
void ControlServer::begin(OnMessage onMessage, OnLinkChange onLinkChange,
FillStatus fillStatus, GetToken getToken, void *ctx) {
_onMessage = onMessage;
_onLinkChange = onLinkChange;
_fillStatus = fillStatus;
_getToken = getToken;
_ctx = ctx;
_server.begin(kHttpPort);
_server.setNoDelay(true);
MDNS.addService("http", "tcp", kHttpPort);
}
void ControlServer::tick(uint32_t nowMs) {
acceptNew(nowMs);
if (_viewer) serveViewer(nowMs);
switch (_phase) {
case Phase::Http: serveHttp(nowMs); break;
case Phase::WebSocket: serveWebSocket(nowMs); break;
default: break;
}
// A socket that opens and never speaks is a scan, not a controller.
if (_phase == Phase::WebSocket && !_authed &&
nowMs - _connectedAtMs > kAuthTimeoutMs) {
sendError(1, "passphrase required");
closeLink("auth timeout");
return;
}
if (_phase == Phase::WebSocket && _authed) {
if (nowMs - _lastStatusMs >= kStatusIntervalMs) sendStatus();
// A WebSocket ping every 15 s keeps the path alive through any idle timeout, and
// the browser answers it automatically -- which is what refreshes _lastInboundMs
// and therefore keeps the armed state alive while the tab is merely open.
if (nowMs - _lastPingMs >= 15000) {
_lastPingMs = nowMs;
sendPing();
}
}
if (_client && !_client.connected() && _phase != Phase::None) closeLink("socket closed");
}
void ControlServer::acceptNew(uint32_t nowMs) {
if (!_server.hasClient()) return;
WiFiClient fresh = _server.accept();
if (!fresh) return;
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
// claim -- is refused, and serveViewer() is what decides that.
if (_viewer) stopViewer();
_viewer = fresh;
_viewer.setNoDelay(true);
_viewerLen = 0;
_viewerHttp[0] = '\0';
_viewerAtMs = nowMs;
return;
}
fresh.setNoDelay(true);
_client = fresh;
_phase = Phase::Http;
_httpLen = 0;
_authed = false;
_connectedAtMs = nowMs;
_lastInboundMs = nowMs;
_reader.reset();
}
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();
return (size_t)n;
}
return 0;
}
void ControlServer::serveHttp(uint32_t nowMs) {
// Read until the header block is complete, then route. A request line larger
// than the buffer is refused instead of buffered, so the board cannot be pushed
// into allocating around an attacker's header size.
for (;;) {
const size_t space = sizeof(_http) - _httpLen - 1;
if (space == 0) {
httpError(_client, "431 Request Header Fields Too Large", "header block too large\n");
closeLink("header too large");
return;
}
const size_t got = readSome((uint8_t *)_http + _httpLen, space);
if (got == 0) {
if (nowMs - _connectedAtMs > 10000) closeLink("http stalled");
return;
}
_httpLen += got;
_http[_httpLen] = '\0';
ws::Request req;
const int r = ws::parseRequest(_http, _httpLen, req);
if (r == 0) continue;
if (r < 0) {
httpError(_client, "400 Bad Request", "malformed request\n");
closeLink("malformed request");
return;
}
// ---- routes ---------------------------------------------------------------
if (req.upgradeWebsocket && strcmp(req.path, kPathWs) == 0 && req.wsKey[0]) {
char accept[64];
if (!ws::acceptFor(req.wsKey, strlen(req.wsKey), accept, sizeof(accept))) {
httpError(_client, "400 Bad Request", "bad websocket key\n");
closeLink("bad ws key");
return;
}
char head[224];
const int n = snprintf(head, sizeof(head),
"HTTP/1.1 101 Switching Protocols\r\n"
"Upgrade: websocket\r\n"
"Connection: Upgrade\r\n"
"Sec-WebSocket-Accept: %s\r\n\r\n",
accept);
if (n <= 0) { closeLink("handshake failed"); return; }
_client.write((const uint8_t *)head, (size_t)n);
_phase = Phase::WebSocket;
_reader.reset();
_connectedAtMs = nowMs;
_lastStatusMs = nowMs;
_lastPingMs = nowMs;
return;
}
if (req.upgradeWebsocket) {
// An upgrade for anything other than a well-formed /ws is not a controller and
// not a page either; there is nothing to route it to.
httpError(_client, "400 Bad Request", "malformed websocket request\n");
closeLink("bad ws request");
return;
}
serveReadonly(_client, req);
closeLink("served");
return;
}
}
// The read-only routes: the controller page, the JSON status, favicon, 404. Shared by
// the ordinary path above and the viewer path below on purpose -- "who may drive the
// keyboard" and "who may see this board" are different questions, and the answer to the
// second one must not be able to drift from the first.
void ControlServer::serveReadonly(WiFiClient &c, const ws::Request &req) {
if (strcmp(req.method, "GET") != 0) {
httpError(c, "405 Method Not Allowed", "GET only\n");
return;
}
if (strcmp(req.path, kPathIndex) == 0 || strcmp(req.path, kPathIndexHtml) == 0) {
char head[192];
const int n = snprintf(head, sizeof(head),
"HTTP/1.1 200 OK\r\n"
"Content-Type: text/html; charset=utf-8\r\n"
"Content-Length: %u\r\n"
"Cache-Control: no-store\r\n"
"Connection: close\r\n\r\n",
(unsigned)kIndexHtmlLen);
if (n > 0) c.write((const uint8_t *)head, (size_t)n);
c.write_P(kIndexHtml, kIndexHtmlLen);
return;
}
if (strcmp(req.path, kPathStatus) == 0) {
// curl-able status, but the passphrase is still required: the numbers say
// whether a machine is being typed on.
char token[kTokenMax + 1] = "";
const char *want = _getToken ? _getToken(_ctx) : "";
if (!ws::tokenFromQuery(req.query, strlen(req.query), token, sizeof(token)) ||
!proto::constTimeEq(token, strlen(token), want, strlen(want))) {
httpError(c, "401 Unauthorized", "token required\n");
return;
}
StatusSnapshot s;
if (_fillStatus) _fillStatus(_ctx, s);
char json[320];
const size_t len = statusJson(s, json, sizeof(json));
httpResponse(c, "200 OK", "application/json", json, len);
return;
}
if (strcmp(req.path, "/favicon.ico") == 0) {
httpResponse(c, "204 No Content", "text/plain", nullptr, 0);
return;
}
httpError(c, "404 Not Found", "keyrelay: / (page), /ws (websocket), "
"/status?token=... (json)\n");
}
// A connection that arrived while the controller slot was occupied: serve it read-only
// and close. This exists because the first version refused everything, which turned the
// most ordinary gesture -- pressing reload on the page that already holds the bridge --
// into a wall of text, and left a running controller with no way to be inspected.
// Still refused here: a second /ws upgrade, because that is a controller claim.
void ControlServer::serveViewer(uint32_t nowMs) {
if (!_viewer) {
stopViewer();
return;
}
while (_viewer.available() && _viewerLen + 1 < sizeof(_viewerHttp)) {
const int b = _viewer.read();
if (b <= 0) break;
_viewerHttp[_viewerLen++] = (char)b;
_viewerHttp[_viewerLen] = '\0';
}
ws::Request req;
const int r = ws::parseRequest(_viewerHttp, _viewerLen, req);
if (r == 0) {
if (_viewerLen + 1 >= sizeof(_viewerHttp)) {
httpError(_viewer, "431 Request Header Fields Too Large", "header block too large\n");
stopViewer();
return;
}
if (nowMs - _viewerAtMs > 10000) stopViewer(); // stalled; drop it, no reply owed
return;
}
if (r < 0) {
httpError(_viewer, "400 Bad Request", "malformed request\n");
stopViewer();
return;
}
if (req.upgradeWebsocket) {
// One controller. The incumbent keeps the bridge; the newcomer is told why.
httpError(_viewer, "503 Service Unavailable",
"keyrelay: another controller already holds this bridge.\n");
_rejected++;
stopViewer();
return;
}
serveReadonly(_viewer, req);
stopViewer();
}
void ControlServer::stopViewer() {
if (_viewer) {
_viewer.flush();
_viewer.stop();
}
_viewer = WiFiClient();
_viewerLen = 0;
}
void ControlServer::serveWebSocket(uint32_t nowMs) {
(void)nowMs;
uint8_t chunk[64];
for (;;) {
const size_t got = readSome(chunk, sizeof(chunk));
if (got == 0) return;
for (size_t i = 0; i < got; i++) {
const ws::In r = _reader.feed(chunk[i]);
if (r == ws::In::NeedMore) continue;
if (r == ws::In::Error) {
sendError(6, ws::describe(_reader.error()));
closeLink("frame error");
return;
}
if (!handleWsFrame(nowMs)) return; // link closed or frame consumed
}
}
}
bool ControlServer::handleWsFrame(uint32_t nowMs) {
const uint8_t opcode = _reader.opcode();
if (opcode == ws::kOpPing) {
// Echo the payload back as a pong. It has to be copied out first: the payload
// lives inside _wsBuf, which is the reader's buffer, and control payloads are
// capped at 125 bytes by the reader, so both buffers here are small and bounded.
uint8_t payload[128];
uint8_t frame[136];
const size_t plen = _reader.payloadLen() < sizeof(payload) ? _reader.payloadLen()
: sizeof(payload);
memcpy(payload, _reader.payload(), plen);
_reader.reset();
const size_t n = ws::encodeFrame(ws::kOpPong, payload, plen, frame, sizeof(frame));
if (n && _client && _client.connected()) _client.write(frame, n);
return true;
}
if (opcode == ws::kOpPong) { _reader.reset(); return true; }
if (opcode == ws::kOpClose) { closeLink("controller closed"); return false; }
proto::Message msg;
const proto::Cmd cmd =
proto::parse((const char *)_reader.payload(), _reader.payloadLen(), msg);
_reader.reset();
if (cmd == proto::Cmd::Bad) {
sendError(6, "unrecognised command");
return true;
}
// ---- authentication --------------------------------------------------------
if (!_authed) {
// While locked out the token is not even looked at: no oracle, and no point
// burning cycles on a brute-force attempt.
if (_lockUntilMs != 0 && (int32_t)(nowMs - _lockUntilMs) < 0) {
sendError(2, "too many failures, try later");
closeLink("locked out");
return false;
}
if (cmd != proto::Cmd::Auth) {
sendError(1, "authenticate first");
closeLink("unauthenticated");
return false;
}
const char *want = _getToken ? _getToken(_ctx) : "";
if (!proto::constTimeEq(msg.text, strlen(msg.text), want, strlen(want))) {
_authFails++;
if (_authFails >= kTokenMaxFails) _lockUntilMs = nowMs + kTokenLockMs;
sendError(1, "wrong passphrase");
closeLink("auth failed");
return false;
}
_authed = true;
_authFails = 0;
_lockUntilMs = 0;
writeTextFrame("OK", 2);
sendStatus();
if (_onLinkChange) _onLinkChange(_ctx, true);
return true;
}
if (_onMessage) _onMessage(_ctx, msg);
return true;
}
void ControlServer::sendPing() {
if (!_client || !_client.connected()) return;
uint8_t frame[8];
const size_t n = ws::encodeFrame(ws::kOpPing, nullptr, 0, frame, sizeof(frame));
if (n) _client.write(frame, n);
}
void ControlServer::writeTextFrame(const char *text, size_t len) {
uint8_t frame[kWsMessageCap + 8];
const size_t n = ws::encodeFrame(ws::kOpText, text, len, frame, sizeof(frame));
if (n == 0) return;
if (!_client || !_client.connected()) return;
_client.write(frame, n);
}
void ControlServer::sendStatus() {
if (_phase != Phase::WebSocket || !_authed) return;
StatusSnapshot s;
if (_fillStatus) _fillStatus(_ctx, s);
char json[320];
const size_t len = statusJson(s, json, sizeof(json));
if (len == 0) return;
char framed[340];
framed[0] = 'S'; // 'S' prefix, then the JSON body
memcpy(framed + 1, json, len);
writeTextFrame(framed, len + 1);
}
void ControlServer::sendError(uint8_t code, const char *text) {
if (_phase != Phase::WebSocket) return;
char line[96];
const size_t len = proto::formatError(line, sizeof(line), code, text);
if (len) writeTextFrame(line, len);
}
void ControlServer::closeLink(const char *why) {
(void)why;
const bool wasLinked = linked();
if (_client && _client.connected()) {
uint8_t frame[16];
const size_t n = ws::encodeFrame(ws::kOpClose, nullptr, 0, frame, sizeof(frame));
if (n && _phase == Phase::WebSocket) {
_client.write(frame, n);
_client.flush(); // without this the close frame is often RST'd instead
}
_client.stop();
}
_phase = Phase::None;
_authed = false;
_httpLen = 0;
_reader.reset();
if (wasLinked && _onLinkChange) _onLinkChange(_ctx, false);
}
} // namespace kr
+129
View File
@@ -0,0 +1,129 @@
/**
* keyrelay -- the control server: HTTP for the page, WebSocket for the keystrokes.
*
* Shape of it:
* - one controller at a time. A second WebSocket upgrade is refused outright rather
* than queued or swapped silently, because two controllers typing into one machine
* is exactly the ambiguity a keyboard must not have. The page says so (E3).
* Read-only HTTP is *not* refused: what is exclusive is the keyboard, not the board.
* Refusing everything meant pressing reload on your own tab failed, and a live
* controller could not be inspected by anything else (serveViewer).
* - non-blocking throughout: tick() runs once per loop() and consumes only what is
* already buffered. The keystroke path must not wait on the network stack.
* - nothing is trusted before the passphrase. The socket opens, must present T<token>
* inside kAuthTimeoutMs, and is closed otherwise; repeated failures lock the
* evaluation out for kTokenLockMs.
*
* Bytes in are decoded by kr::ws::Reader (host-tested); commands by kr::proto::parse
* (host-tested). What is left here is routing, framing and lifecycle -- the part that
* genuinely needs a socket to be worth testing.
*/
#pragma once
#include <WiFi.h>
#include "config.h"
#include "proto.h"
#include "ws_codec.h"
namespace kr {
// What the board knows about itself, as shown to the controller. The JSON field
// names in StatusJson below are the contract the page in web_page.h reads.
struct StatusSnapshot {
const char *version = "0";
uint32_t uptimeS = 0;
bool usbHost = false; // TinyUSB is attached to a target
bool armed = false;
bool netUp = false;
const char *net = "down"; // "sta" | "ap" | "down"
const char *ssid = "";
const char *ip = "";
uint32_t keysSent = 0;
uint32_t chordsSent = 0;
uint32_t dropped = 0;
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;
uint8_t ledState = 0; // the target's caps/num/scroll LEDs, per its HID reports
bool defaultToken = false;
bool apForced = false;
};
/**
* 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.
*/
size_t statusJson(const StatusSnapshot &s, char *out, size_t cap);
class ControlServer {
public:
// ctx-first function pointers rather than std::function: no allocation, and the
// wiring in setup() reads as a list of the callbacks the server may invoke.
using OnMessage = void (*)(void *ctx, const proto::Message &msg);
using OnLinkChange = void (*)(void *ctx, bool linked);
using FillStatus = void (*)(void *ctx, StatusSnapshot &out);
using GetToken = const char *(*)(void *ctx);
void begin(OnMessage onMessage, OnLinkChange onLinkChange, FillStatus fillStatus,
GetToken getToken, void *ctx);
void tick(uint32_t nowMs);
// Push a status frame now. Called on the 1 Hz heartbeat and immediately after an
// arm/disarm, so the page's one source of truth is the board, not its own guess.
void sendStatus();
void sendError(uint8_t code, const char *text);
bool linked() const { return _phase == Phase::WebSocket && _authed; }
uint32_t lastInboundMs() const { return _lastInboundMs; }
uint32_t rejected() const { return _rejected; }
uint32_t authFails() const { return _authFails; }
private:
enum class Phase : uint8_t { None, Http, WebSocket };
void acceptNew(uint32_t nowMs);
void serveHttp(uint32_t nowMs);
void serveReadonly(WiFiClient &c, const ws::Request &req);
void serveViewer(uint32_t nowMs);
void stopViewer();
void serveWebSocket(uint32_t nowMs);
void closeLink(const char *why);
bool handleWsFrame(uint32_t nowMs);
void sendPing();
void writeTextFrame(const char *text, size_t len);
size_t readSome(uint8_t *dst, size_t cap);
WiFiServer _server;
WiFiClient _client;
Phase _phase = Phase::None;
char _http[kHttpHeaderCap];
size_t _httpLen = 0;
// A second socket, read-only, for when the controller slot is taken.
WiFiClient _viewer;
char _viewerHttp[kHttpHeaderCap];
size_t _viewerLen = 0;
uint32_t _viewerAtMs = 0;
uint8_t _wsBuf[kWsMessageCap];
ws::Reader _reader{_wsBuf, sizeof(_wsBuf)};
bool _authed = false;
uint32_t _connectedAtMs = 0;
uint32_t _lastInboundMs = 0;
uint32_t _lastStatusMs = 0;
uint32_t _lastPingMs = 0;
uint32_t _lockUntilMs = 0;
uint32_t _authFails = 0;
uint32_t _rejected = 0;
OnMessage _onMessage = nullptr;
OnLinkChange _onLinkChange = nullptr;
FillStatus _fillStatus = nullptr;
GetToken _getToken = nullptr;
void *_ctx = nullptr;
};
} // namespace kr
+194
View File
@@ -0,0 +1,194 @@
/**
* keyrelay -- the key-state tracker. Pure logic: no Arduino, no TinyUSB, so it
* compiles and is unit-tested on a host (tests/test_keystate.cpp).
*
* Why the controller sends the WHOLE state instead of key up/down events
* ----------------------------------------------------------------------
* A key-up that never arrives (dropped frame, browser tab killed mid-chord, the
* laptop's compositor eating the release) leaves a modifier held down forever on
* the target -- the classic stuck-Ctrl disaster. So the controller restates the
* complete desired state on every event and this module simply *is* that state:
* applying the same state twice is a no-op, and any single message resynchronises
* the target completely. There is no sequence to fall out of step.
*
* Rollover
* --------
* The boot-protocol report has six key slots. Pressing a seventh is reported the
* way a real keyboard does it: ErrorRollOver (0x01) in every slot. Silently
* dropping the seventh key would be worse -- the host would never see it pressed
* and the state would look clean while the user believed a key was down.
*/
#pragma once
#include <stdint.h>
#include <stddef.h>
#include <string.h>
namespace kr {
// USB HID Usage Tables 1.11, section 2.2 "Usage Page 0x07 Keyboard/Keypad",
// section 4.3 for the modifier byte. These are the values on the wire.
enum Modifier : uint8_t {
kModLctrl = 0x01,
kModLshift = 0x02,
kModLalt = 0x04,
kModLgui = 0x08,
kModRctrl = 0x10,
kModRshift = 0x20,
kModRalt = 0x40,
kModRgui = 0x80,
kModAny = 0xFF,
};
// The handful of usages this firmware names. Everything else travels as a number
// decided by the controller (src/web_page.h maps KeyboardEvent.code -> usage).
enum Usage : uint8_t {
kUsageNone = 0x00,
kUsageErrorRollover = 0x01, // "more than six keys, good luck"
kUsagePostFail = 0x02,
kUsageA = 0x04,
kUsageZ = 0x1D,
kUsage1 = 0x1E,
kUsage0 = 0x27,
kUsageEnter = 0x28,
kUsageEscape = 0x29,
kUsageBackspace = 0x2A,
kUsageTab = 0x2B,
kUsageSpace = 0x2C,
kUsageF1 = 0x3A,
kUsageF7 = 0x40,
kUsageF8 = 0x41,
kUsageDelForward = 0x4C, // the "Del" of Ctrl+Alt+Del (not backspace)
kUsagePrintScreen = 0x46,
kUsageContextMenu = 0x65,
kUsageKpEnter = 0x58,
};
// The 8-byte boot-protocol keyboard report: modifier bitmap, a reserved byte the
// spec says must be zero, then up to six key usages. Byte-identical to TinyUSB's
// hid_keyboard_report_t and Arduino's KeyReport.
struct KeyboardReport {
uint8_t modifiers;
uint8_t reserved;
uint8_t keys[6];
};
static_assert(sizeof(KeyboardReport) == 8, "boot-protocol keyboard report is 8 bytes");
constexpr size_t kReportSlots = sizeof(((KeyboardReport *)0)->keys);
// More pressed keys than a report can carry is legal input, just not reportable;
// track this many so that releasing back under the limit reports the truth again.
constexpr size_t kMaxTrackedKeys = 12;
// Build a report from scratch. Used for the synthetic chords (Ctrl+Alt+Del) that
// must not disturb the tracked state.
inline KeyboardReport makeReport(uint8_t modifiers, const uint8_t *usages, size_t count) {
KeyboardReport r;
r.modifiers = modifiers;
r.reserved = 0;
memset(r.keys, 0, sizeof(r.keys));
if (count > kReportSlots) { // same rule as the tracker
memset(r.keys, kUsageErrorRollover, sizeof(r.keys));
return r;
}
size_t n = 0;
for (size_t i = 0; i < count && n < kReportSlots; i++) {
if (usages[i] == kUsageNone) continue;
bool dup = false;
for (size_t j = 0; j < n; j++) {
if (r.keys[j] == usages[i]) { dup = true; break; }
}
if (!dup) r.keys[n++] = usages[i];
}
return r;
}
inline bool reportsEqual(const KeyboardReport &a, const KeyboardReport &b) {
return a.modifiers == b.modifiers && memcmp(a.keys, b.keys, sizeof(a.keys)) == 0;
}
/**
* The complete state of the emulated keyboard: a modifier byte plus the set of
* currently pressed non-modifier keys, in press order.
*/
class KeyboardState {
public:
/**
* Restate the whole desired state. `usages` are the keys the controller says are
* down (order preserved, duplicates and 0x00 ignored).
*
* `dirty()` becomes true only when the resulting report differs from the
* previous one, and every distinct state gets its own dirty flag: the caller
* queues each of them in order, so a fast press+release is two reports and
* cannot collapse into nothing. Only *identical* consecutive reports collapse,
* and those are safe to send once.
*/
void apply(uint8_t modifiers, const uint8_t *usages, size_t count) {
uint8_t next[kMaxTrackedKeys];
size_t n = 0;
for (size_t i = 0; i < count && n < kMaxTrackedKeys; i++) {
const uint8_t u = usages[i];
if (u == kUsageNone) continue;
bool dup = false;
for (size_t j = 0; j < n; j++) {
if (next[j] == u) { dup = true; break; }
}
if (!dup) next[n++] = u;
}
const KeyboardReport prev = _report;
_mods = modifiers;
_count = n;
if (n) memcpy(_keys, next, n);
rebuild();
if (!reportsEqual(prev, _report)) _dirty = true;
}
// All keys up. This is the panic path, so it never leaves anything behind.
void clear() {
const KeyboardReport prev = _report;
_mods = 0;
_count = 0;
rebuild();
if (!reportsEqual(prev, _report)) _dirty = true;
}
const KeyboardReport &report() const { return _report; }
bool dirty() const { return _dirty; }
void ackDirty() { _dirty = false; }
size_t held() const { return _count; }
uint8_t modifiers() const { return _mods; }
// True while the pressed set is too wide for the report (ErrorRollOver is out).
bool rollover() const { return _count > kReportSlots; }
// Is this usage currently pressed? (for the controller's held-key readout)
bool isDown(uint8_t usage) const {
for (size_t i = 0; i < _count; i++) {
if (_keys[i] == usage) return true;
}
return false;
}
private:
void rebuild() {
_report.modifiers = _mods;
_report.reserved = 0;
if (rollover()) {
memset(_report.keys, kUsageErrorRollover, sizeof(_report.keys));
return;
}
memset(_report.keys, 0, sizeof(_report.keys));
for (size_t i = 0; i < _count; i++) _report.keys[i] = _keys[i];
}
uint8_t _mods = 0;
uint8_t _count = 0;
uint8_t _keys[kMaxTrackedKeys] = {0};
KeyboardReport _report = {0, 0, {0, 0, 0, 0, 0, 0}};
bool _dirty = false;
};
} // namespace kr
+978
View File
@@ -0,0 +1,978 @@
/**
* keyrelay -- a WiFi-controlled USB keyboard bridge for the M5Stack AtomS3 Lite.
*
* 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.
*
* 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
* reflashed from the host it is currently plugged into
*
* Safety model, in the order it is enforced
* ----------------------------------------
* 1. Disarmed until somebody says so. Boot, reload, reconnect, socket loss and
* focus loss all leave the bridge disarmed, and disarming sends "all keys up".
* 2. Arming needs the passphrase (or a thumb on the board). Nothing reaches the
* target -- not one key, not one chord -- without it.
* 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.
* 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
* written down, so the board still cannot remember anything to type later.
* 6. A full report queue is a fail-safe event, not a dropped keystroke: release
* everything and disarm rather than type out of step.
*
* Ctrl+Alt+Del is a real key sequence here, which is worth being blunt about: on a
* Linux virtual terminal it reboots the machine, on Windows it is the secure
* attention sequence, and the laptop's own OS eats the physical combination before
* a browser can see it. That is why it is a button with a two-press confirm and
* never something a stray keypress can produce. See README (Security model).
*
* Board pins (AtomS3 Lite / C124, ESP32-S3FN8):
* GPIO35 -> WS2812C RGB LED data line (single LED)
* GPIO41 -> front button, active LOW
* GPIO19/20 -> native USB: USB-OTG (TinyUSB) here; USB-Serial/JTAG in the
* 'console' build environment, which has no HID at all
*
* Boot order is load-bearing (it is inherited from the jiggle firmware on the same
* board): identity -> Keyboard.begin() -> USB.begin() -> recovery hold -> WiFi.
* The first tinyusb_init() of the boot is what fixes the USB descriptors, so every
* setter has to run before it.
*/
#include <Arduino.h>
#if !defined(ARDUINO_USB_MODE)
#error "ESP32-S3 native USB is required; build the 'bridge' or 'console' environment"
#endif
#include <WiFi.h>
#if ARDUINO_USB_MODE == 1
#warning "ARDUINO_USB_MODE=1: control-plane diagnostic build -- no HID keyboard, cannot type"
#define KR_HAVE_HID 0
#else
#include "USB.h"
#include "USBCDC.h"
#include "esp32-hal-tinyusb.h" // usb_persist_restart()
#include "USBHIDKeyboard.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
// bridge. Un-map it and own the CDC interface instead, so the console stays on
// /dev/ttyACM0 and nothing else calls USB.begin() first.
#undef Serial
#endif
#include <Adafruit_NeoPixel.h>
#include <ESPmDNS.h>
#include <Preferences.h>
#include "config.h"
#include "control_server.h"
#include "keystate.h"
#include "proto.h"
#include "text.h"
#if KR_HAVE_HID
// TinyUSB's per-instance readiness check is a plain C function in the linked stack.
// It is declared here rather than included: the inline tud_hid_ready() wrapper in
// 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.
extern "C" bool tud_hid_n_ready(uint8_t instance);
#endif
namespace {
// This file is the glue between the project's own modules and the Arduino/ESP32
// APIs, so kr's members are used unqualified throughout; the ESP32 headers are all
// in the global namespace and nothing in kr collides with them.
using namespace kr;
constexpr uint8_t kLedPin = 35; // WS2812C data line
constexpr uint8_t kButtonPin = 41; // front button, active LOW
constexpr uint8_t kLedCount = 1;
constexpr uint8_t kBrightness = 56; // 0-255; it sits next to a screen
constexpr uint32_t kColorOff = 0x000000;
constexpr uint32_t kColorRed = 0xFF0000;
constexpr uint32_t kColorGreen = 0x00FF40;
constexpr uint32_t kColorBlue = 0x0040FF;
constexpr uint32_t kColorAmber = 0xFF8000;
constexpr uint32_t kColorGreenDim = 0x0A3A16;
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 kConsoleLineCap = 96;
#if KR_HAVE_HID
USBHIDKeyboard Keyboard; // ctor registers the HID interface with TinyUSB
USBCDC Serial(0); // ctor registers the CDC interface, after Keyboard
volatile bool usbStarted = false;
uint8_t targetLeds = 0; // caps/num/scroll as the TARGET reports them
#endif
Preferences prefs;
ControlServer control;
kr::KeyboardState keys;
kr::text::Typewriter typer; // the paste in flight, paced here (see text.h)
// ---------------------------------------------------------------------------
// Persistent settings (NVS). Build-time defaults from config.h are only the
// starting values; changing them here survives reflashing.
// ---------------------------------------------------------------------------
constexpr char kNvsNamespace[] = "keyrelay";
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
char staSsid[33] = "";
char staPass[65] = "";
char token[kr::kTokenMax + 1] = ""; // filled from NVS, or kDefaultToken, in loadSettings
uint8_t apMode = 0;
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);
prefs.end();
if (nvSsid.length()) {
strncpy(staSsid, nvSsid.c_str(), sizeof(staSsid) - 1);
strncpy(staPass, nvPass.c_str(), sizeof(staPass) - 1);
} else {
strncpy(staSsid, KR_STA_SSID, sizeof(staSsid) - 1);
strncpy(staPass, KR_STA_PASS, sizeof(staPass) - 1);
}
if (nvToken.length()) strncpy(token, nvToken.c_str(), sizeof(token) - 1);
else strncpy(token, kr::kDefaultToken, sizeof(token) - 1);
token[sizeof(token) - 1] = '\0';
}
void saveSta(const char *ssid, const char *pass) {
prefs.begin(kNvsNamespace, false);
prefs.putString(kKeyStaSsid, ssid);
prefs.putString(kKeyStaPass, pass);
prefs.end();
strncpy(staSsid, ssid, sizeof(staSsid) - 1);
strncpy(staPass, pass, sizeof(staPass) - 1);
}
void saveToken(const char *fresh) {
prefs.begin(kNvsNamespace, false);
prefs.putString(kKeyToken, fresh);
prefs.end();
strncpy(token, fresh, sizeof(token) - 1);
}
void saveApMode(uint8_t mode) {
prefs.begin(kNvsNamespace, false);
prefs.putInt(kKeyApMode, mode);
prefs.end();
apMode = mode;
}
// ---------------------------------------------------------------------------
// Report queue
// ---------------------------------------------------------------------------
// 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;
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; }
};
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;
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 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.
bool canType() { return usbStarted; }
#else
bool canType() { return false; }
#endif
// ---------------------------------------------------------------------------
// LED
// ---------------------------------------------------------------------------
Adafruit_NeoPixel led(kLedCount, kLedPin, NEO_GRB + NEO_KHZ800);
uint32_t shownColor = kColorOff;
uint32_t flashUntilMs = 0;
uint32_t flashColor = kColorOff;
void showColor(uint32_t color) {
if (color == shownColor) return; // the WS2812 latches; write on change only
led.setPixelColor(0, color);
led.show();
shownColor = color;
}
void flash(uint32_t color, uint32_t ms) {
flashColor = color;
flashUntilMs = millis() + ms;
}
void ledSelfTest() {
const uint32_t colours[] = {kColorRed, kColorGreen, kColorBlue};
for (uint32_t c : colours) {
led.setPixelColor(0, c);
led.show();
delay(60);
}
led.setPixelColor(0, kColorOff);
led.show();
shownColor = kColorOff;
}
void updateLed(uint32_t now) {
if (now < flashUntilMs) { showColor(flashColor); return; }
if (armed) {
// Solid red only means what it should mean: keystrokes are going to a target.
if (canType()) { showColor(kColorRed); return; }
const bool on = (now / kLedBlinkPeriodMs) % 2 == 0;
showColor(on ? kColorRed : kColorOff); // armed with nothing to type into
return;
}
if (control.linked()) { showColor(kColorGreenDim); return; }
showColor(kColorOff);
}
// ---------------------------------------------------------------------------
// Arming
// ---------------------------------------------------------------------------
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
}
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");
Serial.println(F("[arm] refused: no USB host"));
return;
}
if (on == armed) {
control.sendStatus();
return;
}
armed = on;
if (!on) {
releaseEverything();
prevHeld = 0; // the press counter starts from nothing pressed
}
Serial.printf("[arm] %s (%s)\n", on ? "ARMED -- keystrokes go to the target" : "disarmed",
why ? why : "");
flash(on ? kColorRed : kColorGreen, 220);
control.sendStatus();
}
// ---------------------------------------------------------------------------
// WiFi
// ---------------------------------------------------------------------------
bool apUp = false;
uint32_t lastStaAttemptMs = 0;
void startAp() {
if (apUp) return;
WiFi.softAPConfig(IPAddress(192, 168, 4, 1), IPAddress(192, 168, 4, 1),
IPAddress(255, 255, 255, 0));
apUp = WiFi.softAP(kr::kApSsid, kr::kApPass);
Serial.printf("[wifi] access point %s %s (%s)\n", kr::kApSsid,
apUp ? "up" : "FAILED", kr::kApGw);
if (apUp) flash(kColorBlue, 150);
}
void stopAp() {
if (!apUp) return;
WiFi.softAPdisconnect(true);
apUp = false;
Serial.println(F("[wifi] access point down"));
}
void connectSta() {
lastStaAttemptMs = millis();
Serial.printf("[wifi] joining \"%s\"\n", staSsid);
WiFi.disconnect();
WiFi.begin(staSsid, staPass);
}
void wifiBringUp() {
WiFi.persistent(false);
WiFi.setSleep(false); // 20 ms beacon sleep is jitter a keyboard feels
WiFi.mode(WIFI_STA);
WiFi.setHostname(kr::kHostname);
if (staSsid[0] == '\0') {
Serial.println(F("[wifi] no station configured -- access point only"));
WiFi.mode(WIFI_AP_STA);
startAp();
} else {
connectSta();
const uint32_t started = millis();
while (WiFi.status() != WL_CONNECTED && millis() - started < kr::kStaTimeoutMs) {
delay(50);
}
if (WiFi.status() != WL_CONNECTED) {
Serial.println(F("[wifi] station did not come up -- starting the access point too"));
WiFi.mode(WIFI_AP_STA);
startAp();
}
}
if (WiFi.status() == WL_CONNECTED) {
Serial.printf("[wifi] station up ip=%s rssi=%d\n", WiFi.localIP().toString().c_str(),
WiFi.RSSI());
flash(kColorBlue, 150);
}
MDNS.begin(kr::kHostname); // http://keyrelay.local -- addService() is the
} // control server's job, in begin()
// Station preferred, access point as the fallback -- and never silently dropped
// while it is the only way back to the board.
void wifiMaintain(uint32_t now) {
const bool staUp = WiFi.status() == WL_CONNECTED;
if (staSsid[0] != '\0') {
if (!staUp && now - lastStaAttemptMs >= kr::kStaRetryMs) connectSta();
if (staUp && apUp && apMode != 1) stopAp();
if (!staUp && !apUp && apMode != 2) {
WiFi.mode(WIFI_AP_STA);
startAp();
}
} else if (!apUp && apMode != 2) {
WiFi.mode(WIFI_AP_STA);
startAp();
}
}
const char *netMode() {
if (WiFi.status() == WL_CONNECTED) return "sta";
if (apUp) return "ap";
return "down";
}
// ---------------------------------------------------------------------------
// Status / control callbacks
// ---------------------------------------------------------------------------
// WiFi.SSID() and IPAddress::toString() return temporaries, so the snapshot cannot
// point at them -- these buffers outlive the call.
char ssidBuf[40];
char ipBuf[20];
void fillStatus(void *, kr::StatusSnapshot &s) {
s.version = kr::kFirmwareVersion;
s.uptimeS = millis() / 1000;
#if KR_HAVE_HID
s.usbHost = usbStarted;
s.ledState = targetLeds;
#endif
s.armed = armed;
s.netUp = WiFi.status() == WL_CONNECTED || apUp;
s.net = netMode();
if (WiFi.status() == WL_CONNECTED) {
snprintf(ssidBuf, sizeof(ssidBuf), "%s", WiFi.SSID().c_str());
snprintf(ipBuf, sizeof(ipBuf), "%s", WiFi.localIP().toString().c_str());
s.ssid = ssidBuf;
} else if (apUp) {
s.ssid = kr::kApSsid;
snprintf(ipBuf, sizeof(ipBuf), "%s", WiFi.softAPIP().toString().c_str());
} else {
s.ssid = "";
ipBuf[0] = '\0';
}
s.ip = ipBuf;
s.keysSent = keysSent;
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.defaultToken = strcmp(token, kr::kDefaultToken) == 0;
s.apForced = apMode == 1;
}
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.
if (!linked) disarm("controller disconnected");
}
#if KR_HAVE_HID
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");
}
}
#endif
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");
return;
}
chord.active = true;
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");
#endif
}
void onMessage(void *, const proto::Message &msg) {
switch (msg.cmd) {
case proto::Cmd::Hello:
control.sendStatus();
return;
case proto::Cmd::KeepAlive:
return; // freshness is already recorded
case proto::Cmd::Arm:
setArmed(true, "controller");
return;
case proto::Cmd::Disarm:
setArmed(false, "controller");
return;
case proto::Cmd::ReleaseAll:
#if KR_HAVE_HID
releaseEverything();
#endif
return;
case proto::Cmd::State: {
if (!armed) { control.sendError(4, "not armed"); return; }
#if KR_HAVE_HID
keys.apply(msg.mods, msg.usages, msg.nUsages);
const uint32_t held = (uint32_t)keys.held();
if (held > prevHeld) keysSent += held - prevHeld; // count presses, not frames
prevHeld = held;
pushTrackedState();
#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
// frame should be able to turn into something else.
const uint8_t del = kr::kUsageDelForward;
startChord(kr::kModLctrl | kr::kModLalt, &del, 1, kr::kCadHoldMs, "Ctrl+Alt+Del");
return;
}
case proto::Cmd::Chord:
startChord(msg.mods, msg.usages, msg.nUsages, kr::kChordHoldMs, "chord");
return;
case proto::Cmd::Text: {
// 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 KR_HAVE_HID
if (!canType()) { control.sendError(5, "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);
if (o.result == kr::text::Result::Full) {
control.sendError(8, "text buffer full -- wait for the run to drain");
return;
}
if (o.result == kr::text::Result::Untypeable) {
// Named by code, never by content: say what could not be typed, and type
// none of it, rather than send a paste that quietly lost a character.
char why[80];
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);
return;
}
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());
control.sendStatus();
#else
control.sendError(5, "this build has no HID interface");
#endif
return;
}
default:
control.sendError(6, "unrecognised command");
return;
}
}
const char *getToken(void *) { return token; }
// ---------------------------------------------------------------------------
// USB
// ---------------------------------------------------------------------------
#if KR_HAVE_HID
// The identity the target sees in lsusb / its input device list. Kept identical to
// the jiggle firmware on this same board so the two are indistinguishable from the
// host's point of view, which also keeps the by-id device names the tools use
// stable across reflashes. Personal use only: 0x046D belongs to Logitech.
constexpr uint16_t kUsbVid = 0x046D; // Logitech, Inc.
constexpr uint16_t kUsbPid = 0xC33F; // G815 Mechanical Keyboard
constexpr char kUsbManufacturer[] = "Logitech";
constexpr char kUsbProduct[] = "Gaming Keyboard G815";
constexpr char kUsbSerial[] = "00000000";
void setUsbIdentity() {
const bool ok = USB.VID(kUsbVid) && USB.PID(kUsbPid) &&
USB.manufacturerName(kUsbManufacturer) &&
USB.productName(kUsbProduct) && USB.serialNumber(kUsbSerial);
Serial.printf("[usb] identity %04x:%04x \"%s\" \"%s\"%s\n", USB.VID(), USB.PID(),
USB.manufacturerName(), USB.productName(),
ok ? "" : " <-- SETTERS RAN TOO LATE");
}
void onUsbEvent(void *, esp_event_base_t base, int32_t id, void *) {
if (base != ARDUINO_USB_EVENTS) return;
switch (id) {
case ARDUINO_USB_STARTED_EVENT:
usbStarted = true;
Serial.println(F("[usb] target attached"));
flash(kColorBlue, 200);
control.sendStatus();
break;
case ARDUINO_USB_STOPPED_EVENT:
usbStarted = false;
Serial.println(F("[usb] target detached"));
releaseEverything();
disarm("target detached");
control.sendStatus();
break;
case ARDUINO_USB_SUSPEND_EVENT:
Serial.println(F("[usb] target suspended the bus"));
releaseEverything();
disarm("target suspended");
control.sendStatus();
break;
case ARDUINO_USB_RESUME_EVENT:
Serial.println(F("[usb] bus resumed"));
break;
default: break;
}
}
// The target drives the keyboard LEDs (caps lock and friends). Showing its real
// state in the controller beats guessing, because the toggle physically happens on
// the target, not here.
void onKeyboardLed(void *, esp_event_base_t, int32_t, void *event_data) {
auto *data = static_cast<arduino_usb_hid_keyboard_event_data_t *>(event_data);
targetLeds = data->leds;
}
void drainReports() {
for (size_t i = 0; i < kMaxReportsPerLoop; i++) {
const kr::KeyboardReport *r = queue.peek();
if (!r) return;
if (!usbStarted) { // nobody to receive them
dropped += (uint32_t)queue.count;
queue.clear();
return;
}
if (!tud_hid_n_ready(0)) return; // endpoint still busy; try next loop
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();
}
}
#endif // KR_HAVE_HID
// ---------------------------------------------------------------------------
// Button
// ---------------------------------------------------------------------------
bool readButton() { return digitalRead(kButtonPin) == LOW; }
/**
* GPIO0 is not broken out on this board, and once TinyUSB owns GPIO19/20 the host
* cannot pulse the board into its bootloader by itself, so the firmware does it:
* holding the button from power-on hands the USB pads back and forces a
* download-mode boot without touching GPIO0. (Same path the jiggle firmware uses.)
*/
void waitForRecoveryRequest() {
#if KR_HAVE_HID
if (!readButton()) return;
const uint32_t startedAt = millis();
while (millis() - startedAt < kRecoveryHoldMs) {
if (!readButton()) return;
led.setPixelColor(0, kColorRed);
led.show();
delay(20);
}
Serial.println(F("[boot] hold detected -- entering ROM USB download mode"));
for (int i = 0; i < 6; i++) {
led.setPixelColor(0, (i & 1) ? kColorOff : kColorRed);
led.show();
delay(80);
}
usb_persist_restart(RESTART_BOOTLOADER); // does not return
esp_restart();
#endif
}
bool buttonPressed = false;
bool buttonCandidate = false;
uint8_t buttonCandidateCount = 0;
uint32_t lastSampleMs = 0;
void updateButton(uint32_t now) {
if (now - lastSampleMs < kSampleIntervalMs) return;
lastSampleMs = now;
const bool pressed = readButton();
if (pressed != buttonCandidate) {
buttonCandidate = pressed;
buttonCandidateCount = 1;
} else if (buttonCandidateCount < kDebounceSamples &&
++buttonCandidateCount >= kDebounceSamples && pressed != buttonPressed) {
buttonPressed = pressed;
// The button at the device is the kill switch: it can always disarm, and it is
// the only way to arm when there is no network in the room.
setArmed(!armed, "button");
}
}
// ---------------------------------------------------------------------------
// Serial console (CDC on the same composite device, /dev/ttyACM0)
// ---------------------------------------------------------------------------
char line[kConsoleLineCap];
size_t lineLen = 0;
void printHelp() {
Serial.println(F(
"commands:\n"
" status what the board and the target look like\n"
" wifi <ssid> [passphrase] station network to join (empty ssid clears)\n"
" wifi clear forget the station network\n"
" 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"
" 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"));
}
void printStatus() {
kr::StatusSnapshot s;
fillStatus(nullptr, s);
char json[320];
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("text %u chars typed, %u still to go\n", (unsigned)typedChars,
(unsigned)typer.pending());
}
// Split a line into `verb` and the rest of the line, trimmed. Returns false if the
// line is empty.
bool splitLine(char *buf, char *&verb, char *&rest) {
char *p = buf;
while (*p == ' ') p++;
if (*p == '\0') return false;
verb = p;
while (*p && *p != ' ') p++;
rest = nullptr;
if (*p) { *p++ = '\0'; while (*p == ' ') p++; rest = *p ? p : nullptr; }
return true;
}
void handleConsoleLine(char *buf) {
char *verb = nullptr, *rest = nullptr;
if (!splitLine(buf, verb, rest)) return;
if (strcmp(verb, "help") == 0 || strcmp(verb, "?") == 0) { printHelp(); return; }
if (strcmp(verb, "status") == 0) { printStatus(); return; }
if (strcmp(verb, "reboot") == 0) { Serial.println(F("[boot] rebooting")); delay(50);
esp_restart(); return; }
if (strcmp(verb, "arm") == 0) { setArmed(true, "console"); return; }
if (strcmp(verb, "disarm") == 0) { setArmed(false, "console"); return; }
if (strcmp(verb, "wifi") == 0) {
if (!rest) { Serial.println(F("usage: wifi <ssid> [passphrase] | wifi clear")); return; }
char *pass = strchr(rest, ' ');
if (pass) { *pass++ = '\0'; while (*pass == ' ') pass++; }
if (strcmp(rest, "clear") == 0) {
saveSta("", "");
WiFi.disconnect(true);
Serial.println(F("[wifi] station forgotten; the access point is the way back in"));
} else {
saveSta(rest, pass ? pass : "");
Serial.printf("[wifi] station set to \"%s\" -- reconnecting\n", rest);
WiFi.mode(WIFI_STA);
connectSta();
}
return;
}
if (strcmp(verb, "ap") == 0) {
if (!rest) { Serial.printf("ap mode: %s\n",
apMode == 0 ? "auto" : (apMode == 1 ? "force" : "off"));
return; }
if (strcmp(rest, "auto") == 0) { saveApMode(0); Serial.println(F("[wifi] ap: auto")); }
else if (strcmp(rest, "force") == 0) {
saveApMode(1); WiFi.mode(WIFI_AP_STA); startAp();
Serial.println(F("[wifi] ap: always on"));
} else if (strcmp(rest, "off") == 0) {
saveApMode(2); stopAp();
Serial.println(F("[wifi] ap: OFF -- if the station network fails you can only "
"change this over serial"));
} else {
Serial.println(F("usage: ap auto|force|off"));
}
return;
}
if (strcmp(verb, "token") == 0) {
if (!rest) {
Serial.printf("token: %s\n", strcmp(token, kr::kDefaultToken) == 0
? "DEFAULT -- change it" : "set (not shown)");
return;
}
if (strlen(rest) < 6 || strlen(rest) > kr::kTokenMax) {
Serial.printf("usage: token <6..%u chars>\n", (unsigned)kr::kTokenMax);
return;
}
saveToken(rest);
// Never echo the passphrase back into a serial log or a shell history.
memset(rest, '*', strlen(rest));
Serial.println(F("[auth] passphrase stored"));
return;
}
Serial.print(F("unknown command \""));
Serial.print(verb);
Serial.print(F("\" -- try `help`\n"));
}
void pollConsole() {
while (Serial.available()) {
const int c = Serial.read();
if (c < 0) return;
const char ch = (char)c;
if (ch == '\r' || ch == '\n') {
if (lineLen) {
line[lineLen] = '\0';
// A reply is only ever written because a host just sent us bytes, so the
// host is there and reading: let the write block until it is flushed. The
// global timeout is 0 (never wait, drop instead) because logging into a port
// nobody has opened must not stall loop(), but that same setting truncates a
// multi-line reply at the 128-byte TinyUSB CDC TX ring -- measured, in the
// middle of a word. Replies are not allowed to be truncated.
Serial.setTxTimeoutMs(500);
handleConsoleLine(line);
Serial.flush();
Serial.setTxTimeoutMs(0);
lineLen = 0;
}
continue;
}
if (ch == '\b' || ch == 0x7f) { if (lineLen) lineLen--; continue; }
if (lineLen < sizeof(line) - 1) line[lineLen++] = ch;
else lineLen = 0; // absurdly long line: drop it whole
}
}
} // namespace
void setup() {
pinMode(kButtonPin, INPUT_PULLUP);
led.begin();
led.setBrightness(kBrightness);
led.clear();
led.show();
Serial.begin(115200);
Serial.setTxTimeoutMs(0); // never stall the loop because nobody is reading
#if KR_HAVE_HID
USB.onEvent(onUsbEvent);
setUsbIdentity(); // must precede USB.begin()
Keyboard.begin(); // descriptors freeze at USB.begin()
Keyboard.onEvent(onKeyboardLed);
if (!USB.begin()) Serial.println(F("[usb] FAILED to start the TinyUSB device stack"));
#endif
waitForRecoveryRequest();
#if KR_HAVE_HID
for (uint32_t t = millis(); !usbStarted && millis() - t < 1000; delay(10)) {}
#endif
loadSettings();
ledSelfTest();
Serial.println();
Serial.printf("%s %s -- %s\n", kr::kFirmwareName, kr::kFirmwareVersion,
KR_HAVE_HID ? "USB HID keyboard bridge"
: "CONTROL PLANE ONLY (this build cannot type)");
wifiBringUp();
control.begin(onMessage, linkChanged, fillStatus, getToken, nullptr);
if (strcmp(token, kr::kDefaultToken) == 0) {
Serial.println(F("!! the control passphrase is still the default -- set one with"
" `token <passphrase>` before plugging into a target"));
}
Serial.print(F("url http://"));
Serial.println(WiFi.status() == WL_CONNECTED ? WiFi.localIP().toString()
: String(kr::kApGw));
Serial.println(F("button short press = arm / disarm. Console: `help`"));
}
void loop() {
const uint32_t now = millis();
updateButton(now);
control.tick(now);
pollConsole();
wifiMaintain(now);
// Silence disarms (config.h kNoClientTimeoutMs). A live controller says something
// every couple of seconds -- the page's keepalive, or its automatic ping replies --
// 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() &&
static_cast<int32_t>(now - control.lastInboundMs()) >
static_cast<int32_t>(kr::kNoClientTimeoutMs)) {
disarm("controller silent");
}
#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");
}
}
}
// 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; }
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");
break;
}
if (pressed) typedChars++;
}
drainReports();
#endif
updateLed(now);
}
+379
View File
@@ -0,0 +1,379 @@
/**
* keyrelay -- the control message protocol. Pure logic, host-tested
* (tests/test_proto.cpp).
*
* A WebSocket text frame is exactly one command, ASCII, no JSON. Deliberately
* boring: it has to be trivial to type by hand with the test client, trivial to
* audit byte-for-byte on the wire, and impossible for one malformed field to
* desynchronise what follows it.
*
* direction message meaning
* --------- --------------------- -------------------------------------------
* c -> d T<token> authenticate; must be the first message
* c -> d H hello: reply with status now
* c -> d A1 | A0 arm / disarm the bridge
* c -> d S=<mods>:<u1>,<u2>... the COMPLETE key state (see keystate.h)
* c -> d C Ctrl+Alt+Del, one synthetic burst
* 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 K keepalive
* d -> c OK accepted, or the reply to H
* d -> c E<code>:<text> error, e.g. E4:arm required
* d -> c S{json} status heartbeat, 1 Hz and on change
*
* `<mods>` is the modifier byte as two hex digits, `<uN>` a HID usage id as two
* hex digits, lowercase or uppercase. Empty key lists are legal (`S=00:` means
* "nothing pressed"), which is what makes the protocol self-healing.
*
* `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
* does the expansion and the pacing -- see text.h. `R` stops a run in progress.
*
* Nothing except A, R and the handshake is honoured while the bridge is disarmed.
*/
#pragma once
#include <stdint.h>
#include <stddef.h>
#include <string.h>
#include "config.h"
#include "keystate.h"
#include "text.h"
namespace kr {
namespace proto {
enum class Cmd : uint8_t {
None,
Hello,
Auth,
Arm,
Disarm,
State,
SecureAttention, // Ctrl+Alt+Del
Chord, // one-shot burst of an arbitrary chord
Text, // pasted text to type, paced on the board
ReleaseAll,
KeepAlive,
Bad,
};
struct Message {
Cmd cmd;
uint8_t mods;
uint8_t usages[kMaxKeysPerMessage];
uint8_t nUsages;
char text[kTokenMax + 1]; // Auth
uint8_t payload[kTextFrameMax]; // Text: the characters to type
uint8_t nPayload;
uint16_t periodMs; // Text: ms per character, 0 = board default
};
inline void initMessage(Message &m) {
m.cmd = Cmd::None;
m.mods = 0;
m.nUsages = 0;
memset(m.usages, 0, sizeof(m.usages));
m.text[0] = '\0';
m.nPayload = 0;
memset(m.payload, 0, sizeof(m.payload));
m.periodMs = 0;
}
inline const char *name(Cmd c) {
switch (c) {
case Cmd::Hello: return "hello";
case Cmd::Auth: return "auth";
case Cmd::Arm: return "arm";
case Cmd::Disarm: return "disarm";
case Cmd::State: return "state";
case Cmd::SecureAttention: return "secure-attention";
case Cmd::Chord: return "chord";
case Cmd::Text: return "text";
case Cmd::ReleaseAll: return "release-all";
case Cmd::KeepAlive: return "keepalive";
default: return "bad";
}
}
inline int hexNibble(char c) {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
return -1;
}
// Exactly two hex digits (0x00..0xFF), no whitespace, no prefix.
inline bool parseByte(const char *s, size_t len, uint8_t &out) {
if (len != 2) return false;
const int hi = hexNibble(s[0]), lo = hexNibble(s[1]);
if (hi < 0 || lo < 0) return false;
out = static_cast<uint8_t>((hi << 4) | lo);
return true;
}
/**
* Parse `<mods>:[<usage>[,<usage>...]]` -- the colon is required even when no key
* is pressed, so `S=00:` is "nothing down" and `S=00` is a syntax error.
*
* Strict on purpose: every field is exactly two hex digits, a usage id must be
* 1..0xFF (0x00 is "no key", not a key), the list cannot exceed
* kMaxKeysPerMessage, and a trailing comma or a stray space rejects the whole
* message rather than being guessed at. Rejecting means the previous state simply
* stays in effect, which is always the safe outcome.
*/
inline bool parseStateSpec(const char *s, size_t len, uint8_t &mods, uint8_t *usages,
size_t cap, size_t &n) {
n = 0;
mods = 0;
if (len < 3) return false;
if (!parseByte(s, 2, mods)) return false;
if (s[2] != ':') return false; // the delimiter is not optional
size_t i = 3;
while (i < len) {
size_t j = i;
while (j < len && s[j] != ',') j++;
uint8_t u = 0;
if (!parseByte(s + i, j - i, u)) return false; // exactly two hex digits
if (u == kUsageNone) return false;
if (n >= cap) return false;
usages[n++] = u;
if (j == len) break;
i = j + 1;
if (i == len) return false; // trailing comma
}
return true;
}
/**
* Parse `P=<ms>:<hex>` -- the digits are required, the colon is required, the hex
* must be an even count of digits and non-empty, and every byte must decode. The
* characters themselves are NOT validated here: whether a byte is a keystroke this
* board can make is text.h's question, asked once, at the point where accepting it
* is atomic (Typewriter::feed).
*
* A frame is capped at kTextFrameMax characters because one WebSocket frame is one
* command, not a payload; a long paste is the controller's business to split, and
* the board says how much it still has to type.
*/
inline Cmd parseText(const char *data, size_t len, Message &out) {
// `data` starts at the '=', so "P=0:61" arrives here as 5 bytes.
if (len < 5 || data[0] != '=') return Cmd::Bad;
size_t i = 1;
uint32_t ms = 0;
for (; i < len && data[i] >= '0' && data[i] <= '9'; i++) {
ms = ms * 10u + static_cast<uint32_t>(data[i] - '0');
if (ms > kTextPeriodMaxMs) return Cmd::Bad; // absurdly slow is a typo
}
if (i == 1 || i >= len || data[i] != ':') return Cmd::Bad;
const char *hex = data + i + 1;
const size_t hn = len - i - 1;
if (hn == 0 || (hn & 1U)) return Cmd::Bad; // whole bytes only
const size_t n = hn / 2;
if (n > kTextFrameMax) return Cmd::Bad;
for (size_t j = 0; j < n; j++) {
const int hi = hexNibble(hex[j * 2]), lo = hexNibble(hex[j * 2 + 1]);
if (hi < 0 || lo < 0) return Cmd::Bad;
out.payload[j] = static_cast<uint8_t>((hi << 4) | lo);
}
out.nPayload = static_cast<uint8_t>(n);
out.periodMs = static_cast<uint16_t>(ms);
return Cmd::Text;
}
/**
* 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
* command is.
*/
inline Cmd parse(const char *data, size_t len, Message &out) {
initMessage(out);
while (len && (data[len - 1] == '\r' || data[len - 1] == '\n')) len--;
if (len == 0 || len > kWsMessageCap) return out.cmd = Cmd::Bad;
const char c = data[0];
switch (c) {
case 'H':
if (len != 1) return out.cmd = Cmd::Bad;
return out.cmd = Cmd::Hello;
case 'K':
if (len != 1) return out.cmd = Cmd::Bad;
return out.cmd = Cmd::KeepAlive;
case 'R':
if (len != 1) return out.cmd = Cmd::Bad;
return out.cmd = Cmd::ReleaseAll;
case 'C':
if (len != 1) return out.cmd = Cmd::Bad;
return out.cmd = Cmd::SecureAttention;
case 'A':
if (len != 2) return out.cmd = Cmd::Bad;
if (data[1] == '1') return out.cmd = Cmd::Arm;
if (data[1] == '0') return out.cmd = Cmd::Disarm;
return out.cmd = Cmd::Bad;
case 'T': {
const size_t n = len - 1;
if (n == 0 || n > kTokenMax) return out.cmd = Cmd::Bad;
for (size_t i = 0; i < n; i++) {
const char t = data[1 + i];
const bool ok = (t >= 'a' && t <= 'z') || (t >= 'A' && t <= 'Z') ||
(t >= '0' && t <= '9') || t == '.' || t == '_' || t == '-' ||
t == ':';
if (!ok) return out.cmd = Cmd::Bad; // no control characters in a token
}
memcpy(out.text, data + 1, n);
out.text[n] = '\0';
return out.cmd = Cmd::Auth;
}
case 'S':
case 'Y': {
// "S=00:" is the shortest legal message: the colon separates the modifier
// byte from the (possibly empty) key list, so it is never optional.
if (len < 5 || data[1] != '=') return out.cmd = Cmd::Bad;
size_t n = 0;
if (!parseStateSpec(data + 2, len - 2, out.mods, out.usages,
kMaxKeysPerMessage, n)) {
return out.cmd = Cmd::Bad;
}
out.nUsages = static_cast<uint8_t>(n);
// A state may be completely empty ("nothing is pressed"). A chord may be
// empty of *usages* -- that is how a lone Super/Win press is expressed --
// but it must press at least a modifier, or it is not a chord at all.
if (c == 'Y' && n == 0 && out.mods == 0) return out.cmd = Cmd::Bad;
return out.cmd = (c == 'S') ? Cmd::State : Cmd::Chord;
}
case 'P':
return out.cmd = parseText(data + 1, len - 1, out);
default:
return out.cmd = Cmd::Bad;
}
}
/**
* Render a state message -- the client side of the protocol is generated here so
* that the host tests and the tools share one definition of the wire format.
* Returns the length written, or 0 if the buffer was too small; on failure the
* buffer is left as the empty string, so a caller cannot send half a command.
*/
inline size_t formatState(char *out, size_t cap, uint8_t mods, const uint8_t *usages,
size_t n) {
static const char kHex[] = "0123456789abcdef";
if (cap < 6) return 0; // "S=00:" plus its NUL
size_t o = 0;
out[o++] = 'S';
out[o++] = '=';
out[o++] = kHex[(mods >> 4) & 0xF];
out[o++] = kHex[mods & 0xF];
out[o++] = ':';
for (size_t i = 0; i < n; i++) {
const size_t need = o + (i ? 1 : 0) + 2 + 1; // comma, hex, NUL
if (need > cap) { out[0] = '\0'; return 0; }
if (i) out[o++] = ',';
out[o++] = kHex[(usages[i] >> 4) & 0xF];
out[o++] = kHex[usages[i] & 0xF];
}
out[o] = '\0';
return o;
}
/**
* Render a chord (`Y=`) the same way, for the test client.
*/
inline size_t formatChord(char *out, size_t cap, uint8_t mods, const uint8_t *usages,
size_t n) {
const size_t r = formatState(out, cap, mods, usages, n);
if (r) out[0] = 'Y';
return r;
}
/**
* 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
* "board default" and is written literally as `0`.
*
* Returns 0 and leaves the buffer empty rather than sending half a paste.
*/
inline size_t formatText(char *out, size_t cap, uint16_t periodMs,
const uint8_t *bytes, size_t n) {
static const char kHex[] = "0123456789abcdef";
if (!bytes || n == 0 || n > kTextFrameMax) return 0;
// "P=" + the period in decimal + ":" + two hex digits per byte + NUL
char ms[8];
size_t mn = 0;
// Out of range is refused rather than quietly clamped: a frame must not mean a
// different pace than the caller asked for.
if (periodMs > kTextPeriodMaxMs) return 0;
const uint16_t v = periodMs;
if (v == 0) ms[mn++] = '0';
for (uint16_t t = v; t; t /= 10) ms[mn++] = static_cast<char>('0' + (t % 10));
for (size_t a = 0, b = mn - 1; a < b; a++, b--) { const char c = ms[a]; ms[a] = ms[b]; ms[b] = c; }
const size_t need = 2 + mn + 1 + n * 2 + 1;
if (cap < need) { out[0] = '\0'; return 0; }
size_t o = 0;
out[o++] = 'P';
out[o++] = '=';
memcpy(out + o, ms, mn);
o += mn;
out[o++] = ':';
for (size_t i = 0; i < n; i++) {
out[o++] = kHex[(bytes[i] >> 4) & 0xF];
out[o++] = kHex[bytes[i] & 0xF];
}
out[o] = '\0';
return o;
}
// Error frames. Codes are stable so a client can branch on them:
// 1 not authenticated 2 locked out (too many failures)
// 3 another controller holds the bridge
// 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
inline size_t formatError(char *out, size_t cap, uint8_t code, const char *text) {
const size_t n = strlen(text);
if (cap < 4 || n + 4 > 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;
}
/**
* Length-independent comparison of a passphrase. Not cryptographic-grade
* hardening, just enough that a timing walk over a 32-byte token on a LAN is not
* the easiest way in -- and cheap enough to always be on.
*/
inline bool constTimeEq(const char *a, size_t an, const char *b, size_t bn) {
if (an > kTokenMax || bn > kTokenMax) return false;
uint8_t diff = static_cast<uint8_t>(an ^ bn);
const size_t common = an < bn ? an : bn;
for (size_t i = 0; i < common; i++) {
diff |= static_cast<uint8_t>(a[i] ^ b[i]);
}
// Lengths differ: fold in a non-zero difference without an early return.
diff |= static_cast<uint8_t>((an != bn) ? 0x80 : 0x00);
return diff == 0;
}
} // namespace proto
} // namespace kr
+144
View File
@@ -0,0 +1,144 @@
/**
* keyrelay -- SHA-1 and base64, for the WebSocket handshake only.
*
* Pure, dependency-free C++ so the exact code that runs on the board also
* compiles and is unit-tested on a host (tests/test_ws_codec.cpp). RFC 6455
* requires SHA-1 plus standard base64 to turn the client's Sec-WebSocket-Key
* into Sec-WebSocket-Accept; nothing here is used for anything secret, which is
* why SHA-1 (broken for signatures, fine for a handshake checksum) is acceptable.
*
* ESP-IDF ships mbedtls, but linking it in for 20 bytes of hashing while also
* keeping this module host-testable is the worse trade.
*/
#pragma once
#include <stdint.h>
#include <stddef.h>
#include <string.h>
namespace kr {
class Sha1 {
public:
Sha1() { reset(); }
void reset() {
_h[0] = 0x67452301u; _h[1] = 0xEFCDAB89u; _h[2] = 0x98BADCFEu;
_h[3] = 0x10325476u; _h[4] = 0xC3D2E1F0u;
_bits = 0;
_bufLen = 0;
}
void update(const void *data, size_t len) {
const uint8_t *p = static_cast<const uint8_t *>(data);
_bits += static_cast<uint64_t>(len) * 8u;
while (len) {
const size_t take = len < 64 - _bufLen ? len : 64 - _bufLen;
memcpy(_buf + _bufLen, p, take);
_bufLen += take;
p += take;
len -= take;
if (_bufLen == 64) {
processBlock(_buf);
_bufLen = 0;
}
}
}
void finish(uint8_t digest[20]) {
const uint64_t bits = _bits;
const uint8_t pad = 0x80;
update(&pad, 1); // 0x80 ...
const uint8_t zero = 0x00;
while (_bufLen != 56) update(&zero, 1); // ... 0x00s ...
uint8_t tail[8];
for (int i = 0; i < 8; i++) tail[i] = static_cast<uint8_t>(bits >> (56 - 8 * i));
update(tail, 8); // ... and the big-endian bit count
for (int i = 0; i < 5; i++) {
digest[4 * i] = static_cast<uint8_t>(_h[i] >> 24);
digest[4 * i + 1] = static_cast<uint8_t>(_h[i] >> 16);
digest[4 * i + 2] = static_cast<uint8_t>(_h[i] >> 8);
digest[4 * i + 3] = static_cast<uint8_t>(_h[i]);
}
reset();
}
private:
static uint32_t rol32(uint32_t v, unsigned n) { return (v << n) | (v >> (32 - n)); }
void processBlock(const uint8_t *p) {
uint32_t w[80];
for (int i = 0; i < 16; i++) {
w[i] = (uint32_t(p[4 * i]) << 24) | (uint32_t(p[4 * i + 1]) << 16) |
(uint32_t(p[4 * i + 2]) << 8) | uint32_t(p[4 * i + 3]);
}
for (int i = 16; i < 80; i++) w[i] = rol32(w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16], 1);
uint32_t a = _h[0], b = _h[1], c = _h[2], d = _h[3], e = _h[4];
for (int i = 0; i < 80; i++) {
uint32_t f, k;
if (i < 20) { f = (b & c) | (~b & d); k = 0x5A827999u; }
else if (i < 40) { f = b ^ c ^ d; k = 0x6ED9EBA1u; }
else if (i < 60) { f = (b & c) | (b & d) | (c & d); k = 0x8F1BBCDCu; }
else { f = b ^ c ^ d; k = 0xCA62C1D6u; }
const uint32_t tmp = rol32(a, 5) + f + e + k + w[i];
e = d; d = c; c = rol32(b, 30); b = a; a = tmp;
}
_h[0] += a; _h[1] += b; _h[2] += c; _h[3] += d; _h[4] += e;
}
uint32_t _h[5];
uint64_t _bits;
uint8_t _buf[64];
size_t _bufLen;
};
inline void sha1(const void *data, size_t len, uint8_t digest[20]) {
Sha1 s;
s.update(data, len);
s.finish(digest);
}
/**
* Standard base64 with '=' padding, no line breaks.
*
* Returns the number of characters written (excluding the NUL), which is 0 only
* for empty input. If `outCap` is smaller than ((len+2)/3)*4 + 1 it returns 0 and
* writes NOTHING at all, so a caller can never send a partial frame. Callers size
* the buffer from the input length (a 20-byte digest needs 32 bytes).
*/
inline size_t base64Encode(const uint8_t *in, size_t len, char *out, size_t outCap) {
static const char kTab[] =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
const size_t need = ((len + 2) / 3) * 4;
if (outCap < need + 1) return 0;
size_t o = 0;
size_t i = 0;
for (; i + 3 <= len; i += 3) {
const uint32_t v = (uint32_t(in[i]) << 16) | (uint32_t(in[i + 1]) << 8) | in[i + 2];
out[o++] = kTab[(v >> 18) & 63];
out[o++] = kTab[(v >> 12) & 63];
out[o++] = kTab[(v >> 6) & 63];
out[o++] = kTab[v & 63];
}
const size_t rem = len - i;
if (rem == 1) {
const uint32_t v = uint32_t(in[i]) << 16;
out[o++] = kTab[(v >> 18) & 63];
out[o++] = kTab[(v >> 12) & 63];
out[o++] = '=';
out[o++] = '=';
} else if (rem == 2) {
const uint32_t v = (uint32_t(in[i]) << 16) | (uint32_t(in[i + 1]) << 8);
out[o++] = kTab[(v >> 18) & 63];
out[o++] = kTab[(v >> 12) & 63];
out[o++] = kTab[(v >> 6) & 63];
out[o++] = '=';
}
out[o] = '\0';
return o;
}
} // namespace kr
+280
View File
@@ -0,0 +1,280 @@
/**
* keyrelay -- pasted text, turned into paced keystrokes. Pure logic, host-tested
* (tests/test_text.cpp): no Arduino, no TinyUSB, the same code the board runs.
*
* What this is, and what it deliberately is not
* ---------------------------------------------
* A human pasted something and pressed a button. That is the whole justification
* for this module existing: the difference between "a remote keyboard" and "an
* unattended injection device" is a deliberate human action with the arming light
* on, and this preserves both. What it is NOT is a macro facility -- the board
* cannot store a run of text, cannot be told to type later, and forgets the
* characters completely as soon as they have gone out (or been cancelled). There
* is no queue that survives a disconnect, because a controller that vanished is a
* controller that stopped asking.
*
* Why the expansion lives on the board and not in the controller
* --------------------------------------------------------------
* A controller reaches the board over WiFi, in frames, with whatever delay the
* network happens to insert. Typing a character is a press and a release with a
* hold either side of it, and the target samples the report on its own 1 ms poll
* schedule -- so the pacing has to be done by the one component that is on the
* USB bus, not by the one looking at a socket. The controller asks for a pace in
* milliseconds per character; the board keeps it. That also means a browser tab
* that stalls for a second mid-paste cannot produce a burst of 200 keys, and the
* CLI and the page get identical timing without duplicating the logic.
*
* The character map is US-ANSI
* ----------------------------
* Positions, not characters: a usage id names a key on the board, and what the
* target makes of it is the target's keyboard layout. Same caveat every KVM has,
* documented in README ("Layout caveats"). A character this map cannot express --
* anything outside printable ASCII, plus tab and newline -- is refused rather
* than guessed at or silently dropped, because silently dropping one is how a
* pasted passphrase or a pasted `rm` line quietly stops being what it was.
*
* `\r` types Enter. Normalising CRLF to LF is the controller's job (both the page
* and relayctl do it); doing it here would need a look-ahead that a chunk boundary
* could split.
*/
#pragma once
#include <stdint.h>
#include <stddef.h>
#include "config.h"
#include "keystate.h"
namespace kr {
namespace text {
// The result of offering characters to the typewriter. One outcome per frame, and
// it is all-or-nothing: a frame that cannot be accepted contributes nothing, so a
// rejected frame can never leave half a word typed.
enum class Result : uint8_t { Ok, Full, Untypeable };
struct Outcome {
Result result;
size_t index; // Untypeable: offset within this frame
uint8_t byte; // Untypeable: the character itself, for the error text
};
struct Stroke {
uint8_t mods; // modifier byte needed (LShift, for the shifted glyphs)
uint8_t usage; // HID usage id, usage page 0x07
bool typable; // false: this map has no keystroke for the character
};
/**
* The keystroke that produces one character on a US-ANSI keyboard.
*
* Written as a switch rather than a 256-byte table so that every mapping is
* readable at a glance and auditable against the HID usage table -- the letters
* and digits are arithmetic, everything else is named. Tests cover every code
* point in the range, so a missing case cannot hide.
*/
inline Stroke strokeFor(uint8_t ch) {
Stroke s{0, 0, false};
if (ch >= 'a' && ch <= 'z') {
s.usage = static_cast<uint8_t>(kr::kUsageA + (ch - 'a'));
s.typable = true;
return s;
}
if (ch >= 'A' && ch <= 'Z') {
s.mods = kr::kModLshift;
s.usage = static_cast<uint8_t>(kr::kUsageA + (ch - 'A'));
s.typable = true;
return s;
}
if (ch >= '0' && ch <= '9') {
// '1'..'9' are 0x1E..0x26 in a run; '0' is the one that breaks the pattern.
s.usage = (ch == '0') ? static_cast<uint8_t>(kr::kUsage0)
: static_cast<uint8_t>(0x1D + (ch - '0'));
s.typable = true;
return s;
}
uint8_t u = 0, m = 0;
switch (ch) {
case ' ': u = kr::kUsageSpace; break;
case '\t': u = kr::kUsageTab; break;
case '\n': u = kr::kUsageEnter; break;
case '\r': u = kr::kUsageEnter; break;
case '-': u = 0x2D; break;
case '=': u = 0x2E; break;
case '[': u = 0x2F; break;
case ']': u = 0x30; break;
case '\\': u = 0x31; break;
case ';': u = 0x33; break;
case '\'': u = 0x34; break;
case '`': u = 0x35; break;
case ',': u = 0x36; break;
case '.': u = 0x37; break;
case '/': u = 0x38; break;
case '!': u = 0x1E; m = kr::kModLshift; break;
case '@': u = 0x1F; m = kr::kModLshift; break;
case '#': u = 0x20; m = kr::kModLshift; break;
case '$': u = 0x21; m = kr::kModLshift; break;
case '%': u = 0x22; m = kr::kModLshift; break;
case '^': u = 0x23; m = kr::kModLshift; break;
case '&': u = 0x24; m = kr::kModLshift; break;
case '*': u = 0x25; m = kr::kModLshift; break;
case '(': u = 0x26; m = kr::kModLshift; break;
case ')': u = 0x27; m = kr::kModLshift; break;
case '+': u = 0x2E; m = kr::kModLshift; break;
case '_': u = 0x2D; m = kr::kModLshift; break;
case '{': u = 0x2F; m = kr::kModLshift; break;
case '}': u = 0x30; m = kr::kModLshift; break;
case '|': u = 0x31; m = kr::kModLshift; break;
case ':': u = 0x33; m = kr::kModLshift; break;
case '"': u = 0x34; m = kr::kModLshift; break;
case '~': u = 0x35; m = kr::kModLshift; break;
case '<': u = 0x36; m = kr::kModLshift; break;
case '>': u = 0x37; m = kr::kModLshift; break;
case '?': u = 0x38; m = kr::kModLshift; break;
default: break; // no keystroke for this character
}
if (u) { s.usage = u; s.mods = m; s.typable = true; }
return s;
}
/**
* One run of pasted characters, being typed out at a fixed pace.
*
* The characters are held only until they have gone out. Nothing here is
* persistent, nothing here survives a disconnect, and cancel() is reachable from
* every fail-safe path (release-all, disarm, target detached, queue overflow).
*/
class Typewriter {
public:
/**
* Offer a frame's worth of characters. Either all of them are accepted or none
* are, so a controller that is told "no" can retry the same frame unchanged.
*/
Outcome feed(const uint8_t *chars, size_t n) {
if (n == 0) return {Result::Ok, 0, 0};
if (_len + n > kTextJobCap) return {Result::Full, 0, 0};
for (size_t i = 0; i < n; i++) {
if (!strokeFor(chars[i]).typable) return {Result::Untypeable, i, chars[i]};
}
for (size_t i = 0; i < n; i++) _buf[_len++] = chars[i];
return {Result::Ok, 0, 0};
}
/**
* Begin (or continue) typing. `periodMs` 0 means the board's default pace.
*
* A job already running keeps the pace it started with: the timing of text the
* operator is already watching must not depend on when a later frame happened to
* arrive. A controller that wants a different pace sends no more frames until
* `active()` is false.
*/
void start(uint32_t nowMs, uint16_t periodMs) {
if (periodMs == 0) periodMs = kTextPeriodMsDefault;
if (periodMs > kTextPeriodMaxMs) periodMs = kTextPeriodMaxMs;
if (!_running) {
_periodMs = periodMs;
_phase = Phase::Press;
_nextAtMs = nowMs;
}
_running = _len > _pos;
}
// Drop everything still pending. Called by every fail-safe: this is the path
// that makes "release every key" mean "and stop typing", not "pause".
void cancel() {
_running = false;
_phase = Phase::Idle;
_pos = 0;
_len = 0;
}
bool active() const { return _running; }
size_t pending() const { return _len - _pos; }
uint16_t periodMs() const { return _periodMs; }
// A character is held for at most kTextHoldMaxMs and at most half the period, so
// a fast pace stays honest (press and release both land inside their period) and
// a slow one does not sit on the key for a quarter of a second.
uint16_t holdMs() const {
uint16_t h = _periodMs / 2;
if (h > kTextHoldMaxMs) h = kTextHoldMaxMs;
return h ? h : 1;
}
uint16_t gapMs() const {
const uint16_t h = holdMs();
return (_periodMs > h) ? static_cast<uint16_t>(_periodMs - h) : 0;
}
/**
* Emit the report that is due at `nowMs`, if one is. At most one transition per
* call, which is what keeps the report queue shallow no matter what pace was
* asked for.
*
* `baseline` is the operator's own tracked key state: each character is typed on
* top of it and released back to it, so a paste can neither let go of a key
* being held nor strand one afterwards. `typedChar` reports a press, which is
* what the status counter counts.
*/
bool step(uint32_t nowMs, const KeyboardState &baseline, KeyboardReport &out,
bool &typedChar) {
typedChar = false;
if (!_running || _pos >= _len) return false;
if (static_cast<int32_t>(nowMs - _nextAtMs) < 0) return false;
if (_phase == Phase::Release) {
out = baseline.report(); // exactly the restated state, no leftovers
_pos++;
if (_pos >= _len) {
cancel(); // finished: nothing kept, nothing pending
return true;
}
_phase = Phase::Press;
_nextAtMs = nowMs + gapMs();
return true;
}
const Stroke s = strokeFor(_buf[_pos]);
out = pressOf(baseline.report(), s);
typedChar = true;
_phase = Phase::Release;
_nextAtMs = nowMs + holdMs();
return true;
}
private:
enum class Phase : uint8_t { Idle, Press, Release };
// The character added to whatever is already down. More than six keys together
// is reported the way a real keyboard reports it -- ErrorRollOver -- including
// when the baseline got there on its own.
static KeyboardReport pressOf(const KeyboardReport &base, const Stroke &s) {
uint8_t list[kReportSlots + 1] = {0};
size_t n = 0;
bool rolled = false;
for (size_t i = 0; i < kReportSlots; i++) {
const uint8_t u = base.keys[i];
if (u == kUsageErrorRollover) rolled = true; // already too wide for a report
else if (u != kUsageNone) list[n++] = u;
}
if (rolled || n == kReportSlots) {
// Seven keys down is seven keys down, whichever of us pressed the seventh.
n = kReportSlots + 1;
} else {
list[n++] = s.usage;
}
return makeReport(static_cast<uint8_t>(base.modifiers | s.mods), list, n);
}
uint8_t _buf[kTextJobCap] = {0};
size_t _pos = 0; // next character to type
size_t _len = 0; // one past the last character accepted
bool _running = false;
Phase _phase = Phase::Idle;
uint16_t _periodMs = kTextPeriodMsDefault;
uint32_t _nextAtMs = 0;
};
} // namespace text
} // namespace kr
+845
View File
@@ -0,0 +1,845 @@
/**
* keyrelay -- the controller page, served by the board itself (PROGMEM).
*
* Why the page lives on the board
* -------------------------------
* Then the whole system is: plug the board into the target, open one URL on the
* laptop. No host software, no driver, no pairing database, nothing to install on
* a machine you may only be borrowing for ten minutes. The laptop's browser is the
* controller; the board is the wire.
*
* Deliberate UI decisions, all of them load-bearing:
*
* - NOTHING IS FORWARDED UNTIL ARM IS PRESSED. Page load, reconnect and reload
* all start disarmed. The device LED is off until armed and solid red while it
* is, so the state is readable from across the room, not just on screen.
* - Ctrl+Alt+Del is a button, and a hold-to-confirm one. The browser cannot see
* the real Ctrl+Alt+Del (the laptop's OS eats it), and on Linux a single CAD on
* a virtual terminal reboots the machine, so it must never be a typo.
* - Every key event is preventDefault()ed while armed, so the laptop's browser
* does not also act on Ctrl+W, Ctrl+R, F11, etc.
* - Losing focus, visibility or the socket releases every key immediately, disarms,
* 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.
* - 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
* typed them. One human, one keyboard, live.
* See README "What this deliberately is not".
*
* Keys typed while the caret is in one of this page's own fields belong to the page
* and are not forwarded -- otherwise the passphrase box, or this very text box, could
* not be typed in while armed. A modifier that went down outside a field is still
* released, so that exemption cannot strand a Ctrl on the target.
*
* Layout: KeyboardEvent.code names a physical position on a US-ANSY layout, and
* those positions are what travel as HID usage ids. If the TARGET is set to a
* non-US layout, positions map to different characters -- the same caveat every
* KVM has. Fix it on the target (set its layout to US) or remap CODE_TO_USAGE.
*/
#pragma once
#include <stdint.h>
namespace kr {
// Raw string, so the HTML below is literally what goes on the wire.
static const char kIndexHtml[] PROGMEM = R"HTML(<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>keyrelay</title>
<style>
:root{
--bg:#0d1117;--panel:#161b22;--line:#30363d;--fg:#c9d1d9;--dim:#8b949e;
--ok:#3fb950;--warn:#d29922;--hot:#f85149;--accent:#58a6ff;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);
font:14px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
-webkit-font-smoothing:antialiased}
header{display:flex;flex-wrap:wrap;gap:10px;align-items:center;
padding:10px 14px;border-bottom:1px solid var(--line);background:var(--panel)}
h1{font-size:15px;margin:0 12px 0 0;letter-spacing:.5px}
h1 b{color:var(--accent)}
.pills{display:flex;flex-wrap:wrap;gap:6px}
.pill{border:1px solid var(--line);border-radius:999px;padding:2px 9px;font-size:12px;color:var(--dim)}
.pill.on{color:var(--ok);border-color:#1f3b26;background:#12261a}
.pill.off{color:var(--dim)}
.pill.bad{color:var(--hot);border-color:#4a1d1d;background:#2a1214}
main{padding:14px;display:grid;gap:14px;max-width:980px}
.arm{width:100%;padding:26px;font:inherit;font-size:22px;font-weight:700;letter-spacing:2px;
color:var(--fg);background:var(--panel);border:2px solid var(--line);border-radius:12px;
cursor:pointer}
.arm:hover{border-color:var(--accent)}
.arm.armed{color:#fff;background:#4a1216;border-color:var(--hot);
box-shadow:0 0 0 1px var(--hot),0 0 26px -6px var(--hot)}
.banner{border:1px solid var(--line);border-left:4px solid var(--warn);border-radius:8px;
padding:9px 12px;color:var(--dim);font-size:12.5px}
.arm.armed~.banner{border-left-color:var(--hot)}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:12px}
.card{border:1px solid var(--line);border-radius:10px;background:var(--panel);padding:11px 12px}
.card h2{margin:0 0 8px;font-size:11px;text-transform:uppercase;letter-spacing:1.1px;color:var(--dim)}
.kv{display:grid;grid-template-columns:auto 1fr;gap:2px 10px;font-size:12.5px}
.kv div:nth-child(odd){color:var(--dim)}
.kv div:nth-child(even){text-align:right}
.keys{min-height:26px;display:flex;flex-wrap:wrap;gap:4px}
.key{border:1px solid var(--line);border-radius:5px;padding:1px 7px;font-size:12px;background:#0b0f14}
.key.mod{color:var(--accent);border-color:#1f3552}
button.k{font:inherit;font-size:12.5px;color:var(--fg);background:#0b0f14;
border:1px solid var(--line);border-radius:7px;padding:7px 9px;cursor:pointer}
button.k:hover{border-color:var(--accent);color:#fff}
button.k.danger{color:var(--warn);border-color:#4a3a12}
button.k.danger:hover{border-color:var(--hot);color:#fff;background:#2a1214}
button.k.armed-confirm{color:#fff;background:#4a1216;border-color:var(--hot)}
.row{display:flex;flex-wrap:wrap;gap:6px;align-items:center}
label.opt{display:flex;gap:7px;align-items:center;font-size:12.5px;color:var(--dim);cursor:pointer}
input[type=password],input[type=text]{font:inherit;font-size:13px;background:#0b0f14;
color:var(--fg);border:1px solid var(--line);border-radius:7px;padding:6px 8px;width:150px}
textarea{font:inherit;font-size:13px;background:#0b0f14;color:var(--fg);width:100%;
border:1px solid var(--line);border-radius:7px;padding:7px 9px;resize:vertical;
min-height:62px;tab-size:4}
textarea:focus{outline:1px solid var(--accent);border-color:var(--accent)}
textarea:disabled{color:var(--dim);background:#0e131a}
button.k:disabled{color:#4d565f;border-color:var(--line);background:#0b0f14;cursor:not-allowed}
button.k:disabled:hover{color:#4d565f;border-color:var(--line);background:#0b0f14}
.rowhead{display:flex;align-items:center;gap:14px;flex-wrap:wrap;margin-bottom:8px}
.rowhead h2{margin:0}
p.hint{color:var(--dim);font-size:11.5px;margin:8px 0 0}
#log{height:150px;overflow-y:auto;font-size:12px;border:1px solid var(--line);border-radius:8px;
background:#0b0f14;padding:7px 9px;white-space:pre-wrap}
#log .e{color:var(--hot)}
#log .w{color:var(--warn)}
#log .g{color:var(--ok)}
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}
body.armed{outline:3px solid var(--hot);outline-offset:-3px}
body.off{opacity:.92}
</style>
</head>
<body class="off">
<header>
<h1>key<b>relay</b> <span id="ver" style="color:var(--dim);font-weight:400"></span></h1>
<div class="pills">
<span class="pill" id="pNet">network</span>
<span class="pill" id="pUsb">target</span>
<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>
</div>
</header>
<main>
<button class="arm" id="arm">DISARMED &#183; press to arm</button>
<div class="banner" id="banner">
Arming forwards <b>every</b> key you press in this tab &mdash; including Enter &mdash;
to the machine the board is plugged into. Use it only on a machine you own or are
explicitly allowed to type on.
</div>
<div class="grid">
<div class="card">
<h2>Special keys</h2>
<div class="row">
<button class="k danger" id="cad" title="Press twice within 3s. The laptop's own OS swallows the real Ctrl+Alt+Del, so it has to come from here. On a Linux virtual terminal this reboots the target.">
Ctrl+Alt+Del</button>
<button class="k" id="release" title="All keys up, stay armed. Also Ctrl+Alt+Esc.">Release keys</button>
</div>
<div class="row" style="margin-top:6px">
<button class="k" data-chord="05:3a" title="Ctrl+Alt+F1">&#8976; VT1</button>
<button class="k" data-chord="05:3b" title="Ctrl+Alt+F2">&#8976; VT2</button>
<button class="k" data-chord="05:3c" title="Ctrl+Alt+F3">&#8976; VT3</button>
<button class="k" data-chord="05:3d" title="Ctrl+Alt+F4">&#8976; VT4</button>
<button class="k" data-chord="04:2b" title="Alt+Tab">&#8677; Alt+Tab</button>
<button class="k" data-chord="08:" title="Windows / Super, pressed and released on its own">Win</button>
<button class="k" data-chord="00:46" title="PrintScreen">PrtSc</button>
<button class="k" data-chord="00:65" title="Application menu key">Menu</button>
</div>
<div class="row" style="margin-top:6px">
<button class="k" data-chord="08:50" title="Super+Left &mdash; KDE: snap the focused window to the left half of the screen">&#8678; snap left</button>
<button class="k" data-chord="08:4f" title="Super+Right &mdash; KDE: snap the focused window to the right half of the screen">snap right &#8680;</button>
<button class="k" data-chord="08:4b" title="Super+PageUp &mdash; KDE: make the focused window fullscreen">&#x26F6; fullscreen</button>
</div>
<p style="color:var(--dim);font-size:11.5px;margin:8px 0 0">
These are here because the laptop's window manager grabs them before the browser
ever sees them &mdash; press Super+Left on the laptop and the laptop tiles its own
window. So the bridge types the chord instead and the machine it is plugged into
is the one that reacts. Which window does what is that machine's own keybinding
(these three are KDE Plasma's: window left, window right, fullscreen). They need
the bridge armed, and each one is a single deliberate click.
</p>
</div>
<div class="card">
<h2>State</h2>
<div class="kv">
<div>mode</div><div id="sMode">&mdash;</div>
<div>address</div><div id="sIp">&mdash;</div>
<div>uptime</div><div id="sUp">&mdash;</div>
<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>dropped</div><div id="sDrop">0</div>
</div>
<h2 style="margin-top:10px">Held now</h2>
<div class="keys" id="held"><span style="color:var(--dim)">nothing</span></div>
</div>
<div class="card">
<h2>Connection</h2>
<div class="row">
<input type="password" id="token" placeholder="passphrase" autocomplete="off">
<button class="k" id="connect">connect</button>
</div>
<label class="opt" style="margin-top:9px">
<input type="checkbox" id="keepArmed">
stay armed if this tab loses focus
</label>
<p style="color:var(--dim);font-size:11.5px;margin:8px 0 0">
The passphrase is checked by the board on every connection. Without it nothing
can be armed, so anyone else on the network gets a page that does nothing.
</p>
</div>
</div>
<div class="card">
<h2>Send text</h2>
<textarea id="paste" rows="3" spellcheck="false" autocomplete="off"
placeholder="paste text here. Nothing is sent until you press the button below." ></textarea>
<div class="row" style="margin-top:7px">
<button class="k" id="typeSend" disabled>type 0 characters</button>
<button class="k danger" id="typeStop" disabled>stop typing</button>
<label class="opt" title="Off by default: a paste that quietly loses a character is worse than one that refuses">
<input type="checkbox" id="stripBad">drop characters I cannot type</label>
<label class="opt" title="Adds one Enter after the last character">
<input type="checkbox" id="trailingEnter">press Enter when done</label>
<span class="pill off" id="typeStat">idle</span>
</div>
<p class="hint" id="typeHint">
The board types these characters as keystrokes, at a pace it keeps itself, so the
target sees a keyboard and not a paste. It needs the bridge armed, and one press of
this button &mdash; there is no history, nothing is saved, and nothing is ever typed
later. <b>stop typing</b> or <kbd>Ctrl+Alt+Esc</kbd> abandons the rest of a run.
Layout matters: characters are positions, so the target must be set to a US layout
for these to land as they read here.
</p>
</div>
<div class="card">
<div class="rowhead">
<h2>Log</h2>
<label class="opt" title="One line per key pressed, never one per release">
<input type="checkbox" id="logKeys" checked>log each key sent</label>
<button class="k" id="logClear">clear</button>
</div>
<div id="log"></div>
</div>
</main>
<footer>
Shortcuts while armed: <kbd>Ctrl+Alt+Esc</kbd> release every key and disarm &middot;
<kbd>Ctrl+Alt+d</kbd> the Ctrl+Alt+Del confirm &middot; <kbd>Esc</kbd> and every other
key are forwarded to the target untouched.
Nothing is forwarded while disarmed; reload, focus loss and socket loss all disarm.
</footer>
<script>
"use strict";
/* ------------------------------------------------------------------ *
* KeyboardEvent.code -> USB HID usage id (usage page 0x07).
* code names a physical position, so this table is US-ANSI based; it is
* the one thing to edit for a different board.
* ------------------------------------------------------------------ */
const CODE_TO_USAGE = {
KeyA:0x04,KeyB:0x05,KeyC:0x06,KeyD:0x07,KeyE:0x08,KeyF:0x09,KeyG:0x0a,KeyH:0x0b,
KeyI:0x0c,KeyJ:0x0d,KeyK:0x0e,KeyL:0x0f,KeyM:0x10,KeyN:0x11,KeyO:0x12,KeyP:0x13,
KeyQ:0x14,KeyR:0x15,KeyS:0x16,KeyT:0x17,KeyU:0x18,KeyV:0x19,KeyW:0x1a,KeyX:0x1b,
KeyY:0x1c,KeyZ:0x1d,
Digit1:0x1e,Digit2:0x1f,Digit3:0x20,Digit4:0x21,Digit5:0x22,Digit6:0x23,Digit7:0x24,
Digit8:0x25,Digit9:0x26,Digit0:0x27,
Enter:0x28,Escape:0x29,Backspace:0x2a,Tab:0x2b,Space:0x2c,
Minus:0x2d,Equal:0x2e,BracketLeft:0x2f,BracketRight:0x30,Backslash:0x31,
IntlBackslash:0x32,Semicolon:0x33,Quote:0x34,Backquote:0x35,Comma:0x36,Period:0x37,
Slash:0x38,CapsLock:0x39,
F1:0x3a,F2:0x3b,F3:0x3c,F4:0x3d,F5:0x3e,F6:0x3f,F7:0x40,F8:0x41,F9:0x42,F10:0x43,
F11:0x44,F12:0x45,
PrintScreen:0x46,ScrollLock:0x47,Pause:0x48,Insert:0x49,Home:0x4a,PageUp:0x4b,
Delete:0x4c,End:0x4d,PageDown:0x4e,
ArrowRight:0x4f,ArrowLeft:0x50,ArrowDown:0x51,ArrowUp:0x52,NumLock:0x53,
NumpadDivide:0x54,NumpadMultiply:0x55,NumpadSubtract:0x56,NumpadAdd:0x57,
NumpadEnter:0x58,
Numpad1:0x59,Numpad2:0x5a,Numpad3:0x5b,Numpad4:0x5c,Numpad5:0x5d,Numpad6:0x5e,
Numpad7:0x5f,Numpad8:0x60,Numpad9:0x61,Numpad0:0x62,NumpadDecimal:0x63,
IntlRo:0x64,ContextMenu:0x65,NumpadEqual:0x67,
F13:0x68,F14:0x69,F15:0x6a,F16:0x6b,F17:0x6c,F18:0x6d,F19:0x6e,F20:0x6f,F21:0x70,
F22:0x71,F23:0x72,F24:0x73
};
const MOD_CODE = { /* code -> modifier BYTE bit */
ControlLeft:0x01,ShiftLeft:0x02,AltLeft:0x04,MetaLeft:0x08,OSLeft:0x08,
ControlRight:0x10,ShiftRight:0x20,AltRight:0x40,MetaRight:0x80,OSRight:0x80
};
const MOD_NAME = ["LCtrl","LShift","LAlt","LSuper","RCtrl","RShift","RAlt","RSuper"];
const USAGE_NAME = {};
for (const [c,u] of Object.entries(CODE_TO_USAGE)) USAGE_NAME[u] = c;
const $ = id => document.getElementById(id);
const logEl = $("log");
const LOG_LINES = 150;
/**
* One line per thing worth reading.
*
* Two rules, both of them here rather than at the call sites, because a log you
* cannot read is worse than no log:
*
* - nothing empty ever lands. A blank line is pure height, and a few of them a
* second pushed the actual key lines off the visible area for good.
* - an identical line repeating is counted, not repeated. Three hundred copies of
* the same retry say one thing and say it once.
*/
function log(msg, cls) {
const body = (msg === undefined || msg === null) ? "" : String(msg);
if (!body.trim()) return;
const stamped = new Date().toLocaleTimeString() + " " + body;
const top = logEl.lastElementChild;
if (top && top.dataset.raw === stamped) {
const n = (Number(top.dataset.repeat) || 1) + 1;
top.dataset.repeat = String(n);
top.textContent = stamped + " \u00d7" + n;
logEl.scrollTop = logEl.scrollHeight;
return;
}
const line = document.createElement("div");
if (cls) line.className = cls;
line.dataset.raw = stamped;
line.dataset.repeat = "1";
line.textContent = stamped;
logEl.appendChild(line);
while (logEl.firstElementChild && logEl.childElementCount > LOG_LINES) {
logEl.removeChild(logEl.firstElementChild);
}
logEl.scrollTop = logEl.scrollHeight;
}
// What actually went to the target, one line per press. Releases and modifier-only
// changes are not logged: they are half of the traffic and none of the information.
function logKey(label, frame) {
if (!$("logKeys").checked) return;
log("key " + label + " " + frame, "g");
}
/* ------------------------------------------------------------------ *
* Local key state. We restate the WHOLE state on every event, so a lost
* frame cannot leave a key stuck: the next restatement fixes everything.
* ------------------------------------------------------------------ */
let mods = 0;
let keys = []; // usage ids, press order
function resetKeys() { mods = 0; keys = []; paintHeld(); }
function stateFrame() {
const hex = n => n.toString(16).padStart(2, "0");
return "S=" + hex(mods) + ":" + keys.map(hex).join(",");
}
function paintHeld() {
const el = $("held");
el.textContent = "";
const bits = [];
for (let i = 0; i < 8; i++) if (mods & (1 << i)) bits.push(MOD_NAME[i]);
for (const u of keys) bits.push(USAGE_NAME[u] || ("0x" + u.toString(16)));
if (!bits.length) { el.innerHTML = '<span style="color:var(--dim)">nothing</span>'; return; }
for (const b of bits) {
const s = document.createElement("span");
s.className = "key" + (MOD_NAME.includes(b) ? " mod" : "");
s.textContent = b;
el.appendChild(s);
}
}
/* True when the caret is in one of this page's own controls. Those keystrokes are
* answers to the page, not to the target, and forwarding them made the passphrase
* box and the text box impossible to type in while armed. */
function inPageField(ev) {
const t = ev.target;
return !!t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" ||
t.tagName === "SELECT" || t.isContentEditable);
}
/* One press, named: the modifiers held, then the key. Used for the log line, which
* is the only place a human sees what the bridge actually sent. */
function describePress(code) {
const isMod = MOD_CODE[code] !== undefined;
const self = isMod ? code : (USAGE_NAME[CODE_TO_USAGE[code]] || code);
const held = [];
for (let i = 0; i < 8; i++) {
if (!(mods & (1 << i))) continue;
if (isMod && MOD_CODE[code] === (1 << i)) continue; // named as the key itself
held.push(MOD_NAME[i]);
}
return (held.length ? held.join("+") + "+" : "") + self;
}
/* ------------------------------------------------------------------ *
* Link
* ------------------------------------------------------------------ */
let ws = null, armed = false, authed = false, reconnectAt = 0, status = null;
function tokenValue() {
let t = $("token").value.trim();
if (!t) t = sessionStorage.getItem("keyrelay.token") || "";
return t;
}
function connect() {
const tok = tokenValue();
if (!tok) { log("enter the board's passphrase first", "w"); return; }
sessionStorage.setItem("keyrelay.token", tok);
if (ws) { try { ws.close(); } catch (e) {} ws = null; }
const url = (location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/ws";
log("connecting to " + location.host);
ws = new WebSocket(url);
ws.onopen = () => { send("T" + tok); send("H"); authed = false; };
ws.onmessage = ev => onFrame(ev.data);
ws.onclose = () => {
authed = false; setArmed(false, "link lost");
$("pLink").className = "pill bad"; $("pLink").textContent = "link down";
log("link down -- retrying in 2s", "e");
reconnectAt = Date.now() + 2000;
};
ws.onerror = () => { /* onclose follows and does the work */ };
}
function send(text) {
if (ws && ws.readyState === WebSocket.OPEN) { ws.send(text); return true; }
return false;
}
/* Every keystroke goes out immediately: one frame per state change, no queue,
* because the socket is local and the state is idempotent. */
function pushState() {
if (!armed) return;
if (!send(stateFrame())) { setArmed(false, "link lost"); }
}
function onFrame(raw) {
if (raw[0] === "S") { try { applyStatus(JSON.parse(raw.slice(1))); } catch (e) {} return; }
if (raw === "OK") { if (!authed) { authed = true; $("pLink").className = "pill on";
$("pLink").textContent = "linked"; log("authenticated", "g"); } return; }
if (raw[0] === "E") {
const code = raw[1], text = raw.slice(3);
if (code === "1") { authed = false; $("pLink").className = "pill bad";
$("pLink").textContent = "no passphrase"; }
else if (code === "3") setArmed(false, "another controller took over");
// 4 not armed, 5 no target, 7 released and disarmed, 8 buffer full, 9 untypeable:
// every one of those means the rest of a run must not be offered to the board.
if (typing && "45789".includes(code)) abortTyping("board said E" + code);
log("board: " + text + " (E" + code + ")", "e");
return;
}
log("board: " + raw);
}
function applyStatus(s) {
status = s;
$("ver").textContent = "v" + (s.v || "?");
$("sMode").textContent = s.net === "sta" ? "station" : (s.net === "ap" ? "access point" : "down");
$("sIp").textContent = s.ip || "\u2014";
$("sUp").textContent = Math.floor((s.up || 0) / 60) + "m " + ((s.up || 0) % 60) + "s";
$("sKeys").textContent = s.keys || 0;
$("sTyped").textContent = s.typed || 0;
$("sChords").textContent = s.chords || 0;
$("sDrop").textContent = s.drop || 0;
if (typing) $("typeStat").textContent = "typing (" + (s.typing || 0) + " still to go)";
const net = $("pNet");
net.className = "pill " + (s.net === "down" ? "bad" : "on");
net.textContent = s.net === "ap" ? "ap " + (s.ssid || "") : (s.ssid || s.net);
const usb = $("pUsb");
usb.className = "pill " + (s.usb ? "on" : "bad");
usb.textContent = s.usb ? "target attached" : "no target";
const leds = $("pLeds");
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");
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 '
+ '&lt;passphrase&gt;</code> before you plug this into anything.';
}
paintHeld();
noteBoardStatus(s); // `typing: 0` is what releases the next piece of a paste
refreshSendBox();
}
/* ------------------------------------------------------------------ *
* Arming. Off is the only safe default, so this is the one state change
* that always travels, and that the board also controls (its button).
* ------------------------------------------------------------------ */
function setArmed(on, why) {
armed = on;
const b = $("arm");
b.classList.toggle("armed", on);
b.textContent = on ? "ARMED \u00b7 keystrokes go to the target"
: "DISARMED \u00b7 press to arm";
document.body.classList.toggle("armed", on);
document.body.classList.toggle("off", !on);
const p = $("pArmed");
p.className = "pill " + (on ? "bad" : "off");
p.textContent = on ? "ARMED" : "disarmed";
if (!on) { abortTyping("disarmed"); resetKeys(); }
if (why !== "local") log(on ? "ARMED -- typing goes to the target" : "disarmed (" + why + ")",
on ? "g" : "w");
}
$("arm").addEventListener("click", () => {
if (!authed) { log("not linked -- enter the passphrase and press connect", "w"); return; }
const on = !armed;
if (!send(on ? "A1" : "A0")) { log("no link to the board", "e"); return; }
if (!on) send("R");
/* The board's status heartbeat confirms it; no optimistic flip. */
});
/* ------------------------------------------------------------------ *
* Key capture
* ------------------------------------------------------------------ */
document.addEventListener("keydown", ev => {
/* Ctrl+Alt+Esc is the panic key, armed or not: it must always work. */
if (ev.ctrlKey && ev.altKey && ev.code === "Escape") {
ev.preventDefault();
panic();
return;
}
// The caret is in one of this page's own fields: the keystrokes belong to the
// page. Without this you could not type a passphrase, or a paste, while armed.
if (inPageField(ev)) return;
if (!armed) return;
ev.preventDefault(); // the laptop's browser must not act on it either
if (ev.ctrlKey && ev.altKey && ev.code === "KeyD") { ev.preventDefault(); armCad(); return; }
if (ev.repeat) return; // the state already says the key is down
const mod = MOD_CODE[ev.code];
if (mod !== undefined) { mods |= mod; }
else {
const u = CODE_TO_USAGE[ev.code];
if (u === undefined) { log("unmapped key: " + ev.code, "w"); return; }
if (!keys.includes(u)) keys.push(u);
}
paintHeld();
logKey(describePress(ev.code), stateFrame());
pushState();
}, {passive:false});
document.addEventListener("keyup", ev => {
if (inPageField(ev)) {
// Not forwarded -- but a modifier that went down OUTSIDE the field must still be
// let go, or releasing Shift over the text box would strand it on the target.
const m = MOD_CODE[ev.code];
if (m !== undefined && (mods & m)) { mods &= ~m; paintHeld(); pushState(); }
return;
}
if (!armed) return;
ev.preventDefault();
const mod = MOD_CODE[ev.code];
if (mod !== undefined) mods &= ~mod;
else {
const u = CODE_TO_USAGE[ev.code];
if (u !== undefined) keys = keys.filter(k => k !== u);
}
paintHeld();
pushState();
}, {passive:false});
/* Nothing may survive the tab going away -- not a held Ctrl, not a held key, and
* not the rest of a paste nobody is watching any more. */
function panic() {
abortTyping("Ctrl+Alt+Esc");
resetKeys();
send("R");
if (armed) send("A0");
setArmed(false, "panic (Ctrl+Alt+Esc)");
}
function dropEverything(why) {
abortTyping(why);
if (!armed && !mods && !keys.length) return;
resetKeys();
send("R");
if (armed) send("A0");
setArmed(false, why);
}
window.addEventListener("blur", () => {
if ($("keepArmed").checked) {
// Staying armed across a blur is the operator's choice; a run of text still
// stops, because nothing here continues unwatched.
abortTyping("tab lost focus");
if (armed) { resetKeys(); send("R"); }
return;
}
dropEverything("tab lost focus");
});
document.addEventListener("visibilitychange", () => {
if (document.hidden) dropEverything("tab hidden");
});
/* ------------------------------------------------------------------ *
* Special keys
* ------------------------------------------------------------------ */
let cadArmedUntil = 0;
function armCad() {
const now = Date.now();
const btn = $("cad");
if (now < cadArmedUntil) {
cadArmedUntil = 0;
btn.classList.remove("armed-confirm");
btn.textContent = "Ctrl+Alt+Del";
fire("C");
log("Ctrl+Alt+Del sent", "g");
return;
}
if (!armed) { log("arm the bridge first -- Ctrl+Alt+Del is not a free pass", "w"); return; }
cadArmedUntil = now + 3000;
btn.classList.add("armed-confirm");
btn.textContent = "press again to send";
log("Ctrl+Alt+Del armed for 3s. On a Linux virtual terminal this REBOOTS the target.", "w");
setTimeout(() => {
if (Date.now() >= cadArmedUntil) {
btn.classList.remove("armed-confirm");
btn.textContent = "Ctrl+Alt+Del";
}
}, 3100);
}
$("cad").addEventListener("click", armCad);
$("release").addEventListener("click", () => { resetKeys(); send("R"); log("released every key"); });
for (const b of document.querySelectorAll("button[data-chord]")) {
// data-chord is already "<mods>:<usages>", i.e. the wire format of one burst,
// so the buttons carry the exact chord rather than a name to look up.
b.addEventListener("click", () => fire("Y=" + b.getAttribute("data-chord")));
}
function fire(frame) {
if (!armed) { log("disarmed -- nothing was sent", "w"); return; }
if (!authed) { log("not linked to the board", "w"); return; }
if (!send(frame)) { log("link lost", "e"); return; }
log("chord " + frame, "g");
}
/* ------------------------------------------------------------------ *
* Pasted text
*
* A human pasted this into the box and pressed its button -- that is the whole
* justification for it. The difference between a remote keyboard and an
* unattended injection device is a deliberate action with the arming light on,
* and this keeps both. It is not a macro facility: there is no history, nothing
* is scheduled or repeated, and nothing outlives the run.
*
* The board does the typing and keeps the pace, so a stall in this tab cannot
* become a burst of keys on the target. Flow control is the board's own `typing`
* count -- the next piece goes out when the board says it is empty, not when a
* timer guessed it would be. It holds kTextJobCap characters, far more than one
* frame's worth, so a heartbeat that was already in flight when a frame was sent
* can let one piece start early without ever overrunning the buffer.
* ------------------------------------------------------------------ */
const TEXT_FRAME_MAX = 120; // characters per P= frame == config.h kTextFrameMax
const TEXT_JOB_MAX = 2048; // characters per run == config.h kTextJobCap
const TEXT_PACE_MS = 12; // milliseconds per character we ask for
let typing = false; // this tab believes a run is in flight
let typingAbort = false; // stop at the next chunk boundary
let idleWaiters = []; // awaiting "board is empty"
/* Tab, newline and printable ASCII are what a US-ANSI keyboard can make. Anything
* else -- curly quotes, em dashes, UTF-8 accents -- has no key, and a paste that
* quietly loses a character is worse than one that refuses to go. */
function untypeableChars(s) {
const bad = [];
for (const ch of s) {
const c = ch.codePointAt(0);
if (!(c === 9 || c === 10 || (c >= 32 && c <= 126))) bad.push(ch);
}
return bad;
}
/* The box's own line endings are LF. A \r would type a second Enter on the
* target, and the board cannot fix that for us: a CRLF pair can straddle a frame
* boundary, so the normalising belongs to whoever has the whole text. */
function pasteText() { return $("paste").value.replace(/\r\n?/g, "\n"); }
function hexify(s) {
let out = "";
for (const b of new TextEncoder().encode(s)) out += b.toString(16).padStart(2, "0");
return out;
}
function codeName(ch) {
return JSON.stringify(ch) + " U+" +
ch.codePointAt(0).toString(16).toUpperCase().padStart(4, "0");
}
function refreshSendBox() {
const btn = $("typeSend"), stat = $("typeStat");
if (typing) { $("typeStop").disabled = false; return; } // the progress line owns it
const text = pasteText() + ($("trailingEnter").checked ? "\n" : "");
const bad = untypeableChars(text);
const tooLong = text.length > TEXT_JOB_MAX;
const stuck = bad.length > 0 && !$("stripBad").checked;
$("typeStop").disabled = true;
btn.disabled = !armed || !authed || text.length === 0 || stuck || tooLong;
btn.textContent = "type " + text.length + " character" + (text.length === 1 ? "" : "s");
if (text.length === 0) { stat.className = "pill off"; stat.textContent = "idle"; }
else if (tooLong) { stat.className = "pill bad";
stat.textContent = text.length + " is more than one run holds"; }
else if (stuck) { stat.className = "pill bad";
stat.textContent = bad.length + " character(s) have no key"; }
else if (!authed) { stat.className = "pill bad"; stat.textContent = "not linked"; }
else if (!armed) { stat.className = "pill off"; stat.textContent = "arm first"; }
else { stat.className = "pill on"; stat.textContent = "ready"; }
}
function setTypingUi(on, total) {
$("paste").readOnly = on;
$("typeSend").disabled = on;
$("typeStop").disabled = !on;
if (on) { $("typeStat").className = "pill on"; $("typeStat").textContent = "typing 0/" + total; }
}
function abortTyping(why) {
if (!typing) return;
typingAbort = true;
log("paste will stop: " + why, "w");
}
function noteBoardStatus(s) {
// Only "the board is empty" releases the next piece. A heartbeat that says it is
// still typing must leave the waiter alone: treating busy as a failure would end a
// long paste on the very first heartbeat, since the board heartbeats at 1 Hz and
// types for far longer than that. The timeout is the backstop, not this.
if (s && s.typing) return;
if (!idleWaiters.length) return;
const waiters = idleWaiters;
idleWaiters = [];
for (const w of waiters) w(true);
}
function waitBoardIdle(ms) {
return new Promise(res => {
let done = false;
const finish = v => { if (done) return; done = true; clearTimeout(timer); res(v); };
const timer = setTimeout(() => finish(false), ms);
idleWaiters.push(finish);
});
}
async function sendPaste() {
if (typing) { log("already typing -- press stop typing first", "w"); return; }
if (!authed) { log("not linked to the board", "w"); return; }
if (!armed) { log("arm the bridge before sending text", "w"); return; }
let text = pasteText();
if ($("trailingEnter").checked) text += "\n";
if (!text.length) { log("nothing to send", "w"); return; }
const bad = untypeableChars(text);
if (bad.length) {
if (!$("stripBad").checked) {
log(bad.length + " of " + text.length + " characters have no key on a US-ANSI "
+ "keyboard (first is " + codeName(bad[0]) + "). Tick the box to send the "
+ "rest, or fix the text.", "e");
return;
}
text = Array.from(text).filter(ch => !untypeableChars(ch).length).join("");
log("dropped " + bad.length + " character(s) with no key here", "w");
if (!text.length) { log("nothing left to send", "w"); return; }
}
if (text.length > TEXT_JOB_MAX) {
log(text.length + " characters is more than one run holds (" + TEXT_JOB_MAX
+ "). Send it in pieces.", "e");
return;
}
const chunks = [];
for (let i = 0; i < text.length; i += TEXT_FRAME_MAX) chunks.push(text.slice(i, i + TEXT_FRAME_MAX));
typing = true;
typingAbort = false;
setTypingUi(true, text.length);
const started = Date.now();
log("typing " + text.length + " characters, " + TEXT_PACE_MS + " ms each, "
+ chunks.length + " frame" + (chunks.length === 1 ? "" : "s"), "g");
let sent = 0, confirmed = 0, failed = null;
for (const chunk of chunks) {
if (typingAbort || !armed) { failed = "stopped"; break; }
if (!send("P=" + TEXT_PACE_MS + ":" + hexify(chunk))) { failed = "link lost"; break; }
sent += chunk.length;
// One frame's worth of typing, plus a heartbeat's worth of slack: the board
// confirms it is empty, and a lost reply must not hang this tab forever.
const idle = await waitBoardIdle(chunk.length * (TEXT_PACE_MS + 4) + 4000);
if (idle) confirmed = sent;
$("typeStat").textContent = "typing " + confirmed + "/" + text.length;
if (typingAbort || !armed) { failed = "stopped"; break; }
if (!idle) { failed = "the board stopped answering"; break; }
}
const secs = ((Date.now() - started) / 1000).toFixed(1);
typing = false;
setTypingUi(false);
if (failed && failed !== "stopped") log("paste ended early: " + failed + " -- " + confirmed
+ " of " + text.length + " characters typed (" + secs + "s)", "e");
else if (failed) log("paste cancelled -- " + confirmed + " of " + text.length
+ " characters typed (" + secs + "s)", "w");
else log("typed " + confirmed + " characters in " + secs + "s", "g");
refreshSendBox();
}
$("typeSend").addEventListener("click", sendPaste);
$("typeStop").addEventListener("click", () => {
if (!typing) { log("nothing is being typed", "w"); return; }
send("R"); // keys up, and the board drops the rest of the run
abortTyping("stop typing pressed");
});
for (const id of ["paste", "stripBad", "trailingEnter"]) {
$(id).addEventListener("input", refreshSendBox);
$(id).addEventListener("change", refreshSendBox);
}
$("logKeys").addEventListener("change", () => {
log("key logging " + ($("logKeys").checked ? "on" : "off"));
});
$("logClear").addEventListener("click", () => { logEl.textContent = ""; });
$("connect").addEventListener("click", connect);
$("token").addEventListener("keydown", ev => { if (ev.key === "Enter") connect(); });
$("keepArmed").addEventListener("change", () => {
log("stay-armed-on-blur " + ($("keepArmed").checked ? "ON (not recommended)" : "off"));
});
/* Reconnect loop, and a keepalive so the board can tell a sleeping laptop from a
* living one (it disarms on silence, see config.h kNoClientTimeoutMs). */
setInterval(() => {
if ((!ws || ws.readyState !== WebSocket.OPEN) && Date.now() >= reconnectAt && tokenValue()) connect();
if (ws && ws.readyState === WebSocket.OPEN) send("K");
}, 2000);
$("token").value = sessionStorage.getItem("keyrelay.token") || "";
if (sessionStorage.getItem("keyrelay.token")) connect();
else log("enter the board's passphrase, then press connect");
paintHeld();
refreshSendBox();
</script>
</body>
</html>
)HTML";
constexpr size_t kIndexHtmlLen = sizeof(kIndexHtml) - 1; // without the NUL
} // namespace kr
+443
View File
@@ -0,0 +1,443 @@
/**
* keyrelay -- the HTTP upgrade handshake and the RFC 6455 frame codec.
*
* Pure logic (no WiFi, no sockets) so the same code paths run in the host tests
* (tests/test_ws_codec.cpp) and on the board. control_server.cpp owns nothing but
* bytes in and bytes out.
*
* Why hand-rolled instead of a WebSocket library
* ---------------------------------------------
* The control channel is one controller, a handful of tiny text commands, and a
* 1 Hz status line. A library buys fragmentation, permessage-deflate, TLS and
* many clients -- all of which is attack surface and none of which this needs.
* What is implemented here is exactly the subset the controller uses, and every
* deviation from it is a hard error that closes the socket:
*
* - only text, ping, pong and close frames; binary is an error
* - no fragmentation (an unfinised data frame is an error, not a reassembly)
* - client frames MUST be masked (RFC 6455 5.1); an unmasked one is an error
* - reserved bits must be clear; no extensions are negotiated
* - a payload longer than the caller's buffer is an error, never a truncation
*/
#pragma once
#include <stdint.h>
#include <stddef.h>
#include <string.h>
#include "sha1.h"
namespace kr {
namespace ws {
// RFC 6455 1.3: the fixed GUID appended to Sec-WebSocket-Key before hashing.
constexpr char kGuid[] = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
enum Opcode : uint8_t {
kOpContinuation = 0x0,
kOpText = 0x1,
kOpBinary = 0x2,
kOpClose = 0x8,
kOpPing = 0x9,
kOpPong = 0xA,
};
// ---------------------------------------------------------------------------
// Handshake
// ---------------------------------------------------------------------------
/**
* Sec-WebSocket-Accept for a Sec-WebSocket-Key: base64(SHA1(key + GUID)).
* Writes up to `outCap` (32 bytes is enough: 27 chars + NUL, worst case 28 for a
* 20-byte digest). Returns false, and leaves nothing half-written, if the buffer
* is too small or the key is empty.
*/
inline bool acceptFor(const char *key, size_t keyLen, char *out, size_t outCap) {
if (!key || keyLen == 0 || keyLen > 128 || outCap < 32) return false;
char joined[160];
if (keyLen + sizeof(kGuid) - 1 > sizeof(joined)) return false;
memcpy(joined, key, keyLen);
memcpy(joined + keyLen, kGuid, sizeof(kGuid) - 1);
uint8_t digest[20];
sha1(joined, keyLen + sizeof(kGuid) - 1, digest);
return base64Encode(digest, sizeof(digest), out, outCap) > 0;
}
// ---------------------------------------------------------------------------
// Outbound frames (server -> client is never masked, per RFC 6455 5.1)
// ---------------------------------------------------------------------------
/**
* Encode one unmasked frame. Returns the total frame length, or 0 if `cap` is
* too small (in which case nothing was written).
*/
inline size_t encodeFrame(uint8_t opcode, const void *payload, size_t len, uint8_t *out,
size_t cap) {
const bool extended16 = len >= 126 && len <= 0xFFFF;
const size_t header = 2 + (extended16 ? 2 : 0) + (len > 0xFFFF ? 8 : 0);
if (len > 0xFFFF) return 0; // controller messages are tiny
if (!out || cap < header + len) return 0;
size_t o = 0;
out[o++] = static_cast<uint8_t>(0x80 | (opcode & 0x0F)); // FIN set, no RSV
if (len < 126) {
out[o++] = static_cast<uint8_t>(len);
} else {
out[o++] = 126;
out[o++] = static_cast<uint8_t>(len >> 8);
out[o++] = static_cast<uint8_t>(len);
}
if (len) memcpy(out + o, payload, len);
return header + len;
}
inline size_t encodeText(const char *text, size_t len, uint8_t *out, size_t cap) {
return encodeFrame(kOpText, text, len, out, cap);
}
// ---------------------------------------------------------------------------
// Inbound frames
// ---------------------------------------------------------------------------
enum class In : uint8_t { NeedMore, Text, Ping, Pong, Close, Error };
enum class Err : uint8_t {
None = 0,
NotMasked, // a client frame without the mask bit
ReservedBits, // RSV1..3 set: an extension was negotiated by nobody
TooLong, // payload larger than the caller's buffer
Fragmented, // FIN clear on a data frame: reassembly is deliberately absent
BadOpcode, // binary, continuation, or anything unassigned
ControlTooLong, // control payload > 125 bytes (RFC 6455 5.5)
BadControlLen, // 16-bit extended length on a control frame
};
inline const char *describe(Err e) {
switch (e) {
case Err::NotMasked: return "frame not masked";
case Err::ReservedBits: return "reserved bits set";
case Err::TooLong: return "frame too long";
case Err::Fragmented: return "fragmented frame not supported";
case Err::BadOpcode: return "unsupported opcode";
case Err::ControlTooLong: return "control frame too long";
case Err::BadControlLen: return "control frame length encoding";
default: return "ok";
}
}
/**
* Incremental inbound frame decoder: feed one byte at a time (which is exactly
* what a non-blocking WiFiClient gives you) and act on the return value.
*
* The payload lands unmasked in the caller's buffer; `payloadLen()` says how much.
* Call reset() after handling a frame to decode the next one.
*/
class Reader {
public:
Reader(uint8_t *buf, size_t cap) : _buf(buf), _cap(cap) { reset(); }
void reset() {
_state = State::Opcode;
_opcode = 0;
_fin = false;
_masked = false;
_pending = 0;
_extNeeded = 0;
_maskPos = 0;
_got = 0;
_error = Err::None;
_done = Done::Nothing; // without this the reader is stuck on the old frame
}
// Consume one byte. In::NeedMore until a frame is complete, then the frame's
// type once (subsequent feeds keep returning it until reset()).
In feed(uint8_t b) {
if (_done != Done::Nothing) return _done == Done::Failed ? In::Error : frameResult();
switch (_state) {
case State::Opcode: {
_fin = (b & 0x80) != 0;
if (b & 0x70) return fail(Err::ReservedBits);
_opcode = b & 0x0F;
if (_opcode != kOpText && _opcode != kOpPing && _opcode != kOpPong &&
_opcode != kOpClose) {
return fail(Err::BadOpcode); // binary / continuation / unknown
}
// No reassembly anywhere: a fragmented message would have to be buffered
// per opcode, and a controller that cannot send one command per frame is
// not a controller this bridge wants to talk to.
if (!_fin) return fail(Err::Fragmented);
_state = State::Length;
return In::NeedMore;
}
case State::Length: {
_masked = (b & 0x80) != 0;
const uint8_t len7 = b & 0x7F;
const bool control = (_opcode & 0x08) != 0;
if (control && len7 >= 126) return fail(Err::ControlTooLong);
if (len7 < 126) {
_pending = len7;
return startPayload();
}
if (len7 == 126) {
_extNeeded = 2;
_pending = 0;
_state = State::Ext;
return In::NeedMore;
}
_extNeeded = 8; // 127: 64-bit length
_pending = 0;
_state = State::Ext;
return In::NeedMore;
}
case State::Ext: {
_pending = (_pending << 8) | b;
if (--_extNeeded == 0) {
const bool control = (_opcode & 0x08) != 0;
if (control) return fail(Err::BadControlLen);
return startPayload();
}
return In::NeedMore;
}
case State::MaskKey:
_mask[_maskPos++ & 3] = b;
if ((_maskPos & 3) == 0) {
// A masked frame carries its mask key even with an empty payload, so an
// empty frame is complete the moment the key has been read.
if (_pending == 0) {
_state = State::Idle;
_done = Done::Frame;
return frameResult();
}
_state = State::Payload;
}
return In::NeedMore;
case State::Payload: {
// A frame larger than the buffer can never be accepted: partial delivery
// would look like a truncated command to the parser.
if (_got >= _cap) return fail(Err::TooLong);
// The mask index uses the index BEFORE the increment; do it in two
// statements -- `_buf[_got++] = b ^ _mask[_got & 3]` reads and writes _got
// without a sequence point, which is undefined behaviour (and was, until
// -Wsequence-point pointed at it).
const uint8_t plain = static_cast<uint8_t>(b ^ _mask[_got & 3]);
_buf[_got] = plain;
_got++;
if (_got == _pending) {
_state = State::Idle;
_done = Done::Frame;
return frameResult();
}
return In::NeedMore;
}
default:
return In::NeedMore;
}
}
// Convenience: feed a whole buffer; stops at the first completed frame so the
// caller can act on it and come back for the rest.
In feedAll(const uint8_t *data, size_t len, size_t *consumed = nullptr) {
for (size_t i = 0; i < len; i++) {
const In r = feed(data[i]);
if (consumed) *consumed = i + 1;
if (r != In::NeedMore) return r;
}
return In::NeedMore;
}
uint8_t opcode() const { return _opcode; }
const uint8_t *payload() const { return _buf; }
size_t payloadLen() const { return _got; }
Err error() const { return _error; }
bool finished() const { return _done != Done::Nothing; }
private:
enum class State : uint8_t { Idle, Opcode, Length, Ext, MaskKey, Payload };
enum class Done : uint8_t { Nothing, Frame, Failed };
In frameResult() const {
switch (_opcode) {
case kOpText: return In::Text;
case kOpPing: return In::Ping;
case kOpPong: return In::Pong;
default: return In::Close;
}
}
In startPayload() {
if (!_masked) return fail(Err::NotMasked); // browsers always mask; nobody else should get in
if (_pending > _cap) return fail(Err::TooLong);
// Always read the 4 mask bytes first -- even for a zero-length frame.
_maskPos = 0;
_got = 0;
_state = State::MaskKey;
return In::NeedMore;
}
In fail(Err e) {
_error = e;
_done = Done::Failed;
_state = State::Idle;
return In::Error;
}
uint8_t *_buf;
size_t _cap;
State _state;
uint8_t _opcode;
bool _fin;
bool _masked;
uint64_t _pending;
uint8_t _extNeeded;
uint8_t _mask[4];
uint16_t _maskPos;
size_t _got;
Err _error;
Done _done;
};
// ---------------------------------------------------------------------------
// The upgrade request
// ---------------------------------------------------------------------------
struct Request {
char method[8];
char path[96];
char query[96];
char wsKey[64];
bool upgradeWebsocket;
bool complete; // the full header block (CRLF CRLF) was seen
};
inline void initRequest(Request &r) {
r.method[0] = r.path[0] = r.query[0] = r.wsKey[0] = '\0';
r.upgradeWebsocket = false;
r.complete = false;
}
inline char lower(char c) { return (c >= 'A' && c <= 'Z') ? char(c + 32) : c; }
inline bool ciEquals(const char *a, const char *b, size_t n) {
for (size_t i = 0; i < n; i++) {
if (lower(a[i]) != b[i]) return false;
}
return true;
}
inline bool containsCi(const char *hay, size_t hayLen, const char *needle) {
const size_t n = strlen(needle);
if (n > hayLen) return false;
for (size_t i = 0; i + n <= hayLen; i++) {
if (ciEquals(hay + i, needle, n)) return true;
}
return false;
}
/**
* Parse as much of an HTTP request header block as is buffered.
* Returns 0 when more bytes are needed, 1 on a parsed request, -1 when it is
* malformed or the header block is larger than `cap` (reject, don't buffer).
*
* `len` bytes of `buf` are inspected. Only the fields the bridge routes on are
* extracted: method, path, query, the Upgrade token and Sec-WebSocket-Key.
*/
inline int parseRequest(const char *buf, size_t len, Request &out) {
initRequest(out);
if (len < 4) return 0;
const char *end = nullptr;
for (size_t i = 0; i + 3 < len; i++) {
if (buf[i] == '\r' && buf[i + 1] == '\n' && buf[i + 2] == '\r' && buf[i + 3] == '\n') {
end = buf + i;
break;
}
}
if (!end) return 0; // headers incomplete
out.complete = true;
const size_t blockLen = static_cast<size_t>(end - buf);
// Request line: METHOD SP TARGET SP HTTP/x.y
const char *sp1 = static_cast<const char *>(memchr(buf, ' ', blockLen));
if (!sp1) return -1;
const size_t mLen = static_cast<size_t>(sp1 - buf);
if (mLen == 0 || mLen >= sizeof(out.method)) return -1;
memcpy(out.method, buf, mLen);
out.method[mLen] = '\0';
const char *target = sp1 + 1;
const char *sp2 = static_cast<const char *>(memchr(target, ' ', blockLen - mLen - 1));
if (!sp2) return -1;
size_t tLen = static_cast<size_t>(sp2 - target);
const char *q = static_cast<const char *>(memchr(target, '?', tLen));
size_t pLen = q ? static_cast<size_t>(q - target) : tLen;
if (pLen >= sizeof(out.path)) pLen = sizeof(out.path) - 1;
memcpy(out.path, target, pLen);
out.path[pLen] = '\0';
if (q) {
size_t qLen = tLen - pLen - 1;
if (qLen >= sizeof(out.query)) qLen = sizeof(out.query) - 1;
memcpy(out.query, q + 1, qLen);
out.query[qLen] = '\0';
}
// Header lines, case-insensitively, looking only for the two we act on.
const char *nl = static_cast<const char *>(memchr(buf, '\n', blockLen));
if (!nl) return -1; // request line not terminated
const char *p = nl + 1;
while (p < end) {
const char *ls = p;
const char *eol = ls;
while (eol < end && *eol != '\r' && *eol != '\n') eol++;
const size_t n = static_cast<size_t>(eol - ls);
if (n >= 8 && ciEquals(ls, "upgrade:", 8)) {
if (containsCi(ls + 8, n - 8, "websocket")) out.upgradeWebsocket = true;
} else if (n >= 18 && ciEquals(ls, "sec-websocket-key:", 18)) {
const char *v = ls + 18;
size_t vn = n - 18;
while (vn && (*v == ' ' || *v == '\t')) { v++; vn--; }
while (vn && (v[vn - 1] == ' ' || v[vn - 1] == ' ' || v[vn - 1] == '\t')) vn--;
if (vn >= sizeof(out.wsKey)) return -1;
memcpy(out.wsKey, v, vn);
out.wsKey[vn] = '\0';
}
p = eol;
while (p < end && (*p == '\r' || *p == '\n')) p++;
}
return 1;
}
/**
* Pull `token=` out of a query string. Returns false when absent. Values are
* taken verbatim (the passphrase alphabet is [A-Za-z0-9._-] by convention; no
* percent-decoding happens here on purpose, so a token containing '%' simply
* cannot be supplied through the query string -- the WebSocket frame path takes
* it verbatim and is the one the page uses).
*/
inline bool tokenFromQuery(const char *query, size_t len, char *out, size_t cap) {
const char *k = "token=";
const size_t kl = strlen(k);
if (len < kl) return false;
for (size_t i = 0; i + kl <= len; i++) {
if (memcmp(query + i, k, kl) != 0) continue;
if (i != 0 && query[i - 1] != '&') continue;
const char *v = query + i + kl;
size_t vn = 0;
while (i + kl + vn < len && v[vn] != '&') vn++;
if (vn == 0 || vn >= cap) return false;
memcpy(out, v, vn);
out[vn] = '\0';
return true;
}
return false;
}
} // namespace ws
} // namespace kr
+143
View File
@@ -0,0 +1,143 @@
#!/usr/bin/env bash
# Host tests for keyrelay's pure logic modules.
#
# 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.
#
# A C++ compiler is not always installed on a workstation, so the runner picks one:
# $CXX if you set it, it wins
# g++ on PATH native, fastest
# a local container image podman + the first image that has g++ (see images
# below), with this repo bind-mounted read-write into
# /work -- no system packages installed for a test run
set -uo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
root="$(dirname "$here")"
out="$here/build"
if [[ "${1:-}" == "clean" ]]; then
rm -rf "$out"
echo "cleaned $out"
exit 0
fi
# Candidate images, tried in order, that already exist locally. Add your own here.
images=(
"localhost/ai-sandbox:ubuntu-9a08b1d500b0"
"docker.io/library/gcc:14"
)
suites=(test_keystate test_proto test_text test_ws_codec)
build_and_run() {
local cxx="$1"
local prefix=("$cxx")
local flags=(-std=gnu++17 -O1 -g -Wall -Wextra -Wno-unused-function -Isrc -Itests)
mkdir -p "$out"
if [[ -n "${SANITIZE:-}" ]]; then
flags+=(-fsanitize=address,undefined -fno-omit-frame-pointer)
echo "sanitizers: on"
fi
local failed=0 suite
for suite in "${suites[@]}"; do
if ! "${prefix[@]}" "${flags[@]}" "tests/$suite.cpp" -o "$out/$suite" \
2>"$out/$suite.log"; then
echo "BUILD FAILED: $suite"
cat "$out/$suite.log"
failed=1
continue
fi
[[ -s "$out/$suite.log" ]] && cat "$out/$suite.log"
"$out/$suite" || failed=1
done
# The Python side (the CLI client's WebSocket codec, and its key table against
# the one embedded in the controller page) needs only stdlib.
if command -v python3 >/dev/null 2>&1; then
python3 tests/test_relayctl.py || failed=1
else
echo "python3 not found: skipped tests/test_relayctl.py"
fi
return $failed
}
# The controller page is a C++ raw string, so no compiler looks at its JavaScript and
# a typo there fails in somebody's browser instead of in a build. Node runs it -- on
# the host, deliberately, because the container that supplies g++ need not have node.
run_page_suite() {
if command -v node >/dev/null 2>&1; then
node "$here/test_page.js" || return 1
else
echo "node not found: skipped tests/test_page.js"
fi
return 0
}
if [[ "${1:-}" == "--inside" ]]; then
cd "$root" || exit 2
# The container has g++ but not necessarily node; the page suite skips itself.
build_and_run "${CXX:-g++}"
rc=$?
if [[ -z "${KR_QUIET_RESULT:-}" ]]; then
if ((rc)); then echo "RESULT: FAILED"; else echo "RESULT: all suites passed"; fi
fi
exit $rc
fi
if [[ -n "${CXX:-}" ]]; then
echo "compiler: \$CXX = $CXX"
(cd "$root" && build_and_run "$CXX")
rc=$?
(cd "$root" && run_page_suite) || rc=1
if ((rc)); then echo "RESULT: FAILED"; else echo "RESULT: all suites passed"; fi
exit $rc
fi
if command -v g++ >/dev/null 2>&1; then
echo "compiler: $(command -v g++)"
(cd "$root" && build_and_run g++)
rc=$?
(cd "$root" && run_page_suite) || rc=1
if ((rc)); then echo "RESULT: FAILED"; else echo "RESULT: all suites passed"; fi
exit $rc
fi
if command -v podman >/dev/null 2>&1; then
for img in "${images[@]}"; do
if ! timeout 60 podman image exists "$img" 2>/dev/null; then
continue
fi
if ! timeout 60 podman run --rm --entrypoint sh "$img" -c 'command -v g++' \
>/dev/null 2>&1; then
continue
fi
echo "compiler: g++ inside $img (repo mounted at /work)"
# :z relabels for SELinux; the build output lands in tests/build as usual.
# KR_QUIET_RESULT: the host prints the one summary, after its own page suite.
podman run --rm --entrypoint bash -v "$root:/work:z" \
-e "SANITIZE=${SANITIZE:-}" -e KR_QUIET_RESULT=1 \
"$img" /work/tests/run_tests.sh --inside
rc=$?
(cd "$root" && run_page_suite) || rc=1
if ((rc)); then echo "RESULT: FAILED"; exit 1; fi
echo "RESULT: all suites passed"
exit 0
done
fi
cat >&2 <<EOF
No C++ compiler found.
install one e.g. sudo dnf install gcc-c++ (then re-run)
or set \$CXX e.g. CXX=/path/to/g++ tests/run_tests.sh
or add an image add a local podman image with g++ to images[] above
EOF
exit 2
+49
View File
@@ -0,0 +1,49 @@
// Minimal assertion harness for the host tests. No dependencies, prints the
// failing expression with file:line, and exits non-zero on any failure so
// tests/run_tests.sh is usable as a gate.
#pragma once
#include <cstdio>
#include <cstring>
static int g_checks = 0;
static int g_failures = 0;
static const char *g_section = "unset";
#define CASE(name) (g_section = (name))
#define CHECK(cond) \
do { \
g_checks++; \
if (!(cond)) { \
g_failures++; \
std::printf(" FAIL %s:%d [%s] %s\n", __FILE__, __LINE__, \
g_section, #cond); \
} \
} while (0)
#define CHECK_EQ(actual, expected) \
do { \
g_checks++; \
const long long _a = (long long)(actual), _e = (long long)(expected); \
if (_a != _e) { \
g_failures++; \
std::printf(" FAIL %s:%d [%s] %s == %s : got %lld, want %lld\n", __FILE__, \
__LINE__, g_section, #actual, #expected, _a, _e); \
} \
} while (0)
#define CHECK_STR(actual, expected) \
do { \
g_checks++; \
if (std::strcmp((actual), (expected)) != 0) { \
g_failures++; \
std::printf(" FAIL %s:%d [%s] %s == %s : got \"%s\", want \"%s\"\n", \
__FILE__, __LINE__, g_section, #actual, #expected, (actual), \
(expected)); \
} \
} while (0)
static int report(const char *suite) {
std::printf("%-24s %3d checks, %d failures\n", suite, g_checks, g_failures);
return g_failures == 0 ? 0 : 1;
}
+202
View File
@@ -0,0 +1,202 @@
// tests for the key-state tracker: tests/test_keystate.cpp
//
// The properties under test are the ones that decide whether the target machine
// ends up with a key stuck down: idempotence, transition preservation, rollover,
// and that clear() really clears.
#include <cstring>
#include "keystate.h"
#include "test.h"
using kr::KeyboardReport;
using kr::KeyboardState;
namespace {
// A report as 8 hex-byte triples, for readable assertions.
void hexreport(const KeyboardReport &r, char out[32]) {
static const char kHex[] = "0123456789abcdef";
const uint8_t bytes[8] = {r.modifiers, r.reserved, r.keys[0], r.keys[1], r.keys[2],
r.keys[3], r.keys[4], r.keys[5]};
for (int i = 0; i < 8; i++) {
out[3 * i] = kHex[bytes[i] >> 4];
out[3 * i + 1] = kHex[bytes[i] & 0xF];
out[3 * i + 2] = i == 7 ? '\0' : ' ';
}
out[23] = '\0';
}
} // namespace
int main() {
char h1[32], h2[32];
CASE("empty state is all zeroes");
{
KeyboardState s;
CHECK(!s.dirty());
hexreport(s.report(), h1);
CHECK_STR(h1, "00 00 00 00 00 00 00 00");
}
CASE("reserved byte is always zero");
{
KeyboardState s;
const uint8_t keys[] = {kr::kUsageA};
s.apply(kr::kModLctrl | kr::kModRgui, keys, 1);
CHECK_EQ(s.report().reserved, 0);
CHECK_EQ(s.report().modifiers, kr::kModLctrl | kr::kModRgui);
}
CASE("apply is idempotent -- same state twice dirties nothing");
{
KeyboardState s;
const uint8_t keys[] = {kr::kUsageA, kr::kUsageZ};
s.apply(kr::kModLshift, keys, 2);
CHECK(s.dirty());
s.ackDirty();
s.apply(kr::kModLshift, keys, 2);
CHECK(!s.dirty());
s.apply(kr::kModLshift, keys, 2);
CHECK(!s.dirty());
}
CASE("duplicates collapse, order is preserved");
{
KeyboardState s;
const uint8_t keys[] = {kr::kUsageA, kr::kUsageA, kr::kUsageZ};
s.apply(0, keys, 3);
CHECK_EQ(s.held(), 2u);
hexreport(s.report(), h1);
CHECK_STR(h1, "00 00 04 1d 00 00 00 00");
CHECK(s.isDown(kr::kUsageA));
CHECK(s.isDown(kr::kUsageZ));
}
CASE("usage 0x00 is not a key");
{
KeyboardState s;
const uint8_t keys[] = {0x00, kr::kUsageEnter, 0x00};
s.apply(0, keys, 3);
CHECK_EQ(s.held(), 1u);
hexreport(s.report(), h1);
CHECK_STR(h1, "00 00 28 00 00 00 00 00");
}
CASE("a distinct transition always produces a distinct report");
{
// A fast tap: press then release. Both states have to be reportable, or the
// keypress vanishes and the target never sees the character.
KeyboardState s;
const uint8_t down[] = {kr::kUsageA};
s.apply(0, down, 1);
CHECK(s.dirty());
s.ackDirty();
const uint8_t none = 0;
s.apply(0, &none, 0);
CHECK(s.dirty());
hexreport(s.report(), h1);
CHECK_STR(h1, "00 00 00 00 00 00 00 00");
}
CASE("modifiers alone are a reportable change");
{
KeyboardState s;
s.apply(kr::kModLalt, nullptr, 0);
CHECK(s.dirty());
CHECK_EQ(s.modifiers(), kr::kModLalt);
s.apply(0, nullptr, 0);
CHECK(s.dirty());
}
CASE("six keys is exactly the limit");
{
KeyboardState s;
const uint8_t keys[] = {0x04, 0x05, 0x06, 0x07, 0x08, 0x09};
s.apply(0, keys, 6);
CHECK(!s.rollover());
CHECK_EQ(s.held(), 6u);
hexreport(s.report(), h1);
CHECK_STR(h1, "00 00 04 05 06 07 08 09");
}
CASE("a seventh key reports ErrorRollOver in every slot");
{
KeyboardState s;
const uint8_t keys[] = {0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A};
s.apply(0, keys, 7);
CHECK(s.rollover());
hexreport(s.report(), h1);
CHECK_STR(h1, "00 00 01 01 01 01 01 01");
// ... and releasing back under the limit reports the real keys again, so the
// host is never left with a phantom key held down.
const uint8_t six[] = {0x04, 0x05, 0x06, 0x07, 0x08, 0x09};
s.apply(0, six, 6);
CHECK(!s.rollover());
hexreport(s.report(), h2);
CHECK_STR(h2, "00 00 04 05 06 07 08 09");
}
CASE("more keys than we track still cannot wedge the state");
{
KeyboardState s;
uint8_t keys[40];
for (size_t i = 0; i < sizeof(keys); i++) keys[i] = static_cast<uint8_t>(0x04 + (i % 0x1A));
s.apply(0xFF, keys, sizeof(keys));
CHECK(s.rollover());
CHECK(s.held() <= kr::kMaxTrackedKeys);
s.clear();
CHECK_EQ(s.held(), 0u);
CHECK_EQ(s.modifiers(), 0);
}
CASE("clear releases everything; a repeat clear adds nothing");
{
KeyboardState s;
const uint8_t keys[] = {kr::kUsageA, kr::kUsageSpace};
s.apply(kr::kModLctrl | kr::kModLshift, keys, 2);
s.ackDirty();
s.clear();
CHECK(s.dirty()); // the all-keys-up report still has to go out
hexreport(s.report(), h1);
CHECK_STR(h1, "00 00 00 00 00 00 00 00");
s.ackDirty();
s.clear();
CHECK(!s.dirty()); // nothing changed, so no pointless second report
s.apply(kr::kModLctrl, nullptr, 0);
CHECK(s.dirty()); // ... and a real change after a clear is still seen
}
CASE("makeReport builds the Ctrl+Alt+Del chord byte-exactly");
{
const uint8_t del = kr::kUsageDelForward;
const KeyboardReport r =
kr::makeReport(kr::kModLctrl | kr::kModLalt, &del, 1);
hexreport(r, h1);
// 05 = LCtrl|LAlt, 4C = "Delete Forward". This is the exact sequence Windows
// accepts as the secure attention sequence and logind sees as CAD.
CHECK_STR(h1, "05 00 4c 00 00 00 00 00");
}
CASE("makeReport rolls over on more than six keys");
{
const uint8_t keys[] = {0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A};
const KeyboardReport r = kr::makeReport(0, keys, 7);
CHECK_EQ(r.keys[0], kr::kUsageErrorRollover);
CHECK_EQ(r.keys[5], kr::kUsageErrorRollover);
}
CASE("reportsEqual distinguishes modifiers and slots");
{
KeyboardReport a = {0, 0, {4, 5, 0, 0, 0, 0}};
KeyboardReport b = a;
CHECK(kr::reportsEqual(a, b));
b.modifiers = kr::kModLshift;
CHECK(!kr::reportsEqual(a, b));
b = a;
b.keys[2] = 9;
CHECK(!kr::reportsEqual(a, b));
}
return report("keystate");
}
+278
View File
@@ -0,0 +1,278 @@
// tests/test_page.js -- behaviour tests for the controller page's script.
//
// node tests/test_page.js
//
// The page is the primary interface, and it lives inside a C++ raw string where no
// compiler can see it: a typo there does not fail a build, it fails silently in
// somebody's browser while a target is live. So the script is extracted from
// src/web_page.h and run against a minimal DOM shim -- enough to exercise the rules
// that matter, which are what the log is allowed to contain, which characters may
// leave the tab, and when a run of pasted text stops.
//
// It is a behaviour test, not a rendering test: no CSS, no layout, no real keyboard.
// tests/run_tests.sh skips it when node is not installed.
"use strict";
const fs = require("fs");
const path = require("path");
const vm = require("vm");
// ---------------------------------------------------------------------------
// A DOM small enough to read, complete enough to run the page.
// ---------------------------------------------------------------------------
function makeEl(tagName) {
const el = {
tagName: (tagName || "div").toUpperCase(), dataset: {}, className: "",
textContent: "", innerHTML: "", value: "", checked: false, disabled: false,
readOnly: false, children: [], listeners: {},
scrollTop: 0, scrollHeight: 0,
classList: { toggle() {}, add() {}, remove() {}, contains() { return false; } },
get firstChild() { return this.children[0] || null; },
get firstElementChild() { return this.children.find(c => c.tagName) || null; },
get lastElementChild() {
const e = this.children.filter(c => c.tagName);
return e.length ? e[e.length - 1] : null;
},
get childElementCount() { return this.children.filter(c => c.tagName).length; },
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); },
getAttribute() { return null; }, setAttribute() {}, focus() {},
};
return el;
}
// 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
// end. A mistyped id is a null dereference in a browser, and silence here.
const registry = new Map();
const requestedIds = new Set();
const byId = id => {
requestedIds.add(id);
if (!registry.has(id)) registry.set(id, makeEl("div"));
return registry.get(id);
};
const sentFrames = [];
const idleStatus = () => x.onFrame('S{"armed":1,"usb":1,"typing":0}');
const busyStatus = n => x.onFrame(`S{"armed":1,"usb":1,"typing":${n}}`);
class FakeWebSocket {
constructor() {
this.readyState = 1; // OPEN
FakeWebSocket.instances.push(this);
setTimeout(() => this.onopen && this.onopen(), 0);
}
send(text) { sentFrames.push(text); }
close() { this.readyState = 3; }
}
FakeWebSocket.OPEN = 1;
FakeWebSocket.instances = [];
const sandbox = {
document: {
getElementById: byId,
createElement: t => makeEl(t),
querySelectorAll: () => [],
addEventListener() {},
body: makeEl("body"),
hidden: false,
},
window: { addEventListener() {} },
location: { protocol: "http:", host: "keyrelay.local" },
sessionStorage: { getItem: () => null, setItem() {} },
WebSocket: FakeWebSocket,
setInterval: () => 0, // the reconnect/keepalive loop stays shut in a test
setTimeout, clearTimeout, Date, JSON, Math, Promise, TextEncoder, console,
Number, String, Object, Array, Buffer, parseInt,
};
// The page keeps its state in script-scope `let`s, so the harness reaches into that
// scope once, here, instead of duplicating the page's logic in the test.
const expose = `
;globalThis.__x = {
log, logEl, untypeableChars, hexify, describePress, inPageField, pasteText,
sendPaste, refreshSendBox, setArmed, abortTyping, onFrame, connect,
TEXT_FRAME_MAX, TEXT_JOB_MAX, TEXT_PACE_MS,
get ws() { return ws; },
get armed() { return armed; }, set armed(v) { armed = v; },
get authed() { return authed; }, set authed(v) { authed = v; },
get typing() { return typing; },
};`;
const headerPath = path.join(__dirname, "..", "src", "web_page.h");
const headerText = fs.readFileSync(headerPath, "utf8");
const from = headerText.indexOf("<script>");
const to = headerText.indexOf("<\/script>", from);
if (from < 0 || to < 0) {
console.error("no <script> block in " + headerPath);
process.exit(1);
}
const pageScript = headerText.slice(from + "<script>".length, to);
const context = vm.createContext(sandbox);
vm.runInContext(pageScript + expose, context);
const x = sandbox.__x;
const logEl = x.logEl;
// ---------------------------------------------------------------------------
let checks = 0, failures = 0;
const check = (cond, label) => {
checks++;
if (!cond) { failures++; console.log(" FAIL " + label); }
};
const checkEq = (a, b, label) => {
checks++;
if (a !== b) {
failures++;
console.log(` FAIL ${label}: got ${JSON.stringify(a)}, want ${JSON.stringify(b)}`);
}
};
const frames = () => sentFrames.filter(f => f.startsWith("P="));
const textOf = f => Buffer.from(f.slice(f.indexOf(":") + 1), "hex").toString();
const wait = ms => new Promise(r => setTimeout(r, ms));
console.log("the log stays readable");
logEl.children = []; // the page logs a prompt at load; start clean
x.log("");
x.log(" ");
x.log("\n\t ");
x.log(undefined);
x.log(null);
checkEq(logEl.childElementCount, 0,
"empty, whitespace-only and missing messages never render a line");
x.log("first line");
checkEq(logEl.childElementCount, 1, "a real message renders");
x.log("first line");
x.log("first line");
checkEq(logEl.childElementCount, 1, "an identical line is not repeated");
check(/×3$/.test(logEl.lastElementChild.textContent), "the repeat is counted, not stacked");
x.log("second line");
checkEq(logEl.childElementCount, 2, "a different message still gets its own line");
for (let i = 0; i < 400; i++) x.log("flood " + i);
check(logEl.childElementCount <= 150, "the log is bounded, so it cannot grow all day");
console.log("characters");
checkEq(x.untypeableChars("hello world\n").length, 0, "plain text and newlines are fine");
checkEq(x.untypeableChars("\t\ta\t").length, 0, "tabs are fine");
checkEq(x.untypeableChars("café").join(""), "é", "an accent is caught");
checkEq(x.untypeableChars("a\u2019b").join(""), "\u2019", "a curly quote is caught");
checkEq(x.untypeableChars("\u{1f600}").length, 1, "an astral character is caught");
byId("paste").value = "line1\r\nline2";
checkEq(x.pasteText(), "line1\nline2", "CRLF becomes one Enter, not two");
byId("paste").value = "a\rb";
checkEq(x.pasteText(), "a\nb", "and so does a lone CR");
checkEq(x.hexify("hi"), "6869", "hex encoding");
checkEq(x.hexify("a\n"), "610a", "a newline is one byte on the wire");
console.log("key names in the log");
checkEq(x.describePress("KeyA"), "KeyA", "a bare key is named by its code name");
checkEq(x.describePress("Enter"), "Enter", "Enter is named");
checkEq(x.describePress("ArrowRight"), "ArrowRight", "arrows are named");
checkEq(x.inPageField({ target: { tagName: "TEXTAREA" } }), true, "the text box is the page's own");
checkEq(x.inPageField({ target: { tagName: "INPUT" } }), true, "the passphrase box is too");
checkEq(!!x.inPageField({ target: { tagName: "BODY" } }), false, "the page itself forwards");
checkEq(!!x.inPageField({ target: null }), false, "a missing target does not throw");
(async () => {
// Bring the link up the way the page does, so send()'s guard is the real guard.
byId("token").value = "hunter2";
x.connect();
await wait(5); // onopen fires, and clears `authed`
x.authed = true;
x.armed = true;
byId("trailingEnter").checked = false;
byId("stripBad").checked = false;
console.log("sending text");
byId("paste").value = "hello";
sentFrames.length = 0;
let p = x.sendPaste();
await wait(5);
checkEq(frames().length, 1, "the frame goes out once the board is idle");
checkEq(frames()[0], "P=12:68656c6c6f", "carrying the text and the requested pace");
idleStatus();
await p;
checkEq(x.typing, false, "and the run reports itself finished");
// A paste longer than one frame: the board's `typing` count gates the next piece,
// so a board that is still busy must not be handed more to type.
const long = "x".repeat(300);
byId("paste").value = long;
sentFrames.length = 0;
p = x.sendPaste();
await wait(5);
checkEq(frames().length, 1, "one frame out while the board is idle");
busyStatus(118);
await wait(20);
checkEq(frames().length, 1, "the board saying it is still typing holds the next frame back");
for (let i = 0; i < 20 && x.typing; i++) { idleStatus(); await wait(3); }
await p;
const fs2 = frames();
checkEq(fs2.length, Math.ceil(300 / x.TEXT_FRAME_MAX), "300 characters is three frames");
for (const f of fs2) {
check(f.length <= 256, `every frame fits one WebSocket message (${f.length} bytes)`);
check(/^P=12:[0-9a-f]+$/.test(f), "every frame is well formed");
check(f.length <= 5 + 2 * x.TEXT_FRAME_MAX, "and none carries more than a frame's worth");
}
checkEq(fs2.map(f => f.slice(f.indexOf(":") + 1)).join("").length, 600,
"the frames between them carry every character exactly once");
checkEq(fs2.map(textOf).join(""), long, "and that is the text that was pasted");
console.log("refusing what it cannot type");
byId("paste").value = "bad\u2014text";
sentFrames.length = 0;
await x.sendPaste();
checkEq(frames().length, 0,
"an em dash refuses the whole paste -- nothing partial goes out");
byId("stripBad").checked = true;
sentFrames.length = 0;
p = x.sendPaste();
await wait(5);
for (let i = 0; i < 10 && x.typing; i++) { idleStatus(); await wait(3); }
await p;
checkEq(frames().map(textOf).join(""), "badtext", "and the opt-in drop sends the rest");
console.log("stopping");
byId("stripBad").checked = false;
byId("paste").value = "nope";
sentFrames.length = 0;
x.armed = false;
await x.sendPaste();
checkEq(frames().length, 0, "while disarmed, not one character is offered");
x.armed = true;
byId("paste").value = "y".repeat(300);
sentFrames.length = 0;
p = x.sendPaste();
await wait(5);
x.abortTyping("harness: stop typing");
idleStatus();
await p;
checkEq(frames().length, 1, "a stopped run does not send the frames that were left");
checkEq(x.typing, false, "and it ends, rather than hanging");
x.armed = true;
byId("paste").value = "z".repeat(300);
sentFrames.length = 0;
p = x.sendPaste();
await wait(5);
x.setArmed(false, "harness: disarmed mid-run"); // the board disarmed under us
idleStatus();
await p;
checkEq(frames().length, 1, "a disarm mid-run stops the run");
console.log("element ids");
const markup = fs.readFileSync(headerPath, "utf8");
const present = new Set();
for (const m of markup.matchAll(/id="([A-Za-z][A-Za-z0-9]*)"/g)) present.add(m[1]);
check(present.size > 15, `the markup's ids were parsed (${present.size})`);
const missing = [...requestedIds].filter(id => !present.has(id)).sort();
checkEq(missing.join(","), "",
"every element the script reached for exists in the page");
console.log(`page ${checks} checks, ${failures} failures`);
process.exit(failures ? 1 : 0);
})();
+367
View File
@@ -0,0 +1,367 @@
// tests for the control protocol: tests/test_proto.cpp
#include <cstdio>
#include <cstring>
#include <string>
#include "keystate.h"
#include "proto.h"
#include "test.h"
using kr::proto::Cmd;
using kr::proto::Message;
namespace {
Cmd parse(const char *s) {
static Message m;
kr::proto::initMessage(m);
const Cmd c = kr::proto::parse(s, strlen(s), m);
return c;
}
Cmd parse(const char *s, Message &m) { return kr::proto::parse(s, strlen(s), m); }
std::string readFile(const char *path) {
std::string out;
FILE *f = fopen(path, "rb");
if (!f) return out;
char buf[4096];
size_t n;
while ((n = fread(buf, 1, sizeof(buf), f)) > 0) out.append(buf, n);
fclose(f);
return out;
}
} // namespace
int main() {
CASE("single-character commands");
{
CHECK(parse("H") == Cmd::Hello);
CHECK(parse("K") == Cmd::KeepAlive);
CHECK(parse("R") == Cmd::ReleaseAll);
CHECK(parse("C") == Cmd::SecureAttention);
CHECK(parse("A1") == Cmd::Arm);
CHECK(parse("A0") == Cmd::Disarm);
// hand-typed clients end lines with \n: tolerated
CHECK(parse("A1\n") == Cmd::Arm);
CHECK(parse("R\r\n") == Cmd::ReleaseAll);
}
CASE("garbage is rejected, never half-accepted");
{
CHECK(parse("") == Cmd::Bad);
CHECK(parse("A") == Cmd::Bad); // arm/disarm needs its digit
CHECK(parse("A2") == Cmd::Bad);
CHECK(parse("A11") == Cmd::Bad);
CHECK(parse("HH") == Cmd::Bad);
CHECK(parse("Z") == Cmd::Bad);
CHECK(parse("hello") == Cmd::Bad);
CHECK(parse("S") == Cmd::Bad);
CHECK(parse("S=") == Cmd::Bad);
}
CASE("state message: modifiers and usages");
{
Message m;
CHECK(parse("S=00:", m) == Cmd::State);
CHECK_EQ(m.nUsages, 0); // "nothing pressed" is a legal state
CHECK_EQ(m.mods, 0);
CHECK(parse("S=04:28", m) == Cmd::State); // LeftAlt + Enter
CHECK_EQ(m.mods, 0x04);
CHECK_EQ(m.nUsages, 1);
CHECK_EQ(m.usages[0], 0x28);
CHECK(parse("S=0a:04,05,06", m) == Cmd::State);
CHECK_EQ(m.mods, 0x0A);
CHECK_EQ(m.nUsages, 3);
CHECK_EQ(m.usages[2], 0x06);
CHECK(parse("S=0A:AB,cd", m) == Cmd::State); // hex case is free
CHECK_EQ(m.mods, 0x0A);
CHECK_EQ(m.usages[0], 0xAB);
CHECK_EQ(m.usages[1], 0xCD);
}
CASE("state message: strict field syntax");
{
CHECK(parse("S=00:04,") == Cmd::Bad); // trailing comma
CHECK(parse("S=00:4") == Cmd::Bad); // usage must be two hex digits
CHECK(parse("S=00:04gg") == Cmd::Bad); // not hex
CHECK(parse("S=0:0400") == Cmd::Bad); // modifier byte must be two digits
CHECK(parse("S=00:00") == Cmd::Bad); // 0x00 is not a key
CHECK(parse("S=00 :04") == Cmd::Bad); // no spaces anywhere
CHECK(parse("S=00") == Cmd::Bad); // the colon is not optional
CHECK(parse("S =00:") == Cmd::Bad);
}
CASE("state message: bounds");
{
// exactly the cap is fine, one over is not
char big[128];
for (int n = 1; n <= static_cast<int>(kr::kMaxKeysPerMessage) + 2; n++) {
std::strcpy(big, "S=00:");
size_t o = std::strlen(big);
for (int i = 0; i < n; i++) {
big[o++] = '0';
big[o++] = static_cast<char>('4' + (i % 6));
big[o++] = ',';
}
big[o - 1] = '\0';
const bool withinCap = n <= static_cast<int>(kr::kMaxKeysPerMessage);
CHECK(parse(big) == (withinCap ? Cmd::State : Cmd::Bad));
}
}
CASE("chord message must press something");
{
Message m;
CHECK(parse("Y=05:4c", m) == Cmd::Chord); // Ctrl+Alt+Del as a chord
CHECK_EQ(m.mods, 0x05);
CHECK_EQ(m.usages[0], 0x4C);
// A lone Super/Win press is a chord of nothing but a modifier ...
CHECK(parse("Y=08:", m) == Cmd::Chord);
CHECK_EQ(m.mods, 0x08);
CHECK_EQ(m.nUsages, 0);
// ... but a chord that presses neither a modifier nor a key is not a chord.
CHECK(parse("Y=00:") == Cmd::Bad);
CHECK(parse("Y=05") == Cmd::Bad); // the colon is not optional
}
CASE("auth message: token charset and bounds");
{
Message m;
CHECK(parse("Thunter2", m) == Cmd::Auth);
CHECK_STR(m.text, "hunter2");
CHECK(parse("Tabc._-:9", m) == Cmd::Auth);
CHECK_STR(m.text, "abc._-:9");
CHECK(parse("T") == Cmd::Bad); // empty token
CHECK(parse("Tbad token") == Cmd::Bad); // spaces and control chars are out
CHECK(parse("Tbad\tx") == Cmd::Bad);
char over[80];
std::memset(over, 'a', sizeof(over));
over[0] = 'T';
over[kr::kTokenMax + 2] = '\0'; // "T" + 33 chars = one too long
CHECK_EQ(std::strlen(over), kr::kTokenMax + 2u); // "T" + 33 token chars
CHECK(parse(over) == Cmd::Bad);
char fits[80];
std::memset(fits, 'a', sizeof(fits));
fits[0] = 'T';
fits[kr::kTokenMax + 1] = '\0'; // token exactly at the limit
CHECK(parse(fits) == Cmd::Auth);
}
CASE("state renders back to the same wire format");
{
char buf[64];
const uint8_t keys[] = {0x04, 0x28};
CHECK_EQ(kr::proto::formatState(buf, sizeof(buf), 0x0A, keys, 2), 10u);
CHECK_STR(buf, "S=0a:04,28");
// "nothing pressed" needs the colon, and needs a buffer that fits the NUL
CHECK_EQ(kr::proto::formatState(buf, 5, 0, nullptr, 0), 0u);
CHECK_EQ(kr::proto::formatState(buf, 6, 0, nullptr, 0), 5u);
CHECK_STR(buf, "S=00:");
CHECK(kr::proto::formatState(buf, sizeof(buf), 0, nullptr, 0) > 0);
CHECK_STR(buf, "S=00:");
CHECK(kr::proto::formatChord(buf, sizeof(buf), 0x05, keys, 1) > 0);
CHECK_STR(buf, "Y=05:04");
// a too-small buffer reports failure instead of writing past the end
char tiny[6] = {0};
CHECK_EQ(kr::proto::formatState(tiny, sizeof(tiny), 0x0A, keys, 2), 0u);
}
CASE("round trip: render then parse yields the same state");
{
char buf[64];
const uint8_t keys[] = {0x04, 0x05, 0x28, 0x4C};
kr::proto::formatState(buf, sizeof(buf), 0x5A, keys, 4);
Message m;
CHECK(parse(buf, m) == Cmd::State);
CHECK_EQ(m.mods, 0x5A);
CHECK_EQ(m.nUsages, 4);
for (int i = 0; i < 4; i++) CHECK_EQ(m.usages[i], keys[i]);
}
CASE("error frames carry their code");
{
char buf[32];
CHECK(kr::proto::formatError(buf, sizeof(buf), 4, "arm required") > 0);
CHECK_STR(buf, "E4:arm required");
CHECK_EQ(kr::proto::formatError(buf, 4, 4, "too long for this buffer"), 0u);
}
CASE("text message: pace, hex, and the bounds that keep one frame one command");
{
Message m;
CHECK(parse("P=0:6869", m) == Cmd::Text);
CHECK_EQ(m.periodMs, 0); // 0 = the board's own default pace
CHECK_EQ(m.nPayload, 2);
CHECK_EQ(m.payload[0], 'h');
CHECK_EQ(m.payload[1], 'i');
CHECK(parse("P=250:68", m) == Cmd::Text);
CHECK_EQ(m.periodMs, 250);
CHECK(parse("P=1:68", m) == Cmd::Text);
CHECK_EQ(m.periodMs, 1);
// Strict, like every other command: the digits, the colon and a whole number
// of bytes are all required, and a rejected frame types nothing at all.
CHECK(parse("P") == Cmd::Bad);
CHECK(parse("P=") == Cmd::Bad);
CHECK(parse("P=:68") == Cmd::Bad); // the pace is not optional
CHECK(parse("P=12") == Cmd::Bad); // no colon, no text
CHECK(parse("P=12:") == Cmd::Bad); // empty text is not a paste
CHECK(parse("P=12:6") == Cmd::Bad); // half a byte
CHECK(parse("P=12:686") == Cmd::Bad);
CHECK(parse("P=12:zz") == Cmd::Bad);
CHECK(parse("P=12:68 69") == Cmd::Bad); // no spaces
CHECK(parse("P=251:68") == Cmd::Bad); // slower than anything sane
CHECK(parse("P=99999:68") == Cmd::Bad); // and no overflow on the digits
CHECK(parse("P=12:6869extra") == Cmd::Bad);
CHECK(parse("PX:68") == Cmd::Bad);
// One frame is one command: kTextFrameMax characters must fit inside one 256-byte
// frame, and one more must be refused rather than buffered or truncated.
std::string body(2 * kr::kTextFrameMax, '0');
for (size_t i = 0; i < body.size(); i++) body[i] = (i & 1) ? '1' : '6'; // all 'a'
const std::string big = "P=12:" + body;
const std::string more = big + "61";
CHECK_EQ(big.size(), 5u + 2u * kr::kTextFrameMax);
CHECK(big.size() <= kr::kWsMessageCap);
CHECK(kr::proto::parse(big.c_str(), big.size(), m) == Cmd::Text);
CHECK_EQ(m.nPayload, kr::kTextFrameMax);
CHECK_EQ(m.payload[0], 'a');
CHECK(more.size() <= kr::kWsMessageCap); // refused on count, not on length
CHECK(kr::proto::parse(more.c_str(), more.size(), m) == Cmd::Bad);
// A trailing newline is tolerated, as it is for every other command.
CHECK(parse("P=0:6869\n") == Cmd::Text);
}
CASE("constant-time token comparison");
{
CHECK(kr::proto::constTimeEq("hunter2", 7, "hunter2", 7));
CHECK(!kr::proto::constTimeEq("hunter2", 7, "hunter3", 7));
CHECK(!kr::proto::constTimeEq("hunter2", 7, "hunter", 6));
CHECK(!kr::proto::constTimeEq("", 0, "x", 1));
CHECK(kr::proto::constTimeEq("", 0, "", 0));
CHECK(!kr::proto::constTimeEq("hunter22", 8, "hunter2", 7)); // prefix is not a match
}
CASE("every button on the controller page is a chord the firmware accepts");
{
// The page's special keys carry their chord in the wire format itself, as
// data-chord="<mods>:<usages>". That is what keeps the page free of chord logic --
// and what makes a typo in one of them a button that either does nothing or, worse,
// sends a different key than the label on it promises. Nothing on the browser side
// would ever notice, so every one goes through the real parser here.
std::string html = readFile("src/web_page.h");
if (html.empty()) html = readFile("../src/web_page.h");
CHECK(!html.empty());
int buttons = 0, accepted = 0;
const std::string needle = "data-chord=\"";
size_t pos = 0;
while ((pos = html.find(needle, pos)) != std::string::npos) {
const size_t begin = pos + needle.size();
const size_t end = html.find('"', begin);
if (end == std::string::npos) break;
const std::string wire = "Y=" + html.substr(begin, end - begin);
Message m;
kr::proto::initMessage(m);
if (kr::proto::parse(wire.c_str(), wire.size(), m) == Cmd::Chord &&
(m.mods != 0 || m.nUsages > 0)) {
accepted++;
}
buttons++;
pos = end;
}
CHECK(buttons >= 8); // a broken extraction must not read as a pass
CHECK_EQ(buttons, accepted);
// The window-management keys the README documents. Asserted by their bytes so a
// silent rename cannot leave the documentation describing a button that is gone.
const std::string gui = std::string("08:");
CHECK_EQ((int)kr::kModLgui, 0x08); // the page's "08" must mean what this says
CHECK(html.find("data-chord=\"" + gui + "50\"") != std::string::npos); // Super+Left
CHECK(html.find("data-chord=\"" + gui + "4f\"") != std::string::npos); // Super+Right
CHECK(html.find("data-chord=\"" + gui + "4b\"") != std::string::npos); // Super+PageUp
}
CASE("every chord button sends the combination printed on it");
{
// The test above proves each chord PARSES, which is not the same claim: an
// Alt+Tab button whose bytes were Ctrl+Alt+A parses perfectly, is armed-gated,
// arrives intact, and does something else entirely. Nothing in the browser would
// ever notice. So each labelled button is pinned to the combination its label
// promises, by modifier byte and usage id.
struct Expected { const char *label; const char *wire; uint8_t mods; uint8_t usage; };
const Expected expected[] = {
{"Ctrl+Alt+F1", "05:3a", kr::kModLctrl | kr::kModLalt, 0x3a},
{"Ctrl+Alt+F2", "05:3b", kr::kModLctrl | kr::kModLalt, 0x3b},
{"Ctrl+Alt+F3", "05:3c", kr::kModLctrl | kr::kModLalt, 0x3c},
{"Ctrl+Alt+F4", "05:3d", kr::kModLctrl | kr::kModLalt, 0x3d},
{"Alt+Tab", "04:2b", kr::kModLalt, kr::kUsageTab},
{"Win", "08:", kr::kModLgui, 0x00},
{"PrtSc", "00:46", 0x00, 0x46},
{"Menu", "00:65", 0x00, 0x65},
{"Super+Left", "08:50", kr::kModLgui, 0x50},
{"Super+Right", "08:4f", kr::kModLgui, 0x4f},
{"Super+PageUp", "08:4b", kr::kModLgui, 0x4b},
};
std::string html = readFile("src/web_page.h");
if (html.empty()) html = readFile("../src/web_page.h");
CHECK(!html.empty());
for (const auto &e : expected) {
const std::string attr = std::string("data-chord=\"") + e.wire + "\"";
CHECK(html.find(attr) != std::string::npos);
const std::string wire = std::string("Y=") + e.wire;
Message m;
CHECK(kr::proto::parse(wire.c_str(), wire.size(), m) == Cmd::Chord);
if (m.mods != e.mods || m.usages[0] != e.usage ||
(e.usage != 0 && m.nUsages != 1)) {
std::printf(" %s is on the wire as %s, which is not what it says\n",
e.label, wire.c_str());
}
CHECK_EQ(m.mods, e.mods);
CHECK_EQ(e.usage == 0 ? 0 : m.usages[0], e.usage);
CHECK(e.usage == 0 ? m.nUsages == 0 : m.nUsages == 1);
}
// Alt+Tab specifically, because it was wrong once: LAlt alone with Tab, and no
// Ctrl anywhere near it -- Ctrl+Alt+something is a VT switch or a panic key.
CHECK(kr::kModLctrl == 0x01 && kr::kModLalt == 0x04);
CHECK(html.find("data-chord=\"05:04\"") == std::string::npos);
}
CASE("the page's text constants are the board's text constants");
{
// The page splits a paste by kTextFrameMax and refuses more than kTextJobCap,
// using numbers it has to keep in step with config.h by hand. If they drift, the
// board answers E8 in the middle of somebody's paste.
std::string html = readFile("src/web_page.h");
if (html.empty()) html = readFile("../src/web_page.h");
CHECK(!html.empty());
char needle[64];
snprintf(needle, sizeof(needle), "const TEXT_FRAME_MAX = %u;",
(unsigned)kr::kTextFrameMax);
CHECK(html.find(needle) != std::string::npos);
snprintf(needle, sizeof(needle), "const TEXT_JOB_MAX = %u;", (unsigned)kr::kTextJobCap);
CHECK(html.find(needle) != std::string::npos);
// And the page must actually use the P= command the firmware parses.
CHECK(html.find("\"P=\" + TEXT_PACE_MS") != std::string::npos);
}
return report("proto");
}
+248
View File
@@ -0,0 +1,248 @@
#!/usr/bin/env python3
"""Tests for the Python side: relayctl's WebSocket codec, and that its key table
has not drifted from the one in the controller page.
python3 tests/test_relayctl.py
No third-party dependencies, so it runs anywhere the tools do.
"""
from __future__ import annotations
import inspect
import pathlib
import re
import struct
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / "tools"))
import relayctl # noqa: E402
failures = 0
checks = 0
def check(condition, label):
global checks, failures
checks += 1
if not condition:
failures += 1
print(f" FAIL {label}")
def check_eq(actual, expected, label):
global checks, failures
checks += 1
if actual != expected:
failures += 1
print(f" FAIL {label}: got {actual!r}, want {expected!r}")
print("handshake")
# RFC 6455 1.3's worked example -- the same vector the C++ suite asserts, so the
# two implementations are pinned to the same truth.
check_eq(relayctl.expect_accept("dGhlIHNhbXBsZSBub25jZQ=="),
"s3pPLMBiTxaQ9kYGzzhZRbK+xOo=", "Sec-WebSocket-Accept of the RFC example")
check(relayctl.expect_accept("differentKey==") != relayctl.expect_accept("dGhlIQ=="),
"different keys give different accepts")
print("frame encoding")
frame = relayctl.encode_frame(relayctl.OP_TEXT, b"A1")
check_eq(len(frame), 2 + 4 + 2, "short masked frame length")
check_eq(frame[0], 0x81, "FIN + text opcode")
check_eq(frame[1] & 0x80, 0x80, "mask bit set (clients must mask)")
check_eq(frame[1] & 0x7F, 2, "payload length in the 7-bit field")
mask = frame[2:6]
check_eq(bytes(b ^ mask[i & 3] for i, b in enumerate(frame[6:])), b"A1",
"payload round-trips through the mask")
long_payload = b"x" * 300
frame = relayctl.encode_frame(relayctl.OP_TEXT, long_payload)
check_eq(frame[1] & 0x7F, 126, "126 signals the 16-bit length")
check_eq(struct.unpack(">H", frame[2:4])[0], 300, "16-bit length value")
mask = frame[4:8]
check_eq(relayctl.mask_payload(frame[8:], mask), long_payload,
"mask_payload is its own inverse")
check_eq(len(relayctl.encode_frame(relayctl.OP_TEXT, b"")), 6,
"empty frame still carries a mask key")
print("frame decoding")
receiver = relayctl.Receiver()
def server_frame(opcode, payload):
"""The unmasked frames the board sends."""
out = bytearray([0x80 | opcode, len(payload)])
out += payload
return bytes(out)
status = server_frame(relayctl.OP_TEXT, b'S{"armed":1,"usb":1}')
# Deliver it one byte at a time: the decoder must not invent a frame early.
emitted = []
for i in range(len(status)):
emitted += list(receiver.feed(status[i:i + 1]))
check_eq(len(emitted), 1, "exactly one frame from byte-at-a-time delivery")
check_eq(emitted[0][0], relayctl.OP_TEXT, "decoded opcode")
check_eq(emitted[0][1], b'S{"armed":1,"usb":1}',
"decoded payload, including the board's 'S' status prefix")
# Two frames in one read, plus a split across the boundary.
two = server_frame(relayctl.OP_TEXT, b"OK") + server_frame(relayctl.OP_PING, b"hi")
receiver = relayctl.Receiver()
got = list(receiver.feed(two))
check_eq(len(got), 2, "both frames decoded from one read")
check_eq(got[0][1], b"OK", "first frame payload")
check_eq(got[1][0], relayctl.OP_PING, "second frame opcode")
receiver = relayctl.Receiver()
got = list(receiver.feed(two[:5])) + list(receiver.feed(two[5:]))
check_eq(len(got), 2, "a frame split mid-payload is still decoded once")
# 16-bit length path, and the protocol violations the client refuses.
receiver = relayctl.Receiver()
big = bytearray([0x81, 126]) + struct.pack(">H", 130) + b"y" * 130
check_eq(list(receiver.feed(bytes(big)))[0][1], b"y" * 130, "16-bit length decoded")
receiver = relayctl.Receiver()
try:
list(receiver.feed(bytes([0x81, 0x82]) + b"\x00" * 6))
check(False, "a masked server frame must be rejected")
except relayctl.RelayError:
check(True, "masked server frame rejected")
receiver = relayctl.Receiver()
try:
list(receiver.feed(bytes([0x01, 0x01, b"a"[0]])))
check(False, "a fragmented frame must be rejected")
except relayctl.RelayError:
check(True, "fragmented server frame rejected")
print("key table")
check_eq(relayctl.resolve_key("Enter"), 0x28, "Enter is usage 0x28")
check_eq(relayctl.resolve_key("0x28"), 0x28, "numeric usage accepted")
check_eq(relayctl.resolve_key("Delete"), 0x4C, "Delete is Delete-Forward 0x4C")
for bad, why in [("0x00", "usage 0 is not a key"), ("0x1ff", "usage must fit a byte"),
("NotAKey", "unknown name")]:
try:
relayctl.resolve_key(bad)
check(False, why)
except (relayctl.RelayError, ValueError):
check(True, why)
# The page and this tool must agree, or `relayctl key Enter` and pressing Enter in
# the browser would be different keys. Compare the JS object in web_page.h.
page = (pathlib.Path(__file__).resolve().parent.parent / "src" / "web_page.h").read_text()
match = re.search(r"const CODE_TO_USAGE = \{(.*?)\};", page, re.S)
check(match is not None, "CODE_TO_USAGE found in src/web_page.h")
if match:
page_map = {}
for name, usage in re.findall(r"([A-Za-z][A-Za-z0-9]*)\s*:\s*(0x[0-9a-fA-F]+)",
match.group(1)):
page_map[name] = int(usage, 16)
check(len(page_map) > 80, f"page table parsed ({len(page_map)} entries)")
only_page = sorted(set(page_map) - set(relayctl.CODE_TO_USAGE))
only_cli = sorted(set(relayctl.CODE_TO_USAGE) - set(page_map))
check_eq(only_page, [], "keys the page knows but relayctl does not")
check_eq(only_cli, [], "keys relayctl knows but the page does not")
mismatched = sorted(k for k in set(page_map) & set(relayctl.CODE_TO_USAGE)
if page_map[k] != relayctl.CODE_TO_USAGE[k])
check_eq(mismatched, [], "same name, different usage id")
check_eq(page_map.get("Enter"), 0x28, "Enter agrees on 0x28")
check_eq(page_map.get("KeyA"), 0x04, "KeyA agrees on 0x04")
print("pasted text")
root = pathlib.Path(__file__).resolve().parent.parent
relay_src = (root / "tools" / "relayctl.py").read_text()
proto_src = (root / "src" / "proto.h").read_text()
text_src = (root / "src" / "text.h").read_text()
check_eq(relayctl.normalize_paste("a\r\nb"), "a\nb", "CRLF becomes one Enter")
check_eq(relayctl.normalize_paste("a\rb"), "a\nb", "a lone CR becomes Enter")
check_eq(relayctl.normalize_paste("a\nb"), "a\nb", "LF is left alone")
check_eq(relayctl.text_frame(0, "hi"), "P=0:6869", "the frame relayctl sends")
check_eq(relayctl.text_frame(250, "hi"), "P=250:6869", "the pace travels in decimal")
check_eq(relayctl.text_frame(0, "a"), "P=0:61", "one character")
# What the board's character map can type, read straight out of src/text.h: the
# switch spells out the punctuation and the ranges spell out the alphanumerics.
firmware = set()
for literal in re.findall(r"case '(\\.|[^'\\])':", text_src):
firmware.add(ord(literal.encode("latin-1").decode("unicode_escape")))
check("ch >= 'a' && ch <= 'z'" in text_src, "the lowercase range is still a range")
check("ch >= 'A' && ch <= 'Z'" in text_src, "the uppercase range is still a range")
check("ch >= '0' && ch <= '9'" in text_src, "the digit range is still a range")
firmware |= set(range(ord("a"), ord("z") + 1))
firmware |= set(range(ord("A"), ord("Z") + 1))
firmware |= set(range(ord("0"), ord("9") + 1))
expected = {9, 10, 13} | set(range(32, 127))
check_eq(sorted(firmware), sorted(expected),
"the board types exactly tab, newline, CR and printable ASCII")
# ... and the tool's validator must agree with it on every ASCII character, once the
# line endings are normalised. A character the tool waves through that the board then
# refuses would fail the whole paste at the board instead of at the terminal.
for code in range(128):
ch = chr(code)
tool_says_ok = not relayctl.untypeable(relayctl.normalize_paste(ch))
if tool_says_ok != (code in firmware):
check(False, f"code {code:#04x}: tool={tool_says_ok} board={code in firmware}")
check(True, "tool and board agree on all 128 ASCII codes")
for bad in ["\u2019", "\u2014", "\u00e9", "\u00a0", "\x00", "\x07", "\x7f"]:
check_eq(relayctl.untypeable("ok" + bad), [bad], f"refuses {bad!r}")
check_eq(relayctl.untypeable("line1\nline2\tcol\r"), ["\r"],
"CR is flagged before normalisation, and never sent")
# The three numbers have to be the board's numbers, or a paste dies on E8 halfway
# through because the tool split the text somewhere the firmware will not accept.
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
check_eq(relayctl.TEXT_FRAME_MAX, config_value("kTextFrameMax"),
"relayctl TEXT_FRAME_MAX == kTextFrameMax")
check_eq(relayctl.MAX_PASTE_CHARS, config_value("kTextJobCap"),
"relayctl MAX_PASTE_CHARS == kTextJobCap")
check_eq(relayctl.DEFAULT_TEXT_PACE_MS, config_value("kTextPeriodMsDefault"),
"relayctl DEFAULT_TEXT_PACE_MS == kTextPeriodMsDefault")
page_src = (root / "src" / "web_page.h").read_text()
check_eq(int(re.search(r"const TEXT_FRAME_MAX = (\d+);", page_src).group(1)),
config_value("kTextFrameMax"), "page TEXT_FRAME_MAX == kTextFrameMax")
check_eq(int(re.search(r"const TEXT_JOB_MAX = (\d+);", page_src).group(1)),
config_value("kTextJobCap"), "page TEXT_JOB_MAX == kTextJobCap")
check(config_value("kTextFrameMax") * 2 + 7 <= config_value("kWsMessageCap"),
"the largest text frame fits one WebSocket message")
# _drain answered the board's ping with two methods that do not exist, which only
# surfaced on a session longer than the 15 s ping interval. A paste is such a session.
check("_send_frame" not in relay_src, "_drain no longer calls the missing _send_frame")
check("_check_error" not in relay_src, "_drain no longer calls the missing _check_error")
drained = relayctl.Relay._drain
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("protocol strings")
check_eq("S=%02x:%s" % (0x0A, "04,28"), "S=0a:04,28", "state frame format")
check_eq("Y=%02x:%s" % (0x05, "4c"), "Y=05:4c", "chord frame format")
check_eq("T" + "hunter2", "Thunter2", "auth frame format")
# The single-character commands relayctl emits must be ones proto.h accepts, and
# proto.h's own documented list must still mention them: a change on either side
# 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'"):
check(command in proto_src, f"proto.h parses {command}")
check("if (c == 'Y' && n == 0 && out.mods == 0)" in proto_src,
"proto.h still requires a chord to press something")
print(f"relayctl {checks} checks, {failures} failures")
sys.exit(1 if failures else 0)
+386
View File
@@ -0,0 +1,386 @@
// tests for pasted text: tests/test_text.cpp
//
// The character map is the part worth being pedantic about: every one of the 95
// printable ASCII characters has to map to a real keystroke, because a paste that
// silently loses or changes one character is worse than a paste that refuses. So
// the whole range is walked, not sampled.
#include <cstdio>
#include <cstring>
#include <string>
#include <vector>
#include "keystate.h"
#include "proto.h"
#include "test.h"
#include "text.h"
using kr::KeyboardReport;
using kr::KeyboardState;
using kr::text::Result;
using kr::text::Stroke;
using kr::text::Typewriter;
namespace {
int countHeld(const KeyboardReport &r) {
int n = 0;
for (size_t i = 0; i < kr::kReportSlots; i++) {
if (r.keys[i] != kr::kUsageNone) n++;
}
return n;
}
} // namespace
int main() {
CASE("every printable ASCII character maps to a keystroke");
{
for (int ch = 0x20; ch <= 0x7E; ch++) {
const Stroke s = kr::text::strokeFor(static_cast<uint8_t>(ch));
CHECK(s.typable);
CHECK(s.usage != kr::kUsageNone); // typable implies a real usage id
}
}
CASE("shifted glyphs ask for LShift, unshifted ones do not");
{
// Letters: position, plus Shift for the uppercase -- never a different key.
const Stroke a = kr::text::strokeFor('a'), A = kr::text::strokeFor('A');
CHECK(a.typable && A.typable);
CHECK_EQ(a.usage, 0x04);
CHECK_EQ(A.usage, 0x04); // same physical key
CHECK_EQ(a.mods, 0);
CHECK_EQ(A.mods, kr::kModLshift);
CHECK_EQ(kr::text::strokeFor('z').usage, 0x1D);
CHECK_EQ(kr::text::strokeFor('Z').usage, 0x1D);
CHECK_EQ(kr::text::strokeFor('M').usage, 0x10);
// Digits: 1..9 run 0x1E..0x26 and 0 is the one that breaks the arithmetic.
CHECK_EQ(kr::text::strokeFor('1').usage, 0x1E);
CHECK_EQ(kr::text::strokeFor('9').usage, 0x26);
CHECK_EQ(kr::text::strokeFor('0').usage, 0x27);
CHECK_EQ(kr::text::strokeFor('0').mods, 0);
// The shifted number row, which is where a shifted-vs-unshifted slip shows up.
const int shiftPairs[][2] = {
{'!', 0x1E}, {'@', 0x1F}, {'#', 0x20}, {'$', 0x21}, {'%', 0x22},
{'^', 0x23}, {'&', 0x24}, {'*', 0x25}, {'(', 0x26}, {')', 0x27},
{'_', 0x2D}, {'+', 0x2E}, {'{', 0x2F}, {'}', 0x30}, {'|', 0x31},
{':', 0x33}, {'"', 0x34}, {'~', 0x35}, {'<', 0x36}, {'>', 0x37},
{'?', 0x38},
};
for (const auto &p : shiftPairs) {
const Stroke s = kr::text::strokeFor(static_cast<uint8_t>(p[0]));
CHECK(s.typable);
CHECK_EQ(s.usage, p[1]);
CHECK_EQ(s.mods, kr::kModLshift);
}
// And their unshifted partners on the same keys.
CHECK_EQ(kr::text::strokeFor('-').usage, 0x2D);
CHECK_EQ(kr::text::strokeFor('-').mods, 0);
CHECK_EQ(kr::text::strokeFor('=').usage, 0x2E);
CHECK_EQ(kr::text::strokeFor('[').usage, 0x2F);
CHECK_EQ(kr::text::strokeFor(']').usage, 0x30);
CHECK_EQ(kr::text::strokeFor('\\').usage, 0x31);
CHECK_EQ(kr::text::strokeFor(';').usage, 0x33);
CHECK_EQ(kr::text::strokeFor('\'').usage, 0x34);
CHECK_EQ(kr::text::strokeFor('`').usage, 0x35);
CHECK_EQ(kr::text::strokeFor(',').usage, 0x36);
CHECK_EQ(kr::text::strokeFor('.').usage, 0x37);
CHECK_EQ(kr::text::strokeFor('/').usage, 0x38);
}
CASE("whitespace");
{
CHECK_EQ(kr::text::strokeFor(' ').usage, kr::kUsageSpace);
CHECK_EQ(kr::text::strokeFor('\t').usage, kr::kUsageTab);
CHECK_EQ(kr::text::strokeFor('\n').usage, kr::kUsageEnter);
// \r types Enter. Normalising CRLF to LF is the controller's job: doing it here
// would need a look-ahead, and a chunk boundary can split the pair.
CHECK_EQ(kr::text::strokeFor('\r').usage, kr::kUsageEnter);
}
CASE("characters this map cannot express are refused, not guessed at");
{
const int refused[] = {0x00, 0x01, 0x07, 0x08, 0x0B, 0x0C, 0x1B, 0x1F, 0x7F,
0x80, 0xA0, 0xC3, 0xE9, 0xFF}; // e.g. UTF-8 'é'
for (int ch : refused) {
const Stroke s = kr::text::strokeFor(static_cast<uint8_t>(ch));
CHECK(!s.typable);
CHECK_EQ(s.usage, 0);
CHECK_EQ(s.mods, 0);
}
}
CASE("feed: all of a frame or none of it");
{
Typewriter t;
const uint8_t ok[] = {'h', 'i'};
CHECK(kr::text::strokeFor('h').typable);
CHECK(t.feed(ok, 2).result == Result::Ok);
CHECK_EQ(t.pending(), 2);
const uint8_t mixed[] = {'o', '!', 0xC3, 'x'};
const auto bad = t.feed(mixed, 4);
CHECK(bad.result == Result::Untypeable);
CHECK_EQ(bad.index, 2); // offset within THIS frame
CHECK_EQ(bad.byte, 0xC3);
CHECK_EQ(t.pending(), 2); // nothing partial was appended
// The cap is on characters still to type, and a full buffer is reported as such
// rather than truncating a paste.
Typewriter big;
uint8_t run[kr::kTextJobCap];
memset(run, 'a', sizeof(run));
CHECK(big.feed(run, kr::kTextJobCap).result == Result::Ok);
CHECK(big.feed(run, 1).result == Result::Full);
CHECK_EQ(big.pending(), kr::kTextJobCap);
CHECK(Typewriter().feed(nullptr, 0).result == Result::Ok);
}
CASE("pace: 0 means the board's default, and a fast pace still separates edges");
{
Typewriter t;
const uint8_t one[] = {'q'};
t.feed(one, 1);
t.start(1000, 0);
CHECK_EQ(t.periodMs(), kr::kTextPeriodMsDefault);
CHECK(t.holdMs() >= 1);
CHECK(t.holdMs() <= kr::kTextHoldMaxMs);
CHECK_EQ(t.holdMs() + t.gapMs(), t.periodMs()); // one press and one release per char
t.cancel();
t.feed(one, 1);
t.start(0, 9999); // absurdly slow is clamped, not obeyed
CHECK_EQ(t.periodMs(), kr::kTextPeriodMaxMs);
CHECK_EQ(t.holdMs(), kr::kTextHoldMaxMs);
t.cancel();
t.feed(one, 1);
t.start(0, 1); // as fast as it will go
CHECK_EQ(t.holdMs(), 1);
CHECK_EQ(t.gapMs(), 0);
}
CASE("step: the whole report sequence for two characters");
{
Typewriter t;
const uint8_t txt[] = {'h', 'i'};
t.feed(txt, 2);
t.start(1000, 10); // hold 5, gap 5
CHECK_EQ(t.holdMs(), 5);
CHECK_EQ(t.gapMs(), 5);
KeyboardState baseline;
KeyboardReport r;
bool pressed = false;
CHECK(!t.step(999, baseline, r, pressed)); // not due yet
CHECK(t.step(1000, baseline, r, pressed)); // press 'h'
CHECK(pressed);
CHECK_EQ(r.modifiers, 0);
CHECK_EQ(countHeld(r), 1);
CHECK_EQ(r.keys[0], kr::text::strokeFor('h').usage);
CHECK(!t.step(1004, baseline, r, pressed)); // still holding
CHECK(t.step(1005, baseline, r, pressed)); // release
CHECK(!pressed);
CHECK_EQ(countHeld(r), 0);
CHECK(t.step(1010, baseline, r, pressed)); // press 'i'
CHECK(pressed);
CHECK_EQ(r.keys[0], kr::text::strokeFor('i').usage);
CHECK(t.step(1015, baseline, r, pressed)); // release, and the run is over
CHECK(!t.active());
CHECK_EQ(t.pending(), 0);
CHECK(!t.step(2000, baseline, r, pressed)); // and nothing is left to emit
}
CASE("a paste is typed on top of what the operator holds, and back to it");
{
Typewriter t;
const uint8_t txt[] = {'A'}; // uppercase: this one needs Shift
t.feed(txt, 1);
t.start(0, 20);
// The operator is holding Ctrl. A pasted 'A' is therefore Ctrl+Shift+a.
KeyboardState baseline;
const uint8_t held[] = {kr::text::strokeFor('s').usage};
baseline.apply(kr::kModLctrl, held, 1);
KeyboardReport r;
bool pressed = false;
CHECK(t.step(0, baseline, r, pressed));
CHECK_EQ(r.modifiers, kr::kModLctrl | kr::kModLshift);
CHECK_EQ(countHeld(r), 2);
CHECK(baseline.isDown(kr::text::strokeFor('s').usage));
CHECK(t.step(10, baseline, r, pressed)); // release returns to the baseline
CHECK_EQ(r.modifiers, kr::kModLctrl); // Ctrl still down
CHECK_EQ(countHeld(r), 1);
CHECK_EQ(r.keys[0], kr::text::strokeFor('s').usage); // not released by the paste
}
CASE("more than six keys together is reported as a real keyboard reports it");
{
Typewriter t;
const uint8_t txt[] = {'x'};
t.feed(txt, 1);
t.start(0, 20);
KeyboardState six;
const uint8_t keys6[] = {0x04, 0x05, 0x06, 0x07, 0x08, 0x09};
six.apply(0, keys6, 6);
KeyboardReport r;
bool pressed = false;
CHECK(t.step(0, six, r, pressed)); // a seventh key is impossible:
for (size_t i = 0; i < kr::kReportSlots; i++) CHECK_EQ(r.keys[i], kr::kUsageErrorRollover);
KeyboardState rolled;
const uint8_t keys7[] = {0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A};
rolled.apply(0, keys7, 7);
CHECK(rolled.rollover());
Typewriter t2;
t2.feed(txt, 1);
t2.start(0, 20);
CHECK(t2.step(0, rolled, r, pressed));
CHECK_EQ(r.keys[0], kr::kUsageErrorRollover); // rollover propagates, never hides
}
CASE("cancel drops the rest of the run");
{
Typewriter t;
const uint8_t txt[] = {'o', 'n', 'e', ' ', 't', 'w', 'o'};
t.feed(txt, sizeof(txt));
t.start(0, 10);
CHECK(t.active());
KeyboardState baseline;
KeyboardReport r;
bool pressed = false;
CHECK(t.step(0, baseline, r, pressed)); // one character pressed
t.cancel(); // the panic path
CHECK(!t.active());
CHECK_EQ(t.pending(), 0);
CHECK(!t.step(1000, baseline, r, pressed)); // nothing continues afterwards
CHECK_EQ(t.feed(txt, sizeof(txt)).result, Result::Ok);
CHECK_EQ(t.pending(), sizeof(txt)); // and it is reusable
}
CASE("a run already in progress keeps the pace it started with");
{
Typewriter t;
const uint8_t txt[] = {'a'};
t.feed(txt, 1);
t.start(0, 10);
CHECK_EQ(t.periodMs(), 10);
t.feed(txt, 1);
t.start(0, 200); // a later frame must not reprieve
CHECK_EQ(t.periodMs(), 10); // or race the text already visible
t.cancel();
t.feed(txt, 1);
t.start(0, 200);
CHECK_EQ(t.periodMs(), 200); // a fresh run does take the new pace
}
CASE("text renders to the wire format and parses back");
{
char buf[512];
const uint8_t txt[] = {'h', 'i'};
const size_t n = kr::proto::formatText(buf, sizeof(buf), 0, txt, 2);
CHECK_EQ(n, 8u); // "P=0:6869"
CHECK_STR(buf, "P=0:6869");
kr::proto::Message m;
CHECK(kr::proto::parse(buf, strlen(buf), m) == kr::proto::Cmd::Text);
CHECK_EQ(m.periodMs, 0);
CHECK_EQ(m.nPayload, 2);
CHECK_EQ(m.payload[0], 'h');
CHECK_EQ(m.payload[1], 'i');
// The pace travels too, in decimal, and the whole frame is still ASCII.
kr::proto::formatText(buf, sizeof(buf), 250, txt, 2);
CHECK_STR(buf, "P=250:6869");
CHECK(kr::proto::parse(buf, strlen(buf), m) == kr::proto::Cmd::Text);
CHECK_EQ(m.periodMs, 250);
// The largest legal frame must fit in one WebSocket message: 256 bytes, or the
// board rejects the frame before the parser ever sees it.
uint8_t max[kr::kTextFrameMax];
memset(max, 'a', sizeof(max));
const size_t mlen = kr::proto::formatText(buf, sizeof(buf), kr::kTextPeriodMaxMs,
max, kr::kTextFrameMax);
CHECK(mlen > 0);
CHECK(mlen <= kr::kWsMessageCap);
CHECK(kr::proto::parse(buf, mlen, m) == kr::proto::Cmd::Text);
CHECK_EQ(m.nPayload, kr::kTextFrameMax);
// A buffer that cannot hold the whole frame gets nothing, not a truncated paste.
char small[8] = {0};
CHECK_EQ(kr::proto::formatText(small, sizeof(small), 0, max, 20), 0u);
CHECK_STR(small, "");
CHECK_EQ(kr::proto::formatText(buf, sizeof(buf), 0, max, 0), 0u); // empty is not a paste
CHECK_EQ(kr::proto::formatText(buf, sizeof(buf), kr::kTextPeriodMaxMs + 1, txt, 2), 0u);
}
CASE("a frame the controller page sends becomes the reports the target sees");
{
// The browser builds "P=<ms>:<hex>"; the board parses it, expands it and emits
// press/release pairs. This is the whole path in one place, with no browser and no
// USB in it -- which is exactly why it can be asserted here rather than guessed at.
const char *text = "Hi there";
std::string frame = "P=12:";
for (const char *p = text; *p; p++) {
char hex[3];
snprintf(hex, sizeof(hex), "%02x", (unsigned char)*p);
frame += hex;
}
kr::proto::Message m;
CHECK(kr::proto::parse(frame.c_str(), frame.size(), m) == kr::proto::Cmd::Text);
CHECK_EQ(m.nPayload, strlen(text));
CHECK_EQ(m.periodMs, 12);
Typewriter t;
CHECK(t.feed(m.payload, m.nPayload).result == Result::Ok);
t.start(0, m.periodMs);
KeyboardState none; // nothing held by the operator
std::vector<KeyboardReport> out;
uint32_t clockMs = 0;
// Ten seconds of loop passes at 1 ms: long enough to type it all out.
for (int tick = 0; tick < 10000; tick++, clockMs += 1) {
KeyboardReport r;
bool pressed = false;
if (!t.step(clockMs, none, r, pressed)) continue;
out.push_back(r);
}
const size_t len = strlen(text);
CHECK_EQ(out.size(), len * 2); // one press and one release per character
CHECK(!t.active()); // and it finished on its own
for (size_t i = 0; i < len; i++) {
const Stroke want = kr::text::strokeFor((uint8_t)text[i]);
const KeyboardReport &press = out[i * 2];
const KeyboardReport &release = out[i * 2 + 1];
CHECK_EQ(press.modifiers, want.mods);
CHECK_EQ(press.keys[0], want.usage);
CHECK_EQ(countHeld(press), 1);
CHECK_EQ(release.modifiers, 0); // nothing left down between characters
CHECK_EQ(countHeld(release), 0);
}
// 'H' is the shifted glyph and 'i' is not: the two reports differ in Shift and
// nothing else. Usages are positions, so H is 0x0B and i is 0x0C.
CHECK_EQ(out[0].modifiers, kr::kModLshift);
CHECK_EQ(out[0].keys[0], 0x0b);
CHECK_EQ(out[2].modifiers, 0);
CHECK_EQ(out[2].keys[0], 0x0c);
// The space in "Hi there" is index 2, and a key -- not an absence of one.
CHECK_EQ(out[4].keys[0], kr::kUsageSpace);
CHECK_EQ(out[6].keys[0], 0x17); // 't'
}
return report("text");
}
+424
View File
@@ -0,0 +1,424 @@
// tests for SHA-1 / base64 / the WebSocket codec / the handshake parser
#include <cstdlib>
#include <cstring>
#include "ws_codec.h"
#include "test.h" // CHECK / CHECK_EQ / CHECK_STR / report()
using namespace kr;
namespace {
void hexDigest(const uint8_t d[20], char out[41]) {
static const char kHex[] = "0123456789abcdef";
for (int i = 0; i < 20; i++) {
out[2 * i] = kHex[d[i] >> 4];
out[2 * i + 1] = kHex[d[i] & 0xF];
}
out[40] = '\0';
}
// Build a client frame the way a browser does: masked payload, mask key appended
// in front of it. Returns the frame length.
size_t buildClientFrame(uint8_t *out, size_t cap, uint8_t opcode, const char *payload,
size_t len, bool mask = true, bool fin = true, uint8_t rsv = 0,
uint64_t declaredLen = 0xFFFFFFFFull) {
if (declaredLen == 0xFFFFFFFFull) declaredLen = len;
// A test that asks for a frame larger than its own buffer is a broken test, not
// a stack smash: say so and stop.
if (2 + 8 + 4 + len > cap) {
std::fprintf(stderr, "buildClientFrame: %zu bytes needed, buffer is %zu\n", 2 + 8 + 4 + len,
cap);
std::abort();
}
size_t o = 0;
out[o++] = static_cast<uint8_t>((fin ? 0x80 : 0x00) | (rsv << 4) | (opcode & 0x0F));
const uint8_t m = mask ? 0x80 : 0x00;
if (declaredLen < 126) {
out[o++] = static_cast<uint8_t>(m | declaredLen);
} else if (declaredLen <= 0xFFFF) {
out[o++] = m | 126;
out[o++] = static_cast<uint8_t>(declaredLen >> 8);
out[o++] = static_cast<uint8_t>(declaredLen);
} else {
out[o++] = m | 127;
for (int i = 0; i < 8; i++) out[o++] = static_cast<uint8_t>(declaredLen >> (56 - 8 * i));
}
uint8_t maskKey[4] = {0xDE, 0xAD, 0xBE, 0xEF};
if (mask) {
memcpy(out + o, maskKey, 4);
o += 4;
}
for (size_t i = 0; i < len; i++) {
out[o++] = mask ? static_cast<uint8_t>(payload[i] ^ maskKey[i & 3])
: static_cast<uint8_t>(payload[i]);
}
return o;
}
} // namespace
int main() {
char hex[64];
CASE("SHA-1 against the published vectors");
{
uint8_t d[20];
sha1("", 0, d);
hexDigest(d, hex);
CHECK_STR(hex, "da39a3ee5e6b4b0d3255bfef95601890afd80709");
sha1("abc", 3, d);
hexDigest(d, hex);
CHECK_STR(hex, "a9993e364706816aba3e25717850c26c9cd0d89d");
// 56 bytes is the padding boundary: with 0x80 + the 8-byte length the block
// overflows, so the count lands in a second block. Classic SHA-1 bug, and the
// reason this vector is here (digest cross-checked against Python hashlib).
char fiftySix[57];
memset(fiftySix, 'a', 56);
fiftySix[56] = '\0';
sha1(fiftySix, 56, d);
hexDigest(d, hex);
CHECK_STR(hex, "c2db330f6083854c99d4b5bfb6e8f29f201be699");
// NIST's two-block message digest
sha1("abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq", 56, d);
hexDigest(d, hex);
CHECK_STR(hex, "84983e441c3bd26ebaae4aa1f95129e5e54670f1");
// the canonical million-character vector: exercises many blocks
{
Sha1 many;
char chunk[64];
memset(chunk, 'a', sizeof(chunk));
for (int i = 0; i < 1000000 / 64; i++) many.update(chunk, sizeof(chunk));
many.finish(d);
hexDigest(d, hex);
CHECK_STR(hex, "34aa973cd4c4daa4f61eeb2bdbad27316534016f");
}
}
CASE("SHA-1 incremental equals one-shot");
{
const char *text = "the quick brown fox jumps over the lazy dog, twice over";
uint8_t one[20], many[20];
sha1(text, strlen(text), one);
Sha1 s;
for (size_t i = 0; i < strlen(text); i++) s.update(text + i, 1);
s.finish(many);
CHECK(memcmp(one, many, 20) == 0);
// reuse after finish() must start clean
uint8_t again[20];
s.update("abc", 3);
s.finish(again);
sha1("abc", 3, one);
CHECK(memcmp(again, one, 20) == 0);
}
CASE("base64 against RFC 4648");
{
char out[32];
CHECK_EQ(base64Encode(reinterpret_cast<const uint8_t *>("\0"), 0, out, sizeof(out)), 0u);
CHECK_STR(out, "");
CHECK_EQ(base64Encode(reinterpret_cast<const uint8_t *>("f"), 1, out, sizeof(out)), 4u);
CHECK_STR(out, "Zg==");
CHECK_EQ(base64Encode(reinterpret_cast<const uint8_t *>("fo"), 2, out, sizeof(out)), 4u);
CHECK_STR(out, "Zm8=");
CHECK_EQ(base64Encode(reinterpret_cast<const uint8_t *>("foo"), 3, out, sizeof(out)), 4u);
CHECK_STR(out, "Zm9v");
CHECK_EQ(base64Encode(reinterpret_cast<const uint8_t *>("foob"), 4, out, sizeof(out)), 8u);
CHECK_STR(out, "Zm9vYg==");
CHECK_EQ(base64Encode(reinterpret_cast<const uint8_t *>("fooba"), 5, out, sizeof(out)), 8u);
CHECK_STR(out, "Zm9vYmE=");
CHECK_EQ(base64Encode(reinterpret_cast<const uint8_t *>("foobar"), 6, out, sizeof(out)), 8u);
CHECK_STR(out, "Zm9vYmFy");
// one byte short of the requirement must refuse, not overwrite
char tiny[4] = {'x', 'x', 'x', '\0'};
CHECK_EQ(base64Encode(reinterpret_cast<const uint8_t *>("foo"), 3, tiny, 4), 0u);
CHECK_STR(tiny, "xxx");
}
CASE("the RFC 6455 handshake example");
{
const char *key = "dGhlIHNhbXBsZSBub25jZQ==";
char accept[64] = "";
CHECK(ws::acceptFor(key, strlen(key), accept, sizeof(accept)));
CHECK_STR(accept, "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=");
// a different key must produce a different accept, and short buffers refuse
char other[64] = "";
CHECK(ws::acceptFor("anotherKey12345=", strlen("anotherKey12345="), other, sizeof(other)));
CHECK(std::strcmp(accept, other) != 0);
CHECK(!ws::acceptFor("", 0, accept, sizeof(accept)));
CHECK(!ws::acceptFor(key, strlen(key), accept, 8));
}
CASE("outbound frame encoding");
{
uint8_t buf[64];
size_t n = ws::encodeText("A1", 2, buf, sizeof(buf));
CHECK_EQ(n, 4u);
CHECK_EQ(buf[0], 0x81); // FIN + text
CHECK_EQ(buf[1], 2); // unmasked, length 2
CHECK_EQ(buf[2], 'A');
CHECK_EQ(buf[3], '1');
n = ws::encodeText("", 0, buf, sizeof(buf));
CHECK_EQ(n, 2u);
CHECK_EQ(buf[1], 0);
// 125 bytes is the last length that fits the 7-bit field, 126 the first that
// needs the 16-bit extension
char blob[200];
memset(blob, 'x', sizeof(blob));
uint8_t big[400];
n = ws::encodeText(blob, 125, big, sizeof(big));
CHECK_EQ(n, 127u);
CHECK_EQ(big[1], 125);
CHECK_EQ(big[2], 'x');
n = ws::encodeText(blob, 126, big, sizeof(big));
CHECK_EQ(n, 130u);
CHECK_EQ(big[1], 126);
CHECK_EQ(big[2], 0);
CHECK_EQ(big[3], 126);
CHECK_EQ(big[4], 'x');
// a buffer that is one byte too small refuses without writing the frame
uint8_t tight[10] = {0};
CHECK_EQ(ws::encodeText("0123456789ab", 12, tight, sizeof(tight)), 0u);
CHECK_EQ(tight[0], 0);
}
CASE("inbound frame decodes, byte by byte");
{
uint8_t wire[128], payloadBuf[64];
const size_t n = buildClientFrame(wire, sizeof(wire), ws::kOpText, "S=0a:04", 7);
CHECK_EQ(n, 13u); // 2 header + 4 mask + 7 payload
ws::Reader r(payloadBuf, sizeof(payloadBuf));
ws::In res = ws::In::NeedMore;
for (size_t i = 0; i < n; i++) {
res = r.feed(wire[i]);
if (i + 1 < n) CHECK(res == ws::In::NeedMore);
}
CHECK(res == ws::In::Text);
CHECK(r.finished());
CHECK_EQ(r.payloadLen(), 7u);
CHECK(memcmp(r.payload(), "S=0a:04", 7) == 0);
CHECK_EQ(r.error(), ws::Err::None);
// feeding more after completion keeps reporting the same frame until reset()
CHECK(r.feed(0x00) == ws::In::Text);
r.reset();
CHECK(!r.finished());
}
CASE("inbound frame decodes from a whole buffer");
{
uint8_t wire[256], payloadBuf[64];
const size_t a = buildClientFrame(wire, sizeof(wire), ws::kOpText, "A1", 2);
const size_t b = buildClientFrame(wire + a, sizeof(wire) - a, ws::kOpText, "K", 1);
ws::Reader r(payloadBuf, sizeof(payloadBuf));
size_t consumed = 0;
CHECK(r.feedAll(wire, a + b, &consumed) == ws::In::Text);
CHECK_EQ(consumed, a);
CHECK(memcmp(r.payload(), "A1", 2) == 0);
r.reset();
CHECK(r.feedAll(wire + consumed, a + b - consumed, &consumed) == ws::In::Text);
CHECK_EQ(r.payloadLen(), 1u);
CHECK_EQ(r.payload()[0], 'K');
}
CASE("empty frames and the 16-bit length field");
{
uint8_t wire[512];
char blob[130];
for (size_t i = 0; i < sizeof(blob); i++) blob[i] = static_cast<char>('a' + (i % 26));
uint8_t payloadBuf[200];
ws::Reader r(payloadBuf, sizeof(payloadBuf));
const size_t n = buildClientFrame(wire, sizeof(wire), ws::kOpText, nullptr, 0);
CHECK_EQ(n, 2u + 4u); // a masked frame sends its mask key anyway
CHECK(r.feedAll(wire, n) == ws::In::Text);
CHECK_EQ(r.payloadLen(), 0u);
r.reset();
const size_t m = buildClientFrame(wire, sizeof(wire), ws::kOpText, blob, 126);
CHECK_EQ(m, 4u + 4u + 126u); // 2 + 2 extended length, mask, payload
CHECK(r.feedAll(wire, m) == ws::In::Text);
CHECK_EQ(r.payloadLen(), 126u);
CHECK(memcmp(r.payload(), blob, 126) == 0);
// A 64-bit length field is parsed (8 extension bytes) and then refused, because
// the payload it announces cannot fit the caller's buffer. The decoder never
// buffers beyond the cap it was constructed with.
r.reset();
const size_t k = buildClientFrame(wire, sizeof(wire), ws::kOpText, blob, 2, true, true,
0, 0x10000);
CHECK_EQ(k, 2u + 8u + 4u + 2u);
CHECK(r.feedAll(wire, k) == ws::In::Error);
CHECK(r.error() == ws::Err::TooLong);
}
CASE("control frames");
{
uint8_t wire[64], payloadBuf[64];
ws::Reader r(payloadBuf, sizeof(payloadBuf));
size_t n = buildClientFrame(wire, sizeof(wire), ws::kOpPing, "hi", 2);
CHECK(r.feedAll(wire, n) == ws::In::Ping);
CHECK_EQ(r.payloadLen(), 2u);
r.reset();
n = buildClientFrame(wire, sizeof(wire), ws::kOpPong, nullptr, 0);
CHECK(r.feedAll(wire, n) == ws::In::Pong);
r.reset();
n = buildClientFrame(wire, sizeof(wire), ws::kOpClose, "\x03\xe8", 2);
CHECK(r.feedAll(wire, n) == ws::In::Close);
CHECK_EQ(r.payload()[0], 3);
CHECK_EQ(r.payload()[1], 232);
}
CASE("protocol deviations are hard errors, not graceful surprises");
{
uint8_t wire[600], payloadBuf[64];
ws::Reader r(payloadBuf, sizeof(payloadBuf));
// unmasked client frame: RFC 6455 5.1 says the server must fail the connection
size_t n = buildClientFrame(wire, sizeof(wire), ws::kOpText, "A1", 2, /*mask=*/false);
CHECK(r.feedAll(wire, n) == ws::In::Error);
CHECK(r.error() == ws::Err::NotMasked);
r.reset();
n = buildClientFrame(wire, sizeof(wire), ws::kOpBinary, "A1", 2);
CHECK(r.feedAll(wire, n) == ws::In::Error);
CHECK(r.error() == ws::Err::BadOpcode);
r.reset();
n = buildClientFrame(wire, sizeof(wire), ws::kOpText, "A1", 2, true, /*fin=*/false);
CHECK(r.feedAll(wire, n) == ws::In::Error);
CHECK(r.error() == ws::Err::Fragmented);
r.reset();
n = buildClientFrame(wire, sizeof(wire), ws::kOpText, "A1", 2, true, true, /*rsv=*/1);
CHECK(r.feedAll(wire, n) == ws::In::Error);
CHECK(r.error() == ws::Err::ReservedBits);
char blob[200];
for (size_t i = 0; i < sizeof(blob); i++) blob[i] = static_cast<char>('A' + (i % 26));
r.reset();
n = buildClientFrame(wire, sizeof(wire), ws::kOpPing, blob, 126);
CHECK(r.feedAll(wire, n) == ws::In::Error);
CHECK(r.error() == ws::Err::ControlTooLong);
// a text frame larger than the reader's buffer is refused outright
r.reset();
n = buildClientFrame(wire, sizeof(wire), ws::kOpText, blob, 123);
CHECK(r.feedAll(wire, n) == ws::In::Error);
CHECK(r.error() == ws::Err::TooLong);
// 64-bit length field declaring more than the buffer can hold
r.reset();
n = buildClientFrame(wire, sizeof(wire), ws::kOpText, "A1", 2, true, true, 0,
/*declaredLen=*/1u << 20);
CHECK(r.feedAll(wire, n) == ws::In::Error);
CHECK(r.error() == ws::Err::TooLong);
}
CASE("handshake request parsing");
{
const char *req =
"GET /ws?token=hunter2 HTTP/1.1\r\n"
"Host: keyrelay.local\r\n"
"Upgrade: websocket\r\n"
"Connection: Upgrade\r\n"
"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n"
"Sec-WebSocket-Version: 13\r\n"
"\r\n";
ws::Request rq;
CHECK_EQ(ws::parseRequest(req, strlen(req), rq), 1);
CHECK(rq.complete);
CHECK_STR(rq.method, "GET");
CHECK_STR(rq.path, "/ws");
CHECK_STR(rq.query, "token=hunter2");
CHECK(rq.upgradeWebsocket);
CHECK_STR(rq.wsKey, "dGhlIHNhbXBsZSBub25jZQ==");
// and the accept value for the key we just parsed is the RFC's own example
char accept[64] = "";
CHECK(ws::acceptFor(rq.wsKey, strlen(rq.wsKey), accept, sizeof(accept)));
CHECK_STR(accept, "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=");
}
CASE("handshake request parsing is case-insensitive and whitespace-tolerant");
{
const char *req =
"GET / HTTP/1.1\r\n"
"upGRADE: WebSocket\r\n"
"sec-WEBSOCKET-key: dGhlIHNhbXBsZSBub25jZQ== \r\n"
"\r\n";
ws::Request rq;
CHECK_EQ(ws::parseRequest(req, strlen(req), rq), 1);
CHECK(rq.upgradeWebsocket);
CHECK_STR(rq.wsKey, "dGhlIHNhbXBsZSBub25jZQ==");
}
CASE("plain HTTP request is not an upgrade");
{
const char *req = "GET /status?token=abc HTTP/1.0\r\nHost: x\r\n\r\n";
ws::Request rq;
CHECK_EQ(ws::parseRequest(req, strlen(req), rq), 1);
CHECK(!rq.upgradeWebsocket);
CHECK_STR(rq.path, "/status");
CHECK_EQ(rq.wsKey[0], 0);
char token[64] = "";
CHECK(ws::tokenFromQuery(rq.query, strlen(rq.query), token, sizeof(token)));
CHECK_STR(token, "abc");
}
CASE("incomplete or malformed requests are distinguishable");
{
ws::Request rq;
const char *partial = "GET / HTTP/1.1\r\nUpgrade: websocket\r\n";
CHECK_EQ(ws::parseRequest(partial, strlen(partial), rq), 0); // need more bytes
const char *noSpace = "GARBAGE\r\n\r\n";
CHECK_EQ(ws::parseRequest(noSpace, strlen(noSpace), rq), -1);
const char *noLineEnd = "GET /";
CHECK_EQ(ws::parseRequest(noLineEnd, strlen(noLineEnd), rq), 0);
}
CASE("query token extraction");
{
char token[64];
struct { const char *q; bool ok; const char *value; } cases[] = {
{"token=abc", true, "abc"},
{"a=1&token=abc", true, "abc"},
{"token=abc&b=2", true, "abc"},
{"a=1", false, ""},
{"token=", false, ""},
{"xtoken=abc", false, ""}, // must be a whole parameter
{"a=1xtoken=abc", false, ""},
{"", false, ""},
};
for (const auto &c : cases) {
token[0] = '\0';
const bool got = ws::tokenFromQuery(c.q, strlen(c.q), token, sizeof(token));
CHECK(got == c.ok);
if (c.ok) CHECK_STR(token, c.value);
}
// a token longer than the caller's buffer is refused, not truncated
char tiny[4] = "";
CHECK(!ws::tokenFromQuery("token=abcdefghij", strlen("token=abcdefghij"), tiny,
sizeof(tiny)));
}
return report("ws_codec");
}
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""console.py -- talk to the board's serial console (stdlib only, no pyserial).
The console lives on /dev/ttyACM0: it is the CDC interface of the same composite USB
device as the HID keyboard, so it is present on whatever machine the board is plugged
into. It is where the station network and the control passphrase are set.
python3 tools/console.py # interactive, Ctrl-] to quit
python3 tools/console.py /dev/ttyACM0 status # one command, print the reply
python3 tools/console.py wifi "My SSID" "hunter2"
python3 tools/console.py token "a-long-passphrase"
python3 tools/console.py help
The baud rate is nominal: a USB CDC link carries no signalling, so 115200 is a
formality. Do not use this to type keystrokes -- there is no such command, by design.
"""
from __future__ import annotations
import os
import select
import sys
import termios
import tty
DEFAULT_PORT = "/dev/ttyACM0"
QUIT = "\x1d" # Ctrl-]
def open_raw(port: str) -> tuple[int, list]:
fd = os.open(port, os.O_RDWR | os.O_NOCTTY)
saved = termios.tcgetattr(fd)
tty.setraw(fd)
return fd, saved
def drain(fd: int, seconds: float) -> str:
out = bytearray()
deadline_remaining = seconds
while deadline_remaining > 0:
ready, _, _ = select.select([fd], [], [], deadline_remaining)
if not ready:
break
try:
chunk = os.read(fd, 4096)
except OSError:
break
if not chunk:
break
out += chunk
deadline_remaining = 1.0 # keep reading while it keeps talking
return out.decode(errors="replace")
def main() -> int:
args = sys.argv[1:]
port = DEFAULT_PORT
if args and args[0].startswith("/dev/"):
port = args[0]
args = args[1:]
if not os.path.exists(port):
print(f"{port} does not exist -- is the board plugged in here?", file=sys.stderr)
return 1
fd, saved = open_raw(port)
try:
if args:
os.write(fd, (" ".join(args) + "\n").encode())
sys.stdout.write(drain(fd, 2.0))
sys.stdout.flush()
return 0
print(f"{port} -- interactive console, Ctrl-] to quit, `help` for commands")
stdin_fd = sys.stdin.fileno()
while True:
ready, _, _ = select.select([stdin_fd, fd], [], [], 0.2)
if stdin_fd in ready:
char = os.read(stdin_fd, 1)
if not char or char == QUIT.encode():
break
os.write(fd, char)
if fd in ready:
sys.stdout.write(drain(fd, 0.05))
sys.stdout.flush()
return 0
finally:
termios.tcsetattr(fd, termios.TCSAFLUSH, saved)
os.close(fd)
if __name__ == "__main__":
sys.exit(main())
+703
View File
@@ -0,0 +1,703 @@
#!/usr/bin/env python3
"""relayctl -- talk to a keyrelay board from a terminal.
Stdlib only (socket, hashlib, base64, json): the board's protocol is a WebSocket of
tiny text commands, and the point of a keyboard bridge is that it needs nothing
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 cad # Ctrl+Alt+Del, with --yes
relayctl.py --host keyrelay.local listen # print every status heartbeat
relayctl.py --host keyrelay.local disarm
wl-paste | relayctl.py paste --yes # type what is on the clipboard
The passphrase is taken from --token, then $KEYRELAY_TOKEN. It is never written to
disk and never put in a URL (the /status HTTP endpoint takes it as a query parameter
because curl is the point of that endpoint; this tool uses the WebSocket instead).
`paste` is the one command that types more than a single key, because sending text a
human copied is a thing a keyboard bridge is for. It is gated accordingly: --yes is
required, the board is armed by that same deliberate invocation (arming cannot outlive
a controller, so no separate `arm` command could have done it), the text is never
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".
"""
from __future__ import annotations
import argparse
import base64
import hashlib
import json
import os
import socket
import struct
import sys
import time
DEFAULT_PORT = 80
WS_PATH = "/ws"
# The whole point of `keys` is verifying the keystroke path in one armed session.
# A cap keeps it that: enough for a test vector, not a way to type a paragraph.
MAX_SESSION_KEYS = 16
# Pasted text. All three numbers are the board's, not this tool's -- keep them in
# step with src/config.h (kTextFrameMax, kTextJobCap) or the board starts refusing
# frames this tool thought were legal.
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
GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
OP_TEXT, OP_PING, OP_PONG, OP_CLOSE = 0x1, 0x9, 0xA, 0x8
# KeyboardEvent.code -> HID usage id, the same table the controller page carries
# (src/web_page.h). Names here are those codes, so `key ShiftLeft` and
# `key BracketRight` work the way they do in the browser.
CODE_TO_USAGE = {
"KeyA": 0x04, "KeyB": 0x05, "KeyC": 0x06, "KeyD": 0x07, "KeyE": 0x08, "KeyF": 0x09,
"KeyG": 0x0A, "KeyH": 0x0B, "KeyI": 0x0C, "KeyJ": 0x0D, "KeyK": 0x0E, "KeyL": 0x0F,
"KeyM": 0x10, "KeyN": 0x11, "KeyO": 0x12, "KeyP": 0x13, "KeyQ": 0x14, "KeyR": 0x15,
"KeyS": 0x16, "KeyT": 0x17, "KeyU": 0x18, "KeyV": 0x19, "KeyW": 0x1A, "KeyX": 0x1B,
"KeyY": 0x1C, "KeyZ": 0x1D,
"Digit1": 0x1E, "Digit2": 0x1F, "Digit3": 0x20, "Digit4": 0x21, "Digit5": 0x22,
"Digit6": 0x23, "Digit7": 0x24, "Digit8": 0x25, "Digit9": 0x26, "Digit0": 0x27,
"Enter": 0x28, "Escape": 0x29, "Backspace": 0x2A, "Tab": 0x2B, "Space": 0x2C,
"Minus": 0x2D, "Equal": 0x2E, "BracketLeft": 0x2F, "BracketRight": 0x30,
"Backslash": 0x31, "IntlBackslash": 0x32, "Semicolon": 0x33, "Quote": 0x34,
"Backquote": 0x35, "Comma": 0x36, "Period": 0x37, "Slash": 0x38, "CapsLock": 0x39,
"F1": 0x3A, "F2": 0x3B, "F3": 0x3C, "F4": 0x3D, "F5": 0x3E, "F6": 0x3F, "F7": 0x40,
"F8": 0x41, "F9": 0x42, "F10": 0x43, "F11": 0x44, "F12": 0x45,
"PrintScreen": 0x46, "ScrollLock": 0x47, "Pause": 0x48, "Insert": 0x49,
"Home": 0x4A, "PageUp": 0x4B, "Delete": 0x4C, "End": 0x4D, "PageDown": 0x4E,
"ArrowRight": 0x4F, "ArrowLeft": 0x50, "ArrowDown": 0x51, "ArrowUp": 0x52,
"NumLock": 0x53,
"NumpadDivide": 0x54, "NumpadMultiply": 0x55, "NumpadSubtract": 0x56,
"NumpadAdd": 0x57, "NumpadEnter": 0x58, "Numpad1": 0x59, "Numpad2": 0x5A,
"Numpad3": 0x5B, "Numpad4": 0x5C, "Numpad5": 0x5D, "Numpad6": 0x5E, "Numpad7": 0x5F,
"Numpad8": 0x60, "Numpad9": 0x61, "Numpad0": 0x62, "NumpadDecimal": 0x63,
"IntlRo": 0x64, "ContextMenu": 0x65, "NumpadEqual": 0x67,
"F13": 0x68, "F14": 0x69, "F15": 0x6A, "F16": 0x6B, "F17": 0x6C, "F18": 0x6D,
"F19": 0x6E, "F20": 0x6F, "F21": 0x70, "F22": 0x71, "F23": 0x72, "F24": 0x73,
}
MOD_CODE = {
"ControlLeft": 0x01, "ShiftLeft": 0x02, "AltLeft": 0x04, "MetaLeft": 0x08,
"ControlRight": 0x10, "ShiftRight": 0x20, "AltRight": 0x40, "MetaRight": 0x80,
}
class RelayError(RuntimeError):
pass
# ---------------------------------------------------------------------------
# Text -> keystrokes
# ---------------------------------------------------------------------------
def normalize_paste(text: str) -> str:
"""CRLF and a lone CR both become LF.
The board maps \r to Enter, so an unnormalised Windows paste would type two
Enters per line. The board cannot fix this itself: a CRLF pair can straddle a
frame boundary, so normalising belongs to whoever holds the whole text.
"""
return text.replace("\r\n", "\n").replace("\r", "\n")
def untypeable(text: str) -> list[str]:
"""Characters with no key on a US-ANSI keyboard.
Tab, newline and printable ASCII are what the board's character map can reach.
Anything else -- curly quotes, em dashes, UTF-8 accents -- is reported instead of
guessed at, because a paste that quietly loses a character is worse than one that
refuses to go.
"""
return [ch for ch in text
if not (ord(ch) == 9 or ord(ch) == 10 or 32 <= ord(ch) <= 126)]
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()}"
# ---------------------------------------------------------------------------
# WebSocket, the smallest useful slice
# ---------------------------------------------------------------------------
def expect_accept(key: str) -> str:
"""Sec-WebSocket-Accept for a Sec-WebSocket-Key (RFC 6455 1.3)."""
digest = hashlib.sha1((key + GUID).encode()).digest()
return base64.b64encode(digest).decode()
def encode_frame(opcode: int, payload: bytes = b"") -> bytes:
"""Client frames are always masked (RFC 6455 5.1: a server must reject others)."""
out = bytearray([0x80 | opcode])
n = len(payload)
if n < 126:
out.append(0x80 | n)
elif n <= 0xFFFF:
out.append(0x80 | 126)
out += struct.pack(">H", n)
else:
out.append(0x80 | 127)
out += struct.pack(">Q", n)
mask = os.urandom(4)
out += mask
out += bytes(b ^ mask[i & 3] for i, b in enumerate(payload))
return bytes(out)
def mask_payload(payload: bytes, mask: bytes) -> bytes:
"""XOR with the repeating 4-byte mask key. Its own inverse, as the spec says."""
return bytes(b ^ mask[i & 3] for i, b in enumerate(payload))
class Receiver:
"""Incremental frame decoder for the unmasked frames the board sends."""
def __init__(self) -> None:
self.buf = bytearray()
def feed(self, data: bytes):
"""Yield (opcode, payload) for every complete frame in `data`."""
self.buf.extend(data)
while True:
if len(self.buf) < 2:
return
b0, b1 = self.buf[0], self.buf[1]
if b1 & 0x80:
raise RelayError("board sent a masked frame (RFC 6455 forbids that)")
if not b0 & 0x80:
raise RelayError("board sent a fragmented frame")
length = b1 & 0x7F
offset = 2
if length == 126:
if len(self.buf) < offset + 2:
return
length = struct.unpack(">H", bytes(self.buf[offset:offset + 2]))[0]
offset += 2
elif length == 127:
if len(self.buf) < offset + 8:
return
length = struct.unpack(">Q", bytes(self.buf[offset:offset + 8]))[0]
offset += 8
if len(self.buf) < offset + length:
return
payload = bytes(self.buf[offset:offset + length])
del self.buf[:offset + length]
yield b0 & 0x0F, payload
class Relay:
def __init__(self, host: str, port: int = DEFAULT_PORT, token: str = "",
timeout: float = 5.0) -> None:
self.host = host
self.timeout = timeout
self.token = token
self.sock = socket.create_connection((host, port), timeout)
self.receiver = Receiver()
key = base64.b64encode(os.urandom(16)).decode()
request = (
f"GET {WS_PATH} HTTP/1.1\r\n"
f"Host: {host}\r\n"
f"Upgrade: websocket\r\n"
f"Connection: Upgrade\r\n"
f"Sec-WebSocket-Key: {key}\r\n"
f"Sec-WebSocket-Version: 13\r\n\r\n"
)
self.sock.sendall(request.encode())
head = b""
while b"\r\n\r\n" not in head:
chunk = self.sock.recv(4096)
if not chunk:
raise RelayError("server closed during the handshake")
head += chunk
if len(head) > 16384:
raise RelayError("handshake response too large")
status_line = head.split(b"\r\n", 1)[0].decode(errors="replace")
# Header NAMES are case-insensitive; header VALUES are not. Lowercasing the
# whole block -- as the first version of this did -- lowercases the base64
# accept hash too, and base64 is case-sensitive, so every handshake looked
# broken while the board was answering correctly. Parse into fields instead.
fields: dict[str, str] = {}
for line in head.split(b"\r\n\r\n", 1)[0].decode("latin-1").split("\r\n")[1:]:
name, sep, value = line.partition(":")
if sep:
fields[name.strip().lower()] = value.strip()
if " 101 " not in status_line + " ":
detail = "refused"
if "401" in status_line:
detail = "passphrase required (this endpoint does not take one)"
elif "503" in status_line:
detail = "another controller already holds the bridge"
raise RelayError(f"handshake failed: {status_line} ({detail})")
want = expect_accept(key)
got = fields.get("sec-websocket-accept", "")
if got != want:
raise RelayError(f"handshake failed: Sec-WebSocket-Accept is {got!r}, "
f"expected {want!r}")
self._send("T" + self.token)
reply = self._await_reply()
if reply != "OK":
raise RelayError(f"authentication refused: {reply or 'no reply'}")
# -- plumbing -----------------------------------------------------------
def _send(self, text: str) -> None:
self.sock.sendall(encode_frame(OP_TEXT, text.encode()))
def _poll(self, want_status: bool, timeout: float):
deadline = time.time() + timeout
while time.time() < deadline:
for opcode, payload in self._read_any(max(0.0, deadline - time.time())):
if opcode == OP_PING:
self.sock.sendall(encode_frame(OP_PONG, payload))
elif opcode == OP_CLOSE:
raise RelayError("board closed the connection")
elif opcode == OP_TEXT:
text = payload.decode(errors="replace")
if text.startswith("S{"):
if want_status:
return json.loads(text[1:])
elif text == "OK":
if not want_status:
return text
elif text.startswith("E"):
raise RelayError(f"board said {text!r}")
else:
print(f"board: {text}", file=sys.stderr)
if not want_status:
return None
return None
def _read_any(self, timeout: float):
self.sock.settimeout(max(0.05, timeout))
try:
data = self.sock.recv(4096)
except socket.timeout:
return []
if not data:
raise RelayError("connection closed by the board")
return self.receiver.feed(data)
def _await_reply(self, timeout: float = 4.0):
return self._poll(want_status=False, timeout=timeout)
# -- commands -----------------------------------------------------------
def wait_idle(self, timeout: float) -> bool:
"""Poll until the board says it has nothing left to type, or the time is up.
Keeps the connection talking while it waits: a controller that says nothing
for kNoClientTimeoutMs is one the board disarms, and a long paste would
otherwise stop itself halfway through. `typing` in the status frame is the
board's own count, so this is flow control rather than a guess at timing.
"""
deadline = time.time() + timeout
next_beat = time.time() + 2.0
while time.time() < deadline:
if time.time() >= next_beat:
self._send("K")
next_beat = time.time() + 2.0
got = self._poll(want_status=True, timeout=0.25)
if got is not None and not got.get("typing"):
return True
return False
def type_text(self, text: str, delay_ms: int = 0, show: bool = True):
"""Type text, one frame per TEXT_FRAME_MAX characters, board-paced.
Returns (characters confirmed typed, seconds elapsed). A frame the board
refuses raises RelayError out of _poll, so a run that cannot continue does not
quietly carry on with what is left of the text.
"""
pace = delay_ms or DEFAULT_TEXT_PACE_MS
chunks = [text[i:i + TEXT_FRAME_MAX] for i in range(0, len(text), TEXT_FRAME_MAX)]
started = time.time()
sent = confirmed = 0
for chunk in chunks:
self._send(text_frame(delay_ms, chunk))
sent += len(chunk)
# The chunk's own typing time, plus a heartbeat's worth of slack.
if self.wait_idle(len(chunk) * (pace + 4) / 1000.0 + 4.0):
confirmed = sent
if show:
print(f" {confirmed}/{len(text)} characters", end="\r", flush=True)
if confirmed != sent:
break
if show and chunks:
print(" " * 34, end="\r" if confirmed == len(text) else "\n", flush=True)
return confirmed, time.time() - started
def _drain(self, seconds: float = 0.4) -> None:
"""Throw away whatever is already buffered.
The board heartbeats status at 1 Hz whether or not anyone asked, and the key
helpers do not read while they type, so seconds of stale frames pile up in the
socket. Reading a status without draining first returns one of *those* -- which
is how `disarm` looked like it had failed while printing `armed YES`: the frame
shown was a heartbeat sent before the command. Pending error frames are still
raised, because an E-frame is never stale.
"""
deadline = time.time() + seconds
while time.time() < deadline:
for opcode, payload in self._read_any(min(0.1, max(0.01, deadline - time.time()))):
if opcode == OP_PING:
# Answer it the way _poll does: an unanswered ping is a link the
# board may decide is dead, and this method must not be the one
# thing that breaks the keepalive (it called methods that do not
# exist, which only ever showed up on a session over 15 s long).
self.sock.sendall(encode_frame(OP_PONG, payload))
elif opcode == OP_CLOSE:
raise RelayError("board closed the connection")
elif opcode == OP_TEXT:
text = payload.decode("utf-8", "replace")
if text.startswith("E"):
raise RelayError(f"board said {text!r}")
def status(self) -> dict:
self._drain()
self._send("H")
got = self._poll(want_status=True, timeout=self.timeout)
if got is None:
raise RelayError("no status reply")
return got
def _settle(self, want_armed: bool, timeout: float = 2.0) -> dict:
"""Poll until the board reports the arming we asked for."""
deadline = time.time() + timeout
last: dict | None = None
while time.time() < deadline:
self._drain(0.0)
got = self._poll(want_status=True, timeout=0.25)
if got is not None:
last = got
if bool(got.get("armed")) == want_armed:
return got
return last or {}
def arm(self) -> dict:
self._drain()
self._send("A1")
state = self._settle(True)
if not state.get("armed"):
raise RelayError("the board did not arm: " +
", ".join(f"{k}={v}" for k, v in state.items()))
return state
def disarm(self) -> dict:
self._drain()
self._send("R") # keys up first, then disarm
self._send("A0")
state = self._settle(False)
if state.get("armed"):
raise RelayError("the board did not disarm -- use the button on the board")
return state
def release(self) -> None:
self._send("R")
def state(self, mods: int, usages) -> None:
body = ",".join(f"{u:02x}" for u in usages)
self._send(f"S={mods:02x}:{body}")
def one_key(self, usage: int, hold_ms: int) -> None:
self.state(0x00, [usage])
time.sleep(hold_ms / 1000.0)
self.state(0x00, [])
def chord(self, mods: int, usages) -> None:
body = ",".join(f"{u:02x}" for u in usages)
self._send(f"Y={mods:02x}:{body}")
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."""
deadline = time.time() + seconds
next_beat = time.time() + 2.0
while time.time() < deadline:
if time.time() >= next_beat:
self._send("K")
next_beat = time.time() + 2.0
try:
for opcode, payload in self._read_any(1.0):
if opcode == OP_PING:
self.sock.sendall(encode_frame(OP_PONG, payload))
elif opcode == OP_TEXT:
text = payload.decode(errors="replace")
if text.startswith("S{"):
s = json.loads(text[1:])
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"drop={s.get('drop')} leds=0x{s.get('leds'):02x}")
else:
print(f" {text}")
elif opcode == OP_CLOSE:
return
except socket.timeout:
continue
def close(self) -> None:
try:
self.sock.sendall(encode_frame(OP_CLOSE))
except OSError:
pass
self.sock.close()
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def resolve_key(name: str) -> int:
if name.lower().startswith("0x"):
value = int(name, 16)
if not 0 < value <= 0xFF:
raise RelayError("a usage id is 01..ff")
return value
if name in CODE_TO_USAGE:
return CODE_TO_USAGE[name]
known = ", ".join(sorted(CODE_TO_USAGE)[:12])
raise RelayError(f"unknown key {name!r} (try 0x28, or one of: {known}, ...)")
def describe(status: dict) -> str:
lines = [
f" firmware {status.get('v')}",
f" uptime {status.get('up', 0)} s",
f" network {status.get('net')} ssid={status.get('ssid') or '-'} "
f"ip={status.get('ip') or '-'}",
f" target {'attached over USB' if status.get('usb') else 'NOT ATTACHED'}",
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" 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)} "
f"num={bool(status.get('leds', 0) & 1)}",
]
if status.get("dflt"):
lines.append(" WARNING the board still uses the default passphrase")
if status.get("roll"):
lines.append(" NOTE more than six keys are down: ErrorRollOver is out")
return "\n".join(lines)
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--host", default="keyrelay.local",
help="board address (default keyrelay.local; on its own AP use "
"192.168.4.1)")
ap.add_argument("--port", type=int, default=DEFAULT_PORT)
ap.add_argument("--token", default=os.environ.get("KEYRELAY_TOKEN", ""),
help="control passphrase (or $KEYRELAY_TOKEN)")
ap.add_argument("--timeout", type=float, default=5.0)
sub = ap.add_subparsers(dest="cmd", required=True)
sub.add_parser("status", help="print what the board reports")
sub.add_parser("arm", help="arm the bridge (nothing types before this)")
sub.add_parser("disarm", help="release every key and disarm")
sub.add_parser("release", help="release every key, stay armed")
p_cad = sub.add_parser("cad", help="Ctrl+Alt+Del -- needs --yes, and a thought")
p_cad.add_argument("--yes", action="store_true",
help="acknowledge that this reboots a Linux virtual terminal")
p_key = sub.add_parser("key", help="press and release exactly one key")
p_key.add_argument("name", help="key code name (Enter, KeyA, F7...) or 0x28")
p_key.add_argument("--hold", type=int, default=40, metavar="MS",
help="how long to hold it (default 40)")
p_chord = sub.add_parser("chord", help="press one chord: mods plus usages")
p_chord.add_argument("mods", help="modifier byte, e.g. 05 = LCtrl|LAlt")
p_chord.add_argument("usages", nargs="*", help="usage ids, e.g. 4c")
p_listen = sub.add_parser("listen", help="print status heartbeats")
p_listen.add_argument("seconds", nargs="?", type=float, default=20.0)
p_paste = sub.add_parser(
"paste",
help="type text a human is sending (e.g. `wl-paste | relayctl paste --yes`)")
p_paste.add_argument("text", nargs="?",
help="the text; omit it, or give '-', to read stdin")
p_paste.add_argument("--delay", type=int, default=0, metavar="MS",
help="milliseconds per character (0 = the board's default)")
p_paste.add_argument("--strip", action="store_true",
help="drop characters this keyboard has no key for, instead "
"of refusing the paste")
p_paste.add_argument("--trailing-enter", action="store_true",
help="press Enter once the text has been typed")
p_paste.add_argument("--keep-armed", action="store_true",
help="leave the bridge armed afterwards")
p_paste.add_argument("--yes", action="store_true",
help="acknowledge that this types into the target")
p_keys = sub.add_parser(
"keys",
help="verification session: arm, press these keys, disarm again")
p_keys.add_argument("names", nargs="+", metavar="KEY",
help=f"key names or 0xNN, at most {MAX_SESSION_KEYS}")
p_keys.add_argument("--gap", type=int, default=120, metavar="MS",
help="pause between keys (default 120)")
p_keys.add_argument("--hold", type=int, default=40, metavar="MS",
help="how long each key is held (default 40)")
p_keys.add_argument("--keep-armed", action="store_true",
help="stay armed afterwards (the board still disarms on "
"silence 8 s after this process exits)")
args = ap.parse_args()
if not args.token:
print("no passphrase: --token or $KEYRELAY_TOKEN", file=sys.stderr)
return 2
# Checked before anything is contacted: the acknowledgement is the point, and
# finding out you forgot it should not require a reachable board, a token and a
# connection that then has to be torn down again.
if args.cmd == "paste" and not args.yes:
print("paste types a whole run of characters into the machine the board is "
"plugged into.\nRe-run with --yes if a human means it.\n\n"
" printf 'hello\\n' | relayctl.py --host keyrelay.local paste --yes",
file=sys.stderr)
return 4
# Everything about the text that can be settled locally is settled here, before a
# socket is opened: a paste the board would refuse should fail in the terminal
# rather than half-arm a bridge and then stop.
paste_text = ""
if args.cmd == "paste":
# No argument, or '-', is the clipboard: `wl-paste | relayctl paste --yes`.
raw = sys.stdin.read() if args.text in (None, "-") else args.text
paste_text = normalize_paste(raw)
if args.trailing_enter:
paste_text += "\n"
if not paste_text:
print("nothing to type: stdin was empty and no text was given",
file=sys.stderr)
return 2
bad = untypeable(paste_text)
if bad:
if not args.strip:
first = bad[0]
print(f"{len(bad)} of {len(paste_text)} characters have no key on a "
f"US-ANSI keyboard (first is {first!r} "
f"U+{ord(first):04X}).\nRe-run with --strip to type the rest, "
f"or fix the text.", file=sys.stderr)
return 2
paste_text = "".join(ch for ch in paste_text if ord(ch) == 9
or ord(ch) == 10 or 32 <= ord(ch) <= 126)
print(f"dropped {len(bad)} character(s) with no key here")
if not paste_text:
print("nothing left to type", file=sys.stderr)
return 2
if len(paste_text) > MAX_PASTE_CHARS:
print(f"{len(paste_text)} characters is more than one run holds "
f"({MAX_PASTE_CHARS}); send it in pieces", file=sys.stderr)
return 2
try:
relay = Relay(args.host, args.port, args.token, args.timeout)
except (OSError, RelayError) as exc:
print(f"cannot use the board at {args.host}: {exc}", file=sys.stderr)
return 1
try:
if args.cmd == "status":
print(describe(relay.status()))
elif args.cmd == "arm":
relay.arm()
print("armed -- keystrokes now go to the target")
print(describe(relay.status()))
elif args.cmd == "disarm":
relay.disarm()
print("disarmed, every key released")
elif args.cmd == "release":
relay.release()
print("every key released (still armed)")
elif args.cmd == "key":
usage = resolve_key(args.name)
before = relay.status()
if not before.get("armed"):
print("the bridge is disarmed: nothing was sent. Run `arm` first.",
file=sys.stderr)
return 3
print(f"sending {args.name} (usage 0x{usage:02x}) for {args.hold} ms ...")
relay.one_key(usage, args.hold)
elif args.cmd == "chord":
mods = int(args.mods, 16)
usages = [int(u, 16) for u in args.usages]
if not usages and not mods:
print("a chord needs a modifier or a key", file=sys.stderr)
return 2
print(f"sending chord Y={mods:02x}:"
f"{','.join(f'{u:02x}' for u in usages)}")
relay.chord(mods, usages)
elif args.cmd == "cad":
if not args.yes:
print("Ctrl+Alt+Del: on a Linux virtual terminal this REBOOTS the "
"target; on Windows it is the secure attention sequence.\n"
"Re-run with --yes if that is what you meant.", file=sys.stderr)
return 4
status = relay.status()
if not status.get("armed"):
print("the bridge is disarmed: nothing was sent.", file=sys.stderr)
return 3
print("sending Ctrl+Alt+Del ...")
relay.chord(0x05, [0x4C])
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
# this work), so arm-and-type has to be a single client's business. It is
# capped and it disarms afterwards: a session, not a script.
if len(args.names) > MAX_SESSION_KEYS:
print(f"at most {MAX_SESSION_KEYS} keys per session -- this is a "
f"verification tool, not a typer",
file=sys.stderr)
return 2
usages = [resolve_key(n) for n in args.names]
print("target: " + " ".join(
f"{n}({u:#04x})" for n, u in zip(args.names, usages)))
relay.arm()
print("armed -- LED should be solid red on the board")
for name, usage in zip(args.names, usages):
print(f" {name} ...", end=" ", flush=True)
relay.one_key(usage, args.hold)
time.sleep(args.gap / 1000.0)
print()
if args.keep_armed:
print("left armed")
else:
relay.disarm()
print("disarmed, every key released")
print(describe(relay.status()))
elif args.cmd == "paste":
text = paste_text
# Deliberately the count and not the content: what was typed is nobody's
# business, including this terminal's scrollback.
print(f"about to type {len(text)} characters into "
f"{args.host} at {args.delay or DEFAULT_TEXT_PACE_MS} ms each")
relay.arm()
print("armed -- the board's LED should be solid red")
typed, elapsed = relay.type_text(text, args.delay)
print(f"typed {typed} of {len(text)} characters in {elapsed:.1f}s")
if args.keep_armed:
print("left armed")
else:
relay.disarm()
print("disarmed, every key released")
elif args.cmd == "listen":
relay.listen(args.seconds)
except RelayError as exc:
print(str(exc), file=sys.stderr)
return 1
except KeyboardInterrupt:
print()
finally:
relay.close()
return 0
if __name__ == "__main__":
sys.exit(main())
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env python3
"""to_bootloader.py -- put the AtomS3 Lite into ROM USB download mode, stdlib only.
The firmware owns the USB pads (USB-OTG / TinyUSB), so esptool cannot pulse the board
into its bootloader by itself: with an app running, `pio run -t upload` sits at
"Connecting...". The Arduino CDC implementation watches for a line-coding change to
1200 baud and responds by calling usb_persist_restart(RESTART_BOOTLOADER), which hands
the pads back to the built-in USB-Serial/JTAG and forces a download-mode boot. Setting
a tty's baud rate is exactly the SET_LINE_CODING control transfer that triggers it, so
termios is enough -- no pyserial, no GPIO0 (which this board does not break out).
python3 tools/to_bootloader.py [port] # default /dev/ttyACM0
pio run -e bridge -t upload # then flash
With no host cooperation at all: hold the top button while the board powers up and
keep holding for 3 s (the LED goes solid red). The firmware does the same thing.
"""
from __future__ import annotations
import glob
import os
import sys
import termios
import time
VENDOR_ESPRESSIF = "303a"
ROM_MARKER = "JTAG" # the ROM enumerates as "USB JTAG/serial debug unit"
def read_sysfs(path: str) -> str:
try:
with open(path) as handle:
return handle.read().strip()
except OSError:
return ""
def download_mode_port(timeout: float) -> str | None:
"""Wait for the ROM's USB-Serial/JTAG port to show up."""
deadline = time.time() + timeout
while time.time() < deadline:
for device in glob.glob("/sys/bus/usb/devices/*"):
if read_sysfs(f"{device}/idVendor") != VENDOR_ESPRESSIF:
continue
if ROM_MARKER not in read_sysfs(f"{device}/product"):
continue
for tty in glob.glob(f"{device}/*/tty/ttyACM*"):
return "/dev/" + tty.rsplit("/", 1)[-1]
time.sleep(0.2)
return None
def touch(port: str) -> None:
"""Ask the running firmware for a download-mode reboot by setting 1200 baud."""
fd = os.open(port, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK)
try:
# tcgetattr -> [iflag, oflag, cflag, lflag, ispeed, ospeed, cc] (indices 4
# and 5 are the speeds); setting them is what makes the kernel issue the CDC
# SET_LINE_CODING the firmware watches for at 1200 baud.
attrs = termios.tcgetattr(fd)
attrs[4] = termios.B1200
attrs[5] = termios.B1200
termios.tcsetattr(fd, termios.TCSANOW, attrs)
time.sleep(0.3)
finally:
# The chip resets underneath us; EPROTO/ENODEV here means it worked.
try:
os.close(fd)
except OSError:
pass
def main() -> int:
port = sys.argv[1] if len(sys.argv) > 1 else "/dev/ttyACM0"
if not os.path.exists(port):
print(f"{port} does not exist -- is the board plugged in?", file=sys.stderr)
return 1
print(f"touching {port} at 1200 baud ...")
try:
touch(port)
except OSError as exc:
print(f"port dropped out ({exc.strerror}) -- that is the reboot")
found = download_mode_port(timeout=10.0)
if found:
print(f"ROM download mode reached on {found}")
print("now flash with: pio run -e bridge -t upload")
return 0
print("no ROM download port appeared.", file=sys.stderr)
print("fallback: hold the top button while the board powers up and keep holding "
"for 3 s (LED solid red), then flash.", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())
+224
View File
@@ -0,0 +1,224 @@
#!/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.
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
It reads, it does not write: EV_KEY 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).
"""
from __future__ import annotations
import argparse
import glob
import select
import struct
import sys
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
IDENTITIES = {("046d", "c33f"), ("303a", "1001")}
# evdev key code -> label. Covers everything keyrelay can put on the wire, from the
# modifier byte through the navigation cluster and the numpad.
KEY_NAMES = {
1: "Esc", 2: "1", 3: "2", 4: "3", 5: "4", 6: "5", 7: "6", 8: "7", 9: "8",
10: "9", 11: "0", 12: "Minus", 13: "Equal", 14: "Backspace", 15: "Tab",
16: "Q", 17: "W", 18: "E", 19: "R", 20: "T", 21: "Y", 22: "U", 23: "I",
24: "O", 25: "P", 26: "BracketLeft", 27: "BracketRight", 28: "Enter",
29: "LCtrl", 30: "A", 31: "S", 32: "D", 33: "F", 34: "G", 35: "H", 36: "J",
37: "K", 38: "L", 39: "Semicolon", 40: "Quote", 41: "Backquote",
42: "LShift", 43: "Backslash", 44: "Z", 45: "X", 46: "C", 47: "V", 48: "B",
49: "N", 50: "M", 51: "Comma", 52: "Period", 53: "Slash", 54: "RShift",
55: "KpMultiply", 56: "LAlt", 57: "Space", 58: "CapsLock", 59: "F1", 60: "F2",
61: "F3", 62: "F4", 63: "F5", 64: "F6", 65: "F7", 66: "F8", 67: "F9",
68: "F10", 69: "NumLock", 70: "ScrollLock", 71: "Kp7", 72: "Kp8", 73: "Kp9",
74: "KpMinus", 75: "Kp4", 76: "Kp5", 77: "Kp6", 78: "KpPlus", 79: "Kp1",
80: "Kp2", 81: "Kp3", 82: "Kp0", 83: "KpDecimal", 86: "IntlBackslash",
87: "F11", 88: "F12", 96: "KpEnter", 97: "RCtrl", 99: "PrintScreen",
100: "RAlt", 102: "Home", 103: "Up", 104: "PageUp", 105: "Left",
106: "Right", 107: "End", 108: "Down", 109: "PageDown", 110: "Insert",
111: "Delete", 119: "Pause", 125: "LSuper", 126: "RSuper", 127: "ContextMenu",
158: "KpLeftParen", 159: "KpRightParen",
# HID usages 0x68..0x73. Unnamed at first, which hid a whole verification run:
# --show-all existed but the F13..F24 row people actually test with was missing.
183: "F13", 184: "F14", 185: "F15", 186: "F16", 187: "F17", 188: "F18",
189: "F19", 190: "F20", 191: "F21", 192: "F22", 193: "F23", 194: "F24",
}
# 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 = {
57: (" ", " "), 2: ("1", "!"), 3: ("2", "@"), 4: ("3", "#"), 5: ("4", "$"),
6: ("5", "%"), 7: ("6", "^"), 8: ("7", "&"), 9: ("8", "*"), 10: ("9", "("),
11: ("0", ")"), 12: ("-", "_"), 13: ("=", "+"), 26: ("[", "{"), 27: ("]", "}"),
39: (";", ":"), 40: ("'", '"'), 41: ("`", "~"), 43: ("\\", "|"),
51: (",", "<"), 52: (".", ">"), 53: ("/", "?"), 86: ("\\", "|"),
15: ("\t", "\t"), 28: ("\n", "\n"),
}
for _code, _letter in [(16, "q"), (17, "w"), (18, "e"), (19, "r"), (20, "t"), (21, "y"),
(22, "u"), (23, "i"), (24, "o"), (25, "p"), (30, "a"), (31, "s"),
(32, "d"), (33, "f"), (34, "g"), (35, "h"), (36, "j"), (37, "k"),
(38, "l"), (44, "z"), (45, "x"), (46, "c"), (47, "v"), (48, "b"),
(49, "n"), (50, "m")]:
TYPED[_code] = (_letter, _letter.upper())
SHIFTS = {42, 54}
CONTROLS = {29, 97, 56, 100, 125, 126}
# Linux EV_LED indices are ZERO-based (LED_NUML 0, LED_CAPSL 1, LED_SCROLLL 2 ...),
# which is one less than the HID/USB LED bit positions the firmware reports -- the
# first version of this table was copied from the HID numbering and labelled every
# LED one along. Verified against real events: a CapsLock toggle reports code 1.
LED_NAMES = {0: "NumLock", 1: "CapsLock", 2: "ScrollLock", 3: "Compose",
4: "Kana", 5: "Sleep", 6: "Suspend"}
# Sequences worth calling out by name instead of as three separate key presses:
# these are the ones whose whole meaning is the combination.
CHORDS = {
frozenset({29, 56, 111}): "Ctrl+Alt+Del",
frozenset({29, 56, 61}): "Ctrl+Alt+F3 (VT switch)",
frozenset({29, 56, 59}): "Ctrl+Alt+F1 (VT switch)",
frozenset({29, 56, 60}): "Ctrl+Alt+F2 (VT switch)",
frozenset({29, 56, 62}): "Ctrl+Alt+F4 (VT switch)",
frozenset({29, 56, 70}): "Ctrl+Alt+ScrollLock (SysRq reboot path)",
frozenset({125, 56}): "Super+Alt",
}
def read_sysfs(path: str) -> str:
try:
with open(path) as handle:
return handle.read().strip()
except OSError:
return ""
def keyboard_nodes() -> list[str]:
"""evdev nodes of every USB device in IDENTITIES."""
nodes: list[str] = []
for device in glob.glob("/sys/bus/usb/devices/*"):
if (read_sysfs(f"{device}/idVendor"),
read_sysfs(f"{device}/idProduct")) not in IDENTITIES:
continue
# 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))
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--seconds", type=float, default=20.0)
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)")
args = ap.parse_args()
nodes = keyboard_nodes()
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")
path = nodes[0]
print(f"watching {path} for {args.seconds:.0f} s -- press keys on the controller")
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
with open(path, "rb", buffering=0) as handle:
deadline = time.time() + args.seconds
while time.time() < deadline:
remaining = max(0.0, deadline - time.time())
if not select.select([handle], [], [], remaining)[0]:
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
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)
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")
if errors:
print(f" short reads {errors}")
return 0
if __name__ == "__main__":
sys.exit(main())