chore: cut 0.2.0 — README, LICENSE, publish metadata, runtime tune, USB identity, F13

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-01 22:32:17 -06:00
co-authored by Claude Opus 4.7
parent 01b96bdbf6
commit d0d9c451aa
7 changed files with 637 additions and 69 deletions
+49 -18
View File
@@ -7,18 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [0.2.0] - 2026-05-01
### Added ### Added
- **Composite USB device — mouse + keyboard HID** under one VID/PID. A new - **Composite USB device — mouse + keyboard HID** under one VID/PID. A new
`WakingHost` parent state wraps two substates: `WakingWithKeyboard` `WakingHost` parent state wraps two substates: `WakingWithKeyboard`
(`default`) taps Left Shift 4× then idles, and parent's taps **F13** 4× then idles, and a chart timer transitions to
`KBD_WAKE_DURATION` timeout drives the transition to `WakingWithMouse` `WakingWithMouse` which runs the existing shake animation. Reason:
which runs the existing shake animation. Reason: macOS does not reliably macOS does not reliably wake from raw HID mouse motion alone; a
wake from raw HID mouse motion alone; a keyboard event does. Shift is a keyboard event does. F13 was picked over the more intuitive `Shift`
modifier with no character side effect, so it's safe even with focus on a because mainstream OSes don't map F13 by default — if a key ever gets
text field at the moment of wake. Adds a second `HidWriter` against the stuck on the host (e.g. the wake deadline preempts the loop between a
same `embassy_usb::Builder` (no hub simulation; standard USB composite), key-down and key-up report), nothing visible happens. Earlier versions
plus `KbdHid`/`KBD_HID_STATE` machinery and a `send_kbd` helper. 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 - `hsmc` 0.5.1 statechart drives the entire device lifecycle. The control
flow (boot → host wake (kbd → mouse) → settle → spinner → active flow (boot → host wake (kbd → mouse) → settle → spinner → active
jiggle/flash → end-of-day spiral → power-down) is expressed declaratively 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 ### Changed
- **USB descriptor: Logitech G502 (`046d:c07d`, mouse-only) → Logitech - **USB descriptor: Logitech G502 (`046d:c07d`, mouse-only) → pid.codes
Unifying Receiver (`046d:c52b`, real composite mouse+keyboard).** Product hobby slot (`1209:b0b0`).** Product string `"G502 Mouse"`
string `"G502 Mouse"``"USB Receiver"`. Bumping the PID also invalidates `"jiggly"`, manufacturer `"Logitech"``"swaits.com"`. Briefly
the host's cached HID descriptor on first plug after reflash. 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 ~1020 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**: - **Statechart names cleaned up for symmetry**:
- `BootSweep``Booting`; `Ev::SweepDone``Ev::BootDone`; - `BootSweep``Booting`; `Ev::SweepDone``Ev::BootDone`;
`led_rgb_sweep``boot_sweep`. `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_*`. `FINAL_SPIRAL_*`.
- `Ctx.writer: Writer``Ctx.mouse: MouseHid` (parallel to new - `Ctx.writer: Writer``Ctx.mouse: MouseHid` (parallel to new
`Ctx.kbd: KbdHid`); `send``send_mouse`. `Ctx.kbd: KbdHid`); `send``send_mouse`.
- **`RUN_DURATION` from 8 hours to 3 h 50 m**. Math-optimal under the - **`RUN_DURATION` 8 h → 4 h 00 m** and **LED phase boundaries
user's workday distribution (start `triangular(8.0, mode=8.5, 9.5)`, 60 / 30 / 10 → 30 / 25 / 20** (`YELLOW_AT` / `RED_AT` / `FAST_RED_AT`,
lunch 12:0013:00, end `triangular(16.0, mode=17.5, 19.0)`) for "screen in minutes-remaining). Joint optimum from a 4-D Monte Carlo over a
goes to sleep during the lunch hour on most days." See typical office workday distribution (start `triangular(8.0,
`/tmp/jiggly_runtime_v3.py` for the simulation. mode=8.5, 9.5)`, lunch 12:0013: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:1512: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 - 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. 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 - 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 `bootstrap`. All recipes execute inside `mise exec -- sh -eu -c` so the
pinned toolchain is used regardless of shell activation state. 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 [0.1.0]: https://github.com/swaits/jiggly/releases/tag/v0.1.0
Generated
+1 -1
View File
@@ -767,7 +767,7 @@ dependencies = [
[[package]] [[package]]
name = "jiggly" name = "jiggly"
version = "0.1.0" version = "0.2.0"
dependencies = [ dependencies = [
"cortex-m", "cortex-m",
"cortex-m-rt", "cortex-m-rt",
+18 -7
View File
@@ -1,7 +1,15 @@
[package] [package]
name = "jiggly" name = "jiggly"
version = "0.1.0" version = "0.2.0"
edition = "2024" edition = "2024"
authors = ["Stephen Waits <steve@waits.net>"]
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] [features]
default = ["embassy"] 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"))'] } unexpected_cfgs = { level = "allow", check-cfg = ['cfg(feature, values("tokio", "embassy"))'] }
[profile.release] [profile.release]
lto = true lto = "fat" # cross-crate inlining and dead-code elimination
opt-level = "s" opt-level = "z" # optimise for size over speed (vs "s")
codegen-units = 1 codegen-units = 1 # single unit so LTO sees everything
debug = 2 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 overflow-checks = false
panic = "abort" # no unwinding tables; panic-reset just resets
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Stephen Waits <steve@waits.net>
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.
+153
View File
@@ -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:0013: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:1512: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:1512: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/
+271
View File
@@ -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:0013: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:1512: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()
+124 -43
View File
@@ -5,11 +5,12 @@ use core::f32::consts::PI;
use embassy_executor::Spawner; use embassy_executor::Spawner;
use embassy_rp::{ use embassy_rp::{
bind_interrupts, Peri, bind_interrupts,
clocks::RoscRng, clocks::RoscRng,
dma, dma,
flash::Flash,
gpio::{Level, Output}, gpio::{Level, Output},
peripherals::{DMA_CH0, PIO0, USB}, peripherals::{DMA_CH0, FLASH, PIO0, USB},
pio::{InterruptHandler as PioInterruptHandler, Pio}, pio::{InterruptHandler as PioInterruptHandler, Pio},
pio_programs::ws2812::{Grb, PioWs2812, PioWs2812Program}, pio_programs::ws2812::{Grb, PioWs2812, PioWs2812Program},
usb::{Driver, InterruptHandler as UsbInterruptHandler}, usb::{Driver, InterruptHandler as UsbInterruptHandler},
@@ -40,10 +41,13 @@ type MouseHid = HidWriter<'static, UsbDriver, 5>;
type KbdHid = HidWriter<'static, UsbDriver, 8>; type KbdHid = HidWriter<'static, UsbDriver, 8>;
// ── Lifecycle timing (statechart Durations) ──────────────────────── // ── Lifecycle timing (statechart Durations) ────────────────────────
// 3h50m: math-optimal under the user's schedule for "screen sleeps during // 4h00m: optimum from the 4-D Monte Carlo (RUN_DURATION × YELLOW_AT ×
// lunch on most days". Balances early-start re-tap risk against late-start // RED_AT × FAST_RED_AT) over a typical office workday distribution
// alive-through-lunch risk. See /tmp/jiggly_runtime_v3.py. // with a per-minute press-on-warning user model. Lands the screen-sleep
const RUN_DURATION: Duration = Duration::from_hours(3).saturating_add(Duration::from_mins(50)); // in the 12:1512: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 SHUTDOWN_LEAD: Duration = Duration::from_secs(30);
const RUN_BEFORE_SHUTDOWN: Duration = RUN_DURATION.saturating_sub(SHUTDOWN_LEAD); const RUN_BEFORE_SHUTDOWN: Duration = RUN_DURATION.saturating_sub(SHUTDOWN_LEAD);
const SHUTDOWN_ANIM_BUDGET: Duration = Duration::from_secs(5); 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); const FLASH_DURATION: Duration = Duration::from_millis(100);
// Phase boundaries — compared against time *remaining* in Active. // Phase boundaries — compared against time *remaining* in Active.
const YELLOW_AT: EDuration = EDuration::from_secs(60 * 60); // Joint optimum from `scripts/tune_runtime.py`. Yellow and red are
const RED_AT: EDuration = EDuration::from_secs(30 * 60); // kept deliberately short (5 min each); the long phase is fast-red.
const FAST_RED_AT: EDuration = EDuration::from_secs(10 * 60); // 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 // LED breathing math
const LED_TICK: EDuration = EDuration::from_millis(20); 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) ─────────────────────────── // ── Keyboard wake (host-wake first pass) ───────────────────────────
// macOS often won't wake from raw HID mouse motion alone, but reliably wakes // 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; // from any keyboard event. We tap **F13** four times before the mouse shake.
// Shift is a modifier, so it produces no visible character even if focus is // F13F24 are intentionally unmapped on every mainstream OS, so even in the
// on a text field at the moment of wake. // 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_WAKE_TAPS: u32 = 4;
const KBD_TAP_HOLD: EDuration = EDuration::from_millis(30); const KBD_TAP_HOLD: EDuration = EDuration::from_millis(30);
const KBD_TAP_GAP: EDuration = EDuration::from_millis(50); const KBD_TAP_GAP: EDuration = EDuration::from_millis(50);
// Total parent dwell: 4 × (30 + 50) = 320 ms of taps + ~180 ms slack so the // Hard internal deadline on the keyboard-wake entry action. The taps total
// host has a chance to start coming up before the mouse shake begins. // ~320 ms so they finish well before this; the deadline only kicks in if a
const KBD_WAKE_DURATION: Duration = Duration::from_millis(500); // USB write blocks (e.g. the host hasn't bound the keyboard endpoint yet).
// USB HID Boot Keyboard modifier byte: bit 1 = Left Shift. const KBD_WAKE_DEADLINE: EDuration = EDuration::from_millis(500);
const KBD_MOD_LEFT_SHIFT: u8 = 0x02; // 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" // Three quick clockwise circles read more clearly as "spinner / running"
// than one slow lap. 25 frames per circle × 3 × 8 ms = 600 ms total. // than one slow lap. 25 frames per circle × 3 × 8 ms = 600 ms total.
@@ -162,7 +188,6 @@ pub struct Ctx {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum Ev { pub enum Ev {
BootDone, BootDone,
WakeDone,
SpinDone, SpinDone,
Jiggled, Jiggled,
WarnDone, WarnDone,
@@ -182,19 +207,20 @@ Jiggly {
// Wake the host using whichever input the host actually responds to. // Wake the host using whichever input the host actually responds to.
// Keyboard first (more reliable on macOS), then a mouse shake as // Keyboard first (more reliable on macOS), then a mouse shake as
// belt-and-suspenders. The parent state catches WakeDone (emitted by the // belt-and-suspenders. Each substate runs a oneshot entry action with
// mouse path) and exits to Settling. // its own internal deadline; the chart timer is what advances the
// chart. No durings, no events — purely entry + timer.
state WakingHost { state WakingHost {
default(WakingWithKeyboard); default(WakingWithKeyboard);
on(WakeDone) => Settling;
state WakingWithKeyboard { state WakingWithKeyboard {
during: wake_with_keyboard(kbd); entry: keyboard_wake;
on(after KBD_WAKE_DURATION) => WakingWithMouse; on(after KBD_PHASE_DURATION) => WakingWithMouse;
} }
state 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()); 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) { async fn jiggle_pair(&mut self) {
let (dx, dy): (i8, i8) = if (RoscRng::next_u8() & 1) == 0 { let (dx, dy): (i8, i8) = if (RoscRng::next_u8() & 1) == 0 {
(1, 0) (1, 0)
@@ -310,25 +363,23 @@ async fn boot_sweep(neo: &mut Neo, neo_pwr: &mut Output<'static>) -> Ev {
Ev::BootDone Ev::BootDone
} }
// Tap Left Shift 4 times macOS reliably wakes from a keyboard event but is // Tap F13 four times. macOS reliably wakes from any keyboard event but is
// inconsistent about waking from raw mouse motion. The during loop ends after // inconsistent about waking from raw mouse motion. Plain oneshot helper —
// the taps and idles in a long sleep; the parent state's KBD_WAKE_DURATION // the action method that calls this races it against KBD_WAKE_DEADLINE
// timeout drives the transition out, not a completion event from this fn. // AND unconditionally sends an all-keys-released cleanup report after,
async fn wake_with_keyboard(kbd: &mut KbdHid) -> Ev { // 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 { 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; Timer::after(KBD_TAP_HOLD).await;
send_kbd(kbd, 0, [0; 6]).await; send_kbd(kbd, 0, [0; 6]).await;
Timer::after(KBD_TAP_GAP).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 period_frames = (WAKE_FRAMES_PER_HALF * 2) as f32;
let total_frames = WAKE_OSCILLATIONS * WAKE_FRAMES_PER_HALF * 2; let total_frames = WAKE_OSCILLATIONS * WAKE_FRAMES_PER_HALF * 2;
let mut prev_x: f32 = 0.0; let mut prev_x: f32 = 0.0;
@@ -348,7 +399,6 @@ async fn wake_with_mouse(mouse: &mut MouseHid) -> Ev {
prev_y = next_y; prev_y = next_y;
Timer::after(ANIM_FRAME).await; Timer::after(ANIM_FRAME).await;
} }
Ev::WakeDone
} }
async fn animate_spinner(mouse: &mut MouseHid) -> Ev { 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; 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 ────────────────────────────────────────────────────────── // ── Tasks ──────────────────────────────────────────────────────────
#[embassy_executor::task] #[embassy_executor::task]
@@ -594,12 +669,18 @@ async fn main(spawner: Spawner) {
let driver = Driver::new(p.USB, Irqs); let driver = Driver::new(p.USB, Irqs);
// Spoof a Logitech Unifying Receiver — a real-world composite device that // pid.codes community VID with a self-allocated PID — using a real
// exposes both mouse and keyboard HID interfaces under one VID/PID, which // Logitech Unifying Receiver VID/PID was a mistake: Linux has a kernel
// matches what this firmware now does. // driver (`hid-logitech-dj`) that special-cases that PID and tries to
let mut config = UsbConfig::new(0x046d, 0xc52b); // talk HID++ to enumerate paired wireless devices. We don't speak
config.manufacturer = Some("Logitech"); // HID++, so the driver waits through ~1020 s of timeouts before
config.product = Some("USB Receiver"); // 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_power = 100;
config.max_packet_size_0 = 64; config.max_packet_size_0 = 64;