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
+220
View File
@@ -0,0 +1,220 @@
//! 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();
}
}
+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);
+36
View File
@@ -0,0 +1,36 @@
//! Keyboard HID: report send + the F13-tap host-wake helper.
use embassy_time::{Duration as EDuration, Timer, with_timeout};
use embassy_usb::class::hid::HidWriter;
use usbd_hid::descriptor::KeyboardReport;
use crate::config::{KBD_KEY_F13, KBD_TAP_GAP, KBD_TAP_HOLD, KBD_WAKE_TAPS};
use crate::usb::UsbDriver;
pub(crate) type KbdHid = HidWriter<'static, UsbDriver, 8>;
// Tap F13 four times. macOS reliably wakes from any keyboard event but is
// inconsistent about waking from raw mouse motion. Plain oneshot helper —
// the action method that calls this races it against KBD_WAKE_DEADLINE
// AND unconditionally sends an all-keys-released cleanup report after,
// to make sure we never leave a key held on the host.
pub(crate) async fn wake_with_keyboard(kbd: &mut KbdHid) {
for _ in 0..KBD_WAKE_TAPS {
send_kbd(kbd, 0, [KBD_KEY_F13, 0, 0, 0, 0, 0]).await;
Timer::after(KBD_TAP_HOLD).await;
send_kbd(kbd, 0, [0; 6]).await;
Timer::after(KBD_TAP_GAP).await;
}
}
// ── HID primitive ──────────────────────────────────────────────────
pub(crate) async fn send_kbd(kbd: &mut KbdHid, modifier: u8, keycodes: [u8; 6]) {
let report = KeyboardReport {
modifier,
reserved: 0,
leds: 0,
keycodes,
};
let _ = with_timeout(EDuration::from_secs(3), kbd.write_serialize(&report)).await;
}
+146
View File
@@ -0,0 +1,146 @@
//! 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;
}
+17 -688
View File
@@ -1,718 +1,47 @@
#![no_std]
#![no_main]
use core::f32::consts::PI;
use embassy_executor::Spawner;
use embassy_rp::{
Peri, bind_interrupts,
clocks::RoscRng,
dma,
flash::Flash,
bind_interrupts, dma,
gpio::{Level, Output},
peripherals::{DMA_CH0, FLASH, PIO0, USB},
peripherals::{DMA_CH0, PIO0, USB},
pio::{InterruptHandler as PioInterruptHandler, Pio},
pio_programs::ws2812::{Grb, PioWs2812, PioWs2812Program},
pio_programs::ws2812::{PioWs2812, PioWs2812Program},
usb::{Driver, InterruptHandler as UsbInterruptHandler},
watchdog::Watchdog,
};
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::{
Builder, Config as UsbConfig, UsbDevice,
Builder, Config as UsbConfig,
class::hid::{Config as HidConfig, HidBootProtocol, HidSubclass, HidWriter, State},
};
use hsmc::{Duration, statechart};
use libm::{cosf, powf, roundf, sinf};
#[cfg(not(feature = "defmt"))]
use panic_reset as _;
use smart_leds::RGB8;
use static_cell::StaticCell;
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 {
USBCTRL_IRQ => UsbInterruptHandler<USB>;
PIO0_IRQ_0 => PioInterruptHandler<PIO0>;
DMA_IRQ_0 => dma::InterruptHandler<DMA_CH0>;
});
type UsbDriver = Driver<'static, USB>;
type Neo = PioWs2812<'static, PIO0, 0, 1, Grb>;
type MouseHid = HidWriter<'static, UsbDriver, 5>;
type KbdHid = HidWriter<'static, UsbDriver, 8>;
// ── Lifecycle timing (statechart Durations) ────────────────────────
// 4h00m: optimum from the 4-D Monte Carlo (RUN_DURATION × YELLOW_AT ×
// RED_AT × FAST_RED_AT) over a typical office workday distribution
// with a per-minute press-on-warning user model. Lands the screen-sleep
// in the 12:1512:45 sweet spot on ~52 % of days and somewhere in
// lunch on ~74 %. See the README's "Why four hours…" section and
// `scripts/tune_runtime.py` for the simulation.
const RUN_DURATION: Duration = Duration::from_hours(4);
const SHUTDOWN_LEAD: Duration = Duration::from_secs(30);
const RUN_BEFORE_SHUTDOWN: Duration = RUN_DURATION.saturating_sub(SHUTDOWN_LEAD);
const SHUTDOWN_ANIM_BUDGET: Duration = Duration::from_secs(5);
const QUIET_AFTER_ANIM: Duration = SHUTDOWN_LEAD.saturating_sub(SHUTDOWN_ANIM_BUDGET);
const JIGGLE_PERIOD: Duration = Duration::from_secs(270);
const FLASH_DURATION: Duration = Duration::from_millis(100);
// Phase boundaries — compared against time *remaining* in Active.
// Joint optimum from `scripts/tune_runtime.py`. Yellow and red are
// kept deliberately short (5 min each); the long phase is fast-red.
// See the README's "Why four hours…" section for the rationale.
const YELLOW_AT: EDuration = EDuration::from_secs(30 * 60);
const RED_AT: EDuration = EDuration::from_secs(25 * 60);
const FAST_RED_AT: EDuration = EDuration::from_secs(20 * 60);
// LED breathing math
const LED_TICK: EDuration = EDuration::from_millis(20);
const SLOW_GREEN_PERIOD: EDuration = EDuration::from_secs(4);
const YELLOW_PERIOD: EDuration = EDuration::from_secs(3);
const RED_PERIOD: EDuration = EDuration::from_secs(2);
const FAST_RED_PERIOD: EDuration = EDuration::from_millis(500);
// 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.
const WAKING_PULSE_PERIOD: EDuration = EDuration::from_millis(800);
const SETTLING_PULSE_PERIOD: EDuration = EDuration::from_millis(1200);
const SPINNER_FADE_DURATION: EDuration = EDuration::from_millis(600);
// LED brightness (raw WS2812 PWM, 0..=255).
const BREATHE_FLOOR: u8 = 1;
const BREATHE_PEAK: u8 = 16;
const FLASH_PEAK: u8 = 160;
// ── Animation parameters ───────────────────────────────────────────
// 8ms frames + HID poll_ms=8 means each frame's report actually reaches the
// host instead of being coalesced — at 16ms frames against a 60ms poll the
// shake looked sluggish because three reports out of four were dropped on the
// floor.
const ANIM_FRAME: EDuration = EDuration::from_millis(8);
// Frantic side-to-side, the gesture a person makes to wake a sleeping display.
// 10 full oscillations × 8 frames of full period = 80 frames × 8 ms = 640 ms,
// which works out to a ~12 Hz alternation — visibly "shaking", not "sweeping".
const WAKE_OSCILLATIONS: u32 = 10;
const WAKE_FRAMES_PER_HALF: u32 = 4;
const WAKE_AMPLITUDE: f32 = 60.0;
const WAKE_JITTER: f32 = 1.0;
// 2 s pause after the shake so the display has time to actually wake before
// we draw the spinner. The user wants this delay to live *here*, not before
// the shake.
const SETTLING_DELAY: Duration = Duration::from_secs(2);
// ── Keyboard wake (host-wake first pass) ───────────────────────────
// macOS often won't wake from raw HID mouse motion alone, but reliably wakes
// from any keyboard event. We tap **F13** four times before the mouse shake.
// F13F24 are intentionally unmapped on every mainstream OS, so even in the
// nightmare scenario where the deadline preempts the loop *between* a key-
// down and key-up report and the host ends up holding F13 forever, nothing
// visible happens — unlike with Shift, which would silently capitalise every
// keystroke from the user's real keyboard until they unplug the device.
// Earlier versions used Left Shift; that turned out to be exactly that
// nightmare scenario in practice.
const KBD_WAKE_TAPS: u32 = 4;
const KBD_TAP_HOLD: EDuration = EDuration::from_millis(30);
const KBD_TAP_GAP: EDuration = EDuration::from_millis(50);
// Hard internal deadline on the keyboard-wake entry action. The taps total
// ~320 ms so they finish well before this; the deadline only kicks in if a
// USB write blocks (e.g. the host hasn't bound the keyboard endpoint yet).
const KBD_WAKE_DEADLINE: EDuration = EDuration::from_millis(500);
// Statechart timer for the WakingWithKeyboard state — chosen above the
// internal deadline so the chart timer is what drives the transition out.
const KBD_PHASE_DURATION: Duration = Duration::from_millis(550);
// HID Keyboard usage page keycode for F13.
const KBD_KEY_F13: u8 = 0x68;
// Final-cleanup deadline — the all-keys-released report we send after the
// main work loop is bounded by this so a misbehaving endpoint can't pin
// the chart. Best-effort; if it doesn't land we tried.
const KBD_RELEASE_DEADLINE: EDuration = EDuration::from_millis(100);
// ── Mouse wake (host-wake second pass) ─────────────────────────────
// Internal deadline on the mouse-shake entry action — the shake itself takes
// ~640 ms; the cap exists so a misbehaving USB endpoint can't pin the chart.
const MOUSE_WAKE_DEADLINE: EDuration = EDuration::from_millis(1000);
const MOUSE_PHASE_DURATION: Duration = Duration::from_millis(1050);
// Three quick clockwise circles read more clearly as "spinner / running"
// than one slow lap. 25 frames per circle × 3 × 8 ms = 600 ms total.
const RUN_RADIUS: f32 = 40.0;
const RUN_FRAMES_PER_CIRCLE: u32 = 25;
const RUN_CIRCLES: u32 = 3;
// Shared "spin-down" spiral. Both radius and angle are driven by an eased phase
// u(t) = t^EASE_POW with EASE_POW > 1 — slow at the start, ~EASE_POW× the
// average rate at the finish. Because radius and angle share u, the inward
// spiral and the rotation accelerate together: a coin/Euler-disk feel.
const EASE_POW: f32 = 2.5;
const SPIRAL_RADIUS_END: f32 = 2.0;
// Final spiral — the dramatic full version, 30 s before USB goes silent.
const FINAL_SPIRAL_RADIUS_START: f32 = 80.0;
const FINAL_SPIRAL_TURNS: f32 = 5.0;
const FINAL_SPIRAL_FRAMES: u32 = 625; // 5.0 s @ 8 ms/frame
// 5-min warning — medium escalation.
const WARN5_RADIUS_START: f32 = 50.0;
const WARN5_TURNS: f32 = 3.0;
const WARN5_FRAMES: u32 = 312; // ~2.5 s
// 10-min warning — small foreshadow.
const WARN10_RADIUS_START: f32 = 30.0;
const WARN10_TURNS: f32 = 2.0;
const WARN10_FRAMES: u32 = 187; // ~1.5 s
// Offsets from Active-entry. The hsmc parent timer rule: timers in a parent
// state start on parent entry and survive sibling-substate transitions, so
// these three `after`s race concurrently against the same epoch.
const WARN_10_AT: Duration = RUN_BEFORE_SHUTDOWN.saturating_sub(Duration::from_mins(10));
const WARN_5_AT: Duration = RUN_BEFORE_SHUTDOWN.saturating_sub(Duration::from_mins(5));
// ── Watchdog (independent task) ────────────────────────────────────
const WATCHDOG_TIMEOUT: EDuration = EDuration::from_secs(8);
const WATCHDOG_FEED_INTERVAL: EDuration = EDuration::from_secs(5);
// ── Jiggle dwell ───────────────────────────────────────────────────
const PIXEL_DWELL: EDuration = EDuration::from_millis(25);
// ── Boot LED sweep ─────────────────────────────────────────────────
// Brief power-on confirmation. Kept short so the cursor shake — the actual
// "wake the display" gesture — happens promptly after reset.
const BOOT_SWEEP_STEP: EDuration = EDuration::from_millis(60);
// ── Shutdown LED flashes ───────────────────────────────────────────
const SHUTDOWN_FLASH_STEP: EDuration = EDuration::from_millis(400);
pub struct Ctx {
pub mouse: MouseHid,
pub kbd: KbdHid,
pub neo: Neo,
pub neo_pwr: Output<'static>,
pub active_start: Option<Instant>,
}
#[derive(Debug, Clone)]
pub enum Ev {
BootDone,
SpinDone,
Jiggled,
WarnDone,
SpiralDone,
}
statechart! {
Jiggly {
context: Ctx;
events: Ev;
default(Booting);
state Booting {
during: boot_sweep(neo, neo_pwr);
on(BootDone) => WakingHost;
}
// Wake the host using whichever input the host actually responds to.
// Keyboard first (more reliable on macOS), then a mouse shake as
// belt-and-suspenders. Each substate runs a oneshot entry action with
// its own internal deadline; the chart timer is what advances the
// chart. No durings, no events — purely entry + timer.
state WakingHost {
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();
}
}
// ── 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;
}
}
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;
}
}
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.
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)
}
// 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]
async fn main(spawner: Spawner) {
let p = embassy_rp::init(Default::default());
+163
View File
@@ -0,0 +1,163 @@
//! Mouse HID: report send + the family of cursor animations
//! (wake-shake, spinner, eased spirals).
use core::f32::consts::PI;
use embassy_rp::clocks::RoscRng;
use embassy_time::{Duration as EDuration, Timer, with_timeout};
use embassy_usb::class::hid::HidWriter;
use libm::{cosf, powf, roundf, sinf};
use usbd_hid::descriptor::MouseReport;
use crate::chart::Ev;
use crate::config::{
ANIM_FRAME, EASE_POW, FINAL_SPIRAL_FRAMES, FINAL_SPIRAL_RADIUS_START, FINAL_SPIRAL_TURNS,
RUN_CIRCLES, RUN_FRAMES_PER_CIRCLE, RUN_RADIUS, SPIRAL_RADIUS_END, WAKE_AMPLITUDE,
WAKE_FRAMES_PER_HALF, WAKE_JITTER, WAKE_OSCILLATIONS, WARN5_FRAMES, WARN5_RADIUS_START,
WARN5_TURNS, WARN10_FRAMES, WARN10_RADIUS_START, WARN10_TURNS,
};
use crate::usb::UsbDriver;
pub(crate) type MouseHid = HidWriter<'static, UsbDriver, 5>;
// Frantic horizontal mouse shake. Plain oneshot helper — the action method
// that calls this races it against MOUSE_WAKE_DEADLINE.
pub(crate) async fn wake_with_mouse(mouse: &mut MouseHid) {
let period_frames = (WAKE_FRAMES_PER_HALF * 2) as f32;
let total_frames = WAKE_OSCILLATIONS * WAKE_FRAMES_PER_HALF * 2;
let mut prev_x: f32 = 0.0;
let mut prev_y: f32 = 0.0;
let mut acc_x: f32 = 0.0;
let mut acc_y: f32 = 0.0;
for f in 0..total_frames {
let phase = (f as f32) * 2.0 * PI / period_frames;
let next_x = WAKE_AMPLITUDE * sinf(phase);
let jitter = ((RoscRng::next_u8() as f32) / 255.0 - 0.5) * 2.0 * WAKE_JITTER;
let next_y = jitter;
let (dx, dy, used_x, used_y) = step_delta(prev_x, prev_y, next_x, next_y, acc_x, acc_y);
acc_x = used_x;
acc_y = used_y;
send_mouse(mouse, dx, dy).await;
prev_x = next_x;
prev_y = next_y;
Timer::after(ANIM_FRAME).await;
}
}
pub(crate) async fn animate_spinner(mouse: &mut MouseHid) -> Ev {
let total_frames = RUN_CIRCLES * RUN_FRAMES_PER_CIRCLE;
let period_frames = RUN_FRAMES_PER_CIRCLE as f32;
let mut prev_x: f32 = 0.0;
let mut prev_y: f32 = 0.0;
let mut acc_x: f32 = 0.0;
let mut acc_y: f32 = 0.0;
for f in 0..total_frames {
let angle = (f as f32) * 2.0 * PI / period_frames;
let next_x = RUN_RADIUS * sinf(angle);
let next_y = RUN_RADIUS * (1.0 - cosf(angle));
let (dx, dy, used_x, used_y) = step_delta(prev_x, prev_y, next_x, next_y, acc_x, acc_y);
acc_x = used_x;
acc_y = used_y;
send_mouse(mouse, dx, dy).await;
prev_x = next_x;
prev_y = next_y;
Timer::after(ANIM_FRAME).await;
}
Ev::SpinDone
}
async fn animate_spiral(
mouse: &mut MouseHid,
radius_start: f32,
radius_end: f32,
turns: f32,
frames: u32,
) {
let mut prev_x: f32 = 0.0;
let mut prev_y: f32 = 0.0;
let mut acc_x: f32 = 0.0;
let mut acc_y: f32 = 0.0;
for f in 0..frames {
let t = (f as f32) / (frames as f32);
let u = powf(t, EASE_POW);
let angle = u * 2.0 * PI * turns;
let radius = radius_start + (radius_end - radius_start) * u;
// Subtract starting offset so the spiral begins at the cursor's entry point.
let next_x = radius * cosf(angle) - radius_start;
let next_y = radius * sinf(angle);
let (dx, dy, used_x, used_y) = step_delta(prev_x, prev_y, next_x, next_y, acc_x, acc_y);
acc_x = used_x;
acc_y = used_y;
send_mouse(mouse, dx, dy).await;
prev_x = next_x;
prev_y = next_y;
Timer::after(ANIM_FRAME).await;
}
}
pub(crate) async fn animate_final_spiral(mouse: &mut MouseHid) -> Ev {
animate_spiral(
mouse,
FINAL_SPIRAL_RADIUS_START,
SPIRAL_RADIUS_END,
FINAL_SPIRAL_TURNS,
FINAL_SPIRAL_FRAMES,
)
.await;
Ev::SpiralDone
}
pub(crate) async fn animate_warning_5(mouse: &mut MouseHid) -> Ev {
animate_spiral(
mouse,
WARN5_RADIUS_START,
SPIRAL_RADIUS_END,
WARN5_TURNS,
WARN5_FRAMES,
)
.await;
Ev::WarnDone
}
pub(crate) async fn animate_warning_10(mouse: &mut MouseHid) -> Ev {
animate_spiral(
mouse,
WARN10_RADIUS_START,
SPIRAL_RADIUS_END,
WARN10_TURNS,
WARN10_FRAMES,
)
.await;
Ev::WarnDone
}
// Translate continuous (next_x, next_y) target into clamped i8 deltas while
// carrying sub-pixel residue forward, so a 90-frame circle of radius 50 doesn't
// lose ~half its motion to truncation. Returns (dx, dy, residual_x, residual_y).
fn step_delta(
prev_x: f32,
prev_y: f32,
next_x: f32,
next_y: f32,
acc_x: f32,
acc_y: f32,
) -> (i8, i8, f32, f32) {
let want_x = (next_x - prev_x) + acc_x;
let want_y = (next_y - prev_y) + acc_y;
let dx = roundf(want_x.clamp(-127.0, 127.0)) as i8;
let dy = roundf(want_y.clamp(-127.0, 127.0)) as i8;
(dx, dy, want_x - dx as f32, want_y - dy as f32)
}
// ── HID primitive ──────────────────────────────────────────────────
pub(crate) async fn send_mouse(mouse: &mut MouseHid, x: i8, y: i8) {
let report = MouseReport {
buttons: 0,
x,
y,
wheel: 0,
pan: 0,
};
let _ = with_timeout(EDuration::from_secs(3), mouse.write_serialize(&report)).await;
}
+57
View File
@@ -0,0 +1,57 @@
//! USB driver type aliases, identity helpers, and infra tasks
//! (USB device pump + watchdog feeder).
use embassy_rp::{
Peri,
flash::Flash,
peripherals::{FLASH, USB},
usb::Driver,
watchdog::Watchdog,
};
use embassy_time::Timer;
use embassy_usb::UsbDevice;
use static_cell::StaticCell;
use crate::config::{WATCHDOG_FEED_INTERVAL, WATCHDOG_TIMEOUT};
pub(crate) type UsbDriver = Driver<'static, USB>;
// ── USB serial number from RP2040 unique chip ID ───────────────────
// Reads the 64-bit unique ID baked into the on-board SPI flash, formats
// it as 16 ASCII hex chars in a static buffer, and returns a `'static`
// string suitable for `embassy_usb::Config::serial_number`. This makes
// the device's USB identity stable per-board across replugs (so hosts
// stop treating each plug as a new device) while still being unique
// between different boards.
const FLASH_SIZE: usize = 2 * 1024 * 1024; // Xiao RP2040 has 2 MB.
pub(crate) fn make_serial(flash_periph: Peri<'static, FLASH>) -> &'static str {
static SERIAL: StaticCell<[u8; 16]> = StaticCell::new();
const HEX: &[u8; 16] = b"0123456789ABCDEF";
let mut flash = Flash::<_, _, FLASH_SIZE>::new_blocking(flash_periph);
let mut id = [0u8; 8];
let _ = flash.blocking_unique_id(&mut id);
let buf = SERIAL.init([0; 16]);
for (i, &b) in id.iter().enumerate() {
buf[i * 2] = HEX[(b >> 4) as usize];
buf[i * 2 + 1] = HEX[(b & 0x0f) as usize];
}
core::str::from_utf8(buf).unwrap()
}
// ── Tasks ──────────────────────────────────────────────────────────
#[embassy_executor::task]
pub(crate) async fn usb_task(mut device: UsbDevice<'static, UsbDriver>) {
device.run().await;
}
#[embassy_executor::task]
pub(crate) async fn watchdog_task(mut wd: Watchdog) -> ! {
loop {
wd.feed(WATCHDOG_TIMEOUT);
Timer::after(WATCHDOG_FEED_INTERVAL).await;
}
}