refactor: split 832-line main.rs into focused modules

config (constants), usb (driver + tasks), led (NeoPixel anims), mouse
(cursor anims + HID), kbd (F13 wake + HID), chart (statechart + Ctx/Ev
+ JigglyActions). main.rs is now just Irqs + USB builder + chart.run().

No behavior change — comments, deadlines, and tunables preserved verbatim.
Builds clean on default and --features embassy,defmt; clippy -D warnings
passes.
This commit is contained in:
2026-05-04 18:56:27 -06:00
parent 334666b6e7
commit 7effaa293b
7 changed files with 791 additions and 688 deletions
+152
View File
@@ -0,0 +1,152 @@
//! 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 `scripts/tune_runtime.py`
//! — see the README's "Why four hours…" section for the rationale.
use embassy_time::Duration as EDuration;
use hsmc::Duration;
// ── Lifecycle timing (statechart Durations) ────────────────────────
// 4h00m: optimum from the 4-D Monte Carlo (RUN_DURATION × YELLOW_AT ×
// RED_AT × FAST_RED_AT) over a typical office workday distribution
// with a per-minute press-on-warning user model. Lands the screen-sleep
// in the 12:1512:45 sweet spot on ~52 % of days and somewhere in
// lunch on ~74 %. See the README's "Why four hours…" section and
// `scripts/tune_runtime.py` for the simulation.
pub(crate) const RUN_DURATION: Duration = Duration::from_hours(4);
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 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.
pub(crate) const YELLOW_AT: EDuration = EDuration::from_secs(30 * 60);
pub(crate) const RED_AT: EDuration = EDuration::from_secs(25 * 60);
pub(crate) const FAST_RED_AT: EDuration = EDuration::from_secs(20 * 60);
// LED breathing math
pub(crate) const LED_TICK: EDuration = EDuration::from_millis(20);
pub(crate) const SLOW_GREEN_PERIOD: EDuration = EDuration::from_secs(4);
pub(crate) const YELLOW_PERIOD: EDuration = EDuration::from_secs(3);
pub(crate) const RED_PERIOD: EDuration = EDuration::from_secs(2);
pub(crate) const FAST_RED_PERIOD: EDuration = EDuration::from_millis(500);
// Wake-up LED feedback periods. Blue on WakingHost (kbd+mouse), white on
// Settling, then a linear white→green fade across the Spinning duration so
// the LED hands off cleanly to Active's green breathing.
pub(crate) const WAKING_PULSE_PERIOD: EDuration = EDuration::from_millis(800);
pub(crate) const SETTLING_PULSE_PERIOD: EDuration = EDuration::from_millis(1200);
pub(crate) const SPINNER_FADE_DURATION: EDuration = EDuration::from_millis(600);
// LED brightness (raw WS2812 PWM, 0..=255).
pub(crate) const BREATHE_FLOOR: u8 = 1;
pub(crate) const BREATHE_PEAK: u8 = 16;
pub(crate) const FLASH_PEAK: u8 = 160;
// ── Animation parameters ───────────────────────────────────────────
// 8ms frames + HID poll_ms=8 means each frame's report actually reaches the
// host instead of being coalesced — at 16ms frames against a 60ms poll the
// shake looked sluggish because three reports out of four were dropped on the
// floor.
pub(crate) const ANIM_FRAME: EDuration = EDuration::from_millis(8);
// Frantic side-to-side, the gesture a person makes to wake a sleeping display.
// 10 full oscillations × 8 frames of full period = 80 frames × 8 ms = 640 ms,
// which works out to a ~12 Hz alternation — visibly "shaking", not "sweeping".
pub(crate) const WAKE_OSCILLATIONS: u32 = 10;
pub(crate) const WAKE_FRAMES_PER_HALF: u32 = 4;
pub(crate) const WAKE_AMPLITUDE: f32 = 60.0;
pub(crate) const WAKE_JITTER: f32 = 1.0;
// 2 s pause after the shake so the display has time to actually wake before
// we draw the spinner. The user wants this delay to live *here*, not before
// the shake.
pub(crate) const SETTLING_DELAY: Duration = Duration::from_secs(2);
// ── Keyboard wake (host-wake first pass) ───────────────────────────
// macOS often won't wake from raw HID mouse motion alone, but reliably wakes
// from any keyboard event. We tap **F13** four times before the mouse shake.
// F13F24 are intentionally unmapped on every mainstream OS, so even in the
// nightmare scenario where the deadline preempts the loop *between* a key-
// down and key-up report and the host ends up holding F13 forever, nothing
// visible happens — unlike with Shift, which would silently capitalise every
// keystroke from the user's real keyboard until they unplug the device.
// Earlier versions used Left Shift; that turned out to be exactly that
// nightmare scenario in practice.
pub(crate) const KBD_WAKE_TAPS: u32 = 4;
pub(crate) const KBD_TAP_HOLD: EDuration = EDuration::from_millis(30);
pub(crate) const KBD_TAP_GAP: EDuration = EDuration::from_millis(50);
// Hard internal deadline on the keyboard-wake entry action. The taps total
// ~320 ms so they finish well before this; the deadline only kicks in if a
// USB write blocks (e.g. the host hasn't bound the keyboard endpoint yet).
pub(crate) const KBD_WAKE_DEADLINE: EDuration = EDuration::from_millis(500);
// Statechart timer for the WakingWithKeyboard state — chosen above the
// internal deadline so the chart timer is what drives the transition out.
pub(crate) const KBD_PHASE_DURATION: Duration = Duration::from_millis(550);
// HID Keyboard usage page keycode for F13.
pub(crate) const KBD_KEY_F13: u8 = 0x68;
// Final-cleanup deadline — the all-keys-released report we send after the
// main work loop is bounded by this so a misbehaving endpoint can't pin
// the chart. Best-effort; if it doesn't land we tried.
pub(crate) const KBD_RELEASE_DEADLINE: EDuration = EDuration::from_millis(100);
// ── Mouse wake (host-wake second pass) ─────────────────────────────
// Internal deadline on the mouse-shake entry action — the shake itself takes
// ~640 ms; the cap exists so a misbehaving USB endpoint can't pin the chart.
pub(crate) const MOUSE_WAKE_DEADLINE: EDuration = EDuration::from_millis(1000);
pub(crate) const MOUSE_PHASE_DURATION: Duration = Duration::from_millis(1050);
// Three quick clockwise circles read more clearly as "spinner / running"
// than one slow lap. 25 frames per circle × 3 × 8 ms = 600 ms total.
pub(crate) const RUN_RADIUS: f32 = 40.0;
pub(crate) const RUN_FRAMES_PER_CIRCLE: u32 = 25;
pub(crate) const RUN_CIRCLES: u32 = 3;
// Shared "spin-down" spiral. Both radius and angle are driven by an eased phase
// u(t) = t^EASE_POW with EASE_POW > 1 — slow at the start, ~EASE_POW× the
// average rate at the finish. Because radius and angle share u, the inward
// spiral and the rotation accelerate together: a coin/Euler-disk feel.
pub(crate) const EASE_POW: f32 = 2.5;
pub(crate) const SPIRAL_RADIUS_END: f32 = 2.0;
// Final spiral — the dramatic full version, 30 s before USB goes silent.
pub(crate) const FINAL_SPIRAL_RADIUS_START: f32 = 80.0;
pub(crate) const FINAL_SPIRAL_TURNS: f32 = 5.0;
pub(crate) const FINAL_SPIRAL_FRAMES: u32 = 625; // 5.0 s @ 8 ms/frame
// 5-min warning — medium escalation.
pub(crate) const WARN5_RADIUS_START: f32 = 50.0;
pub(crate) const WARN5_TURNS: f32 = 3.0;
pub(crate) const WARN5_FRAMES: u32 = 312; // ~2.5 s
// 10-min warning — small foreshadow.
pub(crate) const WARN10_RADIUS_START: f32 = 30.0;
pub(crate) const WARN10_TURNS: f32 = 2.0;
pub(crate) const WARN10_FRAMES: u32 = 187; // ~1.5 s
// Offsets from Active-entry. The hsmc parent timer rule: timers in a parent
// state start on parent entry and survive sibling-substate transitions, so
// these three `after`s race concurrently against the same epoch.
pub(crate) const WARN_10_AT: Duration = RUN_BEFORE_SHUTDOWN.saturating_sub(Duration::from_mins(10));
pub(crate) const WARN_5_AT: Duration = RUN_BEFORE_SHUTDOWN.saturating_sub(Duration::from_mins(5));
// ── Watchdog (independent task) ────────────────────────────────────
pub(crate) const WATCHDOG_TIMEOUT: EDuration = EDuration::from_secs(8);
pub(crate) const WATCHDOG_FEED_INTERVAL: EDuration = EDuration::from_secs(5);
// ── Jiggle dwell ───────────────────────────────────────────────────
pub(crate) const PIXEL_DWELL: EDuration = EDuration::from_millis(25);
// ── Boot LED sweep ─────────────────────────────────────────────────
// Brief power-on confirmation. Kept short so the cursor shake — the actual
// "wake the display" gesture — happens promptly after reset.
pub(crate) const BOOT_SWEEP_STEP: EDuration = EDuration::from_millis(60);
// ── Shutdown LED flashes ───────────────────────────────────────────
pub(crate) const SHUTDOWN_FLASH_STEP: EDuration = EDuration::from_millis(400);