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:
+49
-18
@@ -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 ~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**:
|
- **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:00–13: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: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
|
- 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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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/
|
||||||
@@ -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()
|
||||||
+124
-43
@@ -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: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 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
|
// F13–F24 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 ~10–20 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;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user