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
+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/