diff --git a/.gitignore b/.gitignore index d47e6bf..81ffef0 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,10 @@ *.bak .DS_Store +# Python bytecode (tools/) +__pycache__/ +*.pyc + # Git worktrees .worktrees/ diff --git a/KMBOX_NET_FIRMWARE_REQUIREMENTS.md b/KMBOX_NET_FIRMWARE_REQUIREMENTS.md new file mode 100644 index 0000000..63f7954 --- /dev/null +++ b/KMBOX_NET_FIRMWARE_REQUIREMENTS.md @@ -0,0 +1,384 @@ +# Hurra-v2 firmware changes for KMBox Net endpoint support + +**Status:** Implemented on `fix/humanization-injection` — all four features +(enum/stubs, mask, monitor, smoothed moves) landed and build clean under both +`PROTOCOL=hurra` and `PROTOCOL=ferrum`; host unit tests pass. The host side +(`hurra-app`) still stubs these commands (`warn_once` "firmware-pending") and +must add the encoders/decoders to match the wire layouts below — note the +bezier payload is **14 bytes** (see §3). +**Audience:** Whoever implements the firmware side. Assumes familiarity with the +iMXRT firmware in this repo but not with the host-side work that motivates it. +**Companion (host) repo:** `hurra-app` — branch `feat/kmbox-net-endpoint`. +Host spec: `hurra-app/docs/superpowers/specs/2026-06-09-kmbox-net-endpoint-design.md`. + +--- + +## 1. Background and why these changes exist + +`hurra-app` is the host-side bridge. It opens the real serial link to a Hurra +device and exposes an input endpoint that third-party tools talk to. Until now +that endpoint was **VCOM/Ferrum** (a virtual COM port speaking the Ferrum ASCII +protocol). We have added a **second endpoint: KMBox Net**, a UDP protocol used by +a large ecosystem of existing tools. + +In KMBox Net the *device* is a UDP server: client tools send fixed-layout command +packets to it and it echoes a 16-byte ACK. The host bridge therefore **emulates a +KMBox Net device** — it binds a UDP socket, accepts those packets, and translates +each into a Hurra binary frame over the serial link to this firmware. + +### The hard architectural rule: the device does the work, the host only translates + +The host bridge is a **thin translator**. It MUST NOT emulate device behaviour. +There is deliberately **no host-side smoothing, no host-side physical-input +synthesis, and no host-side input masking**. Every behaviour that a KMBox Net +client expects must be performed **on this device**, reached through the Hurra +binary protocol. + +Why: the same device must behave identically whether driven through the +VCOM/Ferrum endpoint or the KMBox Net endpoint, and we refuse to maintain a +second, drifting implementation of device behaviour on the host. Keeping the +logic here is the whole point. + +### What already works without firmware changes + +These KMBox Net commands map cleanly onto existing Hurra frames; the host already +forwards them and **no firmware change is needed**: + +| KMBox Net command | Existing Hurra frame the host sends | +|------------------------------|-----------------------------------------------| +| `connect` | (handshake only; host may probe `TYPE_VERSION`)| +| `mouse_move(x,y)` | `TYPE_MOUSE_MOVE 0x10` | +| `mouse_left/right/middle` | `TYPE_BTN_LEFT/RIGHT/MIDDLE 0x20/0x21/0x22` | +| `mouse_wheel(n)` | `TYPE_MOUSE_WHEEL 0x15` | +| `mouse_all(btn,x,y,wheel)` | `TYPE_MOUSE_MO 0x13` | +| `keyboard_all(mod,keys[])` | `TYPE_KB_MULTIDOWN/UP 0x46/0x47` (+ modifiers) | +| `reboot` | `TYPE_REBOOT 0x04` | + +### What this document covers + +Four KMBox Net capabilities need device behaviour the Hurra protocol does **not** +have today. Until the firmware implements them, the host **ACKs the client and +logs `pending firmware: ` once** — it does nothing else. This document +specifies the four firmware features and the new wire frames that drive them. + +The host has already **reserved the wire type codes** in +`hurra-app/include/hurra_types.h`. This firmware MUST use the **same numeric +values** so the two repos stay byte-compatible. The codes are currently free in +this firmware's `enum` in `src/hurra.c` (verified: 0x1B, 0x1C, 0x68, 0x77, and +0x86–0x88 are unused). + +--- + +## 2. Conventions (shared wire contract) + +- **Transport:** TinyFrame over the 4 Mbaud serial link, exactly as today. Each + feature is one new `TYPE_*` value plus a payload, registered with + `TF_AddTypeListener` in `hurra_init()` (`src/hurra.c`). +- **Endianness:** all multi-byte payload fields are **little-endian**, matching + every existing Hurra frame (`rd_i16le` / `wr_i16le` helpers in `src/hurra.c`). +- **Oneway vs. reply:** the movement and mask frames are *oneway* (no reply, + like `TYPE_MOUSE_MOVE`). The telemetry frames are *unsolicited pushes* from the + device (like `TYPE_TLM_AXIS`), gated by an enable toggle. +- **Type code allocation (MUST match host `hurra_types.h`):** + + | New `TYPE_*` | Value | Direction | Purpose | + |------------------------------|--------|-------------------|--------------------------------------| + | `TYPE_MOUSE_MOVE_DUR` | `0x1B` | host → device | duration-stepped (smoothed) move | + | `TYPE_MOUSE_MOVE_BEZIER` | `0x1C` | host → device | cubic Bézier move over a duration | + | `TYPE_PHYS_MASK` | `0x68` | host → device | enable/disable physical-input mask | + | `TYPE_CB_PHYS` | `0x77` | host → device | enable/disable physical-only telemetry| + | `TYPE_TLM_PHYS_AXIS` | `0x86` | device → host | physical-only mouse motion telemetry | + | `TYPE_TLM_PHYS_BUTTONS` | `0x87` | device → host | physical-only button telemetry | + | `TYPE_TLM_PHYS_KB` | `0x88` | device → host | physical-only keyboard telemetry | + +Add these to the `enum` in `src/hurra.c` (lines ~16–68) in their numeric slots, +matching the existing block comments (Mouse 0x10–0x2F, Locks 0x60–0x6F, +callbacks 0x70–0x7F, telemetry 0x80–0x8F). + +--- + +## 3. Feature A — Smoothed / duration moves (`automove`, `bezier`) + +### KMBox Net semantics +- `kmNet_mouse_move_auto(x, y, time_ms)` — move a total delta `(x,y)` spread over + `time_ms`, with a human-like (non-linear, slightly jittered) velocity profile. +- `kmNet_mouse_move_beizer(x, y, ms, x1, y1, x2, y2)` — move along a cubic Bézier + curve from the origin to `(x,y)` over `ms`, with control points `(x1,y1)` and + `(x2,y2)` (relative to the start). + +Both produce a *trajectory over time*, not a single instantaneous jump. + +### Why the firmware must own this +The host must not synthesise the trajectory (that would be host-side emulation, +and it would also flood the serial link with hundreds of tiny `MOUSE_MOVE` +frames). The device already has the right machinery: `src/humanize.c` / +`humanize.h` applies a per-frame jitter/acceleration profile to injected motion, +and the injection path (`kmbox.c: kmbox_take_injection`) already meters injected +delta out across USB frames. What is missing is a **time-bounded, path-aware +source** of injected delta. + +### New frames + +`TYPE_MOUSE_MOVE_DUR 0x1B` — payload **6 bytes**: +``` +int16 dx (total X delta, LE) +int16 dy (total Y delta, LE) +uint16 dur_ms (duration in milliseconds, LE; 0 = treat as immediate) +``` + +`TYPE_MOUSE_MOVE_BEZIER 0x1C` — payload **14 bytes** (7 little-endian int16): +``` +int16 dx (endpoint X delta, LE) +int16 dy (endpoint Y delta, LE) +uint16 dur_ms +int16 x1 (control point 1 X, relative to start, LE) +int16 y1 +int16 x2 (control point 2 X, relative to start, LE) +int16 y2 +``` +> Corrected from an earlier "12 bytes": the field list enumerates 7 int16 = +> 14 bytes, which a 2-control-point cubic genuinely needs. The firmware +> (`l_mouse_move_bezier`) validates `msg->len == 14`, and the host's +> `hurra_types.h:0x1C` comment already documents the same 7-field layout. + +### Firmware requirements +1. Add `TYPE_MOUSE_MOVE_DUR` and `TYPE_MOUSE_MOVE_BEZIER` to the enum and register + listeners `l_mouse_move_dur` / `l_mouse_move_bezier` in `hurra_init()`. +2. Each listener validates `msg->len` (6 and 12 respectively; bump + `s_payload_invalid` and `return TF_STAY` on mismatch, mirroring + `l_mouse_move`), decodes the fields, and **starts a "motion program"** — an + internal generator that, on each injection tick, emits the next incremental + delta along the trajectory until the total/duration is consumed. +3. The generator runs through the **existing injection path** so it composes with + real-mouse passthrough and respects the adaptive feed rate. Concretely, it + should feed `act_move()` / the same `inject.mouse_dx/dy` accumulation that + `act_move` drives, sliced per tick. Reuse `humanize_*` for the velocity + profile of `automove`; for `bezier`, evaluate the cubic at the time-normalized + `t` for the current tick and inject the delta since the last evaluated point. +4. **Timing source:** use the same millisecond clock the rest of the firmware + uses (`millis()` is already used in `kmbox.c`), or the GPT2 1 MHz counter + (`gpt_profile_us()`) for finer resolution. Step the program from the main loop + / injection tick, not from an ISR. +5. **Superseding:** a new `MOUSE_MOVE_DUR`/`BEZIER`/`MOUSE_MOVE` while a program + is running should replace the in-flight program (last-writer-wins), matching + how a real user redirecting the mouse overrides a prior gesture. Do not queue. +6. **Locks/inverts still apply:** the generated motion must pass through the same + `act_move` transforms (`s_invert_x/y`, swap) and lock mask the normal path + uses, so behaviour is identical to a stream of manual moves. +7. Oneway: no reply frame. + +### Acceptance +- Host sends `MOUSE_MOVE_DUR(dx=200, dy=0, dur_ms=500)`; the downstream PC sees + ~200px of rightward motion delivered smoothly over ~0.5s, not one jump. +- Bézier with bowed control points visibly curves. +- Sending a plain `MOUSE_MOVE` mid-program cancels the remaining trajectory. + +--- + +## 4. Feature B — Physical-input monitoring (`monitor`) + +### KMBox Net semantics +`kmNet_monitor(port)` plus the `kmNet_monitor_mouse_*()` / `kmNet_monitor_keyboard(vk)` +query calls let a client observe the **physical** keyboard/mouse the user is +operating — the real HID reports arriving on the device's USB-host side, *before* +any injected state is merged in. Clients use this to read the user's true input. + +### The problem in the current firmware +The existing telemetry (`TYPE_TLM_AXIS 0x80`, `TYPE_TLM_BUTTONS 0x81`, +`TYPE_TLM_KB 0x83`), toggled by `TYPE_CB_BUTTONS/AXES/KEYS 0x74–0x76`, reports the +**merged** state, not the physical-only state. See `src/kmbox.c` +`kmbox_merge_report()`: + +```c +report[doff] |= inject.mouse_buttons; // physical OR injected +proto_notify_buttons(report[doff]); // <-- emits MERGED buttons +... +proto_notify_axes((int16_t)done_dx, (int16_t)done_dy, w_tlm); // merged motion +``` + +So there is no way for a client to distinguish the user's real input from what +the host injected. `monitor` cannot be implemented on top of the existing +telemetry. + +### New frames + +`TYPE_CB_PHYS 0x77` — payload **1 byte**: `uint8 enable` (0/1). Enables/disables +emission of the three physical-only telemetry frames below. Oneway, mirrors the +existing `l_cb_btn/axes/keys` listeners. + +Device → host pushes (emitted only while `CB_PHYS` is enabled): + +`TYPE_TLM_PHYS_AXIS 0x86` — payload **5 bytes** (match `TYPE_TLM_AXIS` layout): +``` +int16 dx (physical mouse X delta this report, LE) +int16 dy (physical mouse Y delta this report, LE) +int8 wheel (physical wheel delta) +``` + +`TYPE_TLM_PHYS_BUTTONS 0x87` — payload **1 byte**: +``` +uint8 buttons (physical button bitmap, BEFORE injected OR) +``` + +`TYPE_TLM_PHYS_KB 0x88` — payload **8 bytes** (match `TYPE_TLM_KB`): +``` +uint8 modifier +uint8 reserved +uint8 keys[6] (physical HID keycodes held, BEFORE injected merge) +``` + +### Firmware requirements +1. Add the enum values; add a `l_cb_phys` listener for `TYPE_CB_PHYS` that sets a + `bool s_cb_phys_enabled` (default false), registered in `hurra_init()`. +2. In `kmbox_merge_report()` (`src/kmbox.c`), **capture the physical report + fields before the injected state is OR'd/added in**, and when + `s_cb_phys_enabled`, emit the `TLM_PHYS_*` frames with those pre-merge values. + The capture point is right at function entry / before the + `report[doff] |= inject.mouse_buttons` line (~`kmbox.c:781`) and before the + motion-merge math that produces `done_dx/done_dy`. +3. Use a parallel set of emit helpers (e.g. `proto_notify_phys_buttons/axes/keys`, + added to `src/proto.h` and implemented in `src/hurra.c` alongside the existing + `hurra_notify_*`). The Ferrum protocol build does not need them — guard or + provide no-op stubs so the `PROTOCOL_FERRUM` build still links (see the + `proto.h` selector; both protocols must define the same `proto_*` symbols). +4. Emit only on change or per-report as appropriate; the existing + `proto_notify_*` are called per merged report, so per-report physical emission + is acceptable and simplest. Rate is bounded by the physical poll rate. +5. The per-button/per-key *query* calls in the KMBox API (`monitor_mouse_left`, + `monitor_keyboard(vk)`, …) are satisfied on the **host** by tracking the latest + `TLM_PHYS_*` state and answering queries from that cache — **no extra firmware + frame needed** for the queries. The firmware only needs to stream the three + `TLM_PHYS_*` frames. + +### Acceptance +- With `CB_PHYS` enabled and nothing injected, moving the real mouse produces + `TLM_PHYS_AXIS` frames whose deltas equal the physical motion. +- Injecting a move via the host does **not** appear in `TLM_PHYS_*` (only in the + merged `TLM_AXIS`), proving the tap is pre-merge. + +--- + +## 5. Feature C — Physical-input masking (`mask` / `unmask_all`) + +### KMBox Net semantics +`kmNet_mask_mouse_left/right/middle/side1/side2/x/y/wheel(enable)` and +`kmNet_mask_keyboard(vk)` / `kmNet_unmask_keyboard(vk)` / `kmNet_unmask_all()` +let a client **block specific physical inputs** from reaching the downstream +(gaming) PC — e.g. swallow the user's real left-click while still allowing +injected clicks. `unmask_all` clears every active mask. + +### The problem in the current firmware +The state containers exist but are **never enforced in the merge path**: +- `g_lock_mask` (a `uint16_t` bitmap, `src/actions.c:14`) is set by the + `TYPE_LOCK_* 0x60–0x66` listeners (`src/hurra.c` `lock_listener`, bit order + `ml=0, mr=1, mm=2, ms1=3, ms2=4, mx=5, my=6`). Today it only gates **injection**, + not physical passthrough. +- `g_masked_keys[]` (`src/actions.c:25`, capacity `ACT_MAX_DISABLED_KEYS=32`), + managed by `act_kb_mask(key, mode)` — also not consulted in the merge path. + +Grepping `src/kmbox.c` confirms neither `g_lock_mask` nor `g_masked_keys` is +referenced there, so physical input is never actually suppressed. + +### New frame + +`TYPE_PHYS_MASK 0x68` — payload **3 bytes**: +``` +uint8 domain (0 = mouse button/axis, 1 = keyboard key) +uint8 code (domain 0: index 0..6 matching the lock-bit order + ml,mr,mm,ms1,ms2,mx,my; plus 7 = wheel + domain 1: the HID keycode to mask) +uint8 enable (1 = mask/suppress this input, 0 = unmask) +``` + +A dedicated "unmask all" is expressed as `domain=0xFF, code=0, enable=0` (the +firmware clears `g_lock_mask` and `g_masked_keys[]` entirely). The host maps +`kmNet_unmask_all()` to this. + +> Rationale for a new frame rather than reusing `TYPE_LOCK_*`: the existing +> `LOCK_*` bits are also used by Ferrum's lock semantics (injection gating); we do +> not want to overload their meaning. `PHYS_MASK` is explicitly about suppressing +> *physical passthrough*. It MAY internally reuse the `g_lock_mask` bits for the +> mouse domain (since the bit order already matches), but the enforcement is new. + +### Firmware requirements +1. Add `TYPE_PHYS_MASK` to the enum; register `l_phys_mask` in `hurra_init()`. + Validate `msg->len == 3`. Update the mask state: + - domain 0, code 0..6 → set/clear the matching `g_lock_mask` bit (reuse the + existing bit order). Code 7 (wheel) → a new `g_mask_wheel` bool, or an + extra bit in `g_lock_mask`. + - domain 1 → `act_kb_mask(code, enable)` (already exists). + - domain 0xFF → clear all: `g_lock_mask = 0`, reset `g_masked_keys` via the + existing reset path, clear `g_mask_wheel`. +2. **Enforce in `kmbox_merge_report()`** (`src/kmbox.c`) — this is the core new + behaviour. When building the report that goes downstream, **zero out the + physical contribution** of any masked input *before* it is sent and before + `proto_notify_*`: + - For each masked mouse button bit, clear that bit from the physical + `report[doff]` button byte. + - For masked X / Y / wheel, zero the corresponding physical delta from the + report. + - For masked keys, remove them from the physical keyboard report's key array. + Do this to the physical fields, then apply the injected merge as usual — so + injected input on a masked control still works, only the user's physical input + is suppressed. (This is the KMBox semantic: the cheat injects, the user's real + input is blocked.) +3. Interaction with monitoring (Feature B): the `TLM_PHYS_*` frames should report + the **true physical input as seen before masking** (so a client can still + observe what the user pressed even while it is masked downstream). Capture + physical telemetry first, then apply masking, then apply injection. +4. Oneway: no reply. + +### Acceptance +- Mask mouse-left, then physically click: the downstream PC sees no left-click. +- With the same mask, an injected left-click via the host still registers. +- `monitor` (if enabled) still shows the physical left-click in `TLM_PHYS_BUTTONS`. +- `unmask_all` restores normal passthrough for everything. + +--- + +## 6. Suggested implementation order + +1. **Enum + stubs first:** add all seven `TYPE_*` values and empty listeners that + validate length and `return TF_STAY`. Confirms wire compatibility with the host + (the host will stop logging `pending firmware` once it gets ACK-equivalent + behaviour; note Hurra frames are oneway so "ACK" is just non-rejection). +2. **Feature C (mask)** — smallest, highest-value, and exercises the merge-path + edit that Feature B also needs. +3. **Feature B (monitor)** — adds the pre-merge tap + `TLM_PHYS_*` emit + the + `proto.h` symbol additions (and Ferrum no-op stubs). +4. **Feature A (smoothed moves)** — the motion-program generator; largest, and + independent of B/C. + +Each feature is independently shippable; the host already degrades gracefully +(ACK + log) for any not-yet-implemented command. + +--- + +## 7. Files you will touch + +| File | Change | +|------|--------| +| `src/hurra.c` | Add 7 enum values; add listeners `l_mouse_move_dur`, `l_mouse_move_bezier`, `l_phys_mask`, `l_cb_phys`; register them in `hurra_init()`; add `hurra_notify_phys_*` emitters. | +| `src/kmbox.c` | In `kmbox_merge_report()`: capture pre-merge physical fields (Feature B), enforce masks (Feature C); drive the motion-program tick (Feature A) through the injection path. | +| `src/actions.c` / `src/actions.h` | Mask-state helpers if extending beyond `g_lock_mask`/`g_masked_keys` (e.g. `g_mask_wheel`); possibly a motion-program API. | +| `src/humanize.c` / `src/humanize.h` | Reuse for the `automove` velocity profile; no API change strictly required. | +| `src/proto.h` | Add `proto_notify_phys_buttons/axes/keys` aliases for both `PROTOCOL_HURRA` and `PROTOCOL_FERRUM` (Ferrum = no-op stubs). | + +## 8. Cross-repo invariant (do not break) + +The numeric `TYPE_*` values in §2 **must** equal those reserved in +`hurra-app/include/hurra_types.h`: + +``` +HURRA_TYPE_MOUSE_MOVE_DUR 0x1B +HURRA_TYPE_MOUSE_MOVE_BEZIER 0x1C +HURRA_TYPE_PHYS_MASK 0x68 +HURRA_TYPE_CB_PHYS 0x77 +HURRA_TYPE_TLM_PHYS_AXIS 0x86 +HURRA_TYPE_TLM_PHYS_BUTTONS 0x87 +HURRA_TYPE_TLM_PHYS_KB 0x88 +``` + +If a payload layout changes during implementation, change it in **both** repos in +the same coordinated PR and update the host's `kmbox`/`input_core` decoders and +this document together. diff --git a/Makefile b/Makefile index a575d58..0d3517a 100644 --- a/Makefile +++ b/Makefile @@ -26,7 +26,16 @@ else $(error PROTOCOL must be 'hurra' or 'ferrum') endif -DEFINES = -DARDUINO_TEENSY_MICROMOD -D__IMXRT1062__ -DF_CPU=816000000 \ +# F_CPU: core clock. 600 MHz is the part's rated max; this board runs +# overclocked. 912 MHz needs ~1525 mV core (set_arm_clock interpolates it, +# capped at the 1575 mV silicon max). IPG = F_CPU/4 = 228 MHz stays a whole +# MHz so the GPT2 1 µs tick and LED scale (both F_CPU-derived) remain exact. +# Above ~864 MHz exceeds NXP's 1300 mV recommended limit — trades silicon +# lifetime for clock. Drop to 816000000 to back off. The tempmon monitor +# (main.c) flags overtemp via LED/telemetry but does NOT downclock — it warns, +# it does not protect; manage cooling accordingly. +F_CPU ?= 912000000 +DEFINES = -DARDUINO_TEENSY_MICROMOD -D__IMXRT1062__ -DF_CPU=$(F_CPU) \ -DCMD_BAUD=$(CMD_BAUD) $(PROTO_DEF) CFLAGS = $(MCU_FLAGS) $(DEFINES) \ @@ -57,8 +66,9 @@ $(TARGET).elf: $(OBJ) $(TARGET).hex: $(TARGET).elf $(OBJCOPY) -O ihex -R .eeprom $< $@ -# Hot-path sources get -O2 instead of -Os -HOT_SRC = src/usb_host.o src/usb_device.o src/kmbox.o \ +# Hot-path sources get -O2 instead of -Os. main.c is included because the +# central poll loop (PIT tick dispatch, EP polling, merge/send) lives there. +HOT_SRC = src/main.o src/usb_host.o src/usb_device.o src/kmbox.o \ src/humanize.o src/actions.o ifeq ($(PROTOCOL),hurra) HOT_SRC += src/hurra.o src/third_party/TinyFrame/TinyFrame.o @@ -85,3 +95,8 @@ test: cc -std=c11 -O2 -DHUMANIZE_HOSTTEST -Isrc -o /tmp/humanize_test \ test/humanize_test.c src/humanize.c -lm /tmp/humanize_test + cc -std=c11 -O2 -Isrc -o /tmp/motion_test \ + test/motion_test.c src/actions.c -lm + /tmp/motion_test + cc -std=c11 -O2 -Isrc -o /tmp/synth_cadence_test test/synth_cadence_test.c + /tmp/synth_cadence_test diff --git a/README.md b/README.md index 9b139d2..ff15dd4 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,42 @@ -# hurra v2 - USB HS +# Hurra v2 — USB HID man-in-the-middle -Bare-metal USB HID man-in-the-middle firmware for the **SparkFun MicroMod Teensy** (NXP i.MX RT1062). Enumerates a real USB HID device on the host port, replays it on the device port to the Mac/PC, and accepts **Hurra binary protocol** commands over UART (default) to inject mouse/keyboard input on top of the live HID stream. A **Ferrum ASCII** compatibility mode is available via `make PROTOCOL=ferrum`. +Bare-metal firmware for the **SparkFun MicroMod Teensy** (NXP i.MX RT1062) that sits between a USB HID device and your computer. It enumerates a real HID device (mouse, keyboard, controller) on its USB host port, replays that device to the host PC on its device port, and lets you **inject your own mouse and keyboard input** on top of the live HID stream over a serial link. -The host-side adapter is [`hurra-app`](https://github.com/VoltCyclone/hurra-app) (`hurra-bridge`), which also exposes a Ferrum-compatible virtual COM port for legacy tools. +Injected motion is passed through an always-on humanization filter (sub-pixel jitter, micro-correction, and dwell) so synthetic input blends with the real device stream. -## Hardware +Two command protocols are supported: -- **SparkFun MicroMod Teensy** on the **MicroMod ATP Carrier Board**. -- **WCH CH343** USB-UART bridge wired to Teensy `RX2`/`TX2` (D16/D17 -> LPUART3; ATP carrier UART_RX2/UART_TX2 headers). USB Full Speed, up to 6 Mbaud, 64-byte bulk MPS. -- A USB HID device (mouse, keyboard, controller) on the Teensy's USB host port. -- Teensy USB device port to the host PC. +- **Hurra binary** (default) — fast TinyFrame-based protocol, driven by the host app. +- **Ferrum ASCII** (`make PROTOCOL=ferrum`) — text protocol for compatibility with legacy tools. + +The host-side companion app is [`hurra-app`](https://github.com/VoltCyclone/hurra-app) (`hurra-bridge`), which talks the Hurra protocol and also exposes a Ferrum-compatible virtual COM port for older tooling. + +## How it works ``` USB HID device ──→ Teensy USB-host ──┐ │ (firmware proxies + injects) Host PC USB ←── Teensy USB-device ───┘ ↑ - │ km.* commands + │ injected input │ Host PC USB ──→ CH343 ──→ Teensy LPUART3 (D16/D17) ``` -## Wire protocol +The firmware forwards every real HID report through unchanged, merges in any input you inject over the serial link, and sends the combined stream to the host PC. -**Default — Hurra binary (TinyFrame):** SOF `0x68`, 1-byte ID/LEN/TYPE, CRC16. Little-endian payloads. Driven by `hurra-app`/`hurra-bridge`; see that repo for the host API. Targets >=8k commands/sec at 4 Mbps over the CH343 link. Boots at **4 Mbaud** (matching the bridge/hello default, so no `--baud` flag is needed); `km.baud(N)` bumps it and the firmware falls back to the 4 Mbaud boot default after extended RX idle. +## Hardware + +- **SparkFun MicroMod Teensy** on the **MicroMod ATP Carrier Board**. +- **WCH CH343** USB-UART bridge wired to Teensy `RX2`/`TX2` (D16/D17 → LPUART3; ATP carrier `UART_RX2`/`UART_TX2` headers). USB Full Speed, up to 6 Mbaud, 64-byte bulk packets. +- A USB HID device (mouse, keyboard, controller) on the Teensy's USB **host** port. +- The Teensy USB **device** port connected to the host PC. -**Compatibility — Ferrum ASCII** (`make PROTOCOL=ferrum`): `\r\n`-terminated text, 115200 baud (resets to 115200 every power cycle). Reference: . +## Command protocols + +**Hurra binary (default).** TinyFrame framing — SOF `0x68`, 1-byte ID/LEN/TYPE, CRC16, little-endian payloads. Driven by `hurra-app` / `hurra-bridge`; see that repo for the host API. Targets ≥8k commands/sec at 4 Mbps over the CH343 link. The firmware boots at **4 Mbaud** (matching the bridge's default, so no `--baud` flag is needed); `km.baud(N)` raises the rate, and the firmware falls back to the 4 Mbaud boot default after the link goes idle. + +**Ferrum ASCII (`make PROTOCOL=ferrum`).** `\r\n`-terminated text commands at 115200 baud (reset to 115200 on every power cycle). Reference: . ``` TX: km.version()\r\n @@ -37,51 +48,78 @@ TX: m(2, 0)\r\n # alias for km.move ## Build & flash ```sh -make # produces firmware.hex (Hurra binary protocol, default) -make PROTOCOL=ferrum # build with Ferrum ASCII protocol instead -make flash # flashes via teensy_loader_cli +make # build firmware.hex (Hurra binary protocol — default) +make PROTOCOL=ferrum # build with the Ferrum ASCII protocol instead +make flash # flash via teensy_loader_cli +make clean # remove objects and build artifacts ``` -Requires: -- ARM GCC (the Teensyduino-bundled toolchain at `~/.platformio/packages/toolchain-gccarmnoneeabi-teensy/bin` by default — edit `Makefile` if yours lives elsewhere). -- `teensy_loader_cli` on `$PATH`. +Requirements: + +- **ARM GCC** — the Teensyduino-bundled toolchain at `~/.platformio/packages/toolchain-gccarmnoneeabi-teensy/bin` by default. Edit the `Makefile` if yours lives elsewhere. +- **`teensy_loader_cli`** on your `$PATH`. ## Test -`ferrum_test.py` speaks Ferrum ASCII, so point it at a `PROTOCOL=ferrum` build's -serial port **or** at the `hurra-bridge` PTY symlink (`~/.hurra-bridge.tty`) when -running the default Hurra firmware — not directly at a Hurra build's port. +`tools/ferrum_test.py` speaks Ferrum ASCII. Point it at the serial port of a `PROTOCOL=ferrum` build, **or** at the `hurra-bridge` PTY symlink (`~/.hurra-bridge.tty`) when running the default Hurra firmware — not directly at a Hurra build's port. ```sh pip install pyserial -tools/ferrum_test.py ~/.hurra-bridge.tty smoke # via the bridge (Hurra firmware) -tools/ferrum_test.py /dev/tty.usbserial-XXXX smoke # direct (PROTOCOL=ferrum build) + +# Smoke test via the bridge (default Hurra firmware) +tools/ferrum_test.py ~/.hurra-bridge.tty smoke + +# Smoke test direct (PROTOCOL=ferrum build) +tools/ferrum_test.py /dev/tty.usbserial-XXXX smoke ``` -That handshakes `km.version()`, nudges the mouse, exercises buttons + wheel, and validates the read forms. For a closed-loop aim test that drives the cursor toward on-screen dots: +The smoke test handshakes `km.version()`, nudges the mouse, exercises the buttons and wheel, and validates the read forms. + +**Closed-loop aim test** — drives the cursor toward on-screen dots: ```sh pip install pyserial pynput -tools/ferrum_aim_test.py /dev/tty.usbserial-XXXX +tools/ferrum_aim_test.py ~/.hurra-bridge.tty +``` + +**Load test** — measures latency, throughput, and integrity of the command channel under sustained load: + +```sh +tools/ferrum_load_test.py ~/.hurra-bridge.tty +``` + +**Humanization analyzer** — compares a captured motion trace against a real human baseline to check the kinematic signatures anti-cheat detectors look for: + +```sh +tools/humanization_analyze.py trace.txt --baseline human.txt +``` + +A host-native unit test for the humanization filter also runs without hardware: + +```sh +make test ``` ## Layout ``` -Makefile ARM GCC build, 816 MHz, -O2 hot path -core/ reset vector, MPU/cache setup, FlexSPI boot data -include/imxrt.h i.MX RT1062 register/peripheral header -src/main.c poll loop: USB host → merge → USB device send -src/usb_host.c/.h EHCI host (USB2) -src/usb_device.c/.h EHCI device (USB1) -src/desc_capture.* descriptor + HID report-layout capture -src/kmbox.c/.h LPUART3 DMA RX/TX ring + HID merge -src/hurra.c/.h Hurra binary parser (TinyFrame) — default protocol -src/ferrum.c/.h Ferrum ASCII parser (opt-in: PROTOCOL=ferrum) -src/proto.h compile-time protocol selector -src/actions.c/.h transport-agnostic injection helpers (act_*) -src/smooth.c/.h bezier-smoothed motion queue -src/humanize.c/.h sub-pixel jitter + dwell -tools/ferrum_test.py protocol smoke harness -tools/ferrum_aim_test.py closed-loop aim test against on-screen dots +Makefile ARM GCC build, 912 MHz default (F_CPU override), -O2 hot path +core/ reset vector, MPU/cache setup, FlexSPI boot data +include/imxrt.h i.MX RT1062 register/peripheral header +src/main.c poll loop: USB host → merge → USB device send +src/usb_host.c/.h EHCI host controller (USB2) +src/usb_device.c/.h EHCI device controller (USB1) +src/desc_capture.* descriptor + HID report-layout capture +src/kmbox.c/.h LPUART3 DMA RX/TX ring + HID report merge +src/hurra.c/.h Hurra binary parser (TinyFrame) — default protocol +src/ferrum.c/.h Ferrum ASCII parser (opt-in: PROTOCOL=ferrum) +src/proto.h compile-time protocol selector +src/actions.c/.h transport-agnostic injection helpers (act_*) +src/humanize.c/.h always-on humanization filter (jitter, micro-correction, dwell) +src/led.c/.h on-board LED status/heartbeat driver +src/third_party/TinyFrame/ TinyFrame framing library (Hurra protocol) +tools/ferrum_test.py protocol smoke harness +tools/ferrum_aim_test.py closed-loop aim test against on-screen dots +tools/ferrum_load_test.py command-channel latency/throughput/integrity load test +tools/humanization_analyze.py kinematic trace analyzer vs. a human baseline ``` diff --git a/core/imxrt1062_mm.ld b/core/imxrt1062_mm.ld index 37d59ce..96dcf71 100644 --- a/core/imxrt1062_mm.ld +++ b/core/imxrt1062_mm.ld @@ -71,6 +71,14 @@ SECTIONS . = ALIGN(32); } > RAM + /* USB EHCI relies on .dmabuffers being non-cacheable so a bare DSB (no + * cache clean/invalidate) is sufficient around qTD arming. core/startup.c + * marks only the first 64 KB of OCRAM (0x20200000) non-cacheable via MPU + * region 10. If .dmabuffers ever grows past 64 KB, the spillover becomes + * cacheable and DMA coherency silently breaks. Fail the build instead. */ + ASSERT(SIZEOF(.bss.dma) <= 64K, + "ERROR: .dmabuffers exceeds the 64 KB non-cacheable MPU window (core/startup.c region 10). Widen the MPU region or shrink DMA buffers.") + .text.csf : { FILL(0xFF) . = ALIGN(1024); diff --git a/core/startup.c b/core/startup.c index 0b80117..ed91d0f 100644 --- a/core/startup.c +++ b/core/startup.c @@ -82,13 +82,39 @@ FLASHMEM uint32_t set_arm_clock(uint32_t frequency) uint32_t cbcmr = CCM_CBCMR; if (frequency > 528000000) { - // Step 1: Raise core voltage - // 600 MHz → TRG(15) ~1.25V; 816 MHz → TRG(19) ~1.30V + // Step 1: Raise core voltage BEFORE raising frequency. + // + // Voltage is interpolated the same way the Teensy core's clockspeed.c + // does it, instead of the old hand-tuned two-point table (TRG 15/19). + // That table under-volted everything above 600 MHz: it gave 816 MHz + // only ~1275 mV where the reference curve wants ~1425 mV, and at + // 912 MHz it would have produced ~1275 mV against a required ~1525 mV — + // a brownout hang under load. Formula: + // base 1250 mV at >528 MHz; above 600 MHz add 25 mV per 28 MHz step. + // Hard-capped at OC_MAX_VOLT_MV (1575 = silicon max, also the TRG-field + // ceiling) so no F_CPU can ever command an out-of-range TRG. Note NXP's + // *recommended* operating max is 1300 mV; steps above ~864 MHz exceed + // that and trade silicon lifetime for clock — intentional here. + // TRG encodes (mV-800)/25: 1250→18, 1425→25, 1525→29, 1575→31. + // Local consts (not #define) so these identifiers don't leak to file + // scope for the rest of startup.c — they're only used in this block. + const uint32_t oc_volt_step_hz = 28000000u; + const uint32_t oc_volt_step_mv = 25u; + const uint32_t oc_max_volt_mv = 1575u; + uint32_t voltage = 1250u; // >528 MHz base + if (frequency > 600000000u) { + voltage += ((frequency - 600000000u) / oc_volt_step_hz) * oc_volt_step_mv; + if (voltage > oc_max_volt_mv) voltage = oc_max_volt_mv; + } + uint32_t trg = (voltage - 800u) / 25u; // DCDC_REG3 TRG target step uint32_t dcdc = DCDC_REG3; - dcdc &= ~DCDC_REG3_TRG_MASK; - dcdc |= DCDC_REG3_TRG(frequency > 600000000 ? 19 : 15); - DCDC_REG3 = dcdc; - while (!(DCDC_REG0 & DCDC_REG0_STS_DC_OK)) ; + // Only raise here; never lower (boot path goes low→high exactly once). + if ((dcdc & DCDC_REG3_TRG_MASK) < DCDC_REG3_TRG(trg)) { + dcdc &= ~DCDC_REG3_TRG_MASK; + dcdc |= DCDC_REG3_TRG(trg); + DCDC_REG3 = dcdc; + while (!(DCDC_REG0 & DCDC_REG0_STS_DC_OK)) ; // wait for rail to settle + } // Step 2: Switch CPU to safe clock (PERIPH_CLK2) before touching ARM PLL. // Use USB1 PLL (480 MHz) / 4 = 120 MHz as safe clock. diff --git a/docs/adaptive-feed-rate-plan.md b/docs/adaptive-feed-rate-plan.md new file mode 100644 index 0000000..efc7e73 --- /dev/null +++ b/docs/adaptive-feed-rate-plan.md @@ -0,0 +1,212 @@ +# Adaptive Feed-Rate + Normalized-Speed Humanization — Implementation Plan + +Status: proposed. Grounded against i.MXRT1060 RM, NXP MCUXpresso SDK (fsl_pit.c / fsl_gpt.c), +PJRC Teensy 4 core (startup.c, imxrt1062.ld), ARM Cortex-M7 TRM, and USB 2.0 / EHCI / HID specs. +Target: Teensy MicroMod (i.MXRT1062, Cortex-M7 @ F_CPU=816 MHz), USB-host mouse passthrough. + +The goal MAKD described: make injection cadence follow the *real* measured mouse poll +interval instead of nominal bInterval, and scale humanization noise by a DPI-independent +speed estimate. Research confirmed the idea is sound — but surfaced two pre-existing clock +bugs that must be fixed first, and several corrections to the mental model. + +--- + +## PHASE 0 — Fix pre-existing clock bugs (BLOCKER, do first, independent of feature) + +These are wrong *today*; the adaptive feature is meaningless until they're fixed because it +both sets timer periods and measures intervals using these miscalibrated clocks. + +### 0.1 PIT clock constant (8.5× error) +- File: `src/main.c:185` +- Current: `uint32_t ipg_mhz = (F_CPU / 4u) / 1000000u;` → 816/4 = 204 MHz (WRONG) +- Truth: PIT is clocked by **PERCLK = 24 MHz**, fixed in `core/startup.c:406` + (`PERCLK_CLK_SEL=1` = 24 MHz XTAL, `PERCLK_PODF` cleared = /1). + Confirmed by NXP fsl_pit.c, RM PIT chapter, and Teensy core (reboot uses LDVAL=2400000 for 100ms). +- Fix: + ```c + #define PIT_CLK_HZ 24000000u /* PERCLK = 24 MHz OSC, per core/startup.c:406 */ + uint32_t ldval = (uint32_t)(((uint64_t)PIT_CLK_HZ * interval_us) / 1000000u) - 1u; + ``` +- Effect: injection cadence currently runs ~8.5× too slow; this restores true bInterval timing. +- Resolution at 24 MHz = 41.667 ns/tick; max 32-bit interval ≈ 179 s. Working range + [125 µs, 10 ms] → LDVAL ≈ 2,999 … 239,999. Ample headroom. + +### 0.2 GPT2 timestamp tick rate (used by the new measurement path) +- File: `src/gpt_profile.h:18` — `GPT2_PR = 203` assumes ipg_clk = 204 MHz → 1 MHz tick. +- Suspect: on this part ipg_clk is NOT reliably 204 MHz (IPG capped ~150 MHz). GPT CLKSRC=001 + selects ipg_clk; the true rate must be derived, not assumed. +- Action (pick one): + - (a) Re-derive the real ipg_clk from the CCM divider chain and recompute GPT2_PR so the + tick is a genuine 1 MHz, OR + - (b) Keep GPT2_PR as-is but store a measured `gpt_ticks_per_us` and convert at use sites. + - Empirical cross-check: compare GPT2 deltas against a known cadence (e.g. a 1 kHz FS mouse + or the PIT once 0.1 is fixed) and confirm 1 tick ≈ 1 µs. +- Until this is verified, treat `gpt_profile_us()` as "ticks", not microseconds. + +### 0.3 Verify register/bit facts already confirmed (no action, reference) +- PIT regs/bits: imxrt.h:7627-7658 (TEN=1<<0, TIE=1<<1, CHN=1<<2, TIF=1<<0). All real. +- GPT regs: CR/PR/SR/CNT correct; note bare `GPT2_OCR`/`GPT2_ICR` don't exist — they're + OCR1/2/3 and ICR1/2 (not used here). +- GPT2 FRR free-run + CLKSRC=001 in gpt_profile.h are correctly encoded. + +--- + +## PHASE 1 — Timestamp real report arrival (offload timing to hardware) + +### 1.1 Capture point +- File: `src/main.c:248`, immediately after `if (ret > 0 && rpt_ptr) {`, BEFORE `kmbox_merge_report`. + ```c + if (ret > 0 && rpt_ptr) { + did_work = true; + uint32_t report_ts = gpt_profile_us(); /* GPT2_CNT single-load, atomic */ + humanize_record_arrival(ep_map[m].iface_protocol, report_ts); + kmbox_merge_report(ep_map[m].iface_protocol, rpt_ptr, ret); + ``` +- Rationale: this is the single precise "a new report just arrived" point. The completion is + *detected* in `usb_host.c:676-679` (qTD token ACTIVE 1→0), but the timestamp belongs at the + main-loop consumption site. Only timestamp the MOUSE interface (protocol==2); keyboard + reports must not perturb mouse cadence. + +### 1.2 Hard limits to design around (from USB/EHCI/HID fact-check) +- There is NO sensor-sample timestamp in USB. We measure DELIVERY time only, with up to one + full poll interval of irreducible aliasing. Never assume "real" motion timing. +- Measured jitter is dominated by host/EHCI quantization (125 µs microframe HS / 1 ms frame FS) + + our ISR/poll latency — the same signal a poll-rate tester averages. +- Confirm bus speed: FS mouse → bInterval in ms; HS mouse → 2^(bInterval-1) microframes. + This is the #1 timing-math bug; `main.c:171-178` already branches on speed — keep that. + +--- + +## PHASE 2 — Derive measured interval + adaptive PIT period (the "dynamic feed rate") + +### 2.1 New state in `humanize.c` struct S (main-loop only, NO volatile needed) +```c +uint32_t last_report_ts; /* GPT ticks of previous mouse report (0 = none yet) */ +uint32_t meas_interval; /* EWMA-smoothed delivery interval, GPT ticks */ +uint32_t arrival_count; /* reports seen since init (skip first few) */ +``` + +### 2.2 `humanize_record_arrival(uint8_t proto, uint32_t ts)` — new fn, main loop +- If proto != mouse: return. +- First report (count==0): store ts, count=1, return (no interval yet). +- dt = ts - last_report_ts (unsigned, single-wrap safe; intervals are sub-ms). +- Reject outliers BEFORE smoothing: + - dropout: dt > ~4× current target → reset baseline (store ts, don't update meas), treat as idle. + - burst/double-report: dt < ~0.5× current target → ignore (USB retry / NAK artifact). +- EWMA (integer, ISR-readable): `meas_interval += ((int32_t)dt - (int32_t)meas_interval) >> 4;` +- Store last_report_ts = ts; count++. + +### 2.3 Adaptive base with SLEW (do NOT jump the period) +- File: `src/main.c:228-231` (main-loop pit_tick handler — FPU/64-bit allowed here, NOT in ISR). +- Convert smoothed interval → target LDVAL using the SAME 24 MHz math as Phase 0.1. +- Clamp target to [125 µs, 10 ms] LDVAL window FIRST (a glitch must never reach the timer). +- Slew `pit_base_ldval` toward target (~3% per tick), don't assign directly: + ```c + if (pit_tick_pending) { + pit_tick_pending = false; did_work = true; + uint32_t tgt = humanize_target_ldval(PIT_CLK_HZ); /* 0 if <5 reports or invalid */ + if (tgt) { + int32_t err = (int32_t)tgt - (int32_t)pit_base_ldval; + /* optional: clamp |err| to a max step for extra safety */ + pit_base_ldval += err >> 5; /* ~3%/tick critically-damped slew */ + } + pit_next_ldval = humanize_timing_next(pit_base_ldval); /* existing ±12% LFSR jitter rides on top */ + } + ``` +- Why this is RM-correct: a new LDVAL latches at the next reload (NXP fsl_pit.c: + "value is loaded after the timer expires"). Writing pit_next_ldval in the ISR at the reload + boundary applies it to the cycle just starting. No TEN disable/re-enable (that would drop the + in-flight countdown = one short/long boundary tick). Keep the existing precompute-in-ISR pattern. +- The existing `humanize_timing_next` LFSR jitter layer is UNCHANGED; it now jitters a moving base. + +### 2.4 Concurrency (verified safe) +- ISR↔main shared: `pit_tick_pending` (volatile bool), `pit_next_ldval` (volatile u32). Both atomic. OK. +- `pit_base_ldval`, all `S.*` fields: main-loop only. No volatile. OK. +- FPU stays out of the ISR (ISR only does the single u32 LDVAL store). Confirmed. + +--- + +## PHASE 3 — Normalized-speed envelope (DPI-independent noise scaling) + +### 3.1 Corrections baked in (from HID/DSP fact-check) +- Per-report delta conflates speed, DPI, AND poll rate. The DPI-independent speed proxy is + **delta_magnitude ÷ measured_interval** (counts/sec), not raw per-report delta. +- Normalize against an OBSERVED running peak (envelope), NOT the field logical max (16-bit + mice almost never saturate, so logical max is a useless normalizer). +- Parse the report descriptor's Logical Max at enumeration; don't assume 8-bit −127..127. + (If descriptor parsing isn't already available, the running-peak envelope makes this + non-blocking — it self-calibrates regardless of field width. Note as a refinement.) +- Decay MUST be time-based, not per-frame: a fixed per-frame decay leaks 8× too fast on an + 8 kHz mouse. Use dt from Phase 2. + +### 3.2 State in S +```c +float peak_speed; /* envelope of counts/sec, running peak with time-based decay */ +``` + +### 3.3 In `humanize_filter` (humanize.c:104-131), where `speed` is already computed +```c +float speed = sqrtf(ex*ex + ey*ey); /* existing: counts this frame */ +/* counts/sec using measured interval; guard div-by-zero */ +float rate = (S.meas_interval > 0) + ? speed * (float)GPT_TICKS_PER_SEC / (float)S.meas_interval + : speed; +/* envelope: instant attack, time-based decay */ +if (rate > S.peak_speed) S.peak_speed = rate; /* attack */ +else S.peak_speed *= expf(-dt_sec / HZ_PEAK_TAU); /* decay; or precomputed per-frame k */ +if (S.peak_speed < 1.0f) S.peak_speed = 1.0f; /* floor: no div-by-zero / blowup at rest */ +float norm = rate / S.peak_speed; /* 0..1 normalized speed */ +/* swap the noise driver from raw speed → normalized speed */ +float nmag = S.n_perp * S.noise_amp * norm; /* was: * speed */ +``` +- `expf` is fine (M7 hardware FPU, main loop). If you want zero transcendental cost, precompute + a per-frame decay constant `k` from the nominal period and use `S.peak_speed *= k;`. +- Open tuning question: scaling noise by `norm` (0..1) vs by `speed` changes feel — keep behind + a level/flag and A/B on hardware. This is a behavior change, not a correctness fix. + +### 3.4 Decision still open (ask before coding 3.x) +- Combined-magnitude envelope (one peak) vs per-axis (peak_x, peak_y). Combined is simpler and + matches the perpendicular-noise model; per-axis is what MAKD's "max X_Y" literally said. + Default to combined unless you want per-axis. + +--- + +## PHASE 4 — Determinism hardening (cheap, lock in what's already good) + +- Research confirmed `S` is already in DTCM and hot code in ITCM *by default* (no annotations), + and DTCM is single-cycle / non-cacheable → already deterministic. Two no-risk hardening steps: + +### 4.1 Make the hot-path placement explicit (LTO/flag-proof) +- File: `src/humanize.c` — add `__attribute__((section(".fastrun")))` to the per-tick path: + `humanize_filter`, `drain_axis`, `humanize_timing_next`, `humanize_return`, `humanize_pending`. + (`humanize_init`, `humanize_set_level` can stay default.) +- Do NOT move `S` to `.dmabuffers` — that would push it to OCRAM (cacheable-ish/slower). Leave + `S` in .bss (→ DTCM). Optional `aligned(32)` is cosmetic here. + +### 4.2 Guard the non-cacheable DMA window +- `core/startup.c:215-219` marks the first 64 KB of OCRAM non-cacheable (MPU region 10) — THIS is + why the `asm volatile("dsb")`-only pattern in usb_host.c is correct and no cache clean/invalidate + is needed. Don't "fix" the dsb pattern away. +- Add a linker `ASSERT(SIZEOF(.bss.dma) <= 64K, ...)` (or widen region 10) so a future growth of + `.dmabuffers` past 64 KB can't silently land in cacheable memory and break DMA coherency. +- Add a one-line comment at the qTD-arm sites noting "dsb suffices BECAUSE DMA mem is MPU-noncacheable". + +--- + +## New / changed public API (humanize.h) +- `void humanize_record_arrival(uint8_t iface_protocol, uint32_t ts_ticks);` +- `uint32_t humanize_target_ldval(uint32_t pit_clk_hz);` /* 0 = no valid measurement yet */ +- (internal) extend struct S; add HZ_PEAK_TAU, GPT_TICKS_PER_SEC constants. + +## Build / sequencing +1. Phase 0 (clock fixes) — land + verify on hardware FIRST. Measure cadence before/after. +2. Phase 1 + 2 (timestamp → adaptive PIT) — verify PIT period tracks a real mouse; scope/log it. +3. Phase 3 (normalized speed) — behind a flag; A/B feel. +4. Phase 4 (hardening) — anytime; zero behavior change. + +## Risks / watch-items +- GPT2 tick calibration (0.2) gates ALL measurement accuracy — verify empirically. +- Closed-loop stability: target-LDVAL math MUST match the PIT-set math exactly, or the loop drifts. +- Slew + outlier rejection prevent a measurement glitch from jerking the period (detectable). +- Report-descriptor field width (8 vs 16-bit) — running-peak envelope self-calibrates, but a true + counts/sec needs the descriptor's logical max only if you ever want absolute (not relative) speed. diff --git a/src/actions.c b/src/actions.c index 91f6e1c..1c85e9f 100644 --- a/src/actions.c +++ b/src/actions.c @@ -12,6 +12,7 @@ uint8_t g_kb_modifier; uint8_t g_kb_keys[6]; int32_t g_pos_x, g_pos_y; uint16_t g_lock_mask; +uint16_t g_phys_mask; // physical-input suppression (see actions.h PHYS_MASK_*) typedef struct { uint8_t button; @@ -30,6 +31,21 @@ static bool s_invert_x = false; static bool s_invert_y = false; static bool s_swap_xy = false; +// ── motion program state (Feature A) ───────────────────────────────────────── +// Endpoint and control points are relative to the program's start. Positions +// are tracked as fixed-point .8 (sub-count precision) so each tick can emit the +// exact integer increment toward curve(t) with no cumulative rounding drift. +typedef enum { MOTION_NONE = 0, MOTION_LINEAR, MOTION_BEZIER } motion_kind_t; +static struct { + motion_kind_t kind; + uint32_t start_ms; + uint32_t dur_ms; + int32_t ex, ey; // endpoint delta (counts) + int32_t c1x, c1y; // bezier control point 1 (counts) + int32_t c2x, c2y; // bezier control point 2 (counts) + int32_t emit_x, emit_y; // counts already emitted toward the path +} g_motion; + static uint8_t btn_idx_to_mask(uint8_t idx) { if (idx >= 1 && idx <= 5) @@ -45,7 +61,9 @@ void act_init(void) g_pos_x = 0; g_pos_y = 0; g_lock_mask = 0; + g_phys_mask = 0; memset(&g_click_sched, 0, sizeof(g_click_sched)); + memset(&g_motion, 0, sizeof(g_motion)); g_masked_count = 0; } @@ -82,7 +100,10 @@ void act_click(uint8_t button_1based, uint8_t count, uint32_t delay_ms) } } -void act_move(int16_t dx, int16_t dy) +// Core mover: applies swap/invert transforms and injects. Shared by the public +// act_move and the motion-program tick. Does NOT cancel the motion program, so +// the tick can drive it without self-aborting. +static void act_move_raw(int16_t dx, int16_t dy) { if (s_swap_xy) { int16_t t = dx; dx = dy; dy = t; } if (s_invert_x) dx = (int16_t)-dx; @@ -92,6 +113,13 @@ void act_move(int16_t dx, int16_t dy) kmbox_inject_mouse(dx, dy, g_buttons, 0); } +void act_move(int16_t dx, int16_t dy) +{ + // A manual move overrides any in-flight trajectory (user redirect wins). + g_motion.kind = MOTION_NONE; + act_move_raw(dx, dy); +} + int8_t act_kb_down(uint8_t key) { if (key >= 0xE0 && key <= 0xE7) { @@ -186,3 +214,150 @@ bool act_get_invert_y(void) { return s_invert_y; } void act_set_invert_y(bool on) { s_invert_y = on; } bool act_get_swap_xy(void) { return s_swap_xy; } void act_set_swap_xy(bool on) { s_swap_xy = on; } + +// ── physical-input masking ─────────────────────────────────────────────────── +void act_phys_mask_mouse(uint8_t code, bool enable) +{ + if (code > PHYS_MASK_WHEEL) return; + uint16_t bit = (uint16_t)(1u << code); + if (enable) g_phys_mask |= bit; + else g_phys_mask &= (uint16_t)~bit; +} + +void act_phys_mask_key(uint8_t hid_key, bool enable) +{ + // Reuse the masked-key table; mode 1 = masked. act_kb_mask owns the table. + act_kb_mask(hid_key, enable ? 1 : 0); +} + +void act_phys_unmask_all(void) +{ + g_phys_mask = 0; + g_masked_count = 0; +} + +bool act_phys_key_masked(uint8_t hid_key) +{ + for (uint8_t i = 0; i < g_masked_count; i++) + if (g_masked_keys[i] == hid_key && g_masked_modes[i] != 0) + return true; + return false; +} + +bool act_phys_kb_mask_active(void) { return g_masked_count != 0; } + +// ── motion program (Feature A) ─────────────────────────────────────────────── +// Cubic Bézier from the origin: B(t) = 3(1-t)^2 t·C1 + 3(1-t) t^2·C2 + t^3·E, +// with the start point at (0,0) folded in (its (1-t)^3 term is zero). Evaluated +// per-axis in fixed-point; only the integer delta from the last emitted point +// is injected, so rounding never accumulates and the final tick lands exactly +// on the endpoint. +static int32_t bezier_axis(int32_t c1, int32_t c2, int32_t e, uint32_t t_q, uint32_t one) +{ + // t_q, one are .16 fixed-point in [0, 65536]. Returns counts (rounded). + // Work in 64-bit: coefficients are small (≤ ~32767) but t^3 scaling needs room. + uint64_t t = t_q; + uint64_t u = one - t_q; // (1 - t) + uint64_t t2 = (t * t) >> 16; + uint64_t u2 = (u * u) >> 16; + uint64_t b1 = (3u * ((u2 * t) >> 16)); // 3(1-t)^2 t (.16) + uint64_t b2 = (3u * ((t2 * u) >> 16)); // 3(1-t) t^2 (.16) + uint64_t b3 = ((t2 * t) >> 16); // t^3 (.16) + int64_t acc = (int64_t)b1 * c1 + (int64_t)b2 * c2 + (int64_t)b3 * e; + // acc is counts<<16; round to nearest. + return (int32_t)((acc + (acc >= 0 ? (1 << 15) : -(1 << 15))) >> 16); +} + +// Smoothstep ease (3t^2 - 2t^3) for automove — a human-like accel/decel profile. +// Returns position fraction in .16 fixed-point for input t_q in .16. +static uint32_t ease_smoothstep(uint32_t t_q, uint32_t one) +{ + uint64_t t = t_q; + uint64_t t2 = (t * t) >> 16; + uint64_t t3 = (t2 * t) >> 16; + // 3t^2 - 2t^3, all .16 + int64_t s = (int64_t)(3u * t2) - (int64_t)(2u * t3); + if (s < 0) s = 0; + if (s > (int64_t)one) s = (int64_t)one; + return (uint32_t)s; +} + +static void motion_start_common(int16_t dx, int16_t dy, uint16_t dur_ms) +{ + g_motion.start_ms = millis(); + g_motion.dur_ms = dur_ms; + g_motion.ex = dx; + g_motion.ey = dy; + g_motion.emit_x = 0; + g_motion.emit_y = 0; +} + +void act_motion_move_dur(int16_t dx, int16_t dy, uint16_t dur_ms) +{ + if (dur_ms == 0) { act_move(dx, dy); return; } // immediate + motion_start_common(dx, dy, dur_ms); + g_motion.kind = MOTION_LINEAR; +} + +void act_motion_bezier(int16_t dx, int16_t dy, uint16_t dur_ms, + int16_t x1, int16_t y1, int16_t x2, int16_t y2) +{ + if (dur_ms == 0) { act_move(dx, dy); return; } + motion_start_common(dx, dy, dur_ms); + g_motion.c1x = x1; g_motion.c1y = y1; + g_motion.c2x = x2; g_motion.c2y = y2; + g_motion.kind = MOTION_BEZIER; +} + +void act_motion_cancel(void) { g_motion.kind = MOTION_NONE; } + +void act_motion_tick(void) +{ + if (g_motion.kind == MOTION_NONE) return; + + uint32_t elapsed = millis() - g_motion.start_ms; + bool last = (elapsed >= g_motion.dur_ms); + uint32_t t_q; // .16 in [0, 65536] + if (last) { + t_q = 65536u; + } else { + // t = elapsed / dur, in .16. Kept strictly 32-bit so this maps to the + // Cortex-M7 hardware UDIV (bounded 2–12 cy, deterministic) instead of the + // software __aeabi_uldivmod (data-dependent loop). Safe in 32 bits: this + // branch only runs when elapsed < dur_ms ≤ 65535, so elapsed ≤ 65534 and + // (elapsed << 16) ≤ 0xFFFE0000 — no overflow of uint32_t. + t_q = (elapsed << 16) / g_motion.dur_ms; + } + + int32_t px, py; // target position at t (counts) + if (g_motion.kind == MOTION_BEZIER) { + px = bezier_axis(g_motion.c1x, g_motion.c2x, g_motion.ex, t_q, 65536u); + py = bezier_axis(g_motion.c1y, g_motion.c2y, g_motion.ey, t_q, 65536u); + } else { + uint32_t s = ease_smoothstep(t_q, 65536u); // eased fraction .16 + px = (int32_t)(((int64_t)g_motion.ex * s) >> 16); + py = (int32_t)(((int64_t)g_motion.ey * s) >> 16); + } + + int32_t step_x = px - g_motion.emit_x; + int32_t step_y = py - g_motion.emit_y; + + // Clamp each step to int16 for the injection path. emit_x/y advances only by + // what is actually emitted, so any clamped residue is carried to the next + // tick rather than dropped (keeps total/endpoint exact). Huge per-tick steps + // only occur if the loop stalls for many ms — vanishingly rare. + if (step_x > INT16_MAX) step_x = INT16_MAX; + if (step_x < INT16_MIN) step_x = INT16_MIN; + if (step_y > INT16_MAX) step_y = INT16_MAX; + if (step_y < INT16_MIN) step_y = INT16_MIN; + g_motion.emit_x += step_x; + g_motion.emit_y += step_y; + + if (step_x || step_y) + act_move_raw((int16_t)step_x, (int16_t)step_y); + + // Only finish once the endpoint is fully delivered (clamped residue could + // otherwise leave the last counts unsent on the final tick). + if (last && g_motion.emit_x == g_motion.ex && g_motion.emit_y == g_motion.ey) + g_motion.kind = MOTION_NONE; +} diff --git a/src/actions.h b/src/actions.h index bf04bdf..c1ed074 100644 --- a/src/actions.h +++ b/src/actions.h @@ -32,3 +32,38 @@ void act_set_invert_y(bool on); bool act_get_swap_xy(void); void act_set_swap_xy(bool on); + +// ── Physical-input masking (KMBox Net `mask` / `unmask_all`) ───────────────── +// A masked physical input is suppressed before it reaches the downstream PC, +// while injected input on the same control still passes. Enforced in the merge +// path (src/kmbox.c); these only manage the mask state. The mouse-button/axis +// bits reuse the lock-bit order (ml=0,mr=1,mm=2,ms1=3,ms2=4,mx=5,my=6) but in a +// dedicated bitmap so they do not collide with g_lock_mask's injection gating. +#define PHYS_MASK_ML 0 +#define PHYS_MASK_MR 1 +#define PHYS_MASK_MM 2 +#define PHYS_MASK_MS1 3 +#define PHYS_MASK_MS2 4 +#define PHYS_MASK_MX 5 +#define PHYS_MASK_MY 6 +#define PHYS_MASK_WHEEL 7 +extern uint16_t g_phys_mask; // bit i set → suppress physical input i + +void act_phys_mask_mouse(uint8_t code, bool enable); // code 0..7 (see above) +void act_phys_mask_key(uint8_t hid_key, bool enable); +void act_phys_unmask_all(void); +bool act_phys_key_masked(uint8_t hid_key); // queried in merge path +bool act_phys_kb_mask_active(void); // any key currently masked + +// ── Motion program (KMBox Net `mouse_move_auto` / `mouse_move_beizer`) ─────── +// A time-bounded source of injected delta: each tick emits the increment needed +// to reach the trajectory's position at the current time, through act_move (so +// inverts/swap and humanization apply exactly as for manual moves). Position- +// based, so call cadence affects granularity only, never the total or endpoint. +// Last-writer-wins: starting a new program or any plain act_move cancels the +// in-flight one (matching a user redirecting the mouse mid-gesture). +void act_motion_move_dur(int16_t dx, int16_t dy, uint16_t dur_ms); +void act_motion_bezier(int16_t dx, int16_t dy, uint16_t dur_ms, + int16_t x1, int16_t y1, int16_t x2, int16_t y2); +void act_motion_tick(void); // step from the poll loop +void act_motion_cancel(void); // abort in-flight program diff --git a/src/gpt_profile.h b/src/gpt_profile.h index a652965..ff89480 100644 --- a/src/gpt_profile.h +++ b/src/gpt_profile.h @@ -1,12 +1,29 @@ #pragma once -// GPT2 free-running microsecond counter for profiling. -// Uses IPG clock (204 MHz) with /204 prescaler -> 1 MHz = 1µs resolution. -// 32-bit counter wraps every ~71.6 minutes. +// GPT2 free-running microsecond counter for profiling AND poll-interval +// measurement. Clocked from ipg_clk (CLKSRC=001). On this board the CCM +// sets IPG = ARM/4 = F_CPU/4 (core/startup.c IPG_PODF=/4), so the /N prescaler +// for a true 1 MHz (1 µs) tick is N = (F_CPU/4)/1e6. Deriving it from F_CPU +// keeps the tick at 1 µs even if F_CPU changes (e.g. 816→600 MHz), instead of +// the old hard-coded /204 that was only correct at 816 MHz. +// 32-bit counter wraps every ~71.6 minutes; unsigned subtraction is single-wrap safe. // Zero CPU overhead — reads are a single register load. #include #include "imxrt.h" +// IPG clock in Hz on this board = ARM (F_CPU) / 4. If the CCM IPG_PODF in +// core/startup.c ever changes, update this divisor to match. +#define GPT_IPG_HZ (F_CPU / 4u) +// Prescaler register value = divisor-1; gives a 1 MHz counter (1 tick = 1 µs). +#define GPT_PRESCALER_1MHZ ((GPT_IPG_HZ / 1000000u) - 1u) +// The 1 µs tick is only exact if IPG is a whole MHz, and the compile-time +// F_CPU only matches the real clock when set_arm_clock actually programs it: +// its <=528 MHz fallback leaves the ROM's 396 MHz state and every IPG-derived +// constant here (and the adaptive feed-rate that consumes these timestamps) +// would be silently wrong. Fail the build instead. +_Static_assert(F_CPU > 528000000u, "F_CPU <=528 MHz: set_arm_clock fallback does not program dividers; GPT2 1 us tick would be wrong"); +_Static_assert(GPT_IPG_HZ % 1000000u == 0, "IPG (F_CPU/4) must be a whole MHz for an exact 1 us GPT2 tick"); + static inline void gpt_profile_init(void) { // Enable GPT2 clocks @@ -14,11 +31,11 @@ static inline void gpt_profile_init(void) CCM_CCGR0_GPT2_SERIAL(CCM_CCGR_ON); // Reset and configure GPT2 - GPT2_CR = 0; // disable - GPT2_PR = 203; // prescaler: IPG/204 = ~1MHz - GPT2_CR = (1 << 9) | // FRR: free-run mode - (1 << 6) | // CLKSRC = 001 (IPG clock) - (1 << 0); // EN: enable timer + GPT2_CR = 0; // disable + GPT2_PR = GPT_PRESCALER_1MHZ; // ipg_clk / (F_CPU/4/1e6) -> 1 MHz tick + GPT2_CR = (1 << 9) | // FRR: free-run mode + (1 << 6) | // CLKSRC = 001 (ipg_clk) + (1 << 0); // EN: enable timer } // Read current microsecond timestamp (wraps at ~71.6 minutes) diff --git a/src/humanize.c b/src/humanize.c index 0f32ee8..7f79c40 100644 --- a/src/humanize.c +++ b/src/humanize.c @@ -2,6 +2,15 @@ #include #include +/* Pin the per-tick hot path to ITCM (.fastrun) for deterministic, cache-miss-free + * latency. Today the compiler already places these in ITCM by default, but making + * it explicit locks that in against future LTO/flag changes. No-op on host tests. */ +#ifdef HUMANIZE_HOSTTEST +#define HZ_FASTRUN +#else +#define HZ_FASTRUN __attribute__((section(".fastrun"))) +#endif + /* ── tunables ───────────────────────────────────────────────────────── */ #define HZ_DEFAULT_LEVEL 2 /* boot default: on, "normal" */ #define HZ_MAX_PER_FRAME 127 /* human per-frame ceiling (counts) */ @@ -10,6 +19,33 @@ #define HZ_TIMING_FLOOR 0.80f /* min multiple of base period */ #define HZ_TIMING_CEIL 1.20f /* max multiple of base period */ +/* ── adaptive feed-rate / measured-interval tunables ─────────────────── + * We measure the *delivery* interval of real mouse reports (GPT2 µs). This is + * dominated by host/EHCI quantization (125 µs microframe / 1 ms FS frame) + our + * poll latency, NOT the mouse's true internal cadence — it is the same signal a + * poll-rate tester averages, just un-averaged. We EWMA it and reject outliers. */ +#define HZ_MEAS_EWMA_SHIFT 4 /* alpha = 1/16 (>>4) on the measured interval */ +#define HZ_MEAS_MIN_COUNT 5 /* reports required before target is trusted */ +#define HZ_MEAS_DROP_MULT 4u /* dt > this*target → dropout (reset baseline) */ +#define HZ_MEAS_BURST_DIV 2u /* dt < target/this → burst/retry (ignore) */ +#define HZ_LDVAL_US_MIN 125u /* must match main.c PIT clamp window */ +#define HZ_LDVAL_US_MAX 10000u + +/* ── per-axis normalized-speed envelope (peak-with-decay) ────────────── + * MAKD's "max X_Y": track a per-axis rolling peak of speed (counts/sec, which is + * DPI-independent given the measured interval) and normalize current speed to it. + * Decay is TIME-based (per measured dt), not per-frame, so it does not leak 8x + * faster on an 8 kHz mouse than a 1 kHz one. Attack is instantaneous. + * HZ_ADAPTIVE_NOISE gates whether the envelope actually drives the noise term; + * default 0 preserves the exact current (raw-speed) behavior so host tests and + * shipping feel are unchanged until this is A/B'd on hardware. The envelope + * state is still maintained (cheaply) so diagnostics can observe it. */ +#ifndef HZ_ADAPTIVE_NOISE +#define HZ_ADAPTIVE_NOISE 0 +#endif +#define HZ_PEAK_TAU_US 200000.0f /* envelope decay time constant (~200 ms) */ +#define HZ_PEAK_FLOOR 1.0f /* min peak (counts/sec) → no div-by-zero */ + /* Per-level perpendicular-noise amplitude (fraction of speed → ~constant * jitter angle). Level 0 = off. Injection is delivered in-frame (no cross-frame * velocity smoothing): the humanization is a bounded per-frame perturbation @@ -26,6 +62,12 @@ static struct { uint32_t a, b, c, ctr; /* SFC32 */ uint32_t timing_lfsr; int idle; + /* adaptive feed-rate: measured delivery interval (GPT2 µs) */ + uint32_t last_ts_us; /* timestamp of previous report (0 = none) */ + uint32_t meas_interval_us; /* EWMA-smoothed delivery interval */ + uint32_t arrival_count; /* reports recorded since init */ + /* per-axis normalized-speed envelope (counts/sec, time-decayed peak) */ + float peak_x, peak_y; /* rolling per-axis speed peak */ } S; /* ── RNG (verbatim from smooth.c) ───────────────────────────────────── */ @@ -74,9 +116,66 @@ void humanize_init(uint32_t interval_us) { S.ewma = 0.85f; humanize_set_level(HZ_DEFAULT_LEVEL); S.noise_amp *= 1.0f + 0.15f * sfc32_uniform(); - (void)interval_us; /* intentionally unused: drain rate is level-preset, not interval-scaled */ + /* Pre-seed the measured-interval EWMA with the nominal bInterval period so + * outlier rejection (burst/drop) is sane from the very first real report. + * Without this, the first inter-report gap — which can be arbitrarily large + * because the device only sends on movement (NAKs when idle) — would seed a + * huge estimate that then rejects every normal-rate report as a "burst", + * permanently locking meas_interval_us at the bad value. Drain rate itself + * is still level-preset, not interval-scaled. */ + if (interval_us < HZ_LDVAL_US_MIN) interval_us = HZ_LDVAL_US_MIN; + if (interval_us > HZ_LDVAL_US_MAX) interval_us = HZ_LDVAL_US_MAX; + S.meas_interval_us = interval_us; +} + +/* ── adaptive feed-rate: measured delivery interval ────────────────────── */ +HZ_FASTRUN +void humanize_record_arrival(uint32_t ts_us) { + if (S.arrival_count == 0) { + S.last_ts_us = ts_us; + S.arrival_count = 1; + return; /* first report: no interval yet */ + } + uint32_t dt = ts_us - S.last_ts_us; /* unsigned, single-wrap safe */ + uint32_t cur = S.meas_interval_us; + if (cur != 0) { + /* Reject outliers before they pollute the EWMA: + * - dropout (idle gap / unplug): far longer than expected → rebase, no update + * - burst / double-report / USB retry: far shorter → ignore entirely */ + if (dt > cur * HZ_MEAS_DROP_MULT) { S.last_ts_us = ts_us; return; } + if (dt < cur / HZ_MEAS_BURST_DIV) { S.last_ts_us = ts_us; return; } + } + S.last_ts_us = ts_us; + if (cur == 0) { + S.meas_interval_us = dt; /* seed EWMA with first interval */ + } else { + /* EWMA: cur += (dt - cur) * alpha, alpha = 1/2^SHIFT, integer, signed step */ + S.meas_interval_us = (uint32_t)((int32_t)cur + + (((int32_t)dt - (int32_t)cur) >> HZ_MEAS_EWMA_SHIFT)); + } + if (S.arrival_count < 0xFFFFFFFFu) S.arrival_count++; +} + +uint32_t humanize_measured_interval_us(void) { + return S.meas_interval_us; +} + +uint32_t humanize_target_ldval(uint32_t pit_clk_hz) { + if (S.arrival_count < HZ_MEAS_MIN_COUNT || S.meas_interval_us == 0) + return 0; /* not yet confident */ + uint32_t us = S.meas_interval_us; + if (us < HZ_LDVAL_US_MIN) us = HZ_LDVAL_US_MIN; + if (us > HZ_LDVAL_US_MAX) us = HZ_LDVAL_US_MAX; + /* This runs once per PIT tick in the main loop. pit_clk_hz (PERCLK) is an + * exact multiple of 1 MHz, so counts = us * (clk/1e6) in pure 32-bit math. + * The previous ((uint64_t)clk * us) / 1e6 form pulled __aeabi_uldivmod — a + * data-dependent software-loop divide — into the per-tick path. Worst case + * here: 10000 µs * 24 = 240000, far inside uint32_t. */ + uint32_t counts = us * (pit_clk_hz / 1000000u); + return counts ? counts - 1u : 0u; } +HZ_FASTRUN static int16_t drain_axis(float *owed, float *res, float emit_v, float noise) { float want = emit_v + noise + *res; /* cap to human ceiling */ @@ -101,6 +200,7 @@ static int16_t drain_axis(float *owed, float *res, float emit_v, float noise) { return out; } +HZ_FASTRUN void humanize_filter(int16_t *dx, int16_t *dy) { if (S.level == 0) return; /* off: passthrough */ @@ -121,8 +221,38 @@ void humanize_filter(int16_t *dx, int16_t *dy) { float ey = S.owed_y; float speed = sqrtf(ex*ex + ey*ey); + + float speed_drive = speed; /* default: raw speed (unchanged) */ +#if HZ_ADAPTIVE_NOISE + /* Per-axis normalized-speed envelope (MAKD's "max X_Y"): only meaningful + * once we have a measured delivery interval to convert counts→counts/sec. + * Attack is instantaneous; decay is time-based over the measured dt so the + * envelope memory is wall-clock constant regardless of poll rate. Gated + * entirely behind HZ_ADAPTIVE_NOISE so the default build's hot path is byte + * -for-byte the prior raw-speed behavior and pays no expf/divide per frame. */ + if (S.meas_interval_us > 0) { + float inv_dt = 1e6f / (float)S.meas_interval_us; /* 1/sec */ + float rate_x = fabsf(ex) * inv_dt; + float rate_y = fabsf(ey) * inv_dt; + float k = expf(-(float)S.meas_interval_us / HZ_PEAK_TAU_US); /* decay */ + S.peak_x = (rate_x > S.peak_x) ? rate_x : S.peak_x * k; + S.peak_y = (rate_y > S.peak_y) ? rate_y : S.peak_y * k; + if (S.peak_x < HZ_PEAK_FLOOR) S.peak_x = HZ_PEAK_FLOOR; + if (S.peak_y < HZ_PEAK_FLOOR) S.peak_y = HZ_PEAK_FLOOR; + /* Drive noise off the 0..1 normalized magnitude instead of raw speed. + * Scaling by `speed` keeps the perpendicular jitter proportional to the + * actual pixel step so it stays a constant *angle*; multiplying by the + * normalized term shapes amplitude by how fast we are vs our own peak. */ + float nrm_x = rate_x / S.peak_x; + float nrm_y = rate_y / S.peak_y; + float norm = sqrtf(nrm_x*nrm_x + nrm_y*nrm_y); /* ~0..~1.41 */ + if (norm > 1.0f) norm = 1.0f; + speed_drive = speed * norm; + } +#endif + S.n_perp = S.ewma * S.n_perp + (1.0f - S.ewma) * sfc32_uniform(); - float nmag = S.n_perp * S.noise_amp * speed; + float nmag = S.n_perp * S.noise_amp * speed_drive; float nx = 0.0f, ny = 0.0f; if (speed > 1e-3f) { nx = -ey / speed * nmag; ny = ex / speed * nmag; } @@ -130,15 +260,18 @@ void humanize_filter(int16_t *dx, int16_t *dy) { *dy = drain_axis(&S.owed_y, &S.res_y, ey, ny); } +HZ_FASTRUN void humanize_return(int16_t dx, int16_t dy) { S.owed_x += (float)dx; S.owed_y += (float)dy; } +HZ_FASTRUN bool humanize_pending(void) { return fabsf(S.owed_x) >= HZ_IDLE_EPS || fabsf(S.owed_y) >= HZ_IDLE_EPS; } +HZ_FASTRUN uint32_t humanize_timing_next(uint32_t base_ldval) { if (S.level == 0) return base_ldval; S.timing_lfsr ^= S.timing_lfsr << 13; @@ -147,6 +280,7 @@ uint32_t humanize_timing_next(uint32_t base_ldval) { float u = (float)(S.timing_lfsr >> 8) * (1.0f / 16777216.0f) - 0.5f; float r = (float)base_ldval * (1.0f + HZ_TIMING_JITTER * u); float lo = (float)base_ldval * HZ_TIMING_FLOOR, hi = (float)base_ldval * HZ_TIMING_CEIL; - if (r < lo) r = lo; if (r > hi) r = hi; + if (r < lo) r = lo; + if (r > hi) r = hi; return (uint32_t)r; } diff --git a/src/humanize.h b/src/humanize.h index 451ede7..12f7be4 100644 --- a/src/humanize.h +++ b/src/humanize.h @@ -13,3 +13,20 @@ bool humanize_pending(void); /* true while owed motion remains to emit */ * frame (it was clamped), so the filter redelivers it as headroom opens. * Real-mouse passthrough keeps priority; only the injected overflow comes back. */ void humanize_return(int16_t dx, int16_t dy); + +/* ── Adaptive feed-rate (measured poll interval) ──────────────────────── + * Record the arrival of a real mouse report, timestamped from the free-running + * 1 MHz GPT2 counter (microseconds). Only mouse reports should be passed. + * Builds an EWMA of the *delivery* interval, rejecting dropouts/double-reports. + * Safe to call from the main loop only (no ISR). */ +void humanize_record_arrival(uint32_t ts_us); + +/* Target PIT LDVAL derived from the measured interval, or 0 when there is not + * yet a confident measurement (caller keeps its current base in that case). + * pit_clk_hz is the PIT input clock (PERCLK, 24 MHz on this board). + * The caller is expected to SLEW its base toward this, not jump to it. */ +uint32_t humanize_target_ldval(uint32_t pit_clk_hz); + +/* Last EWMA-smoothed measured interval in microseconds (0 = none yet). + * Exposed for diagnostics / status reporting. */ +uint32_t humanize_measured_interval_us(void); diff --git a/src/hurra.c b/src/hurra.c index 9f5f467..77f4eb3 100644 --- a/src/hurra.c +++ b/src/hurra.c @@ -32,6 +32,8 @@ enum { TYPE_INVERT_Y = 0x18, TYPE_SWAP_XY = 0x19, TYPE_HUMAN = 0x1A, + TYPE_MOUSE_MOVE_DUR = 0x1B, // KMBox Net automove (duration-stepped) + TYPE_MOUSE_MOVE_BEZIER = 0x1C, // KMBox Net bezier move TYPE_BTN_LEFT = 0x20, TYPE_BTN_RIGHT = 0x21, TYPE_BTN_MIDDLE = 0x22, @@ -56,16 +58,21 @@ enum { TYPE_LOCK_MX = 0x65, TYPE_LOCK_MY = 0x66, TYPE_CATCH_XY = 0x67, + TYPE_PHYS_MASK = 0x68, // KMBox Net physical-input mask / unmask_all // 0x70–0x73 reserved/unused (removed Hurra-only STREAM_AXIS/BTN/MOUSE/KB) TYPE_CB_BUTTONS = 0x74, TYPE_CB_AXES = 0x75, TYPE_CB_KEYS = 0x76, + TYPE_CB_PHYS = 0x77, // KMBox Net physical-only telemetry enable // 0x80–0x8F on-change telemetry callbacks (Ferrum-standard) TYPE_TLM_AXIS = 0x80, TYPE_TLM_BUTTONS = 0x81, // 0x82 reserved/unused (removed Hurra-only TLM_MOUSE) TYPE_TLM_KB = 0x83, // 0x84–0x85 reserved/unused (removed Hurra-only TLM_STATS/TLM_LOG) + TYPE_TLM_PHYS_AXIS = 0x86, // physical-only mouse motion (device→host) + TYPE_TLM_PHYS_BUTTONS = 0x87, // physical-only buttons (device→host) + TYPE_TLM_PHYS_KB = 0x88, // physical-only keyboard (device→host) }; #define HURRA_IDENTITY "kmbox: Hurra v1" @@ -99,6 +106,7 @@ static uint32_t s_baud_apply_at; // ── callback state (Ferrum-standard on-change callbacks) ──────────────────── static uint8_t s_cb_buttons, s_cb_axes, s_cb_keys; +static uint8_t s_cb_phys; // KMBox Net physical-only telemetry enable static uint8_t s_last_btn_emitted = 0; static uint8_t s_last_keys_emitted[6]; @@ -268,6 +276,46 @@ static TF_Result l_human(TinyFrame *tf, TF_Msg *msg) return TF_STAY; } +// KMBox Net automove: total (dx,dy) spread over dur_ms with a human velocity +// profile. Starts a motion program (actions.c) stepped from the poll loop; +// oneway, no reply. dur_ms==0 falls back to an immediate move. +static TF_Result l_mouse_move_dur(TinyFrame *tf, TF_Msg *msg) +{ + (void)tf; + track_id(msg->frame_id); + if (msg->len != 6) { s_payload_invalid++; return TF_STAY; } + int16_t dx = rd_i16le(&msg->data[0]); + int16_t dy = rd_i16le(&msg->data[2]); + uint16_t dur = rd_u16le(&msg->data[4]); + act_motion_move_dur(dx, dy, dur); + return TF_STAY; +} + +// KMBox Net bezier: cubic curve from origin to (dx,dy) over dur_ms with control +// points (x1,y1),(x2,y2) relative to start. Oneway, no reply. +// +// NOTE: payload is 14 bytes (7 little-endian int16 fields). The requirements doc +// header said "12 bytes" but its own field list enumerates dx,dy,dur,x1,y1,x2,y2 +// = 14 bytes, which is what a 2-control-point cubic actually needs; 12 cannot +// carry all 7. Implemented to the field list. Host's encoder must match (§8 +// cross-repo invariant) — confirm hurra-app sends 14, not 12. +#define BEZIER_PAYLOAD_LEN 14 +static TF_Result l_mouse_move_bezier(TinyFrame *tf, TF_Msg *msg) +{ + (void)tf; + track_id(msg->frame_id); + if (msg->len != BEZIER_PAYLOAD_LEN) { s_payload_invalid++; return TF_STAY; } + int16_t dx = rd_i16le(&msg->data[0]); + int16_t dy = rd_i16le(&msg->data[2]); + uint16_t dur = rd_u16le(&msg->data[4]); + int16_t x1 = rd_i16le(&msg->data[6]); + int16_t y1 = rd_i16le(&msg->data[8]); + int16_t x2 = rd_i16le(&msg->data[10]); + int16_t y2 = rd_i16le(&msg->data[12]); + act_motion_bezier(dx, dy, dur, x1, y1, x2, y2); + return TF_STAY; +} + static TF_Result l_mouse_getpos(TinyFrame *tf, TF_Msg *msg) { (void)tf; @@ -491,6 +539,31 @@ static TF_Result l_catch_xy(TinyFrame *tf, TF_Msg *msg) return TF_STAY; } +// KMBox Net mask / unmask_all. Payload 3 bytes: domain, code, enable. +// domain 0 (mouse): code 0..6 = ml,mr,mm,ms1,ms2,mx,my; 7 = wheel +// domain 1 (keyboard): code = HID keycode to mask/unmask +// domain 0xFF: clear every active mask (unmask_all); code/enable ignored +// Oneway, no reply. Enforcement lives in the merge path (src/kmbox.c). +static TF_Result l_phys_mask(TinyFrame *tf, TF_Msg *msg) +{ + (void)tf; + track_id(msg->frame_id); + if (msg->len != 3) { s_payload_invalid++; return TF_STAY; } + uint8_t domain = msg->data[0]; + uint8_t code = msg->data[1]; + bool enable = msg->data[2] != 0; + if (domain == 0xFF) { + act_phys_unmask_all(); + } else if (domain == 0) { + act_phys_mask_mouse(code, enable); + } else if (domain == 1) { + act_phys_mask_key(code, enable); + } else { + s_payload_invalid++; + } + return TF_STAY; +} + // ── admin listeners: INIT / REBOOT / BAUD / SCREEN ────────────────────────── static TF_Result l_init(TinyFrame *tf, TF_Msg *msg) @@ -573,6 +646,7 @@ static TF_Result cb_toggle_listener(TinyFrame *tf, TF_Msg *msg, uint8_t *flag) static TF_Result l_cb_btn (TinyFrame *tf, TF_Msg *m) { return cb_toggle_listener(tf, m, &s_cb_buttons); } static TF_Result l_cb_axes (TinyFrame *tf, TF_Msg *m) { return cb_toggle_listener(tf, m, &s_cb_axes); } static TF_Result l_cb_keys (TinyFrame *tf, TF_Msg *m) { return cb_toggle_listener(tf, m, &s_cb_keys); } +static TF_Result l_cb_phys (TinyFrame *tf, TF_Msg *m) { return cb_toggle_listener(tf, m, &s_cb_phys); } // ── telemetry emit (TLM_AXIS / TLM_BUTTONS / TLM_MOUSE / TLM_KB) ──────────── // @@ -606,6 +680,7 @@ void hurra_init(void) s_baud_pending = 0; s_baud_apply_at = 0; s_cb_buttons = s_cb_axes = s_cb_keys = 0; + s_cb_phys = 0; memset(s_last_keys_emitted, 0, sizeof(s_last_keys_emitted)); s_screen_w = s_screen_h = 0; memset(&s_catch, 0, sizeof(s_catch)); @@ -618,6 +693,8 @@ void hurra_init(void) TF_AddTypeListener(&s_tf, TYPE_MOUSE_MO, l_mouse_mo); TF_AddTypeListener(&s_tf, TYPE_MOUSE_CLICK, l_mouse_click); TF_AddTypeListener(&s_tf, TYPE_MOUSE_WHEEL, l_mouse_wheel); + TF_AddTypeListener(&s_tf, TYPE_MOUSE_MOVE_DUR, l_mouse_move_dur); + TF_AddTypeListener(&s_tf, TYPE_MOUSE_MOVE_BEZIER, l_mouse_move_bezier); TF_AddTypeListener(&s_tf, TYPE_MOUSE_GETPOS, l_mouse_getpos); TF_AddTypeListener(&s_tf, TYPE_BTN_LEFT, l_btn_left); TF_AddTypeListener(&s_tf, TYPE_BTN_RIGHT, l_btn_right); @@ -645,6 +722,7 @@ void hurra_init(void) TF_AddTypeListener(&s_tf, TYPE_LOCK_MX, l_lock_mx); TF_AddTypeListener(&s_tf, TYPE_LOCK_MY, l_lock_my); TF_AddTypeListener(&s_tf, TYPE_CATCH_XY, l_catch_xy); + TF_AddTypeListener(&s_tf, TYPE_PHYS_MASK, l_phys_mask); TF_AddTypeListener(&s_tf, TYPE_INIT, l_init); TF_AddTypeListener(&s_tf, TYPE_REBOOT, l_reboot); TF_AddTypeListener(&s_tf, TYPE_BAUD, l_baud); @@ -652,6 +730,7 @@ void hurra_init(void) TF_AddTypeListener(&s_tf, TYPE_CB_BUTTONS, l_cb_btn); TF_AddTypeListener(&s_tf, TYPE_CB_AXES, l_cb_axes); TF_AddTypeListener(&s_tf, TYPE_CB_KEYS, l_cb_keys); + TF_AddTypeListener(&s_tf, TYPE_CB_PHYS, l_cb_phys); } void hurra_reset(void) { TF_ResetParser(&s_tf); } @@ -718,3 +797,37 @@ void hurra_notify_keys(const uint8_t keys[6]) tlm_send(TYPE_TLM_KB, p, sizeof(p)); } } + +// ── physical-only telemetry (KMBox Net `monitor`) ─────────────────────────── +// Emitted by the merge path with PRE-merge (and pre-mask) physical values when +// CB_PHYS is enabled, so a client can observe the user's true input distinctly +// from injected/merged state. Per-report emission is acceptable (rate bounded +// by the physical poll rate); telemetry yields to TX backpressure like TLM_*. +bool hurra_phys_enabled(void) { return s_cb_phys != 0; } + +void hurra_notify_phys_axes(int16_t dx, int16_t dy, int8_t wheel) +{ + if (!s_cb_phys) return; + uint8_t p[5] = { + (uint8_t)dx, (uint8_t)(dx >> 8), + (uint8_t)dy, (uint8_t)(dy >> 8), + (uint8_t)wheel, + }; + tlm_send(TYPE_TLM_PHYS_AXIS, p, sizeof(p)); +} + +void hurra_notify_phys_buttons(uint8_t buttons) +{ + if (!s_cb_phys) return; + tlm_send(TYPE_TLM_PHYS_BUTTONS, &buttons, 1); +} + +void hurra_notify_phys_keys(uint8_t modifier, const uint8_t keys[6]) +{ + if (!s_cb_phys) return; + uint8_t p[8]; + p[0] = modifier; + p[1] = 0; // reserved (matches TLM_KB layout) + memcpy(&p[2], keys, 6); + tlm_send(TYPE_TLM_PHYS_KB, p, sizeof(p)); +} diff --git a/src/hurra.h b/src/hurra.h index 9ac499f..e3d9ae1 100644 --- a/src/hurra.h +++ b/src/hurra.h @@ -20,3 +20,12 @@ void hurra_tick(void); void hurra_notify_buttons(uint8_t buttons_bitmap); void hurra_notify_axes(int16_t dx, int16_t dy, int8_t scroll); void hurra_notify_keys(const uint8_t keys[6]); + +// Physical-only telemetry (KMBox Net `monitor`). The merge path calls these +// with PRE-merge, pre-mask physical values; they emit TLM_PHYS_* only while +// CB_PHYS is enabled. hurra_phys_enabled() lets the hot path skip the capture +// work entirely when monitoring is off. +bool hurra_phys_enabled(void); +void hurra_notify_phys_buttons(uint8_t buttons_bitmap); +void hurra_notify_phys_axes(int16_t dx, int16_t dy, int8_t wheel); +void hurra_notify_phys_keys(uint8_t modifier, const uint8_t keys[6]); diff --git a/src/kmbox.c b/src/kmbox.c index a44385e..2f19e64 100644 --- a/src/kmbox.c +++ b/src/kmbox.c @@ -7,6 +7,9 @@ #include "imxrt.h" #include "usb_device.h" #include "proto.h" +#include "actions.h" +#include "gpt_profile.h" +#include "synth_cadence.h" #include extern uint32_t millis(void); @@ -74,6 +77,10 @@ static uint32_t last_rx_activity_time; static void km_rx_dma_isr(void) { DMA_CINT = KM_RX_CH; + // Posted-write flush: without this the W1C may not have landed by + // exception return and the ISR re-enters once (wasted cycles in the + // latency-critical window between PIT ticks). + __asm volatile("dsb" ::: "memory"); } // LPUART IDLE-line ISR — fires after one idle character at the end of each @@ -84,6 +91,7 @@ static void km_rx_dma_isr(void) static void km_uart_idle_isr(void) { KM_UART_STAT = LPUART_STAT_IDLE; + __asm volatile("dsb" ::: "memory"); // flush W1C before exception return } // Ring sized to match CH343B's 1 KB USB IN buffer so a full burst lands in @@ -643,6 +651,11 @@ void kmbox_poll_fast(void) // Drive catch_xy deadline check even when no UART RX is arriving. proto_tick(); + // Step any in-flight motion program (automove/bezier). Emits the increment + // toward the trajectory's position at this instant through the injection + // path, so it composes with real-mouse passthrough and humanization. + act_motion_tick(); + if (__builtin_expect(inject.click_release_at != 0, 0) && millis() >= inject.click_release_at) { inject.mouse_buttons &= ~inject.click_release_mask; inject.mouse_dirty = true; @@ -741,15 +754,20 @@ static void kmbox_merge_report_slow(uint8_t *report, uint8_t len, uint8_t rid, uint8_t doff); __attribute__((cold, noinline)) static void kmbox_merge_keyboard(uint8_t *report, uint8_t len); +__attribute__((cold, noinline)) +static void kmbox_phys_mouse(uint8_t *report, uint8_t len); +__attribute__((cold, noinline)) +static void kmbox_phys_keyboard(uint8_t *report, uint8_t len); -// Output cadence tracking. last_merge_ms = when a real mouse report last rode -// through (injection rides those). last_synth_ms = last standalone synth frame. -// Used to keep exactly one mouse report per ~1 ms: injection rides merge +// Output cadence tracking (GPT2 microseconds). last_merge_us = when a real +// mouse report last rode through (injection rides those). last_synth_us = last +// standalone synth frame. The cadence rule (see synth_cadence.h) keeps exactly +// one mouse report per *measured device poll interval*: injection rides merge // reports while the mouse is active, and the synth path only fills in when the -// mouse has gone silent (so the two paths never both emit in the same frame). -static uint32_t last_merge_ms; -static uint32_t last_synth_ms; -#define SYNTH_SILENCE_MS 2 // mouse considered idle after this many ms of no report +// mouse has gone silent, at the same rate the merge path was running — not a +// fixed 1 kHz. The two paths never both emit in the same window. +static uint32_t last_merge_us; +static uint32_t last_synth_us; /* Pull this frame's injected delta from the pending accumulators and run it * through the humanization filter. The filter delivers in-frame and owns @@ -769,10 +787,17 @@ __attribute__((section(".fastrun"))) void kmbox_merge_report(uint8_t iface_protocol, uint8_t * restrict report, uint8_t len) { if (iface_protocol == 2) { - last_merge_ms = millis(); // a real mouse report is riding through now + last_merge_us = gpt_profile_us(); // a real mouse report is riding through now if (__builtin_expect(cached_mouse_report_len == 0, 0)) cached_mouse_report_len = len; + // Feature B/C: emit physical-only telemetry (pre-merge, pre-mask) and + // suppress masked physical inputs — before any injection is merged in. + // Both off (the common case) → the predicate is false and we skip it. + if (__builtin_expect((g_phys_mask || proto_phys_enabled()) && + mouse_layout.valid, 0)) + kmbox_phys_mouse(report, len); + if (mouse_layout.valid && inject.mouse_dirty) { uint8_t doff = mouse_layout.data_off; uint8_t rid = doff ? report[0] : 0; @@ -871,9 +896,17 @@ void kmbox_merge_report(uint8_t iface_protocol, uint8_t * restrict report, uint8 } } merged_this_cycle = true; - } else if (__builtin_expect(iface_protocol == 1 && inject.kb_dirty, 0)) { - kmbox_merge_keyboard(report, len); - merged_this_cycle = true; + } else if (iface_protocol == 1) { + // Feature B/C (keyboard): telemetry + masking run even with nothing + // injected (the user may be typing on a masked key). Gated so an idle + // keyboard with no monitoring/mask pays only this branch test. + if (__builtin_expect((proto_phys_enabled() || act_phys_kb_mask_active()) && + len >= 8, 0)) + kmbox_phys_keyboard(report, len); + if (__builtin_expect(inject.kb_dirty, 0)) { + kmbox_merge_keyboard(report, len); + merged_this_cycle = true; + } } } @@ -964,6 +997,80 @@ static void kmbox_merge_keyboard(uint8_t *report, uint8_t len) proto_notify_keys(&report[2]); } +// Feature B/C (mouse): runs before injection is merged. Reads the physical +// report fields, pushes them as TLM_PHYS_* telemetry (the user's TRUE input, +// observed before masking), then zeroes any masked physical contribution so it +// never reaches the downstream PC. Injected input is applied afterward by the +// normal merge, so an injected click on a masked button still passes. +// Only the fields whose report ID matches this report are touched. +__attribute__((cold, noinline)) +static void kmbox_phys_mouse(uint8_t *report, uint8_t len) +{ + uint8_t doff = mouse_layout.data_off; + uint8_t rid = doff ? report[0] : 0; + + bool xy_here = (rid == mouse_layout.report_id); + bool wheel_here = (mouse_layout.wheel_bit != 0xFFFF && + rid == mouse_layout.wheel_report_id); + + uint8_t phys_btn = xy_here ? report[doff] : 0; + int32_t phys_x = 0, phys_y = 0, phys_w = 0; + if (xy_here) { + phys_x = read_report_field(report, len, mouse_layout.x_bit, + mouse_layout.x_size, doff); + phys_y = read_report_field(report, len, mouse_layout.y_bit, + mouse_layout.y_size, doff); + } + if (wheel_here) + phys_w = read_report_field(report, len, mouse_layout.wheel_bit, + mouse_layout.wheel_size, doff); + + // Telemetry: the true physical input, BEFORE masking (spec §5.3). + if (proto_phys_enabled()) { + if (xy_here) + proto_notify_phys_buttons(phys_btn); + if (xy_here || wheel_here) + proto_notify_phys_axes((int16_t)phys_x, (int16_t)phys_y, + (int8_t)phys_w); + } + + // Masking: zero the masked physical contributions in the outgoing report. + if (g_phys_mask) { + if (xy_here) { + uint8_t bmask = (uint8_t)(g_phys_mask & 0x1F); // ml,mr,mm,ms1,ms2 + if (bmask) report[doff] &= (uint8_t)~bmask; + if (g_phys_mask & (1u << PHYS_MASK_MX)) + write_report_field(report, len, mouse_layout.x_bit, + mouse_layout.x_size, doff, 0); + if (g_phys_mask & (1u << PHYS_MASK_MY)) + write_report_field(report, len, mouse_layout.y_bit, + mouse_layout.y_size, doff, 0); + } + if (wheel_here && (g_phys_mask & (1u << PHYS_MASK_WHEEL))) + write_report_field(report, len, mouse_layout.wheel_bit, + mouse_layout.wheel_size, doff, 0); + } +} + +// Feature B/C (keyboard): standard 8-byte boot report (modifier, reserved, +// 6 keycodes). Pushes the physical modifier+keys as TLM_PHYS_KB (pre-mask), +// then removes any masked keycodes from the outgoing report. Modifiers are not +// individually maskable in the KMBox API, so only keycodes are filtered. +__attribute__((cold, noinline)) +static void kmbox_phys_keyboard(uint8_t *report, uint8_t len) +{ + (void)len; // caller guarantees len >= 8 + if (proto_phys_enabled()) + proto_notify_phys_keys(report[0], &report[2]); + + if (act_phys_kb_mask_active()) { + for (int j = 2; j < 8; j++) { + if (report[j] && act_phys_key_masked(report[j])) + report[j] = 0; + } + } +} + __attribute__((cold, noinline)) static void kmbox_send_wheel_report(void); __attribute__((cold, noinline)) @@ -983,13 +1090,18 @@ void kmbox_send_pending(void) if (merged_this_cycle) return; // Only synthesize a standalone mouse report when the physical mouse has // gone silent — otherwise injection rides the next real report (merge), - // so the two paths never both emit in the same frame (which would flood / - // overwrite at the 1 kHz endpoint). Capped to one synth per ms. - uint32_t ms = millis(); - bool mouse_silent = (uint32_t)(ms - last_merge_ms) >= SYNTH_SILENCE_MS; - if (inject.mouse_dirty && mouse_silent && ms != last_synth_ms && + // so the two paths never both emit in the same poll window (a double-emit + // would overwrite at the endpoint, since the host reads <=1 report per + // bInterval). Silence and cadence both derive from the *measured* device + // poll interval (synth_cadence.h), so on an 8 kHz mouse the synth path + // fills in at 8 kHz too, matching the rate the merge path was running. + uint32_t now_us = gpt_profile_us(); + uint32_t measured_us = humanize_measured_interval_us(); + bool mouse_silent = synth_mouse_silent(now_us, last_merge_us, measured_us); + bool due = synth_due(now_us, last_synth_us, measured_us); + if (inject.mouse_dirty && mouse_silent && due && cached_mouse_ep && mouse_layout.valid) { - last_synth_ms = ms; + last_synth_us = now_us; uint8_t synth[16]; memset(synth, 0, sizeof(synth)); uint8_t doff = mouse_layout.data_off; diff --git a/src/led.c b/src/led.c index 6997633..27cc6e4 100644 --- a/src/led.c +++ b/src/led.c @@ -22,11 +22,12 @@ extern void delay(uint32_t msec); // (LED_CH1_COMP+1) counts, producing the clock for CH0. CH0 toggles pin 13 // every (COMP0+1) of those -> 50% square wave. blink_hz is inversely // proportional to (COMP0+1), so the RATE RATIOS between states are exact -// regardless of the true IP-bus clock; only the absolute scale (LED_HB_K) -// depends on it. If the measured idle blink isn't ~0.5 Hz, scale LED_HB_K by -// the observed ratio and the active/error rates track automatically. -#define LED_CH1_COMP 1464u // CH1 modulo (period = COMP+1) -#define LED_HB_K 20000u // COMP0 = LED_HB_K/centihz - 1 (nominal IP=150MHz) +// regardless of the true IP-bus clock; LED_HB_K sets the absolute scale and +// is derived from the IP-bus (IPG = F_CPU/4, core/startup.c IPG_PODF=/4) so +// the rates are true Hz at any build clock. Reference point: IPG=150 MHz +// (600 MHz core) needs K=20000, hence the /7500 divisor. +#define LED_CH1_COMP 1464u // CH1 modulo (period = COMP+1) +#define LED_HB_K ((F_CPU / 4u) / 7500u) // COMP0 = LED_HB_K/centihz - 1 #define LED_CENTIHZ_MIN 5u // clamp floor (0.05 Hz) static bool s_hb_active; diff --git a/src/main.c b/src/main.c index cb78047..5965944 100644 --- a/src/main.c +++ b/src/main.c @@ -29,13 +29,45 @@ typedef struct { static volatile bool pit_tick_pending; static volatile uint32_t pit_next_ldval; // precomputed by main loop -static uint32_t pit_base_ldval; // nominal reload value (set after enumeration) +static uint32_t pit_base_ldval; // slewed reload value (tracks measured poll rate) +static bool pit_locked; // adaptive base has converged on the measured rate + // (main-loop only: set in tick handler, read by LED status) + +// PIT input clock = PERCLK = 24 MHz crystal oscillator on this board. +// core/startup.c sets CCM_CSCMR1 PERCLK_CLK_SEL=1 (24 MHz XTAL) and PERCLK_PODF=/1. +// This is independent of F_CPU/ARM/IPG — do not derive it from F_CPU. +#define PIT_CLK_HZ 24000000u +#define PIT_INTERVAL_US_MIN 125u +#define PIT_INTERVAL_US_MAX 10000u + +// Overclock thermal guard (see status-tick in main). Trip high, clear low for +// hysteresis. The i.MX RT1062 junction is rated to 105°C; we warn well under +// it since the overclock already runs the core hot. Detection only — we never +// re-clock live (would corrupt F_CPU-derived timebases). Adjust for airflow. +#define OVERTEMP_TRIP_C 85 +#define OVERTEMP_CLEAR_C 73 + +// Convert a desired period in microseconds to a PIT LDVAL (counts-1). +// PIT_CLK_HZ is an exact multiple of 1 MHz, so counts = us * (clk/1e6) with +// pure 32-bit arithmetic — no __aeabi_uldivmod. Max 10000 µs * 24 = 240000. +_Static_assert(PIT_CLK_HZ % 1000000u == 0, "PIT clock must be a whole MHz"); +static inline uint32_t pit_ldval_from_us(uint32_t interval_us) +{ + if (interval_us < PIT_INTERVAL_US_MIN) interval_us = PIT_INTERVAL_US_MIN; + if (interval_us > PIT_INTERVAL_US_MAX) interval_us = PIT_INTERVAL_US_MAX; + uint32_t counts = interval_us * (PIT_CLK_HZ / 1000000u); + return counts ? counts - 1u : 0u; +} static void pit0_isr(void) { PIT_TFLG0 = PIT_TFLG_TIF; PIT_LDVAL0 = pit_next_ldval; // precomputed, no FPU in ISR pit_tick_pending = true; + // The TFLG W1C is a posted write; without a DSB the NVIC can still see + // the IRQ asserted at exception return and immediately re-enter — a + // spurious double tick (timing jitter on the injection cadence). + __asm volatile("dsb" ::: "memory"); } // Static to keep off the stack (~3.6KB struct) @@ -182,8 +214,10 @@ int main(void) // Clamp to [125µs, 10ms] — sane range for humanized injection if (interval_us < 125) interval_us = 125; if (interval_us > 10000) interval_us = 10000; - uint32_t ipg_mhz = (F_CPU / 4u) / 1000000u; // IPG = ARM / 4 - uint32_t ldval = (ipg_mhz * interval_us) - 1; + // PIT is clocked by PERCLK = 24 MHz OSC (core/startup.c sets + // PERCLK_CLK_SEL=1, PERCLK_PODF=/1) — NOT the IPG/ARM clock. Using + // F_CPU/4 here made every period ~8.5x too long. See PIT_CLK_HZ. + uint32_t ldval = pit_ldval_from_us(interval_us); pit_base_ldval = ldval; pit_next_ldval = ldval; PIT_LDVAL0 = ldval; @@ -219,6 +253,7 @@ int main(void) uint32_t led_rx_snapshot = 0; uint32_t led_err_snapshot = 0; uint16_t led_centihz = 0; + bool overtemp = false; // sticky once tripped until temp recovers while (1) { uint32_t now = millis(); @@ -228,6 +263,26 @@ int main(void) if (pit_tick_pending) { pit_tick_pending = false; did_work = true; + // Adaptive feed rate: slew the base period toward the measured + // real poll interval (never jump — an abrupt period change is both + // detectable and can phase-beat the host). humanize_target_ldval + // returns 0 until it has a confident measurement, in which case we + // keep the enumerated nominal base. RM-correct: the new value is + // applied at the next reload via the precompute-in-ISR pattern. + uint32_t tgt = humanize_target_ldval(PIT_CLK_HZ); + if (tgt) { + int32_t err = (int32_t)tgt - (int32_t)pit_base_ldval; + int32_t step = err >> 5; // ~3%/tick proportional slew + if (step == 0 && err != 0) // guarantee final convergence: + step = (err > 0) ? 1 : -1; // >>5 truncates small +err to 0 + pit_base_ldval = (uint32_t)((int32_t)pit_base_ldval + step); + // Locked when the slewed base sits within ~1.5% of the measured + // target (|err| <= tgt/64). Drives LED feedback only. + int32_t aerr = err < 0 ? -err : err; + pit_locked = (aerr <= (int32_t)(tgt >> 6)); + } else { + pit_locked = false; // no confident measurement yet + } pit_next_ldval = humanize_timing_next(pit_base_ldval); } @@ -247,6 +302,11 @@ int main(void) &rpt_ptr, ep_map[m].maxpkt); if (ret > 0 && rpt_ptr) { did_work = true; + // Timestamp real mouse-report arrival (GPT2 1 MHz, single-load, + // atomic). This is the precise "a report just arrived" point; + // only the mouse interface drives the adaptive feed rate. + if (ep_map[m].iface_protocol == 2) + humanize_record_arrival(gpt_profile_us()); kmbox_merge_report(ep_map[m].iface_protocol, rpt_ptr, ret); bool sent = usb_device_send_report( @@ -277,13 +337,30 @@ int main(void) // (glitch-free) when the state actually changes. if ((now - led_status_tick) >= 100) { led_status_tick = now; + // Thermal guard for the overclock. The die sensor updates ~2 Hz + // (tempmon_init). We do NOT dynamically downclock: F_CPU is baked + // into the GPT2 µs tick, LED scale, and PIT math, and re-clocking + // live would corrupt those timebases and can glitch USB mid-frame. + // Instead we surface heat — a fast distinct LED pattern + telemetry + // — so it is visible before damage accrues. ~12°C hysteresis stops + // the heartbeat flapping at the threshold. Tune for your airflow. + int8_t tc = tempmon_read(); + if (!overtemp && tc >= OVERTEMP_TRIP_C) overtemp = true; + else if (overtemp && tc <= OVERTEMP_CLEAR_C) overtemp = false; + uint32_t rx = kmbox_rx_byte_count(); uint32_t err = kmbox_uart_overrun() + kmbox_uart_framing() + kmbox_uart_noise(); uint16_t centihz; - if (err != led_err_snapshot) centihz = 600; // ERROR ~6 Hz - else if (rx != led_rx_snapshot) centihz = 200; // ACTIVE ~2 Hz - else centihz = 50; // IDLE ~0.5 Hz + if (overtemp) centihz = 1200; // OVERTEMP ~12 Hz (highest priority) + else if (err != led_err_snapshot) centihz = 600; // ERROR ~6 Hz + else if (rx != led_rx_snapshot) centihz = 200; // ACTIVE ~2 Hz + else if (pit_locked) centihz = 100; // LOCKED ~1 Hz + else centihz = 50; // ACQUIRING ~0.5 Hz + // Idle-slot only: UART status (ERROR/ACTIVE) keeps priority. When the + // link is quiet, the heartbeat reports adaptive feed-rate convergence — + // slow (0.5 Hz) while acquiring/idle, doubling to 1 Hz once the PIT + // base has locked onto the measured poll rate. No UART involved. led_err_snapshot = err; led_rx_snapshot = rx; if (centihz != led_centihz) { diff --git a/src/proto.h b/src/proto.h index 0fa0758..7fcbc08 100644 --- a/src/proto.h +++ b/src/proto.h @@ -19,6 +19,10 @@ #define proto_notify_buttons hurra_notify_buttons #define proto_notify_axes hurra_notify_axes #define proto_notify_keys hurra_notify_keys + #define proto_phys_enabled hurra_phys_enabled + #define proto_notify_phys_buttons hurra_notify_phys_buttons + #define proto_notify_phys_axes hurra_notify_phys_axes + #define proto_notify_phys_keys hurra_notify_phys_keys #define PROTO_NAME "Hurra" #elif defined(PROTOCOL_FERRUM) #include "ferrum.h" @@ -34,6 +38,13 @@ #define proto_notify_buttons ferrum_notify_buttons #define proto_notify_axes ferrum_notify_axes #define proto_notify_keys ferrum_notify_keys + // Physical-only telemetry is a Hurra/KMBox-Net feature. Ferrum has no wire for + // it; provide no-op stubs so kmbox.c's merge path links unchanged. The compiler + // folds proto_phys_enabled()'s constant false, eliminating the capture branch. + static inline bool proto_phys_enabled(void) { return false; } + static inline void proto_notify_phys_buttons(uint8_t b) { (void)b; } + static inline void proto_notify_phys_axes(int16_t dx, int16_t dy, int8_t w) { (void)dx; (void)dy; (void)w; } + static inline void proto_notify_phys_keys(uint8_t mod, const uint8_t keys[6]) { (void)mod; (void)keys; } #define PROTO_NAME "Ferrum" #else #error "Define PROTOCOL_FERRUM or PROTOCOL_HURRA (set PROTOCOL in Makefile)" diff --git a/src/synth_cadence.h b/src/synth_cadence.h new file mode 100644 index 0000000..aa8d36a --- /dev/null +++ b/src/synth_cadence.h @@ -0,0 +1,43 @@ +#pragma once +#include +#include + +/* Pure synth-emission cadence decisions. No hardware deps — host-testable. + * + * All timestamps are free-running microseconds (GPT2 on target, plain uint32 + * in tests) and may wrap; every comparison uses wrap-safe unsigned subtraction. + * + * "silence" = the real mouse has not delivered a report for long enough that we + * must fabricate a carrier. "due" = enough time has passed since our last synth + * frame to emit another, at the measured device rate. */ + +/* When no confident measurement exists yet, fall back to 1 kHz (1000 us). */ +#define SYNTH_FALLBACK_US 1000u +/* Declare the mouse idle after this many measured intervals with no report. */ +#define SYNTH_SILENCE_PERIODS 2u +/* Never let a burst-fooled measurement drive synth faster than this floor. + * Mirrors humanize.c's HZ_LDVAL_US_MIN (125 us = 8 kHz) so synth can never + * exceed the fastest cadence the rest of the system is clamped to. */ +#define SYNTH_PERIOD_FLOOR_US 125u + +/* Effective cadence period: the measured interval, clamped to the floor, or the + * 1 kHz fallback when measured == 0 (EWMA not yet confident). */ +static inline uint32_t synth_period_us(uint32_t measured_us) { + uint32_t p = measured_us ? measured_us : SYNTH_FALLBACK_US; + if (p < SYNTH_PERIOD_FLOOR_US) p = SYNTH_PERIOD_FLOOR_US; + return p; +} + +/* True when the real mouse is silent: no merge for SILENCE_PERIODS * period. */ +static inline bool synth_mouse_silent(uint32_t now_us, uint32_t last_merge_us, + uint32_t measured_us) { + uint32_t period = synth_period_us(measured_us); + return (uint32_t)(now_us - last_merge_us) >= period * SYNTH_SILENCE_PERIODS; +} + +/* True when a synth frame is due: >= one period since the last synth emission. */ +static inline bool synth_due(uint32_t now_us, uint32_t last_synth_us, + uint32_t measured_us) { + uint32_t period = synth_period_us(measured_us); + return (uint32_t)(now_us - last_synth_us) >= period; +} diff --git a/src/usb_host.c b/src/usb_host.c index b33c02d..99dc980 100644 --- a/src/usb_host.c +++ b/src/usb_host.c @@ -1,4 +1,13 @@ // usb_host.c — EHCI host on USB2, polled completion, DMA in .dmabuffers +// +// CACHE NOTE: the `asm volatile("dsb")` barriers around qTD arming below are +// sufficient WITHOUT any SCB_CleanInvalidateDCache/arm_dcache_* calls *because* +// all .dmabuffers live in the first 64 KB of OCRAM, which core/startup.c marks +// non-cacheable via MPU region 10. On non-cacheable Normal memory a DSB is all +// that's needed to order descriptor writes before the controller reads them. +// If you ever make that region cacheable, you MUST add explicit cache +// maintenance here or DMA will read stale descriptors. (Linker ASSERT in +// core/imxrt1062_mm.ld guards the 64 KB window.) #include #include "imxrt.h" @@ -64,6 +73,7 @@ set_qtd_buffers_medium(volatile uint32_t *b, const void *buf) static void usb2_isr(void) { USB2_USBSTS = USB2_USBSTS; // W1C all pending status bits + __asm volatile("dsb" ::: "memory"); // flush W1C before exception return } static inline void host_power_on(void) diff --git a/test/humanize_test.c b/test/humanize_test.c index e819ecd..dfba5d2 100644 --- a/test/humanize_test.c +++ b/test/humanize_test.c @@ -80,6 +80,74 @@ int main(void) { CHECK(labs(delivered - 200) <= 2, "field-clip carry: returned motion is redelivered"); } + /* (G) Measured interval: a steady 1000 µs cadence converges the EWMA to + * ~1000 µs, and humanize_target_ldval converts it correctly. */ + humanize_init(1000); + humanize_set_level(2); + { + uint32_t t = 50000; /* arbitrary GPT2 start */ + for (int i = 0; i < 64; i++) { humanize_record_arrival(t); t += 1000; } + uint32_t meas = humanize_measured_interval_us(); + CHECK(meas >= 980 && meas <= 1020, "measured interval converges to ~1000us"); + /* At 24 MHz PIT clock, 1000 µs → 24000 counts → LDVAL 23999. */ + uint32_t ld = humanize_target_ldval(24000000u); + CHECK(ld >= 23000 && ld <= 25000, "target ldval ~= 24000-1 for 1ms @ 24MHz"); + } + + /* (H) Confidence gate: fewer than HZ_MEAS_MIN_COUNT reports → target 0. */ + humanize_init(1000); + humanize_set_level(2); + { + uint32_t t = 1000; + humanize_record_arrival(t); t += 1000; + humanize_record_arrival(t); /* only 2 reports */ + CHECK(humanize_target_ldval(24000000u) == 0, "no target before min count"); + } + + /* (I) Outlier rejection: a single huge gap (dropout) must not yank the EWMA. */ + humanize_init(1000); + humanize_set_level(2); + { + uint32_t t = 0; + for (int i = 0; i < 32; i++) { humanize_record_arrival(t); t += 1000; } + uint32_t before = humanize_measured_interval_us(); + humanize_record_arrival(t + 500000); /* 500 ms dropout gap */ + uint32_t after = humanize_measured_interval_us(); + CHECK(labs((long)after - (long)before) <= 5, "dropout gap rejected, EWMA stable"); + } + + /* (K) Seed-poison guard: the FIRST inter-report gap can be huge (device only + * sends on movement / NAKs when idle). Pre-seeding from bInterval in + * humanize_init must keep the estimate sane so normal reports are NOT all + * rejected as "bursts" and the EWMA still converges to the real cadence. */ + humanize_init(1000); /* nominal 1 ms → pre-seed */ + humanize_set_level(2); + { + uint32_t t = 0; + humanize_record_arrival(t); /* first report */ + t += 47000; /* 47 ms idle gap before the next one */ + humanize_record_arrival(t); + for (int i = 0; i < 64; i++) { t += 1000; humanize_record_arrival(t); } + uint32_t meas = humanize_measured_interval_us(); + CHECK(meas >= 900 && meas <= 1100, + "seed-poison guard: huge first gap does not lock out the EWMA"); + CHECK(humanize_target_ldval(24000000u) != 0, "target valid after recovery"); + } + + /* (J) Adaptive-noise default OFF: with the envelope compiled out (default), + * conservation still holds exactly as in (A) — proves no behavior drift. */ + humanize_init(1000); + humanize_set_level(2); + { + long s = 0; uint32_t t = 0; + for (int i = 0; i < 3000; i++) { + humanize_record_arrival(t); t += 1000; /* feed intervals too */ + int16_t dx = 4, dy = 0; humanize_filter(&dx, &dy); s += dx; + } + for (int i = 0; i < 200; i++) { int16_t dx=0,dy=0; humanize_filter(&dx,&dy); s += dx; } + CHECK(labs(s - 3000L*4) <= 2, "conservation holds with interval feed"); + } + printf(failures ? "\n%d FAILED\n" : "\nALL PASSED\n", failures); return failures ? 1 : 0; } diff --git a/test/motion_test.c b/test/motion_test.c new file mode 100644 index 0000000..481a0af --- /dev/null +++ b/test/motion_test.c @@ -0,0 +1,119 @@ +// Host-native unit test for the Feature A motion program (act_motion_*). +// Compiles actions.c with system gcc; stubs the kmbox_* injection sinks and a +// controllable millis() so the trajectory can be stepped deterministically. +// +// cc -std=c11 -O2 -Isrc -o /tmp/motion_test test/motion_test.c src/actions.c -lm + +#include +#include +#include +#include "actions.h" + +static int failures = 0; +#define CHECK(cond, msg) do { if (!(cond)) { \ + printf("FAIL: %s\n", msg); failures++; } } while (0) + +// ── stubbed environment ────────────────────────────────────────────────────── +static uint32_t g_ms; +uint32_t millis(void) { return g_ms; } + +// Injection sink: accumulate the deltas the motion program emits. +static long g_sum_x, g_sum_y; +static int g_emits; +void kmbox_inject_mouse(int16_t dx, int16_t dy, uint8_t buttons, int8_t wheel) { + (void)buttons; (void)wheel; + g_sum_x += dx; g_sum_y += dy; + if (dx || dy) g_emits++; +} +void kmbox_inject_keyboard(uint8_t m, const uint8_t k[6]) { (void)m; (void)k; } +void kmbox_schedule_click_release(uint8_t b, uint32_t d) { (void)b; (void)d; } +void kmbox_schedule_kb_release(uint8_t k, uint32_t d) { (void)k; (void)d; } + +static void reset_sink(void) { g_sum_x = g_sum_y = 0; g_emits = 0; } + +// Step a program to completion, advancing time `step_ms` per tick. +static void run_program(uint32_t dur_ms, uint32_t step_ms) { + // tick once at t=0, then advance until past dur, plus one final tick at end. + act_motion_tick(); + for (uint32_t t = 0; t <= dur_ms + step_ms; t += step_ms) { + g_ms += step_ms; + act_motion_tick(); + } +} + +int main(void) { + act_init(); + + // (A) automove conservation + exact endpoint, fine stepping (1 ms ticks). + reset_sink(); + g_ms = 1000; + act_motion_move_dur(200, -120, 500); + run_program(500, 1); + CHECK(g_sum_x == 200, "automove: total X lands exactly on endpoint"); + CHECK(g_sum_y == -120, "automove: total Y lands exactly on endpoint"); + CHECK(g_emits > 10, "automove: motion is spread across many frames, not one jump"); + + // (B) automove with coarse, irregular stepping still hits the endpoint + // (position-based emission is cadence-independent). + reset_sink(); + g_ms = 5000; + act_motion_move_dur(-77, 333, 200); + act_motion_tick(); + g_ms += 37; act_motion_tick(); + g_ms += 5; act_motion_tick(); + g_ms += 140; act_motion_tick(); + g_ms += 90; act_motion_tick(); // now past dur → final tick clamps to end + CHECK(g_sum_x == -77, "automove coarse: X exact despite irregular ticks"); + CHECK(g_sum_y == 333, "automove coarse: Y exact despite irregular ticks"); + + // (C) bezier conservation + exact endpoint. + reset_sink(); + g_ms = 0; + act_motion_bezier(300, 0, 400, 100, 200, 200, -200); + run_program(400, 1); + CHECK(g_sum_x == 300, "bezier: total X lands on endpoint"); + CHECK(g_sum_y == 0, "bezier: total Y returns to endpoint (bowed path nets 0)"); + + // (D) bezier with bowed control points actually leaves the straight line: + // a curve bowing in +Y must produce some +Y excursion mid-flight even + // though the endpoint Y is 0. + reset_sink(); + g_ms = 0; + act_motion_bezier(300, 0, 400, 0, 250, 300, 250); // both control pts +Y + int saw_positive_y = 0; + long runy = 0; + act_motion_tick(); + for (uint32_t t = 0; t <= 401; t += 1) { + long before = g_sum_y; + g_ms += 1; act_motion_tick(); + runy += (g_sum_y - before); + if (runy > 5) saw_positive_y = 1; // cumulative Y went meaningfully + + } + CHECK(saw_positive_y, "bezier: bowed control points curve off the straight line"); + CHECK(g_sum_y == 0, "bezier: curved path still ends at endpoint Y=0"); + + // (E) last-writer-wins: a plain act_move mid-flight cancels the program. + reset_sink(); + g_ms = 0; + act_motion_move_dur(1000, 0, 1000); + act_motion_tick(); + g_ms += 100; act_motion_tick(); // partial progress + long after_partial = g_sum_x; + act_move(7, 0); // manual override + long after_move = g_sum_x; + g_ms += 500; act_motion_tick(); // should be a no-op now + CHECK(after_move == after_partial + 7, "cancel: manual move applied once"); + CHECK(g_sum_x == after_move, "cancel: program produced no further motion"); + + // (F) dur_ms == 0 is an immediate full move. + reset_sink(); + g_ms = 0; + act_motion_move_dur(42, -9, 0); + CHECK(g_sum_x == 42 && g_sum_y == -9, "dur=0: immediate move delivers full delta"); + act_motion_tick(); // nothing in flight + CHECK(g_sum_x == 42 && g_sum_y == -9, "dur=0: no trailing motion"); + + if (failures == 0) printf("\nALL PASSED\n"); + else printf("\n%d FAILURE(S)\n", failures); + return failures ? 1 : 0; +} diff --git a/test/synth_cadence_test.c b/test/synth_cadence_test.c new file mode 100644 index 0000000..2aef45a --- /dev/null +++ b/test/synth_cadence_test.c @@ -0,0 +1,38 @@ +#include +#include +#include "synth_cadence.h" + +static int failures = 0; +#define CHECK(cond, msg) do { if (!(cond)) { \ + printf("FAIL: %s\n", msg); failures++; } } while (0) + +int main(void) { + /* period selection */ + CHECK(synth_period_us(0) == 1000u, "no measurement -> 1 kHz fallback"); + CHECK(synth_period_us(1000) == 1000u, "1 kHz measured -> 1000us"); + CHECK(synth_period_us(125) == 125u, "8 kHz measured -> 125us"); + CHECK(synth_period_us(50) == 125u, "below floor -> clamped to 125us"); + + /* silence: 1 kHz device, silence after 2 ms */ + CHECK(!synth_mouse_silent(1999, 0, 1000), "1kHz: 1999us since merge -> active"); + CHECK( synth_mouse_silent(2000, 0, 1000), "1kHz: 2000us since merge -> silent"); + + /* silence: 8 kHz device, silence after 250us (2 * 125us) */ + CHECK(!synth_mouse_silent(249, 0, 125), "8kHz: 249us since merge -> active"); + CHECK( synth_mouse_silent(250, 0, 125), "8kHz: 250us since merge -> silent"); + + /* due: fires once per period, not per loop iteration */ + CHECK(!synth_due(999, 0, 1000), "1kHz: 999us since synth -> not due"); + CHECK( synth_due(1000, 0, 1000), "1kHz: 1000us since synth -> due"); + CHECK( synth_due(125, 0, 125), "8kHz: 125us since synth -> due"); + + /* wrap safety: now just past the uint32 wrap, last just before it */ + CHECK( synth_due(50u, 0xFFFFFFFFu - 950u, 1000), + "wrap: 1000us elapsed across the uint32 boundary -> due"); + CHECK(!synth_due(50u, 0xFFFFFFFFu - 800u, 1000), + "wrap: ~850us elapsed across the boundary -> not due"); + + if (failures) { printf("%d FAILURES\n", failures); return 1; } + printf("synth_cadence: all passed\n"); + return 0; +} diff --git a/tools/ferrum_aim_test.py b/tools/ferrum_aim_test.py index 7665c94..b073f4f 100755 --- a/tools/ferrum_aim_test.py +++ b/tools/ferrum_aim_test.py @@ -2,21 +2,44 @@ """Ferrum closed-loop aim test. Spawns a fullscreen Tkinter window with red dots. Reads the Mac cursor -position via pynput, sends km.move(dx, dy) over the CP2102C serial link -to drive the cursor toward each target with a smooth-pull controller -(step size proportional to remaining distance — fast at first, slows -as it approaches). Dots turn green when hit. +position via pynput, sends km.move(dx, dy) Ferrum commands to drive the +cursor toward each target with a smooth-pull controller (speed proportional +to remaining distance — fast at first, slows as it approaches). Dots turn +green when hit. + +Where the commands go: + Point this at the hurra-app VCOM (the hurra-bridge PTY), not the raw + CH343 UART. The device emulates the Ferrum command surface but runs the + faster Hurra binary path underneath on the wire; the bridge does that + translation. Because the PTY is an in-memory pipe, baud rate is + irrelevant here — `--baud` is accepted only so the same invocation works + if you ever point it straight at a real serial port. + +Send rate: + Sending is decoupled from the GUI event loop. A dedicated paced thread + emits commands at `--rate` Hz (default 2000) using a high-resolution + sleep+spin timer, while the GUI loop only does cursor tracking and target + bookkeeping at a slower cadence. The aim controller outputs a velocity + (px/sec); the sender converts it to per-tick deltas with a sub-pixel + accumulator, so total cursor speed is independent of the send rate and + fractional steps are never lost. The device coalesces sub-1ms commands + into each 1 kHz HID frame, so rates well above 1 kHz are useful for + exercising the link and the firmware's injection-accumulation path. + + NOTE: rates above ~1 kHz busy-wait one CPU core for timing precision + (macOS time.sleep() granularity is too coarse to pace sub-millisecond). Setup required: - 1. CP2102C plugged into Mac as the command channel (/dev/tty.usbserial-*) - 2. Teensy USB device port plugged into Mac (USB HID injection out) + 1. hurra-bridge running, exposing a VCOM/PTY (command channel) + 2. Teensy USB device port plugged into Mac (HID injection out) 3. A real USB mouse plugged into the Teensy carrier's USB HOST port — the firmware is a HID proxy; it won't run main() without an - upstream device to enumerate. See src/main.c:117. + upstream device to enumerate. See src/main.c. Usage: pip install pynput pyserial - tools/ferrum_aim_test.py /dev/tty.usbserial-0001 + tools/ferrum_aim_test.py /dev/ttys00N # hurra-bridge PTY + tools/ferrum_aim_test.py /dev/ttys00N --rate 4000 Keys: Space start the run (cycles through all dots in order) @@ -28,6 +51,7 @@ import argparse import math import sys +import threading import time import tkinter as tk @@ -45,11 +69,35 @@ # Aim controller tuning HIT_THRESHOLD_PX = 15 # within this distance = target hit TIMEOUT_S = 3.0 # give up on a target after this long -TICK_MS = 12 # period between km.move sends (~83 Hz) -PULL_GAIN = 0.35 # step size = clamp(dist * gain, 1, max) -STEP_MAX_PX = 40 # cap on per-tick movement +CONTROL_MS = 5 # GUI cursor-tracking tick (~200 Hz); NOT the send rate COOLDOWN_MS = 200 # idle pause between targets, lets cursor settle +# Closed-loop steering law. The inject→cursor→pynput feedback path lags tens of +# ms, so we steer toward the cursor's PREDICTED position one latency ahead +# (measured velocity × LATENCY_S) rather than its stale measured position. That +# makes it brake on time instead of overshooting, and reverse if it would blow +# past — which lets us run a snappy gain and skip a slow crawl-in. +LATENCY_S = 0.045 # s; dead-time used for prediction (--latency override) +APPROACH_TAU = 0.05 # s; speed = predicted_error / TAU, capped +SPEED_MAX = 2500.0 # px/s cap +SPEED_MIN = 20.0 # px/s floor to beat sub-pixel stiction (until in range) +ACCEL_MAX = 80000.0 # px/s^2; gentle slew limit to smooth command spikes +VEL_FILTER_ALPHA = 0.4 # EWMA on the measured cursor velocity (noise control) + +# OS pointer-acceleration / sensitivity compensation. macOS runs injected +# deltas through a nonlinear accel curve, so realized pixels per command unit +# is unknown and speed-dependent. Estimate it online from the ratio of observed +# cursor speed to commanded speed and divide it out so the loop steers in true +# screen pixels. +GAIN_INIT = 1.0 # px observed per command unit (starting guess) +GAIN_MIN = 0.2 +GAIN_MAX = 8.0 +GAIN_ALPHA = 0.10 # EWMA smoothing for the online estimate +GAIN_CMD_FLOOR = 50.0 # only sample gain when commanding faster than this (units/s) +GAIN_OBS_FLOOR = 30.0 # ...and the cursor is actually moving (px/s) + +DEFAULT_RATE_HZ = 2000 + class Dot: __slots__ = ("oval_id", "screen_x", "screen_y", "name", "hit", @@ -65,17 +113,45 @@ def __init__(self, oval_id, screen_x, screen_y, name): def send_move(ser, dx, dy): + """Send one km.move; return True if it went out, False on backpressure/closed.""" line = f"km.move({dx}, {dy})\r\n".encode("ascii") try: ser.write(line) + return True except serial.SerialTimeoutException: - pass # firmware backpressure — drop this tick, next one will resend + # firmware/bridge backpressure — this step (dx,dy) is dropped, not + # retried; the next tick integrates velocity over real elapsed time and + # computes a fresh delta (sub-pixel remainder is carried, the step isn't) + return False + except serial.SerialException: + return False # port closed mid-shutdown class AimTest: - def __init__(self, ser): - self.ser = ser - self.mouse = MouseController() + def __init__(self, ser, rate_hz, latency_s=LATENCY_S): + self.ser = ser + self.rate = rate_hz + self.latency = latency_s + self.mouse = MouseController() + + # Shared state between the GUI control loop and the sender thread. + self._lock = threading.Lock() + self._vx = 0.0 # commanded velocity, units/sec (written by GUI) + self._vy = 0.0 + self._active = threading.Event() # set while actively pulling a target + self._stop = threading.Event() # set to tear the sender thread down + self._sent = 0 # total commands emitted (GIL-atomic int) + self._sender = threading.Thread(target=self._sender_loop, daemon=True) + + # Controller state (GUI thread only). + self.gain = GAIN_INIT # realized px per command unit (adaptive) + self.cur_speed = 0.0 # last commanded speed, px/s (for slew limit) + self.last_cmd_speed = 0.0 # last commanded speed in units/s (for gain est) + self.prev_x = 0.0 # cursor position at previous control tick + self.prev_y = 0.0 + self.prev_t = 0.0 # time of previous control tick + self.vel_x = 0.0 # filtered measured cursor velocity, px/s + self.vel_y = 0.0 self.root = tk.Tk() self.root.title("Ferrum Aim Test") @@ -100,7 +176,9 @@ def __init__(self, ser): self.state = "idle" # idle | running | between | done self.current_idx = 0 self.target_start_t = 0.0 + self.target_sent0 = 0 # self._sent snapshot at target start self.run_start_t = 0.0 + self.run_sent0 = 0 self.root.bind("", self.on_space) self.root.bind("", self.on_escape) @@ -144,13 +222,11 @@ def on_space(self, _evt): self.state = "running" self.current_idx = 0 self.run_start_t = time.monotonic() + self.run_sent0 = self._sent self._begin_target() def on_escape(self, _evt): - try: - self.ser.close() - except Exception: - pass + self._shutdown() self.root.destroy() def on_click(self, evt): @@ -178,52 +254,118 @@ def _begin_target(self): d = self.dots[self.current_idx] self.canvas.itemconfig(d.oval_id, outline="#ffff00", width=3) self.target_start_t = time.monotonic() + self.target_sent0 = self._sent + # Seed the feedback baseline at the current cursor so the first velocity + # sample isn't polluted by the inter-target jump, and start from rest. + self.prev_x, self.prev_y = self.mouse.position + self.prev_t = time.monotonic() + self.vel_x = self.vel_y = 0.0 + self.cur_speed = 0.0 + self.last_cmd_speed = 0.0 self.root.after(0, self._tick) + def _stop_pull(self): + """Pause the sender (between targets) and zero the commanded velocity.""" + self._active.clear() + self.cur_speed = 0.0 + self.last_cmd_speed = 0.0 + self.vel_x = self.vel_y = 0.0 + with self._lock: + self._vx = 0.0 + self._vy = 0.0 + def _tick(self): if self.state != "running": return d = self.dots[self.current_idx] + now = time.monotonic() + dt = now - self.prev_t + if dt <= 0.0 or dt > 0.1: # first tick / stall guard + dt = CONTROL_MS / 1000.0 + self.prev_t = now + cx, cy = self.mouse.position - dx = d.screen_x - cx - dy = d.screen_y - cy - dist = math.hypot(dx, dy) + # Measured cursor velocity (px/s), EWMA-filtered against pynput jitter. + ivx = (cx - self.prev_x) / dt + ivy = (cy - self.prev_y) / dt + self.vel_x = (1.0 - VEL_FILTER_ALPHA) * self.vel_x + VEL_FILTER_ALPHA * ivx + self.vel_y = (1.0 - VEL_FILTER_ALPHA) * self.vel_y + VEL_FILTER_ALPHA * ivy + self.prev_x, self.prev_y = cx, cy + + # --- online gain estimate: observed px/s per commanded unit/s --- + # Tracks the OS pointer-acceleration curve so we steer in true pixels. + obs_speed = math.hypot(self.vel_x, self.vel_y) + if self.last_cmd_speed > GAIN_CMD_FLOOR and obs_speed > GAIN_OBS_FLOOR: + g_inst = obs_speed / self.last_cmd_speed + self.gain = min(GAIN_MAX, max(GAIN_MIN, + (1.0 - GAIN_ALPHA) * self.gain + GAIN_ALPHA * g_inst)) + + dist = math.hypot(d.screen_x - cx, d.screen_y - cy) + + elapsed = max(now - self.run_start_t, 1e-6) + achieved = (self._sent - self.run_sent0) / elapsed self._set_status( f"target {self.current_idx + 1}/{len(self.dots)} " - f"dist={dist:6.1f}px iters={d.iterations}" + f"dist={dist:6.1f}px g={self.gain:4.2f} " + f"send={achieved:6.0f}/s (target {self.rate}/s)" ) if dist <= HIT_THRESHOLD_PX: d.hit = True - d.time_to_hit_s = time.monotonic() - self.target_start_t + d.time_to_hit_s = now - self.target_start_t + d.iterations = self._sent - self.target_sent0 self.canvas.itemconfig(d.oval_id, fill="#20c020", outline="#80ff80", width=2) self.current_idx += 1 self.state = "between" + self._stop_pull() self.root.after(COOLDOWN_MS, self._after_cooldown) return - if time.monotonic() - self.target_start_t > TIMEOUT_S: + if now - self.target_start_t > TIMEOUT_S: + d.iterations = self._sent - self.target_sent0 self.canvas.itemconfig(d.oval_id, fill="#404040", outline="#808080", width=2) self.current_idx += 1 self.state = "between" + self._stop_pull() self.root.after(COOLDOWN_MS, self._after_cooldown) return - # Smooth pull: step proportional to remaining distance, capped. - step = max(1.0, min(STEP_MAX_PX, dist * PULL_GAIN)) - sx = int(round(dx / dist * step)) - sy = int(round(dy / dist * step)) - if sx == 0 and sy == 0: - sx = 1 if dx > 0 else (-1 if dx < 0 else 0) - sy = 1 if dy > 0 else (-1 if dy < 0 else 0) - - send_move(self.ser, sx, sy) - d.iterations += 1 - self.root.after(TICK_MS, self._tick) + # Steer toward the cursor's PREDICTED position one latency ahead, so + # in-flight motion is accounted for: braking happens on time, and if we + # would overshoot the predicted error flips sign and we reverse. + ex = d.screen_x - (cx + self.vel_x * self.latency) + ey = d.screen_y - (cy + self.vel_y * self.latency) + edist = math.hypot(ex, ey) + + speed = min(SPEED_MAX, edist / APPROACH_TAU) + if dist > HIT_THRESHOLD_PX: + speed = max(speed, SPEED_MIN) + + # Gentle slew limit to smooth command-speed spikes (e.g. gain jumps). + dv_max = ACCEL_MAX * dt + if speed > self.cur_speed + dv_max: + speed = self.cur_speed + dv_max + elif speed < self.cur_speed - dv_max: + speed = self.cur_speed - dv_max + self.cur_speed = speed + + # px/s → command units/s via estimated gain, along the predicted error. + cmd_speed = speed / self.gain + self.last_cmd_speed = cmd_speed + if edist > 1e-6: + vx = ex / edist * cmd_speed + vy = ey / edist * cmd_speed + else: + vx = vy = 0.0 + with self._lock: + self._vx = vx + self._vy = vy + self._active.set() + self.root.after(CONTROL_MS, self._tick) def _after_cooldown(self): self.state = "running" @@ -231,6 +373,7 @@ def _after_cooldown(self): def _finish_run(self): self.state = "done" + self._stop_pull() total = time.monotonic() - self.run_start_t hits = sum(1 for d in self.dots if d.hit) avg_t = ( @@ -241,34 +384,98 @@ def _finish_run(self): sum(d.iterations for d in self.dots if d.hit) / hits if hits else 0.0 ) + achieved = (self._sent - self.run_sent0) / max(total, 1e-6) print(f"\n=== Run complete ===") print(f"Total time: {total:.2f} s") print(f"Hits: {hits}/{len(self.dots)}") + print(f"Send rate: {achieved:.0f}/s achieved (target {self.rate}/s)") + print(f"Est. gain: {self.gain:.2f} px/unit (OS accel/sensitivity)") if hits: print(f"Avg time-to-hit: {avg_t * 1000:.0f} ms") - print(f"Avg iterations: {avg_i:.1f}") + print(f"Avg commands: {avg_i:.1f}") print("Per-target:") for d in self.dots: if d.hit: print(f" {d.name:>4} {d.time_to_hit_s * 1000:5.0f} ms " - f"({d.iterations} steps)") + f"({d.iterations} cmds)") else: print(f" {d.name:>4} MISS") self._set_status( f"done — {hits}/{len(self.dots)} hits in {total:.1f}s " - "[space] retry [r] reset [esc] quit" + f"{achieved:.0f}/s [space] retry [r] reset [esc] quit" ) def _set_status(self, txt): self.canvas.itemconfig(self.status, text=txt) + # ----- sender thread ----- + + def _sender_loop(self): + """Emit km.move paced at --rate Hz, decoupled from the Tkinter loop. + + Commanded velocity (units/sec) is set by the GUI control loop. We + integrate over the REAL elapsed time between iterations — not the + nominal period — so the motion stays correct even when the link drains + slower than --rate and blocking writes stretch the interval. The + sub-pixel remainder is carried so small steps accumulate instead of + rounding to zero, and we report what we actually sent back to the + controller for its gain estimate. + """ + period = 1.0 / self.rate + # Sleep until ~spin_margin before the deadline, then busy-spin the rest. + # macOS time.sleep() can overshoot by ~1 ms, so for sub-ms periods this + # is effectively a pure spin — accurate, at the cost of one core. + spin_margin = 0.0015 + fx = fy = 0.0 + last = time.perf_counter() + next_t = last + while not self._stop.is_set(): + next_t += period + while True: + dt_wait = next_t - time.perf_counter() + if dt_wait <= 0: + break + if dt_wait > spin_margin: + time.sleep(dt_wait - spin_margin) + # else: busy-spin the remainder for sub-ms precision + now = time.perf_counter() + if now - next_t > 0.05: # fell behind (stall/GC): don't burst + next_t = now + dt = now - last + last = now + if dt > 0.05: # cap after a long pause + dt = 0.05 + + if not self._active.is_set(): + fx = fy = 0.0 + continue + + with self._lock: + vx, vy = self._vx, self._vy + fx += vx * dt + fy += vy * dt + sx = int(fx); fx -= sx + sy = int(fy); fy -= sy + if (sx or sy) and send_move(self.ser, sx, sy): + self._sent += 1 + + def _shutdown(self): + self._stop.set() + self._active.clear() + if self._sender.is_alive(): + self._sender.join(timeout=1.0) + try: + self.ser.close() + except Exception: + pass + # ----- runloop ----- def run(self): # Verify the device speaks Ferrum before starting the GUI runloop. + # NB: no ser.flush() — it blocks indefinitely on the hurra-bridge PTY. self.ser.reset_input_buffer() self.ser.write(b"km.version()\r\n") - self.ser.flush() deadline = time.monotonic() + 0.5 buf = b"" while time.monotonic() < deadline: @@ -279,19 +486,38 @@ def run(self): ver = buf.decode("ascii", "replace").rstrip("\r\n") if ver != "kmbox: Ferrum": sys.exit(f"device handshake failed: got {ver!r}, expected 'kmbox: Ferrum'") - print(f"Connected: {ver}") - self.root.mainloop() + print(f"Connected: {ver} (send rate target {self.rate}/s)") + self._sender.start() + try: + self.root.mainloop() + finally: + self._shutdown() def main(): ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) - ap.add_argument("port", help="serial device, e.g. /dev/tty.usbserial-0001") - ap.add_argument("--baud", type=int, default=115200) + ap.add_argument("port", help="hurra-bridge VCOM/PTY, e.g. /dev/ttys003") + ap.add_argument("--rate", type=int, default=DEFAULT_RATE_HZ, + help=f"command send rate in Hz (default {DEFAULT_RATE_HZ}); " + "values >1000 busy-wait one core for timing precision") + ap.add_argument("--baud", type=int, default=115200, + help="ignored on a PTY; only used if pointed at a real serial port") + ap.add_argument("--latency", type=float, default=LATENCY_S, + help=f"feedback dead-time (s) used for prediction " + f"(default {LATENCY_S}); raise if it still overshoots, " + "lower if it stalls short of the dot") args = ap.parse_args() - ser = serial.Serial(args.port, args.baud, timeout=0.1, write_timeout=0.1) - AimTest(ser).run() + if args.rate < 1: + sys.exit("--rate must be >= 1") + if args.latency < 0: + sys.exit("--latency must be >= 0") + + # Short write_timeout: under backpressure we want to DROP a stale command + # and keep the control loop fresh, never block on a full buffer. + ser = serial.Serial(args.port, args.baud, timeout=0.1, write_timeout=0.02) + AimTest(ser, args.rate, args.latency).run() if __name__ == "__main__": diff --git a/tools/ferrum_load_test.py b/tools/ferrum_load_test.py new file mode 100755 index 0000000..ac274e4 --- /dev/null +++ b/tools/ferrum_load_test.py @@ -0,0 +1,282 @@ +#!/usr/bin/env python3 +"""Ferrum command-channel load test. + +Hammers the Ferrum command surface to measure how much the link can carry and +whether it stays correct under load. Point it at the same endpoint the app uses +— the hurra-app VCOM (hurra-bridge PTY) — so it exercises the real +host → bridge → 4 M Hurra wire → firmware path. (Baud is a no-op on a PTY; the +real wire rate is the bridge's business.) + +Phases (run in this order; each is opt-in except throughput): + latency (--ping) km.version() round-trips: host→…→firmware→…→host RTT + throughput (default) blast km.move as fast as the link allows; report + commands/s, bytes/s, and drops (writes that timed out + under backpressure) + integrity (--verify) open a km.catch_xy window, send a known (+1,+1) + pattern at a SUSTAINABLE rate (--verify-rate), then + read back the firmware's accumulated injected motion + and compare — catches commands silently lost or + mangled between here and the firmware + +The throughput phase alternates +1/-1 moves so the cursor stays put; the +integrity phase uses small fixed (+1,+1) moves that never saturate the HID +delta field, so a clean run should report ~100% delivered. catch_xy is +time-windowed, so the integrity phase must NOT run flat out — saturating the +buffer makes commands arrive after the window closes and reads as false loss. +Overload behaviour is measured by the throughput phase's drop counter instead. + +Usage: + pip install pyserial + tools/ferrum_load_test.py /dev/ttysNNN # 5 s throughput + tools/ferrum_load_test.py /dev/ttysNNN --duration 10 --ping --verify + tools/ferrum_load_test.py /dev/ttysNNN --rate 5000 # cap the send rate + +NOTE: don't touch the real mouse during --verify; concurrent physical motion +can clip the merged report and skew the accounting. +""" + +import argparse +import re +import statistics +import sys +import time + +try: + import serial +except ImportError: + sys.exit("pyserial required: pip install pyserial") + + +PAIR_RE = re.compile(rb"\(\s*(-?\d+)\s*,\s*(-?\d+)\s*\)") + + +def handshake(ser): + """Confirm the endpoint speaks Ferrum. No ser.flush() — it hangs on the PTY.""" + ser.reset_input_buffer() + ser.write(b"km.version()\r\n") + deadline = time.monotonic() + 0.5 + buf = b"" + while time.monotonic() < deadline: + b = ser.read(1) + if not b: + continue + buf += b + if buf.endswith(b"\r\n"): + break + ver = buf.decode("ascii", "replace").rstrip("\r\n") + if ver != "kmbox: Ferrum": + sys.exit(f"handshake failed: got {ver!r}, expected 'kmbox: Ferrum'") + return ver + + +def read_line(ser, timeout_s): + """Read one CRLF-terminated line, or b'' on timeout.""" + deadline = time.monotonic() + timeout_s + buf = b"" + while time.monotonic() < deadline: + b = ser.read(1) + if not b: + continue + buf += b + if buf.endswith(b"\r\n"): + return buf + return b"" + + +# ----- latency ----- + +def phase_latency(ser, count): + print(f"\n[latency] {count} km.version() round-trips") + rtts = [] + timeouts = 0 + for _ in range(count): + ser.reset_input_buffer() + t0 = time.perf_counter() + ser.write(b"km.version()\r\n") + line = read_line(ser, 0.5) + if line.rstrip(b"\r\n") == b"kmbox: Ferrum": + rtts.append((time.perf_counter() - t0) * 1000.0) + else: + timeouts += 1 + time.sleep(0.002) # don't pipeline; measure one RTT at a time + if rtts: + rtts.sort() + pct = lambda p: rtts[min(len(rtts) - 1, int(p / 100.0 * len(rtts)))] + print(f" ok={len(rtts)} timeouts={timeouts}") + print(f" min={rtts[0]:.2f} median={statistics.median(rtts):.2f} " + f"p95={pct(95):.2f} p99={pct(99):.2f} max={rtts[-1]:.2f} (ms)") + else: + print(f" no replies ({timeouts} timeouts)") + + +# ----- throughput ----- + +def phase_throughput(ser, duration, rate): + cap = f"{rate}/s" if rate else "unlimited" + print(f"\n[throughput] {duration:.1f}s, rate cap {cap}") + cmds = (b"km.move(1, 0)\r\n", b"km.move(-1, 0)\r\n") + period = (1.0 / rate) if rate else 0.0 + + sent = bytes_ok = drops = 0 + start = time.perf_counter() + end = start + duration + next_report = start + 1.0 + next_t = start + i = 0 + while True: + now = time.perf_counter() + if now >= end: + break + if period: + if now < next_t: + dt = next_t - now + if dt > 0.0015: + time.sleep(dt - 0.0015) + continue + next_t += period + if next_t < now: + next_t = now + period + + line = cmds[i & 1] + i += 1 + try: + ser.write(line) + sent += 1 + bytes_ok += len(line) + except serial.SerialTimeoutException: + drops += 1 + except serial.SerialException: + break + + if now >= next_report: + elapsed = now - start + print(f" {elapsed:4.1f}s {sent/elapsed:8.0f} cmd/s " + f"{bytes_ok/elapsed/1024:7.1f} KiB/s drops={drops}") + next_report += 1.0 + + elapsed = max(time.perf_counter() - start, 1e-6) + total = sent + drops + print(f" ---") + print(f" sent: {sent} cmds ({sent/elapsed:.0f}/s)") + print(f" bytes: {bytes_ok/1024:.0f} KiB ({bytes_ok/elapsed/1024:.1f} KiB/s, " + f"{bytes_ok*8/elapsed/1e6:.2f} Mbit/s)") + print(f" drops: {drops} ({100.0*drops/total if total else 0.0:.2f}%)") + + +# ----- integrity ----- + +def phase_integrity(ser, rate): + # catch_xy is a TIME-windowed measurement (firmware-side, clamped to 1000 ms). + # It only correlates if commands flow through in real time within the window, + # so we send at a sustainable rate — NOT flat out. Blasting into a full buffer + # makes commands arrive after the deadline and reads as 100% loss even when + # nothing was dropped. Send-side overload is what the throughput phase's drop + # counter is for; this phase checks correctness at a rate the link can carry. + window_ms = 1000 + blast_ms = 600 # well inside the window, leaves arrival margin + + # Settle: drain whatever a preceding saturation phase left in flight so the + # window lines up with our sends. + time.sleep(0.25) + for reset in (ser.reset_output_buffer, ser.reset_input_buffer): + try: + reset() + except Exception: + pass + + print(f"\n[integrity] catch_xy({window_ms}ms), " + f"{blast_ms}ms of km.move(1, 1) at {rate}/s") + + ser.write(f"km.catch_xy({window_ms})\r\n".encode("ascii")) + time.sleep(0.03) # let the window go active before we start blasting + + cmd = b"km.move(1, 1)\r\n" + period = 1.0 / rate + sent = drops = 0 + start = time.perf_counter() + end = start + blast_ms / 1000.0 + next_t = start + while True: + now = time.perf_counter() + if now >= end: + break + if now < next_t: + dt = next_t - now + if dt > 0.0015: + time.sleep(dt - 0.0015) + continue + next_t += period + if next_t < now: + next_t = now + period + try: + ser.write(cmd) + sent += 1 + except serial.SerialTimeoutException: + drops += 1 + except serial.SerialException: + break + + # The result line is emitted when the firmware window deadline expires. + line = read_line(ser, 1.0) + m = PAIR_RE.search(line) + if not m: + print(f" no catch_xy result (got {line!r}) — is a real mouse attached " + "so reports merge?") + return + cx, cy = int(m.group(1)), int(m.group(2)) + # Each command injects (1, 1); expected accumulator == commands delivered. + print(f" sent: {sent} cmds (write drops {drops})") + print(f" caught: x={cx} y={cy} (firmware-delivered injected motion)") + if sent: + print(f" delivered: {100.0*cx/sent:.1f}% x, {100.0*cy/sent:.1f}% y") + lost = sent - cx + if lost > 0: + print(f" LOST/CARRIED: {lost} cmds ({100.0*lost/sent:.1f}%) — " + "link loss, or motion still draining at window close") + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("port", help="hurra-bridge VCOM/PTY, e.g. /dev/ttys003") + ap.add_argument("--duration", type=float, default=5.0, + help="throughput phase duration in seconds (default 5)") + ap.add_argument("--rate", type=int, default=0, + help="cap send rate in cmd/s (default 0 = unlimited / flat out)") + ap.add_argument("--ping", type=int, nargs="?", const=100, default=0, + metavar="N", help="run latency phase with N pings (default 100)") + ap.add_argument("--verify", action="store_true", + help="run the catch_xy integrity phase") + ap.add_argument("--verify-rate", type=int, default=2000, metavar="HZ", + help="send rate for the integrity phase (default 2000); keep " + "it well under the measured throughput so commands flow " + "in real time within the catch window") + ap.add_argument("--skip-throughput", action="store_true", + help="skip the throughput phase") + ap.add_argument("--baud", type=int, default=115200, + help="ignored on a PTY; only used on a real serial port") + args = ap.parse_args() + + if args.rate < 0: + sys.exit("--rate must be >= 0") + if args.verify_rate < 1: + sys.exit("--verify-rate must be >= 1") + + # Short write_timeout so a full buffer counts as a drop instead of blocking + # forever — that's what makes the drop metric meaningful under overload. + ser = serial.Serial(args.port, args.baud, timeout=0.1, write_timeout=0.02) + try: + ver = handshake(ser) + print(f"Connected: {ver}") + if args.ping: + phase_latency(ser, args.ping) + if not args.skip_throughput: + phase_throughput(ser, args.duration, args.rate) + if args.verify: + phase_integrity(ser, args.verify_rate) + finally: + ser.close() + + +if __name__ == "__main__": + main()