From d0d9c451aa0a6a805a3ce34fa9d65fc3ca29837c Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Fri, 1 May 2026 12:16:36 -0600 Subject: [PATCH] =?UTF-8?q?chore:=20cut=200.2.0=20=E2=80=94=20README,=20LI?= =?UTF-8?q?CENSE,=20publish=20metadata,=20runtime=20tune,=20USB=20identity?= =?UTF-8?q?,=20F13?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Release-prep work for a publish-worthy 0.2.0: - LICENSE: MIT, © 2026 Stephen Waits. - README.md: tagline, what-it-does, hardware, build/flash recipes, ASCII statechart overview, "Why four hours…" runtime-tuning rationale, USB identity section. - Cargo.toml: 0.1.0 → 0.2.0; description, license, repository, readme, keywords, categories; publish = false (firmware, not a library); release profile tightened (lto = "fat", opt-level = "z", panic = "abort"). Flashed binary stays 47 KB; the size knobs are explicit rather than relying on defaults. - CHANGELOG.md: collapse Unreleased → [0.2.0] - 2026-05-01. - scripts/tune_runtime.py: 4-D Monte Carlo over (RUN_DURATION, YELLOW_AT, RED_AT, FAST_RED_AT). PEP 723 inline deps so `uv run` just works. Runtime + LED thresholds re-derived from a typical office workday distribution with a per-minute press-on-warning user model. Joint optimum: RUN_DURATION: 4h00m YELLOW_AT / RED_AT / FAST_RED_AT (min remaining): 30 / 25 / 20 USB identity: VID/PID: 046d:c07d (G502) → 1209:b0b0 (pid.codes) manufacturer: "Logitech" → "swaits.com" product: "G502 Mouse" → "jiggly" bcdDevice: default 0x0010 → 0x0200 (matches firmware version) serial: (none) → RP2040 chip ID as 16 hex chars Bug fix in the descriptor change: an interim version spoofed the Logitech Unifying Receiver (046d:c52b). On Linux, `hid-logitech-dj` matches that exact PID and tries to talk Logitech's HID++ protocol to enumerate paired wireless devices. The firmware doesn't speak HID++, so the driver waits through ~10–20 s of control-transfer timeouts on every plug before unbinding and letting `hid-generic` actually start polling. macOS has no such driver and was always fast. Moving to a pid.codes VID routes the device straight to `hid-generic`. Wake key: tapped Left Shift in early versions to wake the host. In practice that turned out to be exactly the nightmare scenario it sounds like — if the deadline preempted the loop between a Shift-down report and its Shift-up, the host would silently capitalise every keystroke from the user's real keyboard until the device was unplugged. Switched to F13: still wakes any modern OS, but no mainstream OS maps F13 by default, so a stuck F13 has zero visible effect. Also added an unconditional all-keys-released cleanup report at the end of the wake action, bounded by KBD_RELEASE_DEADLINE = 100 ms, so even a deadline that fires mid-press can't leave anything held. Wake refactor: WakingWithKeyboard and WakingWithMouse use oneshot `entry:` actions that race their work against an internal deadline via `embassy_futures::select`; the chart timer (`on(after KBD_PHASE_DURATION)` / `on(after MOUSE_PHASE_DURATION)`) advances. Removes a class of "slow USB ⇒ chart stalls" failure modes from the wake path. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 67 +++++++--- Cargo.lock | 2 +- Cargo.toml | 25 ++-- LICENSE | 21 ++++ README.md | 153 +++++++++++++++++++++++ scripts/tune_runtime.py | 271 ++++++++++++++++++++++++++++++++++++++++ src/main.rs | 167 ++++++++++++++++++------- 7 files changed, 637 insertions(+), 69 deletions(-) create mode 100644 LICENSE create mode 100644 README.md create mode 100644 scripts/tune_runtime.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dad4f5..00bf9a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,18 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.0] - 2026-05-01 + ### Added - **Composite USB device — mouse + keyboard HID** under one VID/PID. A new `WakingHost` parent state wraps two substates: `WakingWithKeyboard` - (`default`) taps Left Shift 4× then idles, and parent's - `KBD_WAKE_DURATION` timeout drives the transition to `WakingWithMouse` - which runs the existing shake animation. Reason: macOS does not reliably - wake from raw HID mouse motion alone; a keyboard event does. Shift is a - modifier with no character side effect, so it's safe even with focus on a - text field at the moment of wake. Adds a second `HidWriter` against the - same `embassy_usb::Builder` (no hub simulation; standard USB composite), - plus `KbdHid`/`KBD_HID_STATE` machinery and a `send_kbd` helper. + taps **F13** 4× then idles, and a chart timer transitions to + `WakingWithMouse` which runs the existing shake animation. Reason: + macOS does not reliably wake from raw HID mouse motion alone; a + keyboard event does. F13 was picked over the more intuitive `Shift` + because mainstream OSes don't map F13 by default — if a key ever gets + stuck on the host (e.g. the wake deadline preempts the loop between a + key-down and key-up report), nothing visible happens. Earlier versions + used Left Shift and exhibited exactly that nightmare scenario in + practice (host typing was capitalised until the device was unplugged). + Belt-and-suspenders: `keyboard_wake` always sends an all-keys-released + report after its work loop, regardless of which side of the deadline + won. Adds a second `HidWriter` against the same `embassy_usb::Builder` + (no hub simulation; standard USB composite), plus `KbdHid` / + `KBD_HID_STATE` machinery and a `send_kbd` helper. - `hsmc` 0.5.1 statechart drives the entire device lifecycle. The control flow (boot → host wake (kbd → mouse) → settle → spinner → active jiggle/flash → end-of-day spiral → power-down) is expressed declaratively @@ -48,10 +56,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- **USB descriptor: Logitech G502 (`046d:c07d`, mouse-only) → Logitech - Unifying Receiver (`046d:c52b`, real composite mouse+keyboard).** Product - string `"G502 Mouse"` → `"USB Receiver"`. Bumping the PID also invalidates - the host's cached HID descriptor on first plug after reflash. +- **USB descriptor: Logitech G502 (`046d:c07d`, mouse-only) → pid.codes + hobby slot (`1209:b0b0`).** Product string `"G502 Mouse"` → + `"jiggly"`, manufacturer `"Logitech"` → `"swaits.com"`. Briefly + spoofed the Logitech Unifying Receiver (`046d:c52b`) on the way from + G502 → final, but Linux's `hid-logitech-dj` kernel driver matches that + PID and runs ~10–20 s of HID++ control-transfer probes the firmware + doesn't answer, blocking actual endpoint polling for that long on + every plug. macOS doesn't have the driver and was unaffected. + Switching to a pid.codes VID lets `hid-generic` bind immediately on + Linux. +- **`config.device_release = 0x0200`** (was unset / default `0x0010`). + Matches firmware version `0.2.0`. +- **`config.serial_number`** now derived from the RP2040's 64-bit unique + chip ID, rendered as a 16-hex-char `&'static str` in a `StaticCell`. + Hosts now treat each replug as the same device, and two boards have + distinct identities. - **Statechart names cleaned up for symmetry**: - `BootSweep` → `Booting`; `Ev::SweepDone` → `Ev::BootDone`; `led_rgb_sweep` → `boot_sweep`. @@ -67,11 +87,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `FINAL_SPIRAL_*`. - `Ctx.writer: Writer` → `Ctx.mouse: MouseHid` (parallel to new `Ctx.kbd: KbdHid`); `send` → `send_mouse`. -- **`RUN_DURATION` from 8 hours to 3 h 50 m**. Math-optimal under the - user's workday distribution (start `triangular(8.0, mode=8.5, 9.5)`, - lunch 12:00–13:00, end `triangular(16.0, mode=17.5, 19.0)`) for "screen - goes to sleep during the lunch hour on most days." See - `/tmp/jiggly_runtime_v3.py` for the simulation. +- **`RUN_DURATION` 8 h → 4 h 00 m** and **LED phase boundaries + 60 / 30 / 10 → 30 / 25 / 20** (`YELLOW_AT` / `RED_AT` / `FAST_RED_AT`, + in minutes-remaining). Joint optimum from a 4-D Monte Carlo over a + typical office workday distribution (start `triangular(8.0, + mode=8.5, 9.5)`, lunch 12:00–13:00, end `triangular(16.0, mode=17.5, + 19.0)`) with a per-minute press-on-warning user model (yellow + ~1.5 %/min, red ~4 %/min, fast-red ~6 %/min, plus small bumps at the + 10'/5' spiral animations). The hand-picked `60/30/10` was almost + exactly 50ᵗʰ-percentile; pushing the warning thresholds much closer + to death lifts `P(any lunch sleep)` from 53 % → 74 % and `P(sweet + 12:15–12:45 spot)` from 34 % → 52 %. The mechanism is non-obvious — + long visible warnings cause more accidental morning RESET-presses + that extend the cycle into the afternoon, which is the opposite of + what the user wants. See `scripts/tune_runtime.py` for the + simulation. - HID `poll_ms` 60 → 8 (125 Hz). At the previous 60 ms poll the host was discarding ~7 of every 8 animation frames the firmware emitted. - Boot LED `R→G→B` step 180 ms → 60 ms; 1.5 s pre-animation USB-settle @@ -115,5 +145,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `bootstrap`. All recipes execute inside `mise exec -- sh -eu -c` so the pinned toolchain is used regardless of shell activation state. -[Unreleased]: https://github.com/swaits/jiggly/compare/v0.1.0...HEAD +[Unreleased]: https://github.com/swaits/jiggly/compare/v0.2.0...HEAD +[0.2.0]: https://github.com/swaits/jiggly/compare/v0.1.0...v0.2.0 [0.1.0]: https://github.com/swaits/jiggly/releases/tag/v0.1.0 diff --git a/Cargo.lock b/Cargo.lock index 63ca5d8..5e73b59 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -767,7 +767,7 @@ dependencies = [ [[package]] name = "jiggly" -version = "0.1.0" +version = "0.2.0" dependencies = [ "cortex-m", "cortex-m-rt", diff --git a/Cargo.toml b/Cargo.toml index 3224bb1..99297ad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,15 @@ [package] -name = "jiggly" -version = "0.1.0" -edition = "2024" +name = "jiggly" +version = "0.2.0" +edition = "2024" +authors = ["Stephen Waits "] +description = "USB mouse jiggler firmware for the Seeed Studio Xiao RP2040 — keeps your screen awake during the workday, then politely shuts up so you can go home." +license = "MIT" +repository = "https://github.com/swaits/jiggly" +readme = "README.md" +keywords = ["embedded", "rp2040", "usb-hid", "embassy", "no-std"] +categories = ["embedded", "no-std"] +publish = false # firmware binary; not a crates.io library [features] default = ["embassy"] @@ -33,8 +41,11 @@ portable-atomic = { version = "1.13", features = ["critical-section"] } unexpected_cfgs = { level = "allow", check-cfg = ['cfg(feature, values("tokio", "embassy"))'] } [profile.release] -lto = true -opt-level = "s" -codegen-units = 1 -debug = 2 +lto = "fat" # cross-crate inlining and dead-code elimination +opt-level = "z" # optimise for size over speed (vs "s") +codegen-units = 1 # single unit so LTO sees everything +debug = 2 # symbols stay in the ELF for `just bloat` / +# `just size`; `cargo objcopy -O binary` strips +# them when producing the flashable .bin / .uf2 overflow-checks = false +panic = "abort" # no unwinding tables; panic-reset just resets diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3e991cc --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Stephen Waits + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d5a65db --- /dev/null +++ b/README.md @@ -0,0 +1,153 @@ +# jiggly + +A USB mouse jiggler that keeps your screen awake during the workday and +goes quiet when you're done. Rust `no_std` firmware for the [Seeed +Studio Xiao RP2040][xiao], built on [embassy][embassy] and an +[hsmc][hsmc] statechart. + +## What it does + +Plugs into USB, presents as a composite mouse + keyboard HID device +(`1209:b0b0`, manufacturer `swaits.com`, product `jiggly`), and: + +- On boot, taps `F13` four times, then jiggles the cursor — enough to + wake any sleeping host. Mouse motion alone doesn't reliably wake + macOS; a key tap does. F13 is chosen because it's harmless if it ever + ends up stuck — no OS maps it by default. +- For the next four hours, nudges the cursor one pixel every 4½ minutes + so the host never falls asleep. +- Breathes the on-board NeoPixel green → yellow → red as time runs + down. Two on-screen "spiral" warnings fire 10 min and 5 min before + expiry. +- At end-of-life, draws a coin-spinning-down spiral on the cursor, + blinks the LED red, and goes silent until you press `RESET`. + +## Hardware + +A Xiao RP2040. Nothing else — the on-board NeoPixel and USB-C are all +you need. + +## Build & flash + +The toolchain is pinned through [mise][mise], builds run through +[just][just]: + +``` +mise install # one-time: rust toolchain, target, cargo helpers +just # list available recipes +just release # optimised build +just uf2 # produce a flashable .uf2 +just flash # build + copy to a mounted RPI-RP2 volume +just ci # check + fmt-check + clippy (-D warnings) + release +``` + +To flash by hand: hold `B` (BOOT) and tap `R` (RESET) on the Xiao, +which mounts the `RPI-RP2` volume. Drop +`target/thumbv6m-none-eabi/release/jiggly.uf2` onto it. + +## How it works + +The whole device lifecycle is one [hsmc][hsmc] statechart: + +``` +Booting LED R→G→B sweep + └─ BootDone ──▶ WakingHost + ├─ WakingWithKeyboard 4× F13 tap, then idle + │ └─ (timeout) ──▶ + └─ WakingWithMouse ~12 Hz horizontal shake + └─ WakeDone ──▶ Settling (2 s pause) + └─ ▶ Spinning (3 quick circles) + └─ SpinDone ──▶ Active +Active green→yellow→red breathing + ├─ every 4½ min: jiggle ±1 px (Flash subtate paints the LED white) + ├─ T-10 min: Warning10 mini-spiral + ├─ T-5 min: Warning5 mini-spiral + └─ T-0: ──▶ Ending fast red blink + └─ Spiraling full coin-down spiral + └─ SpiralDone ──▶ Quiet ──▶ PoweringDown + └─ 3 green flashes, + NeoPixel off, + USB silent +``` + +A separate embassy task feeds the hardware watchdog every 5 s. + +## Why four hours, and why those LED thresholds? + +The point isn't to keep the screen awake forever. It's to keep it +awake while you're at your desk and let it sleep when you're not. The +cleanest "not at desk" signal in a typical workday is **lunch**, so +the design goal is: most days the device should expire some time +during the noon hour, the screen locks, and one tap restarts the +cycle when you sit back down. + +That's a four-knob problem: + +| constant | what it controls | +|---------------|----------------------------------------------------------| +| `RUN_DURATION`| how long one full cycle lasts | +| `YELLOW_AT` | minutes-remaining where breathing-yellow begins | +| `RED_AT` | minutes-remaining where breathing-red begins | +| `FAST_RED_AT` | minutes-remaining where the fast-pulse-red blink begins | + +`scripts/tune_runtime.py` is a Monte Carlo that does a 4-D grid +search over those four constants across 50 000 simulated workdays. +The model: + +- Workday start is `Triangular(8:00, mode 8:30, 9:30)`, end is + `Triangular(16:00, mode 17:30, 19:00)`. Lunch is fixed at 12:00–13:00. +- The user sees the LED and may tap `RESET` to extend the cycle: + ~1.5 %/min during yellow, ~4 %/min during red, ~6 %/min during + fast-red, plus small one-shot bumps the minute each spiral warning + fires. +- Free `RESET` at boot and at 13:00 (re-login after lunch). + +The composite score rewards lunch-hour expiration (especially +12:15–12:45) and penalizes the screen sleeping while the user is at +their desk. + +The winner — and what the firmware ships: + +``` +RUN_DURATION = 4h00m YELLOW_AT = 30 RED_AT = 25 FAST_RED_AT = 20 +``` + +(LED thresholds are minutes-remaining.) The screen sleeps somewhere +during lunch on **~74 %** of simulated days and in the 12:15–12:45 +sweet spot on **~52 %**. + +The interesting result is that the obvious-looking `60 / 30 / 10` +thresholds (long, gentle warning, urgent finish) ranked dead-average +out of 2 245 combos. Long visible warnings turn out to be +counter-productive: a 30-minute yellow phase gives you 30 minutes to +glance up, notice the LED, and tap `RESET` — and a tap during yellow +extends the cycle into the afternoon, the opposite of the goal. +Shrinking yellow and red to "long enough to notice, short enough not +to act on" pushes more days into a clean lunch death. + +If your day looks different — different start/end distribution, +different press habits, different lunch length — edit the constants +and ranges at the top of `scripts/tune_runtime.py`, run it +(`uv run scripts/tune_runtime.py`), and update the four values in +`src/main.rs`. + +## USB identity + +The firmware enumerates as VID `1209` / PID `b0b0`, manufacturer +`swaits.com`, product `jiggly`. `1209` is the [pid.codes][pidcodes] +community VID for open-source projects. + +The serial number is the RP2040's 64-bit unique chip ID rendered as 16 +hex chars — different across boards, stable across replugs, so hosts +treat each plug as the same device they saw last time. + +## License + +MIT — see [LICENSE](LICENSE). + +[xiao]: https://wiki.seeedstudio.com/XIAO-RP2040/ +[embassy]: https://embassy.dev/ +[hsmc]: https://crates.io/crates/hsmc +[mise]: https://mise.jdx.dev/ +[just]: https://just.systems/ +[pidcodes]: https://pid.codes/ diff --git a/scripts/tune_runtime.py b/scripts/tune_runtime.py new file mode 100644 index 0000000..2f6cac0 --- /dev/null +++ b/scripts/tune_runtime.py @@ -0,0 +1,271 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = ["numpy"] +# /// +""" +Monte Carlo tuner for the four lifecycle constants in src/main.rs: + + RUN_DURATION full cycle length, in minutes + YELLOW_AT remaining-minute threshold where breathing-yellow begins + RED_AT remaining-minute threshold where breathing-red begins + FAST_RED_AT remaining-minute threshold where the fast-pulse blink begins + +Sweeps a 4-D grid (with the constraint YELLOW_AT > RED_AT > FAST_RED_AT > 0) +across 50 000 simulated workdays and picks the combination that lands the +screen-sleep in the lunch hour as often as possible. + +The model + + - Day starts at Triangular(8:00, mode 8:30, 9:30) and ends at + Triangular(16:00, mode 17:30, 19:00). Lunch is 12:00–13:00 (fixed). + - Free RESET at start (boot) and at 13:00 (re-login after lunch). + - User-at-desk minute-by-minute, sees the LED, and may tap RESET to + extend the cycle: + yellow 1.5 % / min + red 4.0 % / min + fast-red 6.0 % / min + warning10/5 one-shot bumps the minute the spiral animation fires + - A composite score rewards lunch-hour expiration (especially the + 12:15–12:45 sweet spot) and penalizes daytime failures. + +Usage + + uv run scripts/tune_runtime.py # default 50 000 days + uv run scripts/tune_runtime.py --n 100000 # finer Monte Carlo + +Adjust the per-phase press probabilities and grid ranges at the top of +main() to match your own behavior or your own workday distribution. +""" + +from __future__ import annotations + +import argparse +import itertools +import time + +import numpy as np + +LUNCH_START = 12 * 60 +LUNCH_END = 13 * 60 + +# Per-minute press probabilities per LED phase. +P_PRESS_YELLOW = 0.015 +P_PRESS_RED = 0.040 +P_PRESS_FAST_RED = 0.060 +# One-shot bumps when the on-screen spiral animations fire (10 / 5 min before +# death). Independent of LED-phase boundaries — the firmware fires those at +# fixed offsets from death. +P_WARN10_BUMP = 0.04 +P_WARN5_BUMP = 0.03 + + +def sample_days(n: int, rng: np.random.Generator) -> tuple[np.ndarray, np.ndarray]: + s = (rng.triangular(8.0, 8.5, 9.5, n) * 60).astype(np.int32) + e = (rng.triangular(16.0, 17.5, 19.0, n) * 60).astype(np.int32) + return s, e + + +def simulate( + rt: int, + yellow_at: int, + red_at: int, + fast_red_at: int, + s: np.ndarray, + e: np.ndarray, + rng: np.random.Generator, +) -> dict: + n = len(s) + expire = s + rt + + presses = np.zeros(n, dtype=np.int32) + slept_work = np.zeros(n, dtype=np.int32) + slept_lunch = np.zeros(n, dtype=np.int32) + after_hours = np.zeros(n, dtype=np.int32) + + t_min = int(s.min()) + t_max = int(max(e.max(), expire.max())) + 1 + + for t in range(t_min, t_max): + # Free re-tap when the user re-logs in at 13:00. + if t == LUNCH_END: + in_workday = (t >= s) & (t < e) + expire = np.where(in_workday, t + rt, expire) + + in_workday = (t >= s) & (t < e) + at_lunch = LUNCH_START <= t < LUNCH_END + device_running = t < expire + device_dead = ~device_running + + if at_lunch: + slept_lunch += (in_workday & device_dead).astype(np.int32) + else: + slept_work += (in_workday & device_dead).astype(np.int32) + + past_end = (t >= e) & device_running + after_hours += past_end.astype(np.int32) + + if not at_lunch: + eligible = in_workday & device_running + if eligible.any(): + remaining = expire - t + p = np.zeros(n, dtype=np.float32) + yellow = (remaining > red_at) & (remaining <= yellow_at) + red = (remaining > fast_red_at) & (remaining <= red_at) + fast_red = (remaining > 0) & (remaining <= fast_red_at) + p[yellow] = P_PRESS_YELLOW + p[red] = P_PRESS_RED + p[fast_red] = P_PRESS_FAST_RED + p[remaining == 10] += P_WARN10_BUMP + p[remaining == 5] += P_WARN5_BUMP + + roll = rng.random(n).astype(np.float32) + press = eligible & (roll < p) + np.putmask(expire, press, t + rt) + presses += press.astype(np.int32) + + return { + "rt": rt, + "yellow_at": yellow_at, + "red_at": red_at, + "fast_red_at": fast_red_at, + "presses": presses, + "slept_work": slept_work, + "slept_lunch": slept_lunch, + "after_hours": after_hours, + } + + +def summarize(r: dict) -> dict: + sw = r["slept_work"] + sl = r["slept_lunch"] + ah = r["after_hours"] + pr = r["presses"] + sweet = (sl >= 15) & (sl <= 45) + return { + "rt": r["rt"], + "yellow_at": r["yellow_at"], + "red_at": r["red_at"], + "fast_red_at": r["fast_red_at"], + "p_sweet": sweet.mean(), + "p_lunch_any": (sl > 0).mean(), + "p_no_work_sleep": (sw == 0).mean(), + "mean_lunch": sl.mean(), + "mean_work_sleep": sw.mean(), + "mean_presses": pr.mean(), + "mean_after": ah.mean(), + } + + +def score(r: dict) -> float: + return ( + r["p_sweet"] + + 0.5 * r["p_lunch_any"] + - 1.5 * (1 - r["p_no_work_sleep"]) + - 0.05 * r["mean_after"] / 60 + ) + + +def fmt_h(m: float) -> str: + m = int(round(m)) + h, mm = divmod(m, 60) + return f"{h}h{mm:02d}m" if h else f"{mm}m" + + +def fmt_rt(m: int) -> str: + h, mm = divmod(int(m), 60) + return f"{h}h{mm:02d}m" + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("--n", type=int, default=50_000, help="days per combo") + ap.add_argument("--seed", type=int, default=2026) + args = ap.parse_args() + + # 4-D search grid. Wider/finer is more honest; narrower is faster. + rt_range = list(range(230, 251, 5)) # 230..250 step 5 (5) + yellow_at_range = list(range(20, 71, 5)) # 20..70 step 5 (11) + red_at_range = list(range(10, 41, 5)) # 10..40 step 5 (7) + fast_red_at_range = list(range(4, 21, 2)) # 4..20 step 2 (9) + + print(f"tune_runtime — N={args.n} days/combo") + print(f" RT {rt_range[0]}..{rt_range[-1]} step 5 ({len(rt_range)})") + print(f" YELLOW_AT {yellow_at_range[0]}..{yellow_at_range[-1]} step 5 ({len(yellow_at_range)})") + print(f" RED_AT {red_at_range[0]}..{red_at_range[-1]} step 5 ({len(red_at_range)})") + print(f" FAST_RED_AT {fast_red_at_range[0]}..{fast_red_at_range[-1]} step 2 ({len(fast_red_at_range)})") + print(f" press: yellow {P_PRESS_YELLOW}/min, red {P_PRESS_RED}/min, " + f"fast {P_PRESS_FAST_RED}/min") + print() + + rng = np.random.default_rng(args.seed) + s, e = sample_days(args.n, rng) + + combos = [ + (rt, ya, ra, fra) + for rt, ya, ra, fra in itertools.product( + rt_range, yellow_at_range, red_at_range, fast_red_at_range + ) + if ya > ra > fra > 0 + ] + print(f" {len(combos)} valid combos to evaluate...") + t0 = time.time() + + results = [] + for i, (rt, ya, ra, fra) in enumerate(combos): + sim_rng = np.random.default_rng(args.seed + 1 + i) + r = simulate(rt, ya, ra, fra, s, e, sim_rng) + results.append(summarize(r)) + if (i + 1) % 200 == 0: + elapsed = time.time() - t0 + rate = (i + 1) / elapsed + eta = (len(combos) - i - 1) / rate + print(f" ... {i+1}/{len(combos)} ({rate:.1f}/sec, ETA {eta:.0f}s)") + + print(f" done in {time.time() - t0:.0f}s") + print() + + by_score = sorted(results, key=lambda r: -score(r))[:25] + print("=== top 25 by composite score ===") + print(f"{'RT':>6} {'YEL':>4} {'RED':>4} {'FST':>4} | " + f"{'p_sweet':>7} {'p_any':>6} {'p_no_fail':>9} | " + f"{'lunch':>5} {'work':>4} {'press':>5} {'score':>6}") + print("-" * 86) + for r in by_score: + print(f"{fmt_rt(r['rt']):>6} {r['yellow_at']:>4} {r['red_at']:>4} {r['fast_red_at']:>4} | " + f"{r['p_sweet']*100:>6.1f}% {r['p_lunch_any']*100:>5.1f}% " + f"{r['p_no_work_sleep']*100:>8.1f}% | " + f"{fmt_h(r['mean_lunch']):>5} {fmt_h(r['mean_work_sleep']):>4} " + f"{r['mean_presses']:>5.2f} {score(r):>6.3f}") + + # Where does the firmware's currently-shipping combo land? + shipping = next( + (r for r in results + if r["rt"] == 240 and r["yellow_at"] == 30 + and r["red_at"] == 25 and r["fast_red_at"] == 20), + None, + ) + if shipping is not None: + rank = 1 + sum(1 for r in results if score(r) > score(shipping)) + print() + print("=== current firmware (RT=4h00 YEL=30 RED=25 FST=20) ===") + print(f" p_sweet={shipping['p_sweet']*100:.1f}% " + f"p_any={shipping['p_lunch_any']*100:.1f}% " + f"p_no_fail={shipping['p_no_work_sleep']*100:.1f}% " + f"score={score(shipping):.3f}") + print(f" rank = {rank} / {len(results)}") + + best = by_score[0] + print() + print(f"PICK: RT={fmt_rt(best['rt'])} YELLOW_AT={best['yellow_at']} " + f"RED_AT={best['red_at']} FAST_RED_AT={best['fast_red_at']}") + print(f" P(sweet 12:15-12:45) = {best['p_sweet']*100:.1f}%") + print(f" P(any lunch sleep) = {best['p_lunch_any']*100:.1f}%") + print(f" P(no work fail) = {best['p_no_work_sleep']*100:.1f}%") + print(f" mean lunch dead = {fmt_h(best['mean_lunch'])}") + print(f" mean work sleep = {fmt_h(best['mean_work_sleep'])}") + print(f" mean presses = {best['mean_presses']:.2f}/day") + print(f" mean after-hrs = {fmt_h(best['mean_after'])}") + + +if __name__ == "__main__": + main() diff --git a/src/main.rs b/src/main.rs index ab4a62e..19307c3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,11 +5,12 @@ use core::f32::consts::PI; use embassy_executor::Spawner; use embassy_rp::{ - bind_interrupts, + Peri, bind_interrupts, clocks::RoscRng, dma, + flash::Flash, gpio::{Level, Output}, - peripherals::{DMA_CH0, PIO0, USB}, + peripherals::{DMA_CH0, FLASH, PIO0, USB}, pio::{InterruptHandler as PioInterruptHandler, Pio}, pio_programs::ws2812::{Grb, PioWs2812, PioWs2812Program}, usb::{Driver, InterruptHandler as UsbInterruptHandler}, @@ -40,10 +41,13 @@ type MouseHid = HidWriter<'static, UsbDriver, 5>; type KbdHid = HidWriter<'static, UsbDriver, 8>; // ── Lifecycle timing (statechart Durations) ──────────────────────── -// 3h50m: math-optimal under the user's schedule for "screen sleeps during -// lunch on most days". Balances early-start re-tap risk against late-start -// alive-through-lunch risk. See /tmp/jiggly_runtime_v3.py. -const RUN_DURATION: Duration = Duration::from_hours(3).saturating_add(Duration::from_mins(50)); +// 4h00m: optimum from the 4-D Monte Carlo (RUN_DURATION × YELLOW_AT × +// RED_AT × FAST_RED_AT) over a typical office workday distribution +// with a per-minute press-on-warning user model. Lands the screen-sleep +// in the 12:15–12:45 sweet spot on ~52 % of days and somewhere in +// lunch on ~74 %. See the README's "Why four hours…" section and +// `scripts/tune_runtime.py` for the simulation. +const RUN_DURATION: Duration = Duration::from_hours(4); const SHUTDOWN_LEAD: Duration = Duration::from_secs(30); const RUN_BEFORE_SHUTDOWN: Duration = RUN_DURATION.saturating_sub(SHUTDOWN_LEAD); const SHUTDOWN_ANIM_BUDGET: Duration = Duration::from_secs(5); @@ -52,9 +56,12 @@ const JIGGLE_PERIOD: Duration = Duration::from_secs(270); const FLASH_DURATION: Duration = Duration::from_millis(100); // Phase boundaries — compared against time *remaining* in Active. -const YELLOW_AT: EDuration = EDuration::from_secs(60 * 60); -const RED_AT: EDuration = EDuration::from_secs(30 * 60); -const FAST_RED_AT: EDuration = EDuration::from_secs(10 * 60); +// Joint optimum from `scripts/tune_runtime.py`. Yellow and red are +// kept deliberately short (5 min each); the long phase is fast-red. +// See the README's "Why four hours…" section for the rationale. +const YELLOW_AT: EDuration = EDuration::from_secs(30 * 60); +const RED_AT: EDuration = EDuration::from_secs(25 * 60); +const FAST_RED_AT: EDuration = EDuration::from_secs(20 * 60); // LED breathing math const LED_TICK: EDuration = EDuration::from_millis(20); @@ -90,17 +97,36 @@ const SETTLING_DELAY: Duration = Duration::from_secs(2); // ── Keyboard wake (host-wake first pass) ─────────────────────────── // macOS often won't wake from raw HID mouse motion alone, but reliably wakes -// from a keyboard event. Tap Left Shift four times before the mouse shake; -// Shift is a modifier, so it produces no visible character even if focus is -// on a text field at the moment of wake. +// from any keyboard event. We tap **F13** four times before the mouse shake. +// F13–F24 are intentionally unmapped on every mainstream OS, so even in the +// nightmare scenario where the deadline preempts the loop *between* a key- +// down and key-up report and the host ends up holding F13 forever, nothing +// visible happens — unlike with Shift, which would silently capitalise every +// keystroke from the user's real keyboard until they unplug the device. +// Earlier versions used Left Shift; that turned out to be exactly that +// nightmare scenario in practice. const KBD_WAKE_TAPS: u32 = 4; const KBD_TAP_HOLD: EDuration = EDuration::from_millis(30); const KBD_TAP_GAP: EDuration = EDuration::from_millis(50); -// Total parent dwell: 4 × (30 + 50) = 320 ms of taps + ~180 ms slack so the -// host has a chance to start coming up before the mouse shake begins. -const KBD_WAKE_DURATION: Duration = Duration::from_millis(500); -// USB HID Boot Keyboard modifier byte: bit 1 = Left Shift. -const KBD_MOD_LEFT_SHIFT: u8 = 0x02; +// Hard internal deadline on the keyboard-wake entry action. The taps total +// ~320 ms so they finish well before this; the deadline only kicks in if a +// USB write blocks (e.g. the host hasn't bound the keyboard endpoint yet). +const KBD_WAKE_DEADLINE: EDuration = EDuration::from_millis(500); +// Statechart timer for the WakingWithKeyboard state — chosen above the +// internal deadline so the chart timer is what drives the transition out. +const KBD_PHASE_DURATION: Duration = Duration::from_millis(550); +// HID Keyboard usage page keycode for F13. +const KBD_KEY_F13: u8 = 0x68; +// Final-cleanup deadline — the all-keys-released report we send after the +// main work loop is bounded by this so a misbehaving endpoint can't pin +// the chart. Best-effort; if it doesn't land we tried. +const KBD_RELEASE_DEADLINE: EDuration = EDuration::from_millis(100); + +// ── Mouse wake (host-wake second pass) ───────────────────────────── +// Internal deadline on the mouse-shake entry action — the shake itself takes +// ~640 ms; the cap exists so a misbehaving USB endpoint can't pin the chart. +const MOUSE_WAKE_DEADLINE: EDuration = EDuration::from_millis(1000); +const MOUSE_PHASE_DURATION: Duration = Duration::from_millis(1050); // Three quick clockwise circles read more clearly as "spinner / running" // than one slow lap. 25 frames per circle × 3 × 8 ms = 600 ms total. @@ -162,7 +188,6 @@ pub struct Ctx { #[derive(Debug, Clone)] pub enum Ev { BootDone, - WakeDone, SpinDone, Jiggled, WarnDone, @@ -182,19 +207,20 @@ Jiggly { // Wake the host using whichever input the host actually responds to. // Keyboard first (more reliable on macOS), then a mouse shake as - // belt-and-suspenders. The parent state catches WakeDone (emitted by the - // mouse path) and exits to Settling. + // belt-and-suspenders. Each substate runs a oneshot entry action with + // its own internal deadline; the chart timer is what advances the + // chart. No durings, no events — purely entry + timer. state WakingHost { default(WakingWithKeyboard); - on(WakeDone) => Settling; state WakingWithKeyboard { - during: wake_with_keyboard(kbd); - on(after KBD_WAKE_DURATION) => WakingWithMouse; + entry: keyboard_wake; + on(after KBD_PHASE_DURATION) => WakingWithMouse; } state WakingWithMouse { - during: wake_with_mouse(mouse); + entry: mouse_wake; + on(after MOUSE_PHASE_DURATION) => Settling; } } @@ -264,6 +290,33 @@ impl JigglyActions for JigglyActionContext<'_> { self.active_start = Some(Instant::now()); } + async fn keyboard_wake(&mut self) { + let _ = embassy_futures::select::select( + wake_with_keyboard(&mut self.kbd), + Timer::after(KBD_WAKE_DEADLINE), + ) + .await; + // Belt-and-suspenders: always send an all-keys-released report, + // even if the deadline preempted the loop *between* a key-down + // and its key-up. Without this, a stuck modifier (Shift!) or key + // on the host side could persist until the user unplugs the + // device. Bounded by KBD_RELEASE_DEADLINE so a misbehaving + // endpoint can't pin the chart. + let _ = embassy_futures::select::select( + send_kbd(&mut self.kbd, 0, [0; 6]), + Timer::after(KBD_RELEASE_DEADLINE), + ) + .await; + } + + async fn mouse_wake(&mut self) { + let _ = embassy_futures::select::select( + wake_with_mouse(&mut self.mouse), + Timer::after(MOUSE_WAKE_DEADLINE), + ) + .await; + } + async fn jiggle_pair(&mut self) { let (dx, dy): (i8, i8) = if (RoscRng::next_u8() & 1) == 0 { (1, 0) @@ -310,25 +363,23 @@ async fn boot_sweep(neo: &mut Neo, neo_pwr: &mut Output<'static>) -> Ev { Ev::BootDone } -// Tap Left Shift 4 times — macOS reliably wakes from a keyboard event but is -// inconsistent about waking from raw mouse motion. The during loop ends after -// the taps and idles in a long sleep; the parent state's KBD_WAKE_DURATION -// timeout drives the transition out, not a completion event from this fn. -async fn wake_with_keyboard(kbd: &mut KbdHid) -> Ev { +// Tap F13 four times. macOS reliably wakes from any keyboard event but is +// inconsistent about waking from raw mouse motion. Plain oneshot helper — +// the action method that calls this races it against KBD_WAKE_DEADLINE +// AND unconditionally sends an all-keys-released cleanup report after, +// to make sure we never leave a key held on the host. +async fn wake_with_keyboard(kbd: &mut KbdHid) { for _ in 0..KBD_WAKE_TAPS { - send_kbd(kbd, KBD_MOD_LEFT_SHIFT, [0; 6]).await; + send_kbd(kbd, 0, [KBD_KEY_F13, 0, 0, 0, 0, 0]).await; Timer::after(KBD_TAP_HOLD).await; send_kbd(kbd, 0, [0; 6]).await; Timer::after(KBD_TAP_GAP).await; } - // Idle until the parent timeout fires. The hsmc runtime cancels this - // future on transition, so the long sleep just parks us. - loop { - Timer::after(EDuration::from_secs(60)).await; - } } -async fn wake_with_mouse(mouse: &mut MouseHid) -> Ev { +// Frantic horizontal mouse shake. Plain oneshot helper — the action method +// that calls this races it against MOUSE_WAKE_DEADLINE. +async fn wake_with_mouse(mouse: &mut MouseHid) { let period_frames = (WAKE_FRAMES_PER_HALF * 2) as f32; let total_frames = WAKE_OSCILLATIONS * WAKE_FRAMES_PER_HALF * 2; let mut prev_x: f32 = 0.0; @@ -348,7 +399,6 @@ async fn wake_with_mouse(mouse: &mut MouseHid) -> Ev { prev_y = next_y; Timer::after(ANIM_FRAME).await; } - Ev::WakeDone } async fn animate_spinner(mouse: &mut MouseHid) -> Ev { @@ -553,6 +603,31 @@ async fn paint(neo: &mut Neo, r: u8, g: u8, b: u8) { neo.write(&[RGB8 { r, g, b }]).await; } +// ── USB serial number from RP2040 unique chip ID ─────────────────── +// Reads the 64-bit unique ID baked into the on-board SPI flash, formats +// it as 16 ASCII hex chars in a static buffer, and returns a `'static` +// string suitable for `embassy_usb::Config::serial_number`. This makes +// the device's USB identity stable per-board across replugs (so hosts +// stop treating each plug as a new device) while still being unique +// between different boards. +const FLASH_SIZE: usize = 2 * 1024 * 1024; // Xiao RP2040 has 2 MB. + +fn make_serial(flash_periph: Peri<'static, FLASH>) -> &'static str { + static SERIAL: StaticCell<[u8; 16]> = StaticCell::new(); + const HEX: &[u8; 16] = b"0123456789ABCDEF"; + + let mut flash = Flash::<_, _, FLASH_SIZE>::new_blocking(flash_periph); + let mut id = [0u8; 8]; + let _ = flash.blocking_unique_id(&mut id); + + let buf = SERIAL.init([0; 16]); + for (i, &b) in id.iter().enumerate() { + buf[i * 2] = HEX[(b >> 4) as usize]; + buf[i * 2 + 1] = HEX[(b & 0x0f) as usize]; + } + core::str::from_utf8(buf).unwrap() +} + // ── Tasks ────────────────────────────────────────────────────────── #[embassy_executor::task] @@ -594,12 +669,18 @@ async fn main(spawner: Spawner) { let driver = Driver::new(p.USB, Irqs); - // Spoof a Logitech Unifying Receiver — a real-world composite device that - // exposes both mouse and keyboard HID interfaces under one VID/PID, which - // matches what this firmware now does. - let mut config = UsbConfig::new(0x046d, 0xc52b); - config.manufacturer = Some("Logitech"); - config.product = Some("USB Receiver"); + // pid.codes community VID with a self-allocated PID — using a real + // Logitech Unifying Receiver VID/PID was a mistake: Linux has a kernel + // driver (`hid-logitech-dj`) that special-cases that PID and tries to + // talk HID++ to enumerate paired wireless devices. We don't speak + // HID++, so the driver waits through ~10–20 s of timeouts before + // unbinding and letting `hid-generic` actually start polling our + // endpoints. Generic VID/PID routes straight to `hid-generic`. + let mut config = UsbConfig::new(0x1209, 0xb0b0); + config.manufacturer = Some("swaits.com"); + config.product = Some("jiggly"); + config.serial_number = Some(make_serial(p.FLASH)); + config.device_release = 0x0200; // matches firmware version 0.2.0 config.max_power = 100; config.max_packet_size_0 = 64;