diff --git a/src/chart.rs b/src/chart.rs new file mode 100644 index 0000000..255f5e2 --- /dev/null +++ b/src/chart.rs @@ -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, +} + +#[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(); + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..b476af9 --- /dev/null +++ b/src/config.rs @@ -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: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. +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. +// 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); diff --git a/src/kbd.rs b/src/kbd.rs new file mode 100644 index 0000000..6d2069d --- /dev/null +++ b/src/kbd.rs @@ -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; +} diff --git a/src/led.rs b/src/led.rs new file mode 100644 index 0000000..a57a4f1 --- /dev/null +++ b/src/led.rs @@ -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) -> 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; +} diff --git a/src/main.rs b/src/main.rs index 6925e39..8a1bd48 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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; PIO0_IRQ_0 => PioInterruptHandler; DMA_IRQ_0 => dma::InterruptHandler; }); -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); - -// 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. -// 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, -} - -#[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) -> 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()); diff --git a/src/mouse.rs b/src/mouse.rs new file mode 100644 index 0000000..994b7a0 --- /dev/null +++ b/src/mouse.rs @@ -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; +} diff --git a/src/usb.rs b/src/usb.rs new file mode 100644 index 0000000..c708bdb --- /dev/null +++ b/src/usb.rs @@ -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; + } +}