6 Commits
Author SHA1 Message Date
swaits 359e693646 chore: repoint remaining links at git.swaits.com
Rewrites lingering github.com / *.sr.ht references to this Gitea instance,
translating GitHub and Sourcehut URL shapes to Gitea's (/blob/<ref>/ and
sr.ht /tree/<ref>/item/ -> /src/branch/<ref>/, /tree/<tag> -> /src/tag/<tag>,
/actions/workflows/X -> /actions?workflow=X, /edit/ -> /_edit/).

Left pointing at the old hosts on purpose: pull-request links (PRs were not
migrated), forks that were never migrated, and GitHub-only surfaces Gitea
lacks. OpenPGP identity proofs are untouched by design.
2026-08-31 18:32:19 -06:00
swaits 2bc8935ff0 chore: point repository at git.swaits.com 2026-08-31 17:19:18 -06:00
swaitsandClaude Opus 4.7 c45fceaf7a chore: cut 0.3.0 — new tuning crate, retuned lifecycle constants
- Adds an in-repo `tuning/` crate that solves the four-knob LED-threshold
  tuning problem as a 4-objective Pareto search using published
  `heuropt` 0.8 (NSGA-III + a-posteriori weighted ranking), replacing
  `scripts/tune_runtime.py`'s single-composite-score grid. `just tune`
  runs it; the crate is its own workspace root with a local
  `.cargo/config.toml` overriding the firmware's inherited
  `thumbv6m-none-eabi` build target so it can use `std`.
- Retunes the shipping defaults from the new Pareto front:
  `RUN_DURATION` 4h00m → 3h51m, `YELLOW_AT` 30 → 22, `RED_AT` 25 → 11,
  `FAST_RED_AT` 20 → 4 (LED thresholds in minutes-remaining). Across
  1,000 simulated workdays the new combination averages 26 minutes of
  lunch sleep and lands in the 12:15–12:45 sweet spot on ~57 % of days,
  with zero mean work-time failure and ~2 min/day of after-hours waste.
- Bumps `config.device_release` 0x0200 → 0x0300 to match firmware
  version 0.3.0.
- README "Why four hours…" → "Why these timings…", rewritten for the
  new methodology with the actual run statistics. `src/config.rs`
  module-level + lifecycle/phase comments updated accordingly.
- Picks up a small `cargo fmt` drift in `src/chart.rs` and `src/led.rs`
  that had crept in under the 0.2.0 module split.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 06:32:17 -06:00
swaits 7effaa293b refactor: split 832-line main.rs into focused modules
config (constants), usb (driver + tasks), led (NeoPixel anims), mouse
(cursor anims + HID), kbd (F13 wake + HID), chart (statechart + Ctx/Ev
+ JigglyActions). main.rs is now just Irqs + USB builder + chart.run().

No behavior change — comments, deadlines, and tunables preserved verbatim.
Builds clean on default and --features embassy,defmt; clippy -D warnings
passes.
2026-05-04 18:56:27 -06:00
swaitsandClaude Opus 4.7 334666b6e7 chore: bump hsmc 0.5.1 → 0.6.0
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 15:53:11 -06:00
swaits 8cb071b132 feat: defmt-over-RTT debug build + probe-rs flow
Add an opt-in `defmt` feature that swaps panic-reset for panic-probe,
links defmt-rtt, and enables hsmc's `trace-defmt` so chart transitions
print over RTT. Default builds are unchanged (still panic-reset, no
defmt overhead).

- Cargo.toml: `defmt` feature pulls defmt + defmt-rtt + panic-probe
  and forwards `trace-defmt` to hsmc; mirror-flag pattern matches
  the existing `embassy` feature.
- build.rs: link `defmt.x` only when the feature is on.
- src/main.rs: cfg-gate the panic handler swap, log boot, USB
  serial, kbd/mouse wake completion vs. deadline, and jiggle dx/dy.
- main.rs: watchdog.pause_on_debug(true) so probe-rs halts don't
  trip the 8 s reset.
- justfile: build-debug, debug, attach, probes recipes
  (release mode — debug builds overflow flash with defmt+panic-probe).
- mise.toml: add cargo:probe-rs-tools.
2026-05-04 12:57:31 -06:00
20 changed files with 2072 additions and 937 deletions
+1
View File
@@ -1,2 +1,3 @@
/.cargo /.cargo
/target /target
/tuning/target
+49 -3
View File
@@ -7,6 +7,51 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [0.3.0] - 2026-05-07
### Added
- **`tuning/` crate** — a host-side multi-objective NSGA-III tuner for
jiggly's four lifecycle constants. Runs against published [`heuropt`][heuropt]
0.8 (with the `parallel` rayon feature) and prints the Pareto front,
extreme tradeoffs per objective, the firmware's current shipping
defaults, and a single weighted-rank recommendation. The crate is its
own workspace root with a local `.cargo/config.toml` overriding the
firmware's inherited `thumbv6m-none-eabi` build target so it can use
`std`. Invoked via `just tune` or `cargo run --release` from inside
`tuning/`.
### Changed
- **Lifecycle timings retuned** via the new tuner's 4-objective
(work-time failure, lunch sleep, presses, after-hours waste) Pareto
search, then collapsed by explicit decision weights. New shipping
values: **`RUN_DURATION` 4h00m → 3h51m**, **`YELLOW_AT` 30 → 22**,
**`RED_AT` 25 → 11**, **`FAST_RED_AT` 20 → 4** (LED thresholds in
minutes-remaining). The 0.2.0 single-composite-score grid had baked
the user's weight choices into the search itself; the new approach
surfaces the legitimate tradeoffs first and applies preferences
afterward. Across 1,000 simulated workdays the new combination
averages 26 minutes of lunch sleep, lands in the 12:1512:45 sweet
spot on ~57 % of days, with zero mean work-time failure and ~2 min/day
of after-hours waste. The 0.2.0 shipping defaults survive on the new
Pareto front but rank well below the new pick under the same weights.
- **`config.device_release` 0x0200 → 0x0300** — matches firmware
version 0.3.0.
- **README section heading "Why four hours…" → "Why these timings…"**,
rewritten to describe the new methodology, the four objectives, the
explicit decision weights, and the actual run statistics.
### Removed
- **`scripts/tune_runtime.py`** — the Python single-composite-score
grid search is superseded by the in-repo `tuning/` crate's NSGA-III
multi-objective search. The new tuner ships with the firmware, builds
reproducibly through `cargo`/`mise`, and is just a normal Rust
dependency on `heuropt` (no separate `uv` invocation).
[heuropt]: https://crates.io/crates/heuropt
## [0.2.0] - 2026-05-01 ## [0.2.0] - 2026-05-01
### Added ### Added
@@ -145,6 +190,7 @@ 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.2.0...HEAD [Unreleased]: https://git.swaits.com/swaits/jiggly/compare/v0.3.0...main
[0.2.0]: https://github.com/swaits/jiggly/compare/v0.1.0...v0.2.0 [0.3.0]: https://git.swaits.com/swaits/jiggly/compare/v0.2.0...v0.3.0
[0.1.0]: https://github.com/swaits/jiggly/releases/tag/v0.1.0 [0.2.0]: https://git.swaits.com/swaits/jiggly/compare/v0.1.0...v0.2.0
[0.1.0]: https://git.swaits.com/swaits/jiggly/releases/tag/v0.1.0
Generated
+98 -7
View File
@@ -86,6 +86,12 @@ version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d7e60934ceec538daadb9d8432424ed043a904d8e0243f3c6446bce549a46ac" checksum = "2d7e60934ceec538daadb9d8432424ed043a904d8e0243f3c6446bce549a46ac"
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.11.1" version = "2.11.1"
@@ -262,6 +268,57 @@ version = "0.3.13"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f578e8e2c440e7297e008bb5486a3a8a194775224bbc23729b0dbdfaeebf162e" checksum = "f578e8e2c440e7297e008bb5486a3a8a194775224bbc23729b0dbdfaeebf162e"
[[package]]
name = "defmt"
version = "0.3.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0963443817029b2024136fc4dd07a5107eb8f977eaf18fcd1fdeb11306b64ad"
dependencies = [
"defmt 1.0.1",
]
[[package]]
name = "defmt"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "548d977b6da32fa1d1fda2876453da1e7df63ad0304c8b3dae4dbe7b96f39b78"
dependencies = [
"bitflags 1.3.2",
"defmt-macros",
]
[[package]]
name = "defmt-macros"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d4fc12a85bcf441cfe44344c4b72d58493178ce635338a3f3b78943aceb258e"
dependencies = [
"defmt-parser",
"proc-macro-error2",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "defmt-parser"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
dependencies = [
"thiserror",
]
[[package]]
name = "defmt-rtt"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6eca0aae8aa2cf8333200ecbd236274697bc0a394765c858b3d9372eb1abcfa"
dependencies = [
"critical-section",
"defmt 0.3.100",
]
[[package]] [[package]]
name = "digest" name = "digest"
version = "0.10.7" version = "0.10.7"
@@ -472,7 +529,7 @@ version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a25746d8b152b72fbf2a217f489a083dbbe243f281f09184a1f2cfbe9bbb245f" checksum = "a25746d8b152b72fbf2a217f489a083dbbe243f281f09184a1f2cfbe9bbb245f"
dependencies = [ dependencies = [
"bitflags", "bitflags 2.11.1",
"embassy-futures", "embassy-futures",
"embassy-net-driver-channel", "embassy-net-driver-channel",
"embassy-sync", "embassy-sync",
@@ -718,10 +775,11 @@ dependencies = [
[[package]] [[package]]
name = "hsmc" name = "hsmc"
version = "0.5.1" version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "497ed9593e4baf6ef120b24aff80cb898f6832d32cdf01be9d90be0c06624e77" checksum = "18bb06fda289e60e17bc7483a222fc6d9d6865b92648f836242b7a79115cc91d"
dependencies = [ dependencies = [
"defmt 0.3.100",
"embassy-futures", "embassy-futures",
"embassy-sync", "embassy-sync",
"embassy-time", "embassy-time",
@@ -731,9 +789,9 @@ dependencies = [
[[package]] [[package]]
name = "hsmc-macros" name = "hsmc-macros"
version = "0.5.1" version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49d0b1413ed69ba168f9d0edbf4a8ea21569eaf352edf4e11335ae3d765ba004" checksum = "289a7c9f1b677168229c8ad4e992380c3951d0908b52b1d2bfae8ff3c980c35d"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -767,10 +825,12 @@ dependencies = [
[[package]] [[package]]
name = "jiggly" name = "jiggly"
version = "0.2.0" version = "0.3.0"
dependencies = [ dependencies = [
"cortex-m", "cortex-m",
"cortex-m-rt", "cortex-m-rt",
"defmt 0.3.100",
"defmt-rtt",
"embassy-executor", "embassy-executor",
"embassy-futures", "embassy-futures",
"embassy-rp", "embassy-rp",
@@ -779,6 +839,7 @@ dependencies = [
"embassy-usb", "embassy-usb",
"hsmc", "hsmc",
"libm", "libm",
"panic-probe",
"panic-reset", "panic-reset",
"portable-atomic", "portable-atomic",
"smart-leds", "smart-leds",
@@ -960,6 +1021,16 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "panic-probe"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4047d9235d1423d66cc97da7d07eddb54d4f154d6c13805c6d0793956f4f25b0"
dependencies = [
"cortex-m",
"defmt 0.3.100",
]
[[package]] [[package]]
name = "panic-reset" name = "panic-reset"
version = "0.1.1" version = "0.1.1"
@@ -1150,7 +1221,7 @@ version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [ dependencies = [
"bitflags", "bitflags 2.11.1",
] ]
[[package]] [[package]]
@@ -1420,6 +1491,26 @@ dependencies = [
"winapi-util", "winapi-util",
] ]
[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "thread_local" name = "thread_local"
version = "1.1.9" version = "1.1.9"
+30 -5
View File
@@ -1,22 +1,42 @@
[package] [package]
name = "jiggly" name = "jiggly"
version = "0.2.0" version = "0.3.0"
edition = "2024" edition = "2024"
authors = ["Stephen Waits <steve@waits.net>"] 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." 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" license = "MIT"
repository = "https://github.com/swaits/jiggly" repository = "https://git.swaits.com/swaits/jiggly"
readme = "README.md" readme = "README.md"
keywords = ["embedded", "rp2040", "usb-hid", "embassy", "no-std"] keywords = ["embedded", "rp2040", "usb-hid", "embassy", "no-std"]
categories = ["embedded", "no-std"] categories = ["embedded", "no-std"]
publish = false # firmware binary; not a crates.io library publish = false # firmware binary; not a crates.io library
[features] [features]
default = ["embassy"] default = ["embassy", "panic-reset"]
# `hsmc` emits `#[cfg(feature = "embassy")]` from inside `statechart!` and # `hsmc` emits `#[cfg(feature = "embassy")]` from inside `statechart!` and
# the cfg is evaluated in this crate's context, so we mirror the flag here # the cfg is evaluated in this crate's context, so we mirror the flag here
# and forward it to `hsmc/embassy`. # and forward it to `hsmc/embassy`.
embassy = ["hsmc/embassy"] embassy = ["hsmc/embassy"]
# Default panic handler — silently resets the chip. Mutually exclusive with
# `defmt` (which uses panic-probe). Build a defmt firmware with
# `cargo build --no-default-features --features embassy,defmt`.
panic-reset = ["dep:panic-reset"]
# Same trick as `embassy` above: `hsmc` emits `#[cfg(feature = "trace-defmt")]`
# from inside `__chart_observe!` (re-exported via `statechart!`), and that
# cfg is evaluated in *this* crate's feature set, not hsmc's. So we mirror
# the flag here and forward it to `hsmc/trace-defmt`. Without this mirror,
# `--features defmt` would compile cleanly but emit zero state-transition
# logs, because every defmt arm in __chart_observe! would be cfg'd out.
trace-defmt = ["hsmc/trace-defmt"]
# Debug build over RTT. Pulls in defmt + defmt-rtt, swaps the panic handler
# for panic-probe (so panics get printed instead of silently resetting), and
# turns on hsmc's chart/state/transition tracing.
defmt = [
"dep:defmt",
"dep:defmt-rtt",
"dep:panic-probe",
"trace-defmt",
]
[dependencies] [dependencies]
embassy-executor = { version = "0.10.0", features = ["platform-cortex-m", "executor-thread"] } embassy-executor = { version = "0.10.0", features = ["platform-cortex-m", "executor-thread"] }
@@ -25,18 +45,23 @@ embassy-rp = { version = "0.10.0", features = ["rp2040", "time-driver", "c
embassy-sync = "0.8" embassy-sync = "0.8"
embassy-futures = "0.1" embassy-futures = "0.1"
embassy-usb = "0.6.0" embassy-usb = "0.6.0"
hsmc = { version = "0.5.1", default-features = false } hsmc = { version = "0.6.0", default-features = false }
usbd-hid = "0.10.0" usbd-hid = "0.10.0"
smart-leds = "0.4" smart-leds = "0.4"
libm = "0.2" libm = "0.2"
cortex-m = { version = "0.7.6", features = ["inline-asm"] } cortex-m = { version = "0.7.6", features = ["inline-asm"] }
cortex-m-rt = "0.7.5" cortex-m-rt = "0.7.5"
panic-reset = "0.1" panic-reset = { version = "0.1", optional = true }
static_cell = "2.1" static_cell = "2.1"
# Cortex-M0+ has no hardware CAS; static_cell + heapless need this shim. # Cortex-M0+ has no hardware CAS; static_cell + heapless need this shim.
portable-atomic = { version = "1.13", features = ["critical-section"] } portable-atomic = { version = "1.13", features = ["critical-section"] }
# Defmt stack — only linked when the `defmt` feature is enabled.
defmt = { version = "0.3", optional = true }
defmt-rtt = { version = "0.4", optional = true }
panic-probe = { version = "0.3", features = ["print-defmt"], optional = true }
[lints.rust] [lints.rust]
unexpected_cfgs = { level = "allow", check-cfg = ['cfg(feature, values("tokio", "embassy"))'] } unexpected_cfgs = { level = "allow", check-cfg = ['cfg(feature, values("tokio", "embassy"))'] }
+35 -26
View File
@@ -14,7 +14,7 @@ Plugs into USB, presents as a composite mouse + keyboard HID device
wake any sleeping host. Mouse motion alone doesn't reliably wake 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 macOS; a key tap does. F13 is chosen because it's harmless if it ever
ends up stuck — no OS maps it by default. ends up stuck — no OS maps it by default.
- For the next four hours, nudges the cursor one pixel every 4½ minutes - For the next 3 h 51 m, nudges the cursor one pixel every 4½ minutes
so the host never falls asleep. so the host never falls asleep.
- Breathes the on-board NeoPixel green → yellow → red as time runs - Breathes the on-board NeoPixel green → yellow → red as time runs
down. Two on-screen "spiral" warnings fire 10 min and 5 min before down. Two on-screen "spiral" warnings fire 10 min and 5 min before
@@ -72,7 +72,7 @@ Active green→yellow→red breathing
A separate embassy task feeds the hardware watchdog every 5 s. A separate embassy task feeds the hardware watchdog every 5 s.
## Why four hours, and why those LED thresholds? ## Why these timings, and why those LED thresholds?
The point isn't to keep the screen awake forever. It's to keep it 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 awake while you're at your desk and let it sleep when you're not. The
@@ -90,9 +90,15 @@ That's a four-knob problem:
| `RED_AT` | minutes-remaining where breathing-red begins | | `RED_AT` | minutes-remaining where breathing-red begins |
| `FAST_RED_AT` | minutes-remaining where the fast-pulse-red blink 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 The `tuning/` crate solves it as a four-objective Pareto search
search over those four constants across 50 000 simulated workdays. using [`heuropt`][heuropt] and NSGA-III:
The model:
1. **minimize work-time failures** (screen sleeps while the user is at their desk)
2. **maximize lunch sleep**
3. **minimize button presses**
4. **minimize after-hours waste** (screen still awake past clock-out)
The user model:
- Workday start is `Triangular(8:00, mode 8:30, 9:30)`, end is - 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. `Triangular(16:00, mode 17:30, 19:00)`. Lunch is fixed at 12:0013:00.
@@ -102,34 +108,36 @@ The model:
fires. fires.
- Free `RESET` at boot and at 13:00 (re-login after lunch). - Free `RESET` at boot and at 13:00 (re-login after lunch).
The composite score rewards lunch-hour expiration (especially NSGA-III returns a Pareto front of ≈28 non-dominated points across
12:1512:45) and penalizes the screen sleeping while the user is at those four objectives — every one of them a legitimate tradeoff. To
their desk. pick a single recommendation the tuner applies explicit decision
weights (lunch_sleep 30 %, after_hours 25 %, work_fail 20 %, presses
The winner — and what the firmware ships: 15 %, balance 10 %) plus a press-count comfort cap. The pick — and
what the firmware ships:
``` ```
RUN_DURATION = 4h00m YELLOW_AT = 30 RED_AT = 25 FAST_RED_AT = 20 RUN_DURATION = 3h51m YELLOW_AT = 22 RED_AT = 11 FAST_RED_AT = 4
``` ```
(LED thresholds are minutes-remaining.) The screen sleeps somewhere (LED thresholds are minutes-remaining.) Across 1 000 simulated
during lunch on **~74 %** of simulated days and in the 12:1512:45 workdays this combination averages **26 minutes** of lunch sleep
sweet spot on **~52 %**. and lands in the 12:1512:45 sweet spot on **~57 %** of days, with
**zero** mean work-time failure and ~2 minutes/day of after-hours
waste at a cost of ~2.9 button presses/day.
The interesting result is that the obvious-looking `60 / 30 / 10` The interesting result is that **shorter warning phases are better**.
thresholds (long, gentle warning, urgent finish) ranked dead-average A long yellow phase gives you 30 minutes to glance up, notice the
out of 2 245 combos. Long visible warnings turn out to be LED, and tap `RESET` out of an abundance of caution — and a tap
counter-productive: a 30-minute yellow phase gives you 30 minutes to during yellow extends the cycle into the afternoon, the opposite of
glance up, notice the LED, and tap `RESET` — and a tap during yellow the goal. The Pareto-front winner runs an 11-minute yellow, a 7-
extends the cycle into the afternoon, the opposite of the goal. minute red, and a 4-minute fast-red: long enough to register the
Shrinking yellow and red to "long enough to notice, short enough not warning, short enough that the natural reaction is to wait it out.
to act on" pushes more days into a clean lunch death.
If your day looks different — different start/end distribution, If your day looks different — different start/end distribution,
different press habits, different lunch length — edit the constants different press habits, different lunch length — edit the model
and ranges at the top of `scripts/tune_runtime.py`, run it constants in `tuning/src/main.rs`, run `just tune` (or `cargo run
(`uv run scripts/tune_runtime.py`), and update the four values in --release` from inside `tuning/`), and update the four values in
`src/main.rs`. `src/config.rs`.
## USB identity ## USB identity
@@ -148,6 +156,7 @@ MIT — see [LICENSE](LICENSE).
[xiao]: https://wiki.seeedstudio.com/XIAO-RP2040/ [xiao]: https://wiki.seeedstudio.com/XIAO-RP2040/
[embassy]: https://embassy.dev/ [embassy]: https://embassy.dev/
[hsmc]: https://crates.io/crates/hsmc [hsmc]: https://crates.io/crates/hsmc
[heuropt]: https://crates.io/crates/heuropt
[mise]: https://mise.jdx.dev/ [mise]: https://mise.jdx.dev/
[just]: https://just.systems/ [just]: https://just.systems/
[pidcodes]: https://pid.codes/ [pidcodes]: https://pid.codes/
+7
View File
@@ -9,4 +9,11 @@ fn main() {
println!("cargo:rustc-link-search={}", out.display()); println!("cargo:rustc-link-search={}", out.display());
println!("cargo:rerun-if-changed=memory.x"); println!("cargo:rerun-if-changed=memory.x");
println!("cargo:rerun-if-changed=build.rs"); println!("cargo:rerun-if-changed=build.rs");
// defmt ships its own linker script (`defmt.x`) that defines the
// `_defmt_*` interner sections. Only link it when the feature is on —
// otherwise the symbols don't exist and `ld` will refuse the build.
if env::var_os("CARGO_FEATURE_DEFMT").is_some() {
println!("cargo:rustc-link-arg=-Tdefmt.x");
}
} }
+33
View File
@@ -7,6 +7,7 @@ firmware := "jiggly"
target := "thumbv6m-none-eabi" target := "thumbv6m-none-eabi"
base_addr := "0x10000000" base_addr := "0x10000000"
family_id := "0xE48BFF56" family_id := "0xE48BFF56"
chip := "RP2040"
out_release := "target" / target / "release" / firmware out_release := "target" / target / "release" / firmware
out_bin := "target" / target / "release" / firmware + ".bin" out_bin := "target" / target / "release" / firmware + ".bin"
@@ -99,6 +100,31 @@ flash: uf2
echo "Timed out waiting for XIAO UF2 volume. Copy {{ out_uf2 }} manually." >&2 echo "Timed out waiting for XIAO UF2 volume. Copy {{ out_uf2 }} manually." >&2
exit 1 exit 1
# Build the firmware with defmt-over-RTT enabled. Mutually exclusive with
# the default `panic-reset` feature, so we drop default features.
# DEFMT_LOG=trace overrides the `off` default in mise.toml so the macros
# actually emit. Built in release mode because thumbv6m debug builds plus
# defmt-rtt + panic-probe overflow flash quickly.
build-debug:
DEFMT_LOG=trace cargo build --release --no-default-features --features embassy,defmt
# Flash the defmt firmware via the attached SWD probe and tail RTT logs.
# Plug the probe into the four SWD pads on the back of the Xiao
# (SWCLK + SWDIO + GND — leave 3V3 disconnected so USB-C powers the board).
# The Xiao's own USB-C can stay plugged into a separate host the whole time;
# the SWD path and the USB path are independent.
debug: build-debug
probe-rs run --chip {{ chip }} {{ out_release }}
# Just tail RTT from a board that's already running the defmt firmware
# (no flash). Useful for rejoining a session after Ctrl-C without a reset.
attach:
probe-rs attach --chip {{ chip }} {{ out_release }}
# List attached debug probes — sanity check that the DAPLink shows up.
probes:
probe-rs list
# Install the toolchain and cargo helpers declared in mise.toml. # Install the toolchain and cargo helpers declared in mise.toml.
bootstrap: bootstrap:
mise install mise install
@@ -106,3 +132,10 @@ bootstrap:
# Show firmware file size summary. # Show firmware file size summary.
stats: uf2 stats: uf2
@ls -lh {{ out_release }} {{ out_bin }} {{ out_uf2 }} @ls -lh {{ out_release }} {{ out_bin }} {{ out_uf2 }}
# Run the multi-objective NSGA-III tuner against the published heuropt crate
# and print the recommended (RUN_DURATION, YELLOW_AT, RED_AT, FAST_RED_AT)
# pick. See README "Why these timings…" for the methodology and
# `tuning/src/main.rs` to edit the day model or weights.
tune:
cd tuning && cargo run --release
+1
View File
@@ -7,6 +7,7 @@ just = "latest"
"cargo:cargo-watch" = "latest" "cargo:cargo-watch" = "latest"
"cargo:cargo-bloat" = "latest" "cargo:cargo-bloat" = "latest"
"cargo:cargo-expand" = "latest" "cargo:cargo-expand" = "latest"
"cargo:probe-rs-tools" = "latest"
[env] [env]
CARGO_TARGET_DIR = "target" CARGO_TARGET_DIR = "target"
-271
View File
@@ -1,271 +0,0 @@
# /// 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()
+219
View File
@@ -0,0 +1,219 @@
//! The Jiggly hierarchical state machine: context, event enum, the
//! `statechart!` definition itself, and the entry-action impls
//! (the `during:` activities live in the `led` and `mouse` modules).
use embassy_rp::{clocks::RoscRng, gpio::Output};
use embassy_time::{Instant, Timer};
use hsmc::statechart;
use crate::config::{
BREATHE_PEAK, FLASH_DURATION, FLASH_PEAK, JIGGLE_PERIOD, KBD_PHASE_DURATION,
KBD_RELEASE_DEADLINE, KBD_WAKE_DEADLINE, MOUSE_PHASE_DURATION, MOUSE_WAKE_DEADLINE,
PIXEL_DWELL, QUIET_AFTER_ANIM, RUN_BEFORE_SHUTDOWN, SETTLING_DELAY, SHUTDOWN_FLASH_STEP,
WARN_5_AT, WARN_10_AT,
};
use crate::kbd::{KbdHid, send_kbd, wake_with_keyboard};
use crate::led::{
Neo, blink_fast_red, boot_sweep, breathe_color, fade_to_green, paint, pulse_blue, pulse_white,
};
use crate::mouse::{
MouseHid, animate_final_spiral, animate_spinner, animate_warning_5, animate_warning_10,
send_mouse, wake_with_mouse,
};
pub struct Ctx {
pub mouse: MouseHid,
pub kbd: KbdHid,
pub neo: Neo,
pub neo_pwr: Output<'static>,
pub active_start: Option<Instant>,
}
#[derive(Debug, Clone)]
pub enum Ev {
BootDone,
SpinDone,
Jiggled,
WarnDone,
SpiralDone,
}
statechart! {
Jiggly {
context: Ctx;
events: Ev;
default(Booting);
state Booting {
during: boot_sweep(neo, neo_pwr);
on(BootDone) => WakingHost;
}
// Wake the host using whichever input the host actually responds to.
// Keyboard first (more reliable on macOS), then a mouse shake as
// belt-and-suspenders. Each substate runs a oneshot entry action with
// its own internal deadline; the chart timer is what advances the
// chart. No durings, no events — purely entry + timer.
state WakingHost {
during: pulse_blue(neo);
default(WakingWithKeyboard);
state WakingWithKeyboard {
entry: keyboard_wake;
on(after KBD_PHASE_DURATION) => WakingWithMouse;
}
state WakingWithMouse {
entry: mouse_wake;
on(after MOUSE_PHASE_DURATION) => Settling;
}
}
// Quiet pause so the display has time to come out of sleep before the
// cursor starts drawing the spinner.
state Settling {
during: pulse_white(neo);
on(after SETTLING_DELAY) => Spinning;
}
state Spinning {
during: animate_spinner(mouse);
during: fade_to_green(neo);
on(SpinDone) => Active;
}
state Active {
entry: capture_active_start;
on(every JIGGLE_PERIOD) => jiggle_pair;
on(after WARN_10_AT) => Warning10;
on(after WARN_5_AT) => Warning5;
on(after RUN_BEFORE_SHUTDOWN) => Ending;
default(Breathing);
state Breathing {
during: breathe_color(neo, active_start);
on(Jiggled) => Flashing;
}
state Flashing {
entry: paint_white;
on(after FLASH_DURATION) => Breathing;
}
state Warning10 {
during: animate_warning_10(mouse);
on(WarnDone) => Breathing;
}
state Warning5 {
during: animate_warning_5(mouse);
on(WarnDone) => Breathing;
}
}
state Ending {
during: blink_fast_red(neo);
default(Spiraling);
state Spiraling {
during: animate_final_spiral(mouse);
on(SpiralDone) => Quiet;
}
state Quiet {
on(after QUIET_AFTER_ANIM) => PoweringDown;
}
}
state PoweringDown {
entry: shutdown_flashes;
entry: power_off_neo;
}
}
}
impl JigglyActions for JigglyActionContext<'_> {
async fn capture_active_start(&mut self) {
self.active_start = Some(Instant::now());
}
async fn keyboard_wake(&mut self) {
let result = embassy_futures::select::select(
wake_with_keyboard(&mut self.kbd),
Timer::after(KBD_WAKE_DEADLINE),
)
.await;
#[cfg(feature = "defmt")]
match result {
embassy_futures::select::Either::First(_) => defmt::info!("kbd wake: completed"),
embassy_futures::select::Either::Second(_) => {
defmt::warn!(
"kbd wake: deadline hit ({} ms)",
KBD_WAKE_DEADLINE.as_millis()
)
}
}
#[cfg(not(feature = "defmt"))]
let _ = result;
// 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 result = embassy_futures::select::select(
wake_with_mouse(&mut self.mouse),
Timer::after(MOUSE_WAKE_DEADLINE),
)
.await;
#[cfg(feature = "defmt")]
match result {
embassy_futures::select::Either::First(_) => defmt::info!("mouse wake: completed"),
embassy_futures::select::Either::Second(_) => defmt::warn!(
"mouse wake: deadline hit ({} ms)",
MOUSE_WAKE_DEADLINE.as_millis()
),
}
#[cfg(not(feature = "defmt"))]
let _ = result;
}
async fn jiggle_pair(&mut self) {
let (dx, dy): (i8, i8) = if (RoscRng::next_u8() & 1) == 0 {
(1, 0)
} else {
(0, 1)
};
#[cfg(feature = "defmt")]
defmt::info!("jiggle: dx={} dy={}", dx, dy);
send_mouse(&mut self.mouse, dx, dy).await;
Timer::after(PIXEL_DWELL).await;
send_mouse(&mut self.mouse, -dx, -dy).await;
let _ = self.emit(Ev::Jiggled);
}
async fn paint_white(&mut self) {
paint(&mut self.neo, FLASH_PEAK, FLASH_PEAK, FLASH_PEAK).await;
}
async fn shutdown_flashes(&mut self) {
for _ in 0..3 {
paint(&mut self.neo, 0, BREATHE_PEAK, 0).await;
Timer::after(SHUTDOWN_FLASH_STEP).await;
paint(&mut self.neo, 0, 0, 0).await;
Timer::after(SHUTDOWN_FLASH_STEP).await;
}
}
async fn power_off_neo(&mut self) {
self.neo_pwr.set_low();
}
}
+150
View File
@@ -0,0 +1,150 @@
//! Compile-time tuning constants — timing, geometry, brightness.
//!
//! Every magic number lives here so the rest of the firmware reads as
//! pure behaviour. Several values are joint-tuned by the `tuning/` crate
//! — see the README's "Why these timings…" section for the rationale.
use embassy_time::Duration as EDuration;
use hsmc::Duration;
// ── Lifecycle timing (statechart Durations) ────────────────────────
// 3h51m: a-posteriori pick from a 4-objective NSGA-III Pareto search
// over (RUN_DURATION, YELLOW_AT, RED_AT, FAST_RED_AT) — minimize work-
// time failures, maximize lunch sleep, minimize button presses, minimize
// after-hours waste. See the README's "Why these timings?" section and
// `tuning/` for the search.
pub(crate) const RUN_DURATION: Duration = Duration::from_mins(3 * 60 + 51);
pub(crate) const SHUTDOWN_LEAD: Duration = Duration::from_secs(30);
pub(crate) const RUN_BEFORE_SHUTDOWN: Duration = RUN_DURATION.saturating_sub(SHUTDOWN_LEAD);
pub(crate) const SHUTDOWN_ANIM_BUDGET: Duration = Duration::from_secs(5);
pub(crate) const QUIET_AFTER_ANIM: Duration = SHUTDOWN_LEAD.saturating_sub(SHUTDOWN_ANIM_BUDGET);
pub(crate) const JIGGLE_PERIOD: Duration = Duration::from_secs(270);
pub(crate) const FLASH_DURATION: Duration = Duration::from_millis(100);
// Phase boundaries — compared against time *remaining* in Active.
// Joint pick from the NSGA-III Pareto search; see the README's
// "Why these timings…" section for the rationale.
pub(crate) const YELLOW_AT: EDuration = EDuration::from_secs(22 * 60);
pub(crate) const RED_AT: EDuration = EDuration::from_secs(11 * 60);
pub(crate) const FAST_RED_AT: EDuration = EDuration::from_secs(4 * 60);
// LED breathing math
pub(crate) const LED_TICK: EDuration = EDuration::from_millis(20);
pub(crate) const SLOW_GREEN_PERIOD: EDuration = EDuration::from_secs(4);
pub(crate) const YELLOW_PERIOD: EDuration = EDuration::from_secs(3);
pub(crate) const RED_PERIOD: EDuration = EDuration::from_secs(2);
pub(crate) const FAST_RED_PERIOD: EDuration = EDuration::from_millis(500);
// Wake-up LED feedback periods. Blue on WakingHost (kbd+mouse), white on
// Settling, then a linear white→green fade across the Spinning duration so
// the LED hands off cleanly to Active's green breathing.
pub(crate) const WAKING_PULSE_PERIOD: EDuration = EDuration::from_millis(800);
pub(crate) const SETTLING_PULSE_PERIOD: EDuration = EDuration::from_millis(1200);
pub(crate) const SPINNER_FADE_DURATION: EDuration = EDuration::from_millis(600);
// LED brightness (raw WS2812 PWM, 0..=255).
pub(crate) const BREATHE_FLOOR: u8 = 1;
pub(crate) const BREATHE_PEAK: u8 = 16;
pub(crate) const FLASH_PEAK: u8 = 160;
// ── Animation parameters ───────────────────────────────────────────
// 8ms frames + HID poll_ms=8 means each frame's report actually reaches the
// host instead of being coalesced — at 16ms frames against a 60ms poll the
// shake looked sluggish because three reports out of four were dropped on the
// floor.
pub(crate) const ANIM_FRAME: EDuration = EDuration::from_millis(8);
// Frantic side-to-side, the gesture a person makes to wake a sleeping display.
// 10 full oscillations × 8 frames of full period = 80 frames × 8 ms = 640 ms,
// which works out to a ~12 Hz alternation — visibly "shaking", not "sweeping".
pub(crate) const WAKE_OSCILLATIONS: u32 = 10;
pub(crate) const WAKE_FRAMES_PER_HALF: u32 = 4;
pub(crate) const WAKE_AMPLITUDE: f32 = 60.0;
pub(crate) const WAKE_JITTER: f32 = 1.0;
// 2 s pause after the shake so the display has time to actually wake before
// we draw the spinner. The user wants this delay to live *here*, not before
// the shake.
pub(crate) const SETTLING_DELAY: Duration = Duration::from_secs(2);
// ── Keyboard wake (host-wake first pass) ───────────────────────────
// macOS often won't wake from raw HID mouse motion alone, but reliably wakes
// from any keyboard event. We tap **F13** four times before the mouse shake.
// F13F24 are intentionally unmapped on every mainstream OS, so even in the
// nightmare scenario where the deadline preempts the loop *between* a key-
// down and key-up report and the host ends up holding F13 forever, nothing
// visible happens — unlike with Shift, which would silently capitalise every
// keystroke from the user's real keyboard until they unplug the device.
// Earlier versions used Left Shift; that turned out to be exactly that
// nightmare scenario in practice.
pub(crate) const KBD_WAKE_TAPS: u32 = 4;
pub(crate) const KBD_TAP_HOLD: EDuration = EDuration::from_millis(30);
pub(crate) const KBD_TAP_GAP: EDuration = EDuration::from_millis(50);
// Hard internal deadline on the keyboard-wake entry action. The taps total
// ~320 ms so they finish well before this; the deadline only kicks in if a
// USB write blocks (e.g. the host hasn't bound the keyboard endpoint yet).
pub(crate) const KBD_WAKE_DEADLINE: EDuration = EDuration::from_millis(500);
// Statechart timer for the WakingWithKeyboard state — chosen above the
// internal deadline so the chart timer is what drives the transition out.
pub(crate) const KBD_PHASE_DURATION: Duration = Duration::from_millis(550);
// HID Keyboard usage page keycode for F13.
pub(crate) 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.
pub(crate) 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.
pub(crate) const MOUSE_WAKE_DEADLINE: EDuration = EDuration::from_millis(1000);
pub(crate) const MOUSE_PHASE_DURATION: Duration = Duration::from_millis(1050);
// Three quick clockwise circles read more clearly as "spinner / running"
// than one slow lap. 25 frames per circle × 3 × 8 ms = 600 ms total.
pub(crate) const RUN_RADIUS: f32 = 40.0;
pub(crate) const RUN_FRAMES_PER_CIRCLE: u32 = 25;
pub(crate) const RUN_CIRCLES: u32 = 3;
// Shared "spin-down" spiral. Both radius and angle are driven by an eased phase
// u(t) = t^EASE_POW with EASE_POW > 1 — slow at the start, ~EASE_POW× the
// average rate at the finish. Because radius and angle share u, the inward
// spiral and the rotation accelerate together: a coin/Euler-disk feel.
pub(crate) const EASE_POW: f32 = 2.5;
pub(crate) const SPIRAL_RADIUS_END: f32 = 2.0;
// Final spiral — the dramatic full version, 30 s before USB goes silent.
pub(crate) const FINAL_SPIRAL_RADIUS_START: f32 = 80.0;
pub(crate) const FINAL_SPIRAL_TURNS: f32 = 5.0;
pub(crate) const FINAL_SPIRAL_FRAMES: u32 = 625; // 5.0 s @ 8 ms/frame
// 5-min warning — medium escalation.
pub(crate) const WARN5_RADIUS_START: f32 = 50.0;
pub(crate) const WARN5_TURNS: f32 = 3.0;
pub(crate) const WARN5_FRAMES: u32 = 312; // ~2.5 s
// 10-min warning — small foreshadow.
pub(crate) const WARN10_RADIUS_START: f32 = 30.0;
pub(crate) const WARN10_TURNS: f32 = 2.0;
pub(crate) const WARN10_FRAMES: u32 = 187; // ~1.5 s
// Offsets from Active-entry. The hsmc parent timer rule: timers in a parent
// state start on parent entry and survive sibling-substate transitions, so
// these three `after`s race concurrently against the same epoch.
pub(crate) const WARN_10_AT: Duration = RUN_BEFORE_SHUTDOWN.saturating_sub(Duration::from_mins(10));
pub(crate) const WARN_5_AT: Duration = RUN_BEFORE_SHUTDOWN.saturating_sub(Duration::from_mins(5));
// ── Watchdog (independent task) ────────────────────────────────────
pub(crate) const WATCHDOG_TIMEOUT: EDuration = EDuration::from_secs(8);
pub(crate) const WATCHDOG_FEED_INTERVAL: EDuration = EDuration::from_secs(5);
// ── Jiggle dwell ───────────────────────────────────────────────────
pub(crate) const PIXEL_DWELL: EDuration = EDuration::from_millis(25);
// ── Boot LED sweep ─────────────────────────────────────────────────
// Brief power-on confirmation. Kept short so the cursor shake — the actual
// "wake the display" gesture — happens promptly after reset.
pub(crate) const BOOT_SWEEP_STEP: EDuration = EDuration::from_millis(60);
// ── Shutdown LED flashes ───────────────────────────────────────────
pub(crate) const SHUTDOWN_FLASH_STEP: EDuration = EDuration::from_millis(400);
+36
View File
@@ -0,0 +1,36 @@
//! Keyboard HID: report send + the F13-tap host-wake helper.
use embassy_time::{Duration as EDuration, Timer, with_timeout};
use embassy_usb::class::hid::HidWriter;
use usbd_hid::descriptor::KeyboardReport;
use crate::config::{KBD_KEY_F13, KBD_TAP_GAP, KBD_TAP_HOLD, KBD_WAKE_TAPS};
use crate::usb::UsbDriver;
pub(crate) type KbdHid = HidWriter<'static, UsbDriver, 8>;
// Tap F13 four times. macOS reliably wakes from any keyboard event but is
// inconsistent about waking from raw mouse motion. Plain oneshot helper —
// the action method that calls this races it against KBD_WAKE_DEADLINE
// AND unconditionally sends an all-keys-released cleanup report after,
// to make sure we never leave a key held on the host.
pub(crate) async fn wake_with_keyboard(kbd: &mut KbdHid) {
for _ in 0..KBD_WAKE_TAPS {
send_kbd(kbd, 0, [KBD_KEY_F13, 0, 0, 0, 0, 0]).await;
Timer::after(KBD_TAP_HOLD).await;
send_kbd(kbd, 0, [0; 6]).await;
Timer::after(KBD_TAP_GAP).await;
}
}
// ── HID primitive ──────────────────────────────────────────────────
pub(crate) async fn send_kbd(kbd: &mut KbdHid, modifier: u8, keycodes: [u8; 6]) {
let report = KeyboardReport {
modifier,
reserved: 0,
leds: 0,
keycodes,
};
let _ = with_timeout(EDuration::from_secs(3), kbd.write_serialize(&report)).await;
}
+156
View File
@@ -0,0 +1,156 @@
//! WS2812 NeoPixel: paint primitive, breath/blink math, and the
//! long-running `during` activities the statechart drives during
//! Booting / WakingHost / Settling / Spinning / Active / Ending.
use core::f32::consts::PI;
use embassy_rp::{
gpio::Output,
peripherals::PIO0,
pio_programs::ws2812::{Grb, PioWs2812},
};
use embassy_time::{Duration as EDuration, Instant, Timer};
use libm::cosf;
use smart_leds::RGB8;
use crate::chart::Ev;
use crate::config::{
BOOT_SWEEP_STEP, BREATHE_FLOOR, BREATHE_PEAK, FAST_RED_AT, FAST_RED_PERIOD, LED_TICK, RED_AT,
RED_PERIOD, RUN_BEFORE_SHUTDOWN, SETTLING_PULSE_PERIOD, SLOW_GREEN_PERIOD,
SPINNER_FADE_DURATION, WAKING_PULSE_PERIOD, YELLOW_AT, YELLOW_PERIOD,
};
pub(crate) type Neo = PioWs2812<'static, PIO0, 0, 1, Grb>;
// ── During activities (free async fns) ─────────────────────────────
pub(crate) async fn boot_sweep(neo: &mut Neo, neo_pwr: &mut Output<'static>) -> Ev {
neo_pwr.set_high();
Timer::after_millis(2).await;
paint(neo, BREATHE_PEAK, 0, 0).await;
Timer::after(BOOT_SWEEP_STEP).await;
paint(neo, 0, BREATHE_PEAK, 0).await;
Timer::after(BOOT_SWEEP_STEP).await;
paint(neo, 0, 0, BREATHE_PEAK).await;
Timer::after(BOOT_SWEEP_STEP).await;
paint(neo, 0, 0, 0).await;
Ev::BootDone
}
pub(crate) async fn breathe_color(neo: &mut Neo, active_start: &mut Option<Instant>) -> Ev {
loop {
let elapsed = active_start.map(|s| s.elapsed()).unwrap_or_default();
let total_run = run_before_shutdown_e();
let remaining = total_run
.checked_sub(elapsed)
.unwrap_or(EDuration::from_ticks(0));
let (r, g, b) = breathe_for(remaining, elapsed);
paint(neo, r, g, b).await;
Timer::after(LED_TICK).await;
}
}
pub(crate) async fn blink_fast_red(neo: &mut Neo) -> Ev {
let start = Instant::now();
loop {
let t = start.elapsed();
let level = if blink_on(FAST_RED_PERIOD, t) {
BREATHE_PEAK
} else {
BREATHE_FLOOR
};
paint(neo, level, 0, 0).await;
Timer::after(LED_TICK).await;
}
}
pub(crate) async fn pulse_blue(neo: &mut Neo) -> Ev {
let start = Instant::now();
loop {
let level = sin_breath(
WAKING_PULSE_PERIOD,
start.elapsed(),
BREATHE_FLOOR,
BREATHE_PEAK,
);
paint(neo, 0, 0, level).await;
Timer::after(LED_TICK).await;
}
}
pub(crate) async fn pulse_white(neo: &mut Neo) -> Ev {
let start = Instant::now();
loop {
let level = sin_breath(
SETTLING_PULSE_PERIOD,
start.elapsed(),
BREATHE_FLOOR,
BREATHE_PEAK,
);
paint(neo, level, level, level).await;
Timer::after(LED_TICK).await;
}
}
// Linear fade from white(peak) → green(peak) so the LED hands off into
// Active's green breathing without a visible jump.
pub(crate) async fn fade_to_green(neo: &mut Neo) -> Ev {
let start = Instant::now();
let total_ms = SPINNER_FADE_DURATION.as_millis() as f32;
loop {
let t = ((start.elapsed().as_millis() as f32) / total_ms).min(1.0);
let rb = (BREATHE_PEAK as f32 * (1.0 - t)) as u8;
paint(neo, rb, BREATHE_PEAK, rb).await;
Timer::after(LED_TICK).await;
}
}
// ── Color / breathing helpers ──────────────────────────────────────
fn run_before_shutdown_e() -> EDuration {
// RUN_BEFORE_SHUTDOWN is a `core::time::Duration`; convert to the embassy
// type once at the call site so the comparator below is apples-to-apples.
EDuration::from_secs(RUN_BEFORE_SHUTDOWN.as_secs())
}
fn breathe_for(remaining: EDuration, elapsed: EDuration) -> (u8, u8, u8) {
if remaining > YELLOW_AT {
let level = sin_breath(SLOW_GREEN_PERIOD, elapsed, BREATHE_FLOOR, BREATHE_PEAK);
(0, level, 0)
} else if remaining > RED_AT {
// Green is perceptually brighter on WS2812 — scale it down for a warm yellow.
let level = sin_breath(YELLOW_PERIOD, elapsed, BREATHE_FLOOR, BREATHE_PEAK);
(level, ((level as u16 * 5) / 10) as u8, 0)
} else if remaining > FAST_RED_AT {
let level = sin_breath(RED_PERIOD, elapsed, BREATHE_FLOOR, BREATHE_PEAK);
(level, 0, 0)
} else {
let level = if blink_on(FAST_RED_PERIOD, elapsed) {
BREATHE_PEAK
} else {
BREATHE_FLOOR
};
(level, 0, 0)
}
}
fn sin_breath(period: EDuration, t: EDuration, floor: u8, peak: u8) -> u8 {
let period_ms = period.as_millis() as f32;
let t_ms = (t.as_millis() % period.as_millis()) as f32;
let phase = 2.0 * PI * t_ms / period_ms;
let val = (1.0 - cosf(phase)) * 0.5;
let span = peak.saturating_sub(floor) as f32;
floor + (val * span) as u8
}
fn blink_on(period: EDuration, t: EDuration) -> bool {
let period_ms = period.as_millis();
(t.as_millis() % period_ms) < (period_ms / 2)
}
// ── LED primitive ──────────────────────────────────────────────────
pub(crate) async fn paint(neo: &mut Neo, r: u8, g: u8, b: u8) {
neo.write(&[RGB8 { r, g, b }]).await;
}
+35 -625
View File
@@ -1,33 +1,40 @@
#![no_std] #![no_std]
#![no_main] #![no_main]
use core::f32::consts::PI;
use embassy_executor::Spawner; use embassy_executor::Spawner;
use embassy_rp::{ use embassy_rp::{
Peri, bind_interrupts, bind_interrupts, dma,
clocks::RoscRng,
dma,
flash::Flash,
gpio::{Level, Output}, gpio::{Level, Output},
peripherals::{DMA_CH0, FLASH, PIO0, USB}, peripherals::{DMA_CH0, PIO0, USB},
pio::{InterruptHandler as PioInterruptHandler, Pio}, pio::{InterruptHandler as PioInterruptHandler, Pio},
pio_programs::ws2812::{Grb, PioWs2812, PioWs2812Program}, pio_programs::ws2812::{PioWs2812, PioWs2812Program},
usb::{Driver, InterruptHandler as UsbInterruptHandler}, usb::{Driver, InterruptHandler as UsbInterruptHandler},
watchdog::Watchdog, watchdog::Watchdog,
}; };
use embassy_sync::{blocking_mutex::raw::CriticalSectionRawMutex, channel::Channel}; use embassy_sync::{blocking_mutex::raw::CriticalSectionRawMutex, channel::Channel};
use embassy_time::{Duration as EDuration, Instant, Timer, with_timeout}; use embassy_time::Timer;
use embassy_usb::{ use embassy_usb::{
Builder, Config as UsbConfig, UsbDevice, Builder, Config as UsbConfig,
class::hid::{Config as HidConfig, HidBootProtocol, HidSubclass, HidWriter, State}, class::hid::{Config as HidConfig, HidBootProtocol, HidSubclass, HidWriter, State},
}; };
use hsmc::{Duration, statechart}; #[cfg(not(feature = "defmt"))]
use libm::{cosf, powf, roundf, sinf};
use panic_reset as _; use panic_reset as _;
use smart_leds::RGB8;
use static_cell::StaticCell; use static_cell::StaticCell;
use usbd_hid::descriptor::{KeyboardReport, MouseReport, SerializedDescriptor}; use usbd_hid::descriptor::{KeyboardReport, MouseReport, SerializedDescriptor};
#[cfg(feature = "defmt")]
use {defmt_rtt as _, panic_probe as _};
mod chart;
mod config;
mod kbd;
mod led;
mod mouse;
mod usb;
use chart::{Ctx, Ev, Jiggly};
use config::{WATCHDOG_FEED_INTERVAL, WATCHDOG_TIMEOUT};
use led::Neo;
use usb::{make_serial, usb_task, watchdog_task};
bind_interrupts!(struct Irqs { bind_interrupts!(struct Irqs {
USBCTRL_IRQ => UsbInterruptHandler<USB>; USBCTRL_IRQ => UsbInterruptHandler<USB>;
@@ -35,621 +42,18 @@ bind_interrupts!(struct Irqs {
DMA_IRQ_0 => dma::InterruptHandler<DMA_CH0>; DMA_IRQ_0 => dma::InterruptHandler<DMA_CH0>;
}); });
type UsbDriver = Driver<'static, USB>;
type Neo = PioWs2812<'static, PIO0, 0, 1, Grb>;
type MouseHid = HidWriter<'static, UsbDriver, 5>;
type KbdHid = HidWriter<'static, UsbDriver, 8>;
// ── Lifecycle timing (statechart Durations) ────────────────────────
// 4h00m: optimum from the 4-D Monte Carlo (RUN_DURATION × YELLOW_AT ×
// RED_AT × FAST_RED_AT) over a typical office workday distribution
// with a per-minute press-on-warning user model. Lands the screen-sleep
// in the 12: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 RUN_BEFORE_SHUTDOWN: Duration = RUN_DURATION.saturating_sub(SHUTDOWN_LEAD);
const SHUTDOWN_ANIM_BUDGET: Duration = Duration::from_secs(5);
const QUIET_AFTER_ANIM: Duration = SHUTDOWN_LEAD.saturating_sub(SHUTDOWN_ANIM_BUDGET);
const JIGGLE_PERIOD: Duration = Duration::from_secs(270);
const FLASH_DURATION: Duration = Duration::from_millis(100);
// Phase boundaries — compared against time *remaining* in Active.
// Joint optimum from `scripts/tune_runtime.py`. Yellow and red are
// kept deliberately short (5 min each); the long phase is fast-red.
// See the README's "Why four hours…" section for the rationale.
const YELLOW_AT: EDuration = EDuration::from_secs(30 * 60);
const RED_AT: EDuration = EDuration::from_secs(25 * 60);
const FAST_RED_AT: EDuration = EDuration::from_secs(20 * 60);
// LED breathing math
const LED_TICK: EDuration = EDuration::from_millis(20);
const SLOW_GREEN_PERIOD: EDuration = EDuration::from_secs(4);
const YELLOW_PERIOD: EDuration = EDuration::from_secs(3);
const RED_PERIOD: EDuration = EDuration::from_secs(2);
const FAST_RED_PERIOD: EDuration = EDuration::from_millis(500);
// LED brightness (raw WS2812 PWM, 0..=255).
const BREATHE_FLOOR: u8 = 1;
const BREATHE_PEAK: u8 = 16;
const FLASH_PEAK: u8 = 160;
// ── Animation parameters ───────────────────────────────────────────
// 8ms frames + HID poll_ms=8 means each frame's report actually reaches the
// host instead of being coalesced — at 16ms frames against a 60ms poll the
// shake looked sluggish because three reports out of four were dropped on the
// floor.
const ANIM_FRAME: EDuration = EDuration::from_millis(8);
// Frantic side-to-side, the gesture a person makes to wake a sleeping display.
// 10 full oscillations × 8 frames of full period = 80 frames × 8 ms = 640 ms,
// which works out to a ~12 Hz alternation — visibly "shaking", not "sweeping".
const WAKE_OSCILLATIONS: u32 = 10;
const WAKE_FRAMES_PER_HALF: u32 = 4;
const WAKE_AMPLITUDE: f32 = 60.0;
const WAKE_JITTER: f32 = 1.0;
// 2 s pause after the shake so the display has time to actually wake before
// we draw the spinner. The user wants this delay to live *here*, not before
// the shake.
const SETTLING_DELAY: Duration = Duration::from_secs(2);
// ── Keyboard wake (host-wake first pass) ───────────────────────────
// macOS often won't wake from raw HID mouse motion alone, but reliably wakes
// from any keyboard event. We tap **F13** four times before the mouse shake.
// F13F24 are intentionally unmapped on every mainstream OS, so even in the
// nightmare scenario where the deadline preempts the loop *between* a key-
// down and key-up report and the host ends up holding F13 forever, nothing
// visible happens — unlike with Shift, which would silently capitalise every
// keystroke from the user's real keyboard until they unplug the device.
// Earlier versions used Left Shift; that turned out to be exactly that
// nightmare scenario in practice.
const KBD_WAKE_TAPS: u32 = 4;
const KBD_TAP_HOLD: EDuration = EDuration::from_millis(30);
const KBD_TAP_GAP: EDuration = EDuration::from_millis(50);
// Hard internal deadline on the keyboard-wake entry action. The taps total
// ~320 ms so they finish well before this; the deadline only kicks in if a
// USB write blocks (e.g. the host hasn't bound the keyboard endpoint yet).
const KBD_WAKE_DEADLINE: EDuration = EDuration::from_millis(500);
// Statechart timer for the WakingWithKeyboard state — chosen above the
// internal deadline so the chart timer is what drives the transition out.
const KBD_PHASE_DURATION: Duration = Duration::from_millis(550);
// HID Keyboard usage page keycode for F13.
const KBD_KEY_F13: u8 = 0x68;
// Final-cleanup deadline — the all-keys-released report we send after the
// main work loop is bounded by this so a misbehaving endpoint can't pin
// the chart. Best-effort; if it doesn't land we tried.
const KBD_RELEASE_DEADLINE: EDuration = EDuration::from_millis(100);
// ── Mouse wake (host-wake second pass) ─────────────────────────────
// Internal deadline on the mouse-shake entry action — the shake itself takes
// ~640 ms; the cap exists so a misbehaving USB endpoint can't pin the chart.
const MOUSE_WAKE_DEADLINE: EDuration = EDuration::from_millis(1000);
const MOUSE_PHASE_DURATION: Duration = Duration::from_millis(1050);
// Three quick clockwise circles read more clearly as "spinner / running"
// than one slow lap. 25 frames per circle × 3 × 8 ms = 600 ms total.
const RUN_RADIUS: f32 = 40.0;
const RUN_FRAMES_PER_CIRCLE: u32 = 25;
const RUN_CIRCLES: u32 = 3;
// Shared "spin-down" spiral. Both radius and angle are driven by an eased phase
// u(t) = t^EASE_POW with EASE_POW > 1 — slow at the start, ~EASE_POW× the
// average rate at the finish. Because radius and angle share u, the inward
// spiral and the rotation accelerate together: a coin/Euler-disk feel.
const EASE_POW: f32 = 2.5;
const SPIRAL_RADIUS_END: f32 = 2.0;
// Final spiral — the dramatic full version, 30 s before USB goes silent.
const FINAL_SPIRAL_RADIUS_START: f32 = 80.0;
const FINAL_SPIRAL_TURNS: f32 = 5.0;
const FINAL_SPIRAL_FRAMES: u32 = 625; // 5.0 s @ 8 ms/frame
// 5-min warning — medium escalation.
const WARN5_RADIUS_START: f32 = 50.0;
const WARN5_TURNS: f32 = 3.0;
const WARN5_FRAMES: u32 = 312; // ~2.5 s
// 10-min warning — small foreshadow.
const WARN10_RADIUS_START: f32 = 30.0;
const WARN10_TURNS: f32 = 2.0;
const WARN10_FRAMES: u32 = 187; // ~1.5 s
// Offsets from Active-entry. The hsmc parent timer rule: timers in a parent
// state start on parent entry and survive sibling-substate transitions, so
// these three `after`s race concurrently against the same epoch.
const WARN_10_AT: Duration = RUN_BEFORE_SHUTDOWN.saturating_sub(Duration::from_mins(10));
const WARN_5_AT: Duration = RUN_BEFORE_SHUTDOWN.saturating_sub(Duration::from_mins(5));
// ── Watchdog (independent task) ────────────────────────────────────
const WATCHDOG_TIMEOUT: EDuration = EDuration::from_secs(8);
const WATCHDOG_FEED_INTERVAL: EDuration = EDuration::from_secs(5);
// ── Jiggle dwell ───────────────────────────────────────────────────
const PIXEL_DWELL: EDuration = EDuration::from_millis(25);
// ── Boot LED sweep ─────────────────────────────────────────────────
// Brief power-on confirmation. Kept short so the cursor shake — the actual
// "wake the display" gesture — happens promptly after reset.
const BOOT_SWEEP_STEP: EDuration = EDuration::from_millis(60);
// ── Shutdown LED flashes ───────────────────────────────────────────
const SHUTDOWN_FLASH_STEP: EDuration = EDuration::from_millis(400);
pub struct Ctx {
pub mouse: MouseHid,
pub kbd: KbdHid,
pub neo: Neo,
pub neo_pwr: Output<'static>,
pub active_start: Option<Instant>,
}
#[derive(Debug, Clone)]
pub enum Ev {
BootDone,
SpinDone,
Jiggled,
WarnDone,
SpiralDone,
}
statechart! {
Jiggly {
context: Ctx;
events: Ev;
default(Booting);
state Booting {
during: boot_sweep(neo, neo_pwr);
on(BootDone) => WakingHost;
}
// Wake the host using whichever input the host actually responds to.
// Keyboard first (more reliable on macOS), then a mouse shake as
// belt-and-suspenders. Each substate runs a oneshot entry action with
// its own internal deadline; the chart timer is what advances the
// chart. No durings, no events — purely entry + timer.
state WakingHost {
default(WakingWithKeyboard);
state WakingWithKeyboard {
entry: keyboard_wake;
on(after KBD_PHASE_DURATION) => WakingWithMouse;
}
state WakingWithMouse {
entry: mouse_wake;
on(after MOUSE_PHASE_DURATION) => Settling;
}
}
// Quiet pause so the display has time to come out of sleep before the
// cursor starts drawing the spinner.
state Settling {
on(after SETTLING_DELAY) => Spinning;
}
state Spinning {
during: animate_spinner(mouse);
on(SpinDone) => Active;
}
state Active {
entry: capture_active_start;
on(every JIGGLE_PERIOD) => jiggle_pair;
on(after WARN_10_AT) => Warning10;
on(after WARN_5_AT) => Warning5;
on(after RUN_BEFORE_SHUTDOWN) => Ending;
default(Breathing);
state Breathing {
during: breathe_color(neo, active_start);
on(Jiggled) => Flashing;
}
state Flashing {
entry: paint_white;
on(after FLASH_DURATION) => Breathing;
}
state Warning10 {
during: animate_warning_10(mouse);
on(WarnDone) => Breathing;
}
state Warning5 {
during: animate_warning_5(mouse);
on(WarnDone) => Breathing;
}
}
state Ending {
during: blink_fast_red(neo);
default(Spiraling);
state Spiraling {
during: animate_final_spiral(mouse);
on(SpiralDone) => Quiet;
}
state Quiet {
on(after QUIET_AFTER_ANIM) => PoweringDown;
}
}
state PoweringDown {
entry: shutdown_flashes;
entry: power_off_neo;
}
}
}
impl JigglyActions for JigglyActionContext<'_> {
async fn capture_active_start(&mut self) {
self.active_start = Some(Instant::now());
}
async fn keyboard_wake(&mut self) {
let _ = embassy_futures::select::select(
wake_with_keyboard(&mut self.kbd),
Timer::after(KBD_WAKE_DEADLINE),
)
.await;
// Belt-and-suspenders: always send an all-keys-released report,
// even if the deadline preempted the loop *between* a key-down
// and its key-up. Without this, a stuck modifier (Shift!) or key
// on the host side could persist until the user unplugs the
// device. Bounded by KBD_RELEASE_DEADLINE so a misbehaving
// endpoint can't pin the chart.
let _ = embassy_futures::select::select(
send_kbd(&mut self.kbd, 0, [0; 6]),
Timer::after(KBD_RELEASE_DEADLINE),
)
.await;
}
async fn mouse_wake(&mut self) {
let _ = embassy_futures::select::select(
wake_with_mouse(&mut self.mouse),
Timer::after(MOUSE_WAKE_DEADLINE),
)
.await;
}
async fn jiggle_pair(&mut self) {
let (dx, dy): (i8, i8) = if (RoscRng::next_u8() & 1) == 0 {
(1, 0)
} else {
(0, 1)
};
send_mouse(&mut self.mouse, dx, dy).await;
Timer::after(PIXEL_DWELL).await;
send_mouse(&mut self.mouse, -dx, -dy).await;
let _ = self.emit(Ev::Jiggled);
}
async fn paint_white(&mut self) {
paint(&mut self.neo, FLASH_PEAK, FLASH_PEAK, FLASH_PEAK).await;
}
async fn shutdown_flashes(&mut self) {
for _ in 0..3 {
paint(&mut self.neo, 0, BREATHE_PEAK, 0).await;
Timer::after(SHUTDOWN_FLASH_STEP).await;
paint(&mut self.neo, 0, 0, 0).await;
Timer::after(SHUTDOWN_FLASH_STEP).await;
}
}
async fn power_off_neo(&mut self) {
self.neo_pwr.set_low();
}
}
// ── During activities (free async fns) ─────────────────────────────
async fn boot_sweep(neo: &mut Neo, neo_pwr: &mut Output<'static>) -> Ev {
neo_pwr.set_high();
Timer::after_millis(2).await;
paint(neo, BREATHE_PEAK, 0, 0).await;
Timer::after(BOOT_SWEEP_STEP).await;
paint(neo, 0, BREATHE_PEAK, 0).await;
Timer::after(BOOT_SWEEP_STEP).await;
paint(neo, 0, 0, BREATHE_PEAK).await;
Timer::after(BOOT_SWEEP_STEP).await;
paint(neo, 0, 0, 0).await;
Ev::BootDone
}
// Tap F13 four times. macOS reliably wakes from any keyboard event but is
// inconsistent about waking from raw mouse motion. Plain oneshot helper —
// the action method that calls this races it against KBD_WAKE_DEADLINE
// AND unconditionally sends an all-keys-released cleanup report after,
// to make sure we never leave a key held on the host.
async fn wake_with_keyboard(kbd: &mut KbdHid) {
for _ in 0..KBD_WAKE_TAPS {
send_kbd(kbd, 0, [KBD_KEY_F13, 0, 0, 0, 0, 0]).await;
Timer::after(KBD_TAP_HOLD).await;
send_kbd(kbd, 0, [0; 6]).await;
Timer::after(KBD_TAP_GAP).await;
}
}
// Frantic horizontal mouse shake. Plain oneshot helper — the action method
// that calls this races it against MOUSE_WAKE_DEADLINE.
async fn wake_with_mouse(mouse: &mut MouseHid) {
let period_frames = (WAKE_FRAMES_PER_HALF * 2) as f32;
let total_frames = WAKE_OSCILLATIONS * WAKE_FRAMES_PER_HALF * 2;
let mut prev_x: f32 = 0.0;
let mut prev_y: f32 = 0.0;
let mut acc_x: f32 = 0.0;
let mut acc_y: f32 = 0.0;
for f in 0..total_frames {
let phase = (f as f32) * 2.0 * PI / period_frames;
let next_x = WAKE_AMPLITUDE * sinf(phase);
let jitter = ((RoscRng::next_u8() as f32) / 255.0 - 0.5) * 2.0 * WAKE_JITTER;
let next_y = jitter;
let (dx, dy, used_x, used_y) = step_delta(prev_x, prev_y, next_x, next_y, acc_x, acc_y);
acc_x = used_x;
acc_y = used_y;
send_mouse(mouse, dx, dy).await;
prev_x = next_x;
prev_y = next_y;
Timer::after(ANIM_FRAME).await;
}
}
async fn animate_spinner(mouse: &mut MouseHid) -> Ev {
let total_frames = RUN_CIRCLES * RUN_FRAMES_PER_CIRCLE;
let period_frames = RUN_FRAMES_PER_CIRCLE as f32;
let mut prev_x: f32 = 0.0;
let mut prev_y: f32 = 0.0;
let mut acc_x: f32 = 0.0;
let mut acc_y: f32 = 0.0;
for f in 0..total_frames {
let angle = (f as f32) * 2.0 * PI / period_frames;
let next_x = RUN_RADIUS * sinf(angle);
let next_y = RUN_RADIUS * (1.0 - cosf(angle));
let (dx, dy, used_x, used_y) = step_delta(prev_x, prev_y, next_x, next_y, acc_x, acc_y);
acc_x = used_x;
acc_y = used_y;
send_mouse(mouse, dx, dy).await;
prev_x = next_x;
prev_y = next_y;
Timer::after(ANIM_FRAME).await;
}
Ev::SpinDone
}
async fn animate_spiral(
mouse: &mut MouseHid,
radius_start: f32,
radius_end: f32,
turns: f32,
frames: u32,
) {
let mut prev_x: f32 = 0.0;
let mut prev_y: f32 = 0.0;
let mut acc_x: f32 = 0.0;
let mut acc_y: f32 = 0.0;
for f in 0..frames {
let t = (f as f32) / (frames as f32);
let u = powf(t, EASE_POW);
let angle = u * 2.0 * PI * turns;
let radius = radius_start + (radius_end - radius_start) * u;
// Subtract starting offset so the spiral begins at the cursor's entry point.
let next_x = radius * cosf(angle) - radius_start;
let next_y = radius * sinf(angle);
let (dx, dy, used_x, used_y) = step_delta(prev_x, prev_y, next_x, next_y, acc_x, acc_y);
acc_x = used_x;
acc_y = used_y;
send_mouse(mouse, dx, dy).await;
prev_x = next_x;
prev_y = next_y;
Timer::after(ANIM_FRAME).await;
}
}
async fn animate_final_spiral(mouse: &mut MouseHid) -> Ev {
animate_spiral(
mouse,
FINAL_SPIRAL_RADIUS_START,
SPIRAL_RADIUS_END,
FINAL_SPIRAL_TURNS,
FINAL_SPIRAL_FRAMES,
)
.await;
Ev::SpiralDone
}
async fn animate_warning_5(mouse: &mut MouseHid) -> Ev {
animate_spiral(
mouse,
WARN5_RADIUS_START,
SPIRAL_RADIUS_END,
WARN5_TURNS,
WARN5_FRAMES,
)
.await;
Ev::WarnDone
}
async fn animate_warning_10(mouse: &mut MouseHid) -> Ev {
animate_spiral(
mouse,
WARN10_RADIUS_START,
SPIRAL_RADIUS_END,
WARN10_TURNS,
WARN10_FRAMES,
)
.await;
Ev::WarnDone
}
async fn breathe_color(neo: &mut Neo, active_start: &mut Option<Instant>) -> Ev {
loop {
let elapsed = active_start.map(|s| s.elapsed()).unwrap_or_default();
let total_run = run_before_shutdown_e();
let remaining = total_run
.checked_sub(elapsed)
.unwrap_or(EDuration::from_ticks(0));
let (r, g, b) = breathe_for(remaining, elapsed);
paint(neo, r, g, b).await;
Timer::after(LED_TICK).await;
}
}
async fn blink_fast_red(neo: &mut Neo) -> Ev {
let start = Instant::now();
loop {
let t = start.elapsed();
let level = if blink_on(FAST_RED_PERIOD, t) {
BREATHE_PEAK
} else {
BREATHE_FLOOR
};
paint(neo, level, 0, 0).await;
Timer::after(LED_TICK).await;
}
}
// ── Color / breathing helpers ──────────────────────────────────────
fn run_before_shutdown_e() -> EDuration {
// RUN_BEFORE_SHUTDOWN is a `core::time::Duration`; convert to the embassy
// type once at the call site so the comparator below is apples-to-apples.
EDuration::from_secs(RUN_BEFORE_SHUTDOWN.as_secs())
}
fn breathe_for(remaining: EDuration, elapsed: EDuration) -> (u8, u8, u8) {
if remaining > YELLOW_AT {
let level = sin_breath(SLOW_GREEN_PERIOD, elapsed, BREATHE_FLOOR, BREATHE_PEAK);
(0, level, 0)
} else if remaining > RED_AT {
// Green is perceptually brighter on WS2812 — scale it down for a warm yellow.
let level = sin_breath(YELLOW_PERIOD, elapsed, BREATHE_FLOOR, BREATHE_PEAK);
(level, ((level as u16 * 5) / 10) as u8, 0)
} else if remaining > FAST_RED_AT {
let level = sin_breath(RED_PERIOD, elapsed, BREATHE_FLOOR, BREATHE_PEAK);
(level, 0, 0)
} else {
let level = if blink_on(FAST_RED_PERIOD, elapsed) {
BREATHE_PEAK
} else {
BREATHE_FLOOR
};
(level, 0, 0)
}
}
fn sin_breath(period: EDuration, t: EDuration, floor: u8, peak: u8) -> u8 {
let period_ms = period.as_millis() as f32;
let t_ms = (t.as_millis() % period.as_millis()) as f32;
let phase = 2.0 * PI * t_ms / period_ms;
let val = (1.0 - cosf(phase)) * 0.5;
let span = peak.saturating_sub(floor) as f32;
floor + (val * span) as u8
}
fn blink_on(period: EDuration, t: EDuration) -> bool {
let period_ms = period.as_millis();
(t.as_millis() % period_ms) < (period_ms / 2)
}
// Translate continuous (next_x, next_y) target into clamped i8 deltas while
// carrying sub-pixel residue forward, so a 90-frame circle of radius 50 doesn't
// lose ~half its motion to truncation. Returns (dx, dy, residual_x, residual_y).
fn step_delta(
prev_x: f32,
prev_y: f32,
next_x: f32,
next_y: f32,
acc_x: f32,
acc_y: f32,
) -> (i8, i8, f32, f32) {
let want_x = (next_x - prev_x) + acc_x;
let want_y = (next_y - prev_y) + acc_y;
let dx = roundf(want_x.clamp(-127.0, 127.0)) as i8;
let dy = roundf(want_y.clamp(-127.0, 127.0)) as i8;
(dx, dy, want_x - dx as f32, want_y - dy as f32)
}
// ── HID + LED primitives ───────────────────────────────────────────
async fn send_mouse(mouse: &mut MouseHid, x: i8, y: i8) {
let report = MouseReport {
buttons: 0,
x,
y,
wheel: 0,
pan: 0,
};
let _ = with_timeout(EDuration::from_secs(3), mouse.write_serialize(&report)).await;
}
async fn send_kbd(kbd: &mut KbdHid, modifier: u8, keycodes: [u8; 6]) {
let report = KeyboardReport {
modifier,
reserved: 0,
leds: 0,
keycodes,
};
let _ = with_timeout(EDuration::from_secs(3), kbd.write_serialize(&report)).await;
}
async fn paint(neo: &mut Neo, r: u8, g: u8, b: u8) {
neo.write(&[RGB8 { r, g, b }]).await;
}
// ── USB serial number from RP2040 unique chip ID ───────────────────
// Reads the 64-bit unique ID baked into the on-board SPI flash, formats
// it as 16 ASCII hex chars in a static buffer, and returns a `'static`
// string suitable for `embassy_usb::Config::serial_number`. This makes
// the device's USB identity stable per-board across replugs (so hosts
// stop treating each plug as a new device) while still being unique
// between different boards.
const FLASH_SIZE: usize = 2 * 1024 * 1024; // Xiao RP2040 has 2 MB.
fn make_serial(flash_periph: Peri<'static, FLASH>) -> &'static str {
static SERIAL: StaticCell<[u8; 16]> = StaticCell::new();
const HEX: &[u8; 16] = b"0123456789ABCDEF";
let mut flash = Flash::<_, _, FLASH_SIZE>::new_blocking(flash_periph);
let mut id = [0u8; 8];
let _ = flash.blocking_unique_id(&mut id);
let buf = SERIAL.init([0; 16]);
for (i, &b) in id.iter().enumerate() {
buf[i * 2] = HEX[(b >> 4) as usize];
buf[i * 2 + 1] = HEX[(b & 0x0f) as usize];
}
core::str::from_utf8(buf).unwrap()
}
// ── Tasks ──────────────────────────────────────────────────────────
#[embassy_executor::task]
async fn usb_task(mut device: UsbDevice<'static, UsbDriver>) {
device.run().await;
}
#[embassy_executor::task]
async fn watchdog_task(mut wd: Watchdog) -> ! {
loop {
wd.feed(WATCHDOG_TIMEOUT);
Timer::after(WATCHDOG_FEED_INTERVAL).await;
}
}
// ── Main ───────────────────────────────────────────────────────────
#[embassy_executor::main] #[embassy_executor::main]
async fn main(spawner: Spawner) { async fn main(spawner: Spawner) {
let p = embassy_rp::init(Default::default()); let p = embassy_rp::init(Default::default());
#[cfg(feature = "defmt")]
defmt::info!("jiggly v{} boot", env!("CARGO_PKG_VERSION"));
let mut watchdog = Watchdog::new(p.WATCHDOG); let mut watchdog = Watchdog::new(p.WATCHDOG);
// Pause the countdown while a debugger has the core halted, so
// breakpoints and single-stepping under `probe-rs` don't trip the
// 8 s reset. Cheap and always-correct, so leave it on for release too.
watchdog.pause_on_debug(true);
watchdog.start(WATCHDOG_TIMEOUT); watchdog.start(WATCHDOG_TIMEOUT);
// NeoPixel: GPIO11 powers it, GPIO12 is the WS2812 data line driven from // NeoPixel: GPIO11 powers it, GPIO12 is the WS2812 data line driven from
@@ -679,8 +83,11 @@ async fn main(spawner: Spawner) {
let mut config = UsbConfig::new(0x1209, 0xb0b0); let mut config = UsbConfig::new(0x1209, 0xb0b0);
config.manufacturer = Some("swaits.com"); config.manufacturer = Some("swaits.com");
config.product = Some("jiggly"); config.product = Some("jiggly");
config.serial_number = Some(make_serial(p.FLASH)); let serial = make_serial(p.FLASH);
config.device_release = 0x0200; // matches firmware version 0.2.0 #[cfg(feature = "defmt")]
defmt::info!("usb serial: {}", serial);
config.serial_number = Some(serial);
config.device_release = 0x0300; // matches firmware version 0.3.0
config.max_power = 100; config.max_power = 100;
config.max_packet_size_0 = 64; config.max_packet_size_0 = 64;
@@ -731,6 +138,9 @@ async fn main(spawner: Spawner) {
spawner.spawn(usb_task(usb).unwrap()); spawner.spawn(usb_task(usb).unwrap());
spawner.spawn(watchdog_task(watchdog).unwrap()); spawner.spawn(watchdog_task(watchdog).unwrap());
#[cfg(feature = "defmt")]
defmt::info!("usb + watchdog tasks spawned, starting statechart");
static EVENT_CHAN: Channel<CriticalSectionRawMutex, Ev, 8> = Channel::new(); static EVENT_CHAN: Channel<CriticalSectionRawMutex, Ev, 8> = Channel::new();
let ctx = Ctx { let ctx = Ctx {
+163
View File
@@ -0,0 +1,163 @@
//! Mouse HID: report send + the family of cursor animations
//! (wake-shake, spinner, eased spirals).
use core::f32::consts::PI;
use embassy_rp::clocks::RoscRng;
use embassy_time::{Duration as EDuration, Timer, with_timeout};
use embassy_usb::class::hid::HidWriter;
use libm::{cosf, powf, roundf, sinf};
use usbd_hid::descriptor::MouseReport;
use crate::chart::Ev;
use crate::config::{
ANIM_FRAME, EASE_POW, FINAL_SPIRAL_FRAMES, FINAL_SPIRAL_RADIUS_START, FINAL_SPIRAL_TURNS,
RUN_CIRCLES, RUN_FRAMES_PER_CIRCLE, RUN_RADIUS, SPIRAL_RADIUS_END, WAKE_AMPLITUDE,
WAKE_FRAMES_PER_HALF, WAKE_JITTER, WAKE_OSCILLATIONS, WARN5_FRAMES, WARN5_RADIUS_START,
WARN5_TURNS, WARN10_FRAMES, WARN10_RADIUS_START, WARN10_TURNS,
};
use crate::usb::UsbDriver;
pub(crate) type MouseHid = HidWriter<'static, UsbDriver, 5>;
// Frantic horizontal mouse shake. Plain oneshot helper — the action method
// that calls this races it against MOUSE_WAKE_DEADLINE.
pub(crate) async fn wake_with_mouse(mouse: &mut MouseHid) {
let period_frames = (WAKE_FRAMES_PER_HALF * 2) as f32;
let total_frames = WAKE_OSCILLATIONS * WAKE_FRAMES_PER_HALF * 2;
let mut prev_x: f32 = 0.0;
let mut prev_y: f32 = 0.0;
let mut acc_x: f32 = 0.0;
let mut acc_y: f32 = 0.0;
for f in 0..total_frames {
let phase = (f as f32) * 2.0 * PI / period_frames;
let next_x = WAKE_AMPLITUDE * sinf(phase);
let jitter = ((RoscRng::next_u8() as f32) / 255.0 - 0.5) * 2.0 * WAKE_JITTER;
let next_y = jitter;
let (dx, dy, used_x, used_y) = step_delta(prev_x, prev_y, next_x, next_y, acc_x, acc_y);
acc_x = used_x;
acc_y = used_y;
send_mouse(mouse, dx, dy).await;
prev_x = next_x;
prev_y = next_y;
Timer::after(ANIM_FRAME).await;
}
}
pub(crate) async fn animate_spinner(mouse: &mut MouseHid) -> Ev {
let total_frames = RUN_CIRCLES * RUN_FRAMES_PER_CIRCLE;
let period_frames = RUN_FRAMES_PER_CIRCLE as f32;
let mut prev_x: f32 = 0.0;
let mut prev_y: f32 = 0.0;
let mut acc_x: f32 = 0.0;
let mut acc_y: f32 = 0.0;
for f in 0..total_frames {
let angle = (f as f32) * 2.0 * PI / period_frames;
let next_x = RUN_RADIUS * sinf(angle);
let next_y = RUN_RADIUS * (1.0 - cosf(angle));
let (dx, dy, used_x, used_y) = step_delta(prev_x, prev_y, next_x, next_y, acc_x, acc_y);
acc_x = used_x;
acc_y = used_y;
send_mouse(mouse, dx, dy).await;
prev_x = next_x;
prev_y = next_y;
Timer::after(ANIM_FRAME).await;
}
Ev::SpinDone
}
async fn animate_spiral(
mouse: &mut MouseHid,
radius_start: f32,
radius_end: f32,
turns: f32,
frames: u32,
) {
let mut prev_x: f32 = 0.0;
let mut prev_y: f32 = 0.0;
let mut acc_x: f32 = 0.0;
let mut acc_y: f32 = 0.0;
for f in 0..frames {
let t = (f as f32) / (frames as f32);
let u = powf(t, EASE_POW);
let angle = u * 2.0 * PI * turns;
let radius = radius_start + (radius_end - radius_start) * u;
// Subtract starting offset so the spiral begins at the cursor's entry point.
let next_x = radius * cosf(angle) - radius_start;
let next_y = radius * sinf(angle);
let (dx, dy, used_x, used_y) = step_delta(prev_x, prev_y, next_x, next_y, acc_x, acc_y);
acc_x = used_x;
acc_y = used_y;
send_mouse(mouse, dx, dy).await;
prev_x = next_x;
prev_y = next_y;
Timer::after(ANIM_FRAME).await;
}
}
pub(crate) async fn animate_final_spiral(mouse: &mut MouseHid) -> Ev {
animate_spiral(
mouse,
FINAL_SPIRAL_RADIUS_START,
SPIRAL_RADIUS_END,
FINAL_SPIRAL_TURNS,
FINAL_SPIRAL_FRAMES,
)
.await;
Ev::SpiralDone
}
pub(crate) async fn animate_warning_5(mouse: &mut MouseHid) -> Ev {
animate_spiral(
mouse,
WARN5_RADIUS_START,
SPIRAL_RADIUS_END,
WARN5_TURNS,
WARN5_FRAMES,
)
.await;
Ev::WarnDone
}
pub(crate) async fn animate_warning_10(mouse: &mut MouseHid) -> Ev {
animate_spiral(
mouse,
WARN10_RADIUS_START,
SPIRAL_RADIUS_END,
WARN10_TURNS,
WARN10_FRAMES,
)
.await;
Ev::WarnDone
}
// Translate continuous (next_x, next_y) target into clamped i8 deltas while
// carrying sub-pixel residue forward, so a 90-frame circle of radius 50 doesn't
// lose ~half its motion to truncation. Returns (dx, dy, residual_x, residual_y).
fn step_delta(
prev_x: f32,
prev_y: f32,
next_x: f32,
next_y: f32,
acc_x: f32,
acc_y: f32,
) -> (i8, i8, f32, f32) {
let want_x = (next_x - prev_x) + acc_x;
let want_y = (next_y - prev_y) + acc_y;
let dx = roundf(want_x.clamp(-127.0, 127.0)) as i8;
let dy = roundf(want_y.clamp(-127.0, 127.0)) as i8;
(dx, dy, want_x - dx as f32, want_y - dy as f32)
}
// ── HID primitive ──────────────────────────────────────────────────
pub(crate) async fn send_mouse(mouse: &mut MouseHid, x: i8, y: i8) {
let report = MouseReport {
buttons: 0,
x,
y,
wheel: 0,
pan: 0,
};
let _ = with_timeout(EDuration::from_secs(3), mouse.write_serialize(&report)).await;
}
+57
View File
@@ -0,0 +1,57 @@
//! USB driver type aliases, identity helpers, and infra tasks
//! (USB device pump + watchdog feeder).
use embassy_rp::{
Peri,
flash::Flash,
peripherals::{FLASH, USB},
usb::Driver,
watchdog::Watchdog,
};
use embassy_time::Timer;
use embassy_usb::UsbDevice;
use static_cell::StaticCell;
use crate::config::{WATCHDOG_FEED_INTERVAL, WATCHDOG_TIMEOUT};
pub(crate) type UsbDriver = Driver<'static, USB>;
// ── 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.
pub(crate) fn make_serial(flash_periph: Peri<'static, FLASH>) -> &'static str {
static SERIAL: StaticCell<[u8; 16]> = StaticCell::new();
const HEX: &[u8; 16] = b"0123456789ABCDEF";
let mut flash = Flash::<_, _, FLASH_SIZE>::new_blocking(flash_periph);
let mut id = [0u8; 8];
let _ = flash.blocking_unique_id(&mut id);
let buf = SERIAL.init([0; 16]);
for (i, &b) in id.iter().enumerate() {
buf[i * 2] = HEX[(b >> 4) as usize];
buf[i * 2 + 1] = HEX[(b & 0x0f) as usize];
}
core::str::from_utf8(buf).unwrap()
}
// ── Tasks ──────────────────────────────────────────────────────────
#[embassy_executor::task]
pub(crate) async fn usb_task(mut device: UsbDevice<'static, UsbDriver>) {
device.run().await;
}
#[embassy_executor::task]
pub(crate) async fn watchdog_task(mut wd: Watchdog) -> ! {
loop {
wd.feed(WATCHDOG_TIMEOUT);
Timer::after(WATCHDOG_FEED_INTERVAL).await;
}
}
+6
View File
@@ -0,0 +1,6 @@
# Override the firmware crate's thumbv6m-none-eabi default. This crate is a
# host-side simulation tool; it needs std and the host toolchain. Closer
# .cargo/config.toml files win key-by-key, so this `target` overrides the
# parent's `target = "thumbv6m-none-eabi"`.
[build]
target = "x86_64-unknown-linux-gnu"
+243
View File
@@ -0,0 +1,243 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "autocfg"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "crossbeam-deque"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "either"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "getrandom"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"wasip2",
]
[[package]]
name = "heuropt"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5496b6d41f95f70a1c9bd0dd65a7d856b15ecd4109ccc105296c267138e36a98"
dependencies = [
"rand",
"rand_distr",
"rayon",
]
[[package]]
name = "jiggly-tuning"
version = "0.1.0"
dependencies = [
"heuropt",
"rand",
]
[[package]]
name = "libc"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "libm"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
"libm",
]
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "rand"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
dependencies = [
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom",
]
[[package]]
name = "rand_distr"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463"
dependencies = [
"num-traits",
"rand",
]
[[package]]
name = "rayon"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "wasip2"
version = "1.0.3+wasi-0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "wit-bindgen"
version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "zerocopy"
version = "0.8.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "jiggly-tuning"
version = "0.1.0"
edition = "2024"
authors = ["Stephen Waits <steve@waits.net>"]
description = "Multi-objective tuner for jiggly's four lifecycle constants — internal tool, not published."
license = "MIT"
publish = false # internal tuning tool; not for crates.io
# Standalone workspace so this crate doesn't get pulled into any parent
# workspace and so cargo doesn't walk up looking for one.
[workspace]
[features]
default = ["parallel"]
parallel = ["heuropt/parallel"]
[dependencies]
heuropt = { version = "0.8", default-features = false }
rand = "0.9"
[profile.release]
opt-level = 3
lto = "thin"
codegen-units = 1
+728
View File
@@ -0,0 +1,728 @@
//! Tune the four lifecycle constants of the `jiggly` USB-mouse-jiggler firmware
//! as a **multi-objective** optimization problem.
//!
//! `heuropt` lets us optimize the goals as separate objectives and surface the
//! Pareto front of legitimate tradeoffs:
//!
//! 1. **minimize work-time failures** — the screen sleeping while the user is
//! working is the worst outcome. (`mean_work_sleep`, minutes/day)
//! 2. **maximize lunch sleep** — the entire design goal. (`mean_lunch`,
//! minutes/day, encoded as a Maximize objective)
//! 3. **minimize human interactions** — every button press is UX cost.
//! (`mean_presses`, per day)
//! 4. **minimize after-hours waste** — keeping the screen alive past the end
//! of the workday is screen burn for nothing. (`mean_after`, minutes/day)
//!
//! Decision: a 4-element `Vec<f64>` for `(RT, YELLOW_AT, RED_AT, FAST_RED_AT)`,
//! continuous-relaxed and rounded to integer minutes inside `evaluate`. The
//! firmware ordering constraint `YA > RA > FRA > 0` is encoded as
//! `constraint_violation` so the algorithm's feasible-beats-infeasible logic
//! handles it automatically.
//!
//! Solver: NSGA-III with 4 objectives and Das-Dennis H=6 → 84 reference
//! points, matching the population size. Each `evaluate` runs a 1,000-workday
//! Monte Carlo, so this is a deliberately meaty evaluator. The `parallel`
//! feature (rayon, on by default) gives ~8× wall-clock on a typical laptop.
//!
//! ```sh
//! cargo run --release # from inside tuning/
//! just tune # from the firmware repo root
//! ```
//!
//! Output is in jiggly's native units — `RT` as `Xh00m`, thresholds as plain
//! minutes, durations as `Xh00m` / `Mm`, probabilities as percentages.
use std::time::Instant;
use rand::Rng as _;
use rand::SeedableRng;
use rand::rngs::StdRng;
use heuropt::prelude::*;
const LUNCH_START: i32 = 12 * 60;
const LUNCH_END: i32 = 13 * 60;
const P_PRESS_YELLOW: f64 = 0.015;
const P_PRESS_RED: f64 = 0.040;
const P_PRESS_FAST_RED: f64 = 0.060;
const P_WARN10_BUMP: f64 = 0.04;
const P_WARN5_BUMP: f64 = 0.03;
// Sweet-spot lunch-sleep window (minutes spent dead during 12:0013:00).
const SWEET_LO: u32 = 15;
const SWEET_HI: u32 = 45;
const N_DAYS: usize = 1000;
// -----------------------------------------------------------------------------
// A-posteriori decision weights (must sum to 1.0).
// -----------------------------------------------------------------------------
const W_LUNCH: f64 = 0.30; // top — design goal
const W_AFTER: f64 = 0.25; // top — minimize after-hours waste
const W_WORK: f64 = 0.20; // medium — failures bad but recoverable
const W_PRESS: f64 = 0.15; // matters with a hinge below
const W_BALANCE: f64 = 0.10; // bonus for longer yellow + red phases
// Press hinge: full reward at or below LOW, linearly drops to 0 at COMFORT_CAP,
// and any candidate with mean_presses > COMFORT_CAP is rejected outright.
//
// Counts every daily press: morning boot, 13:00 lunch retap, warning-phase
// reactions, and any death-restart presses during the workday. With ~2
// baseline presses already mandatory each day, the LOW threshold sits just
// above baseline (2 + a half warning press) and the cap allows up to
// 1.5 additional presses on top of baseline before rejecting.
const PRESS_HINGE_LOW: f64 = 2.5;
const PRESS_COMFORT_CAP: f64 = 3.5;
// Balance bonus saturates: a min(yellow_width, red_width) of >= this many
// minutes scores the full balance term.
const BALANCE_SATURATION_MIN: f64 = 10.0;
// -----------------------------------------------------------------------------
// Day model + Monte Carlo
// -----------------------------------------------------------------------------
#[derive(Default, Clone, Copy)]
struct DayOutcome {
presses: u32,
slept_work: u32,
slept_lunch: u32,
after_hours: u32,
}
#[derive(Clone, Copy)]
struct Stats {
/// Probability of landing in the 12:1512:45 sweet spot.
p_sweet: f64,
mean_lunch: f64,
mean_work_sleep: f64,
mean_presses: f64,
mean_after: f64,
}
fn sample_triangular(low: f64, mode: f64, high: f64, rng: &mut StdRng) -> f64 {
let u: f64 = rng.random();
let c = (mode - low) / (high - low);
if u < c {
low + ((high - low) * (mode - low) * u).sqrt()
} else {
high - ((high - low) * (high - mode) * (1.0 - u)).sqrt()
}
}
/// Pre-sampled simulated workdays. Sampling once and reusing across all
/// `evaluate` calls is the standard SAA pattern: every parameter combination
/// is scored on the same days, so differences in objective values reflect the
/// parameters rather than Monte Carlo noise between evaluations.
struct JigglyTuning {
days: Vec<(i32, i32, u64)>, // start_min, end_min, per-day RNG seed
}
impl JigglyTuning {
fn new(n_days: usize, seed: u64) -> Self {
let mut rng = StdRng::seed_from_u64(seed);
let days = (0..n_days)
.map(|_| {
let s = (sample_triangular(8.0, 8.5, 9.5, &mut rng) * 60.0) as i32;
let e = (sample_triangular(16.0, 17.5, 19.0, &mut rng) * 60.0) as i32;
let day_seed: u64 = rng.random();
(s, e, day_seed)
})
.collect();
Self { days }
}
fn simulate_one(
s: i32,
e: i32,
day_seed: u64,
rt: i32,
ya: i32,
ra: i32,
fra: i32,
) -> DayOutcome {
let mut rng = StdRng::seed_from_u64(day_seed);
let mut expire = s + rt;
// Boot press at workday start: user presses to begin cycle 1.
let mut o = DayOutcome {
presses: 1,
..Default::default()
};
// Allow the loop to extend past the larger of (workday end, last
// possible cycle end given any in-loop expire bumps). Cap at one
// extra cycle's worth so a long string of presses can't blow the
// budget.
let t_max = e.max(expire).max(s + 2 * rt) + 1;
let mut prev_running = true;
for t in s..t_max {
// 13:00 re-login press: user comes back from lunch, presses to
// start cycle 2.
if t == LUNCH_END && t < e {
expire = t + rt;
o.presses += 1;
}
let in_workday = t >= s && t < e;
let at_lunch = (LUNCH_START..LUNCH_END).contains(&t);
let device_running = t < expire;
let device_dead = !device_running;
// Death-restart press: when the device transitions from running
// to dead during workday (not at lunch), user notices the screen
// sleeping and presses to restart. Counts as a press for THIS
// minute; subsequent at-desk minutes are now covered.
if prev_running && device_dead && in_workday && !at_lunch {
expire = t + rt;
o.presses += 1;
prev_running = true;
continue;
}
prev_running = device_running;
if device_dead && in_workday {
if at_lunch {
o.slept_lunch += 1;
} else {
o.slept_work += 1;
}
}
if t >= e && device_running {
o.after_hours += 1;
}
if !at_lunch && in_workday && device_running {
let remaining = expire - t;
let mut p = 0.0;
if remaining > ra && remaining <= ya {
p = P_PRESS_YELLOW;
} else if remaining > fra && remaining <= ra {
p = P_PRESS_RED;
} else if remaining > 0 && remaining <= fra {
p = P_PRESS_FAST_RED;
}
if remaining == 10 {
p += P_WARN10_BUMP;
}
if remaining == 5 {
p += P_WARN5_BUMP;
}
let roll: f64 = rng.random();
if roll < p {
expire = t + rt;
o.presses += 1;
}
}
}
o
}
fn aggregate(&self, rt: i32, ya: i32, ra: i32, fra: i32) -> Stats {
let n = self.days.len() as f64;
let mut sweet = 0u32;
let mut sum_lunch = 0.0_f64;
let mut sum_work = 0.0_f64;
let mut sum_presses = 0.0_f64;
let mut sum_after = 0.0_f64;
for &(s, e, ds) in &self.days {
let o = Self::simulate_one(s, e, ds, rt, ya, ra, fra);
if (SWEET_LO..=SWEET_HI).contains(&o.slept_lunch) {
sweet += 1;
}
sum_lunch += o.slept_lunch as f64;
sum_work += o.slept_work as f64;
sum_presses += o.presses as f64;
sum_after += o.after_hours as f64;
}
Stats {
p_sweet: sweet as f64 / n,
mean_lunch: sum_lunch / n,
mean_work_sleep: sum_work / n,
mean_presses: sum_presses / n,
mean_after: sum_after / n,
}
}
}
impl Problem for JigglyTuning {
type Decision = Vec<f64>;
fn objectives(&self) -> ObjectiveSpace {
ObjectiveSpace::new(vec![
Objective::minimize("work_failure_min"),
Objective::maximize("lunch_sleep_min"),
Objective::minimize("presses_per_day"),
Objective::minimize("after_hours_min"),
])
}
fn evaluate(&self, x: &Vec<f64>) -> Evaluation {
let rt = x[0].round() as i32;
let ya = x[1].round() as i32;
let ra = x[2].round() as i32;
let fra = x[3].round() as i32;
// Soft constraint: YA > RA > FRA > 0 (any violation is positive).
let mut violation = 0.0_f64;
if ra >= ya {
violation += (ra - ya + 1) as f64;
}
if fra >= ra {
violation += (fra - ra + 1) as f64;
}
if fra <= 0 {
violation += (1 - fra) as f64;
}
let stats = self.aggregate(rt, ya, ra, fra);
Evaluation::constrained(
vec![
stats.mean_work_sleep,
stats.mean_lunch, // Objective is Maximize → as_minimization will negate
stats.mean_presses,
stats.mean_after,
],
violation.max(0.0),
)
}
}
// -----------------------------------------------------------------------------
// Output formatting (jiggly's native units — `Xh00m` / `Mm`, percentages)
// -----------------------------------------------------------------------------
fn fmt_minutes(m: f64) -> String {
let total = m.round() as i32;
let h = total / 60;
let mm = total % 60;
if h > 0 {
format!("{h}h{mm:02}m")
} else {
format!("{mm}m")
}
}
fn fmt_rt(m: i32) -> String {
let h = m / 60;
let mm = m % 60;
format!("{h}h{mm:02}m")
}
/// One row in the Pareto-front summary table.
#[derive(Clone)]
struct Row {
rt: i32,
ya: i32,
ra: i32,
fra: i32,
work_fail: f64,
lunch: f64,
presses: f64,
after: f64,
p_sweet: f64,
}
fn row_for(decision: &[f64], stats: &Stats) -> Row {
Row {
rt: decision[0].round() as i32,
ya: decision[1].round() as i32,
ra: decision[2].round() as i32,
fra: decision[3].round() as i32,
work_fail: stats.mean_work_sleep,
lunch: stats.mean_lunch,
presses: stats.mean_presses,
after: stats.mean_after,
p_sweet: stats.p_sweet,
}
}
fn print_header() {
println!(
"{:<6} {:>3} {:>3} {:>3} {:>9} {:>9} {:>8} {:>8} {:>7}",
"RT", "YA", "RA", "FRA", "work fail↓", "lunch↑", "presses↓", "after↓", "p_sweet",
);
println!("{}", "-".repeat(78));
}
fn print_row(label: &str, r: &Row) {
let prefix = if label.is_empty() {
String::new()
} else {
format!("{label} ")
};
println!(
"{}{:<6} {:>3} {:>3} {:>3} {:>9} {:>9} {:>7.2}/d {:>8} {:>6.1}%",
prefix,
fmt_rt(r.rt),
r.ya,
r.ra,
r.fra,
fmt_minutes(r.work_fail),
fmt_minutes(r.lunch),
r.presses,
fmt_minutes(r.after),
r.p_sweet * 100.0,
);
}
// -----------------------------------------------------------------------------
// Main
// -----------------------------------------------------------------------------
fn main() {
let problem = JigglyTuning::new(N_DAYS, 2026);
let bounds = vec![
(230.0, 250.0), // RT
(20.0, 70.0), // YELLOW_AT
(10.0, 40.0), // RED_AT
(4.0, 20.0), // FAST_RED_AT
];
let initializer = RealBounds::new(bounds.clone());
// Canonical NSGA-II/-III operator pair (SBX + PolyMut) with bounds.
let variation = CompositeVariation {
crossover: SimulatedBinaryCrossover::new(bounds.clone(), 30.0, 1.0),
mutation: PolynomialMutation::new(bounds, 20.0, 1.0 / 4.0),
};
// M=4, H=6 → C(9,3) = 84 reference points. Match the population size.
let pop = 84;
let gens = 25;
let config = Nsga3Config {
population_size: pop,
generations: gens,
reference_divisions: 6,
seed: 42,
};
println!("Optimizing jiggly's 4 lifecycle constants — 4-objective Pareto search");
println!(" algorithm: NSGA-III (84 ref points, M=4, H=6)");
println!(" N_DAYS: {N_DAYS} simulated workdays per evaluation");
println!(" search: RT∈[230,250], YA∈[20,70], RA∈[10,40], FRA∈[4,20]");
println!(
" budget: {pop} pop × {gens} gens = {} evaluations",
pop * (gens + 1)
);
println!();
let mut opt = Nsga3::new(config, initializer, variation);
let t0 = Instant::now();
let result = opt.run(&problem);
let elapsed = t0.elapsed();
println!(
"NSGA-III finished in {:.2}s ({} evaluations, |front|={})",
elapsed.as_secs_f64(),
result.evaluations,
result.pareto_front.len(),
);
println!();
// Materialize each Pareto member's full Stats so we can print rich rows.
// Multiple f64 decisions can round to the same integer combo — dedupe.
let mut seen = std::collections::HashSet::new();
let mut rows: Vec<Row> = result
.pareto_front
.iter()
.filter_map(|c| {
let rt = c.decision[0].round() as i32;
let ya = c.decision[1].round() as i32;
let ra = c.decision[2].round() as i32;
let fra = c.decision[3].round() as i32;
if !seen.insert((rt, ya, ra, fra)) {
return None;
}
let stats = problem.aggregate(rt, ya, ra, fra);
Some(row_for(&c.decision, &stats))
})
.collect();
// Drop any infeasible front entries (shouldn't happen for a converged
// run, but guard anyway).
rows.retain(|r| r.ya > r.ra && r.ra > r.fra && r.fra > 0);
println!("=== Pareto front (sorted by lunch sleep, descending) ===");
print_header();
rows.sort_by(|a, b| {
b.lunch
.partial_cmp(&a.lunch)
.unwrap_or(std::cmp::Ordering::Equal)
});
for r in rows.iter().take(15) {
print_row("", r);
}
if rows.len() > 15 {
println!(" ... ({} more on the front)", rows.len() - 15);
}
println!();
// Re-rank by each individual objective to surface extreme tradeoffs.
let best_by = |key: fn(&Row) -> f64, want_high: bool| -> Option<&Row> {
rows.iter().min_by(|a, b| {
let ka = key(a);
let kb = key(b);
let cmp = ka.partial_cmp(&kb).unwrap_or(std::cmp::Ordering::Equal);
if want_high { cmp.reverse() } else { cmp }
})
};
println!("=== extreme tradeoffs ===");
print_header();
if let Some(r) = best_by(|r| r.work_fail, false) {
print_row("FEWEST WORK FAILS ", r);
}
if let Some(r) = best_by(|r| r.lunch, true) {
print_row("MOST LUNCH SLEEP ", r);
}
if let Some(r) = best_by(|r| r.presses, false) {
print_row("FEWEST PRESSES ", r);
}
if let Some(r) = best_by(|r| r.after, false) {
print_row("LEAST AFTER-HOURS ", r);
}
println!();
// Match the four constants in `../src/config.rs` (RUN_DURATION, YELLOW_AT,
// RED_AT, FAST_RED_AT). Update this when the firmware ships new defaults
// so the comparison block reflects what's actually flashed.
let shipping = problem.aggregate(231, 22, 11, 4);
let shipping_row = row_for(&[231.0, 22.0, 11.0, 4.0], &shipping);
println!("=== firmware shipping default (RT=3h51m YEL=22 RED=11 FST=4) ===");
print_header();
print_row("", &shipping_row);
println!();
// -------------------------------------------------------------------------
// A-posteriori pick: rank the front by weighted preferences.
// -------------------------------------------------------------------------
//
// Every point on the front is incomparable in the strict Pareto sense —
// none dominates another. To surface ONE recommendation we apply explicit
// weights to four normalized outcome axes plus two structural terms:
//
// * `lunch_sleep` (max), `after_hours` (min), `work_fail` (min) —
// normalized to [0, 1] across the candidate set.
// * `presses` — hinge: full reward when <= PRESS_HINGE_LOW, ramps to
// zero at PRESS_COMFORT_CAP, candidates above the cap are rejected.
// * `balance` — bonus for longer warning phases:
// `min(YA - RA, RA - FRA)` saturated at BALANCE_SATURATION_MIN.
//
// Anyone with different priorities can read the front above and pick a
// different row. We add the firmware's shipping defaults to the
// candidate set so they compete on equal footing with the front.
let mut candidates: Vec<(String, Row)> = rows
.iter()
.map(|r| ("front".to_string(), r.clone()))
.collect();
let shipping_candidate_idx = candidates.len();
candidates.push(("shipping default".to_string(), shipping_row.clone()));
let scores = compute_weighted_scores(
&candidates
.iter()
.map(|(_, r)| r.clone())
.collect::<Vec<_>>(),
);
let mut ranked: Vec<(usize, f64)> = scores.iter().copied().enumerate().collect();
ranked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
println!("=== ranked by weighted preferences ===");
println!(
" weights: lunch_sleep {}% · after_hours {}% · work_fail {}% · presses {}% · balance {}%",
(W_LUNCH * 100.0) as i32,
(W_AFTER * 100.0) as i32,
(W_WORK * 100.0) as i32,
(W_PRESS * 100.0) as i32,
(W_BALANCE * 100.0) as i32,
);
println!(
" press hinge: full reward ≤ {:.1}/d, ramps to 0 at {:.1}/d, REJECTED above",
PRESS_HINGE_LOW, PRESS_COMFORT_CAP,
);
println!(
" balance bonus: min(yellow_width, red_width), saturates at {:.0} min",
BALANCE_SATURATION_MIN,
);
println!(
" candidate set: {} Pareto-front rows + 1 shipping default",
rows.len()
);
println!();
println!("{:>4} {:>5} source", "rank", "score");
print_header();
for (rank, &(idx, score)) in ranked.iter().take(5).enumerate() {
let (label, r) = &candidates[idx];
println!("{:>4} {:.3} {label}", rank + 1, score);
print_row("", r);
}
println!();
let &(top_idx, top_score) = ranked.first().expect("at least one candidate");
let (top_label, top) = &candidates[top_idx];
let shipping_rank = ranked
.iter()
.position(|(i, _)| *i == shipping_candidate_idx)
.map(|p| p + 1)
.unwrap_or(0);
let max_work = candidates
.iter()
.map(|(_, r)| r.work_fail)
.fold(0.0, f64::max);
println!("=== RECOMMENDED PICK ({top_label}) ===");
println!(
" RT={} YELLOW_AT={} RED_AT={} FAST_RED_AT={}",
fmt_rt(top.rt),
top.ya,
top.ra,
top.fra,
);
println!(" weighted score = {top_score:.3}");
println!();
let yellow_w = top.ya - top.ra;
let red_w = top.ra - top.fra;
println!("Why:");
println!(
"{} mean lunch sleep ({:.1}% land in the 12:1512:45 sweet spot)",
fmt_minutes(top.lunch),
top.p_sweet * 100.0,
);
println!(
"{} mean after-hours awake (kept tight, your second priority)",
fmt_minutes(top.after),
);
println!(
"{} mean work-time failure ({} better than the worst candidate)",
fmt_minutes(top.work_fail),
ratio_str(max_work, top.work_fail.max(1e-9)),
);
let press_note = if top.presses <= PRESS_HINGE_LOW {
format!("inside your no-penalty zone ≤{:.1}/d", PRESS_HINGE_LOW)
} else if top.presses < PRESS_COMFORT_CAP {
format!(
"above the {:.1}/d hinge but below your {:.1}/d cap",
PRESS_HINGE_LOW, PRESS_COMFORT_CAP,
)
} else {
format!("AT or ABOVE your {:.1}/d comfort cap", PRESS_COMFORT_CAP)
};
println!(
"{:.2} button presses/day total — {}",
top.presses, press_note,
);
println!(" (counts: boot + 13:00 retap + warning-phase reactions + death-restarts)");
println!(
" • warning phases: yellow {} min, red {} min, fast-red {} min (balance score {:.2})",
yellow_w,
red_w,
top.fra,
balance_score_for(top),
);
if top_label != "shipping default" {
println!();
println!(
"(Shipping default ranks #{shipping_rank} of {}.)",
candidates.len(),
);
} else {
println!();
println!(
"Note: the optimizer found {} non-dominated alternatives, but under",
rows.len(),
);
println!("these weights the firmware's shipping defaults score highest.");
}
}
/// Score every row in `rows` by a weighted sum that combines normalized
/// outcome axes with a press hinge and a phase-balance bonus.
///
/// `work_fail`, `lunch`, and `after` are normalized to `[0, 1]` across `rows`
/// (best→1, worst→0; direction-aware). `presses` uses a hinge that rewards
/// values at or below `PRESS_HINGE_LOW`, ramps linearly to zero at
/// `PRESS_COMFORT_CAP`, and rejects candidates above the cap by returning
/// `f64::NEG_INFINITY`. `balance` is a bonus for longer yellow + red
/// phases, computed as `min(YA - RA, RA - FRA)` saturated at
/// `BALANCE_SATURATION_MIN`.
fn compute_weighted_scores(rows: &[Row]) -> Vec<f64> {
let work_min = rows
.iter()
.map(|r| r.work_fail)
.fold(f64::INFINITY, f64::min);
let work_max = rows
.iter()
.map(|r| r.work_fail)
.fold(f64::NEG_INFINITY, f64::max);
let lunch_min = rows.iter().map(|r| r.lunch).fold(f64::INFINITY, f64::min);
let lunch_max = rows
.iter()
.map(|r| r.lunch)
.fold(f64::NEG_INFINITY, f64::max);
let after_min = rows.iter().map(|r| r.after).fold(f64::INFINITY, f64::min);
let after_max = rows
.iter()
.map(|r| r.after)
.fold(f64::NEG_INFINITY, f64::max);
rows.iter()
.map(|r| {
// Hard comfort cap on presses.
if r.presses > PRESS_COMFORT_CAP {
return f64::NEG_INFINITY;
}
let work = norm_min(r.work_fail, work_min, work_max);
let lunch = norm_max(r.lunch, lunch_min, lunch_max);
let after = norm_min(r.after, after_min, after_max);
// Hinge: 1.0 at or below LOW, linear ramp to 0.0 at the cap.
let press_score = if r.presses <= PRESS_HINGE_LOW {
1.0
} else {
((PRESS_COMFORT_CAP - r.presses) / (PRESS_COMFORT_CAP - PRESS_HINGE_LOW))
.clamp(0.0, 1.0)
};
// Balance bonus: longer yellow + red is better, saturated.
let balance_score = balance_score_for(r);
W_LUNCH * lunch
+ W_AFTER * after
+ W_WORK * work
+ W_PRESS * press_score
+ W_BALANCE * balance_score
})
.collect()
}
/// Balance bonus for a row: `min(YA - RA, RA - FRA)` clamped to
/// `[0, BALANCE_SATURATION_MIN]` and divided by saturation so the result is
/// in `[0, 1]`.
fn balance_score_for(r: &Row) -> f64 {
let yellow_w = (r.ya - r.ra) as f64;
let red_w = (r.ra - r.fra) as f64;
let raw = yellow_w.min(red_w).max(0.0);
(raw / BALANCE_SATURATION_MIN).clamp(0.0, 1.0)
}
/// Normalize a minimize-direction value to `[0, 1]` (best→1, worst→0).
fn norm_min(v: f64, lo: f64, hi: f64) -> f64 {
if (hi - lo).abs() < 1e-12 {
1.0
} else {
(hi - v) / (hi - lo)
}
}
/// Normalize a maximize-direction value to `[0, 1]` (best→1, worst→0).
fn norm_max(v: f64, lo: f64, hi: f64) -> f64 {
if (hi - lo).abs() < 1e-12 {
1.0
} else {
(v - lo) / (hi - lo)
}
}
/// Render `worst / best` as e.g. "7.5×" for the recommendation rationale.
fn ratio_str(worst: f64, best: f64) -> String {
if best <= 1e-9 {
return "∞×".to_string();
}
format!("{:.1}×", worst / best)
}