Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c45fceaf7a
|
||
|
|
7effaa293b
|
||
|
|
334666b6e7
|
||
|
|
8cb071b132
|
@@ -1,2 +1,3 @@
|
|||||||
/.cargo
|
/.cargo
|
||||||
/target
|
/target
|
||||||
|
/tuning/target
|
||||||
|
|||||||
+47
-1
@@ -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:15–12: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://github.com/swaits/jiggly/compare/v0.3.0...HEAD
|
||||||
|
[0.3.0]: https://github.com/swaits/jiggly/compare/v0.2.0...v0.3.0
|
||||||
[0.2.0]: https://github.com/swaits/jiggly/compare/v0.1.0...v0.2.0
|
[0.2.0]: https://github.com/swaits/jiggly/compare/v0.1.0...v0.2.0
|
||||||
[0.1.0]: https://github.com/swaits/jiggly/releases/tag/v0.1.0
|
[0.1.0]: https://github.com/swaits/jiggly/releases/tag/v0.1.0
|
||||||
|
|||||||
Generated
+98
-7
@@ -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"
|
||||||
|
|||||||
+29
-4
@@ -1,6 +1,6 @@
|
|||||||
[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."
|
||||||
@@ -12,11 +12,31 @@ 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"))'] }
|
||||||
|
|
||||||
|
|||||||
@@ -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:00–13:00.
|
`Triangular(16:00, mode 17:30, 19:00)`. Lunch is fixed at 12:00–13: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:15–12: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:15–12:45
|
workdays this combination averages **26 minutes** of lunch sleep
|
||||||
sweet spot on **~52 %**.
|
and lands in the 12:15–12: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/
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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:00–13:00 (fixed).
|
|
||||||
- Free RESET at start (boot) and at 13:00 (re-login after lunch).
|
|
||||||
- User-at-desk minute-by-minute, sees the LED, and may tap RESET to
|
|
||||||
extend the cycle:
|
|
||||||
yellow 1.5 % / min
|
|
||||||
red 4.0 % / min
|
|
||||||
fast-red 6.0 % / min
|
|
||||||
warning10/5 one-shot bumps the minute the spiral animation fires
|
|
||||||
- A composite score rewards lunch-hour expiration (especially the
|
|
||||||
12:15–12:45 sweet spot) and penalizes daytime failures.
|
|
||||||
|
|
||||||
Usage
|
|
||||||
|
|
||||||
uv run scripts/tune_runtime.py # default 50 000 days
|
|
||||||
uv run scripts/tune_runtime.py --n 100000 # finer Monte Carlo
|
|
||||||
|
|
||||||
Adjust the per-phase press probabilities and grid ranges at the top of
|
|
||||||
main() to match your own behavior or your own workday distribution.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import itertools
|
|
||||||
import time
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
LUNCH_START = 12 * 60
|
|
||||||
LUNCH_END = 13 * 60
|
|
||||||
|
|
||||||
# Per-minute press probabilities per LED phase.
|
|
||||||
P_PRESS_YELLOW = 0.015
|
|
||||||
P_PRESS_RED = 0.040
|
|
||||||
P_PRESS_FAST_RED = 0.060
|
|
||||||
# One-shot bumps when the on-screen spiral animations fire (10 / 5 min before
|
|
||||||
# death). Independent of LED-phase boundaries — the firmware fires those at
|
|
||||||
# fixed offsets from death.
|
|
||||||
P_WARN10_BUMP = 0.04
|
|
||||||
P_WARN5_BUMP = 0.03
|
|
||||||
|
|
||||||
|
|
||||||
def sample_days(n: int, rng: np.random.Generator) -> tuple[np.ndarray, np.ndarray]:
|
|
||||||
s = (rng.triangular(8.0, 8.5, 9.5, n) * 60).astype(np.int32)
|
|
||||||
e = (rng.triangular(16.0, 17.5, 19.0, n) * 60).astype(np.int32)
|
|
||||||
return s, e
|
|
||||||
|
|
||||||
|
|
||||||
def simulate(
|
|
||||||
rt: int,
|
|
||||||
yellow_at: int,
|
|
||||||
red_at: int,
|
|
||||||
fast_red_at: int,
|
|
||||||
s: np.ndarray,
|
|
||||||
e: np.ndarray,
|
|
||||||
rng: np.random.Generator,
|
|
||||||
) -> dict:
|
|
||||||
n = len(s)
|
|
||||||
expire = s + rt
|
|
||||||
|
|
||||||
presses = np.zeros(n, dtype=np.int32)
|
|
||||||
slept_work = np.zeros(n, dtype=np.int32)
|
|
||||||
slept_lunch = np.zeros(n, dtype=np.int32)
|
|
||||||
after_hours = np.zeros(n, dtype=np.int32)
|
|
||||||
|
|
||||||
t_min = int(s.min())
|
|
||||||
t_max = int(max(e.max(), expire.max())) + 1
|
|
||||||
|
|
||||||
for t in range(t_min, t_max):
|
|
||||||
# Free re-tap when the user re-logs in at 13:00.
|
|
||||||
if t == LUNCH_END:
|
|
||||||
in_workday = (t >= s) & (t < e)
|
|
||||||
expire = np.where(in_workday, t + rt, expire)
|
|
||||||
|
|
||||||
in_workday = (t >= s) & (t < e)
|
|
||||||
at_lunch = LUNCH_START <= t < LUNCH_END
|
|
||||||
device_running = t < expire
|
|
||||||
device_dead = ~device_running
|
|
||||||
|
|
||||||
if at_lunch:
|
|
||||||
slept_lunch += (in_workday & device_dead).astype(np.int32)
|
|
||||||
else:
|
|
||||||
slept_work += (in_workday & device_dead).astype(np.int32)
|
|
||||||
|
|
||||||
past_end = (t >= e) & device_running
|
|
||||||
after_hours += past_end.astype(np.int32)
|
|
||||||
|
|
||||||
if not at_lunch:
|
|
||||||
eligible = in_workday & device_running
|
|
||||||
if eligible.any():
|
|
||||||
remaining = expire - t
|
|
||||||
p = np.zeros(n, dtype=np.float32)
|
|
||||||
yellow = (remaining > red_at) & (remaining <= yellow_at)
|
|
||||||
red = (remaining > fast_red_at) & (remaining <= red_at)
|
|
||||||
fast_red = (remaining > 0) & (remaining <= fast_red_at)
|
|
||||||
p[yellow] = P_PRESS_YELLOW
|
|
||||||
p[red] = P_PRESS_RED
|
|
||||||
p[fast_red] = P_PRESS_FAST_RED
|
|
||||||
p[remaining == 10] += P_WARN10_BUMP
|
|
||||||
p[remaining == 5] += P_WARN5_BUMP
|
|
||||||
|
|
||||||
roll = rng.random(n).astype(np.float32)
|
|
||||||
press = eligible & (roll < p)
|
|
||||||
np.putmask(expire, press, t + rt)
|
|
||||||
presses += press.astype(np.int32)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"rt": rt,
|
|
||||||
"yellow_at": yellow_at,
|
|
||||||
"red_at": red_at,
|
|
||||||
"fast_red_at": fast_red_at,
|
|
||||||
"presses": presses,
|
|
||||||
"slept_work": slept_work,
|
|
||||||
"slept_lunch": slept_lunch,
|
|
||||||
"after_hours": after_hours,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def summarize(r: dict) -> dict:
|
|
||||||
sw = r["slept_work"]
|
|
||||||
sl = r["slept_lunch"]
|
|
||||||
ah = r["after_hours"]
|
|
||||||
pr = r["presses"]
|
|
||||||
sweet = (sl >= 15) & (sl <= 45)
|
|
||||||
return {
|
|
||||||
"rt": r["rt"],
|
|
||||||
"yellow_at": r["yellow_at"],
|
|
||||||
"red_at": r["red_at"],
|
|
||||||
"fast_red_at": r["fast_red_at"],
|
|
||||||
"p_sweet": sweet.mean(),
|
|
||||||
"p_lunch_any": (sl > 0).mean(),
|
|
||||||
"p_no_work_sleep": (sw == 0).mean(),
|
|
||||||
"mean_lunch": sl.mean(),
|
|
||||||
"mean_work_sleep": sw.mean(),
|
|
||||||
"mean_presses": pr.mean(),
|
|
||||||
"mean_after": ah.mean(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def score(r: dict) -> float:
|
|
||||||
return (
|
|
||||||
r["p_sweet"]
|
|
||||||
+ 0.5 * r["p_lunch_any"]
|
|
||||||
- 1.5 * (1 - r["p_no_work_sleep"])
|
|
||||||
- 0.05 * r["mean_after"] / 60
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def fmt_h(m: float) -> str:
|
|
||||||
m = int(round(m))
|
|
||||||
h, mm = divmod(m, 60)
|
|
||||||
return f"{h}h{mm:02d}m" if h else f"{mm}m"
|
|
||||||
|
|
||||||
|
|
||||||
def fmt_rt(m: int) -> str:
|
|
||||||
h, mm = divmod(int(m), 60)
|
|
||||||
return f"{h}h{mm:02d}m"
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
ap = argparse.ArgumentParser()
|
|
||||||
ap.add_argument("--n", type=int, default=50_000, help="days per combo")
|
|
||||||
ap.add_argument("--seed", type=int, default=2026)
|
|
||||||
args = ap.parse_args()
|
|
||||||
|
|
||||||
# 4-D search grid. Wider/finer is more honest; narrower is faster.
|
|
||||||
rt_range = list(range(230, 251, 5)) # 230..250 step 5 (5)
|
|
||||||
yellow_at_range = list(range(20, 71, 5)) # 20..70 step 5 (11)
|
|
||||||
red_at_range = list(range(10, 41, 5)) # 10..40 step 5 (7)
|
|
||||||
fast_red_at_range = list(range(4, 21, 2)) # 4..20 step 2 (9)
|
|
||||||
|
|
||||||
print(f"tune_runtime — N={args.n} days/combo")
|
|
||||||
print(f" RT {rt_range[0]}..{rt_range[-1]} step 5 ({len(rt_range)})")
|
|
||||||
print(f" YELLOW_AT {yellow_at_range[0]}..{yellow_at_range[-1]} step 5 ({len(yellow_at_range)})")
|
|
||||||
print(f" RED_AT {red_at_range[0]}..{red_at_range[-1]} step 5 ({len(red_at_range)})")
|
|
||||||
print(f" FAST_RED_AT {fast_red_at_range[0]}..{fast_red_at_range[-1]} step 2 ({len(fast_red_at_range)})")
|
|
||||||
print(f" press: yellow {P_PRESS_YELLOW}/min, red {P_PRESS_RED}/min, "
|
|
||||||
f"fast {P_PRESS_FAST_RED}/min")
|
|
||||||
print()
|
|
||||||
|
|
||||||
rng = np.random.default_rng(args.seed)
|
|
||||||
s, e = sample_days(args.n, rng)
|
|
||||||
|
|
||||||
combos = [
|
|
||||||
(rt, ya, ra, fra)
|
|
||||||
for rt, ya, ra, fra in itertools.product(
|
|
||||||
rt_range, yellow_at_range, red_at_range, fast_red_at_range
|
|
||||||
)
|
|
||||||
if ya > ra > fra > 0
|
|
||||||
]
|
|
||||||
print(f" {len(combos)} valid combos to evaluate...")
|
|
||||||
t0 = time.time()
|
|
||||||
|
|
||||||
results = []
|
|
||||||
for i, (rt, ya, ra, fra) in enumerate(combos):
|
|
||||||
sim_rng = np.random.default_rng(args.seed + 1 + i)
|
|
||||||
r = simulate(rt, ya, ra, fra, s, e, sim_rng)
|
|
||||||
results.append(summarize(r))
|
|
||||||
if (i + 1) % 200 == 0:
|
|
||||||
elapsed = time.time() - t0
|
|
||||||
rate = (i + 1) / elapsed
|
|
||||||
eta = (len(combos) - i - 1) / rate
|
|
||||||
print(f" ... {i+1}/{len(combos)} ({rate:.1f}/sec, ETA {eta:.0f}s)")
|
|
||||||
|
|
||||||
print(f" done in {time.time() - t0:.0f}s")
|
|
||||||
print()
|
|
||||||
|
|
||||||
by_score = sorted(results, key=lambda r: -score(r))[:25]
|
|
||||||
print("=== top 25 by composite score ===")
|
|
||||||
print(f"{'RT':>6} {'YEL':>4} {'RED':>4} {'FST':>4} | "
|
|
||||||
f"{'p_sweet':>7} {'p_any':>6} {'p_no_fail':>9} | "
|
|
||||||
f"{'lunch':>5} {'work':>4} {'press':>5} {'score':>6}")
|
|
||||||
print("-" * 86)
|
|
||||||
for r in by_score:
|
|
||||||
print(f"{fmt_rt(r['rt']):>6} {r['yellow_at']:>4} {r['red_at']:>4} {r['fast_red_at']:>4} | "
|
|
||||||
f"{r['p_sweet']*100:>6.1f}% {r['p_lunch_any']*100:>5.1f}% "
|
|
||||||
f"{r['p_no_work_sleep']*100:>8.1f}% | "
|
|
||||||
f"{fmt_h(r['mean_lunch']):>5} {fmt_h(r['mean_work_sleep']):>4} "
|
|
||||||
f"{r['mean_presses']:>5.2f} {score(r):>6.3f}")
|
|
||||||
|
|
||||||
# Where does the firmware's currently-shipping combo land?
|
|
||||||
shipping = next(
|
|
||||||
(r for r in results
|
|
||||||
if r["rt"] == 240 and r["yellow_at"] == 30
|
|
||||||
and r["red_at"] == 25 and r["fast_red_at"] == 20),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
if shipping is not None:
|
|
||||||
rank = 1 + sum(1 for r in results if score(r) > score(shipping))
|
|
||||||
print()
|
|
||||||
print("=== current firmware (RT=4h00 YEL=30 RED=25 FST=20) ===")
|
|
||||||
print(f" p_sweet={shipping['p_sweet']*100:.1f}% "
|
|
||||||
f"p_any={shipping['p_lunch_any']*100:.1f}% "
|
|
||||||
f"p_no_fail={shipping['p_no_work_sleep']*100:.1f}% "
|
|
||||||
f"score={score(shipping):.3f}")
|
|
||||||
print(f" rank = {rank} / {len(results)}")
|
|
||||||
|
|
||||||
best = by_score[0]
|
|
||||||
print()
|
|
||||||
print(f"PICK: RT={fmt_rt(best['rt'])} YELLOW_AT={best['yellow_at']} "
|
|
||||||
f"RED_AT={best['red_at']} FAST_RED_AT={best['fast_red_at']}")
|
|
||||||
print(f" P(sweet 12:15-12:45) = {best['p_sweet']*100:.1f}%")
|
|
||||||
print(f" P(any lunch sleep) = {best['p_lunch_any']*100:.1f}%")
|
|
||||||
print(f" P(no work fail) = {best['p_no_work_sleep']*100:.1f}%")
|
|
||||||
print(f" mean lunch dead = {fmt_h(best['mean_lunch'])}")
|
|
||||||
print(f" mean work sleep = {fmt_h(best['mean_work_sleep'])}")
|
|
||||||
print(f" mean presses = {best['mean_presses']:.2f}/day")
|
|
||||||
print(f" mean after-hrs = {fmt_h(best['mean_after'])}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
+219
@@ -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
@@ -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.
|
||||||
|
// F13–F24 are intentionally unmapped on every mainstream OS, so even in the
|
||||||
|
// nightmare scenario where the deadline preempts the loop *between* a key-
|
||||||
|
// down and key-up report and the host ends up holding F13 forever, nothing
|
||||||
|
// visible happens — unlike with Shift, which would silently capitalise every
|
||||||
|
// keystroke from the user's real keyboard until they unplug the device.
|
||||||
|
// Earlier versions used Left Shift; that turned out to be exactly that
|
||||||
|
// nightmare scenario in practice.
|
||||||
|
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
@@ -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
@@ -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
@@ -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:15–12:45 sweet spot on ~52 % of days and somewhere in
|
|
||||||
// lunch on ~74 %. See the README's "Why four hours…" section and
|
|
||||||
// `scripts/tune_runtime.py` for the simulation.
|
|
||||||
const RUN_DURATION: Duration = Duration::from_hours(4);
|
|
||||||
const SHUTDOWN_LEAD: Duration = Duration::from_secs(30);
|
|
||||||
const RUN_BEFORE_SHUTDOWN: Duration = RUN_DURATION.saturating_sub(SHUTDOWN_LEAD);
|
|
||||||
const SHUTDOWN_ANIM_BUDGET: Duration = Duration::from_secs(5);
|
|
||||||
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.
|
|
||||||
// F13–F24 are intentionally unmapped on every mainstream OS, so even in the
|
|
||||||
// nightmare scenario where the deadline preempts the loop *between* a key-
|
|
||||||
// down and key-up report and the host ends up holding F13 forever, nothing
|
|
||||||
// visible happens — unlike with Shift, which would silently capitalise every
|
|
||||||
// keystroke from the user's real keyboard until they unplug the device.
|
|
||||||
// Earlier versions used Left Shift; that turned out to be exactly that
|
|
||||||
// nightmare scenario in practice.
|
|
||||||
const KBD_WAKE_TAPS: u32 = 4;
|
|
||||||
const KBD_TAP_HOLD: EDuration = EDuration::from_millis(30);
|
|
||||||
const KBD_TAP_GAP: EDuration = EDuration::from_millis(50);
|
|
||||||
// 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
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
Generated
+243
@@ -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",
|
||||||
|
]
|
||||||
@@ -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
|
||||||
@@ -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:00–13: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:15–12: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:15–12: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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user