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:
+220
@@ -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
@@ -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);
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
//! Keyboard HID: report send + the F13-tap host-wake helper.
|
||||||
|
|
||||||
|
use embassy_time::{Duration as EDuration, Timer, with_timeout};
|
||||||
|
use embassy_usb::class::hid::HidWriter;
|
||||||
|
use usbd_hid::descriptor::KeyboardReport;
|
||||||
|
|
||||||
|
use crate::config::{KBD_KEY_F13, KBD_TAP_GAP, KBD_TAP_HOLD, KBD_WAKE_TAPS};
|
||||||
|
use crate::usb::UsbDriver;
|
||||||
|
|
||||||
|
pub(crate) type KbdHid = HidWriter<'static, UsbDriver, 8>;
|
||||||
|
|
||||||
|
// Tap F13 four times. macOS reliably wakes from any keyboard event but is
|
||||||
|
// inconsistent about waking from raw mouse motion. Plain oneshot helper —
|
||||||
|
// the action method that calls this races it against KBD_WAKE_DEADLINE
|
||||||
|
// AND unconditionally sends an all-keys-released cleanup report after,
|
||||||
|
// to make sure we never leave a key held on the host.
|
||||||
|
pub(crate) async fn wake_with_keyboard(kbd: &mut KbdHid) {
|
||||||
|
for _ in 0..KBD_WAKE_TAPS {
|
||||||
|
send_kbd(kbd, 0, [KBD_KEY_F13, 0, 0, 0, 0, 0]).await;
|
||||||
|
Timer::after(KBD_TAP_HOLD).await;
|
||||||
|
send_kbd(kbd, 0, [0; 6]).await;
|
||||||
|
Timer::after(KBD_TAP_GAP).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── HID primitive ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub(crate) async fn send_kbd(kbd: &mut KbdHid, modifier: u8, keycodes: [u8; 6]) {
|
||||||
|
let report = KeyboardReport {
|
||||||
|
modifier,
|
||||||
|
reserved: 0,
|
||||||
|
leds: 0,
|
||||||
|
keycodes,
|
||||||
|
};
|
||||||
|
let _ = with_timeout(EDuration::from_secs(3), kbd.write_serialize(&report)).await;
|
||||||
|
}
|
||||||
+146
@@ -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
@@ -1,718 +1,47 @@
|
|||||||
#![no_std]
|
#![no_std]
|
||||||
#![no_main]
|
#![no_main]
|
||||||
|
|
||||||
use core::f32::consts::PI;
|
|
||||||
|
|
||||||
use embassy_executor::Spawner;
|
use embassy_executor::Spawner;
|
||||||
use embassy_rp::{
|
use embassy_rp::{
|
||||||
Peri, bind_interrupts,
|
bind_interrupts, dma,
|
||||||
clocks::RoscRng,
|
|
||||||
dma,
|
|
||||||
flash::Flash,
|
|
||||||
gpio::{Level, Output},
|
gpio::{Level, Output},
|
||||||
peripherals::{DMA_CH0, FLASH, PIO0, USB},
|
peripherals::{DMA_CH0, PIO0, USB},
|
||||||
pio::{InterruptHandler as PioInterruptHandler, Pio},
|
pio::{InterruptHandler as PioInterruptHandler, Pio},
|
||||||
pio_programs::ws2812::{Grb, PioWs2812, PioWs2812Program},
|
pio_programs::ws2812::{PioWs2812, PioWs2812Program},
|
||||||
usb::{Driver, InterruptHandler as UsbInterruptHandler},
|
usb::{Driver, InterruptHandler as UsbInterruptHandler},
|
||||||
watchdog::Watchdog,
|
watchdog::Watchdog,
|
||||||
};
|
};
|
||||||
use embassy_sync::{blocking_mutex::raw::CriticalSectionRawMutex, channel::Channel};
|
use embassy_sync::{blocking_mutex::raw::CriticalSectionRawMutex, channel::Channel};
|
||||||
use embassy_time::{Duration as EDuration, Instant, Timer, with_timeout};
|
use embassy_time::Timer;
|
||||||
use embassy_usb::{
|
use embassy_usb::{
|
||||||
Builder, Config as UsbConfig, UsbDevice,
|
Builder, Config as UsbConfig,
|
||||||
class::hid::{Config as HidConfig, HidBootProtocol, HidSubclass, HidWriter, State},
|
class::hid::{Config as HidConfig, HidBootProtocol, HidSubclass, HidWriter, State},
|
||||||
};
|
};
|
||||||
use hsmc::{Duration, statechart};
|
|
||||||
use libm::{cosf, powf, roundf, sinf};
|
|
||||||
#[cfg(not(feature = "defmt"))]
|
#[cfg(not(feature = "defmt"))]
|
||||||
use panic_reset as _;
|
use panic_reset as _;
|
||||||
use smart_leds::RGB8;
|
|
||||||
use static_cell::StaticCell;
|
use static_cell::StaticCell;
|
||||||
use usbd_hid::descriptor::{KeyboardReport, MouseReport, SerializedDescriptor};
|
use usbd_hid::descriptor::{KeyboardReport, MouseReport, SerializedDescriptor};
|
||||||
#[cfg(feature = "defmt")]
|
#[cfg(feature = "defmt")]
|
||||||
use {defmt_rtt as _, panic_probe as _};
|
use {defmt_rtt as _, panic_probe as _};
|
||||||
|
|
||||||
|
mod chart;
|
||||||
|
mod config;
|
||||||
|
mod kbd;
|
||||||
|
mod led;
|
||||||
|
mod mouse;
|
||||||
|
mod usb;
|
||||||
|
|
||||||
|
use chart::{Ctx, Ev, Jiggly};
|
||||||
|
use config::{WATCHDOG_FEED_INTERVAL, WATCHDOG_TIMEOUT};
|
||||||
|
use led::Neo;
|
||||||
|
use usb::{make_serial, usb_task, watchdog_task};
|
||||||
|
|
||||||
bind_interrupts!(struct Irqs {
|
bind_interrupts!(struct Irqs {
|
||||||
USBCTRL_IRQ => UsbInterruptHandler<USB>;
|
USBCTRL_IRQ => UsbInterruptHandler<USB>;
|
||||||
PIO0_IRQ_0 => PioInterruptHandler<PIO0>;
|
PIO0_IRQ_0 => PioInterruptHandler<PIO0>;
|
||||||
DMA_IRQ_0 => dma::InterruptHandler<DMA_CH0>;
|
DMA_IRQ_0 => dma::InterruptHandler<DMA_CH0>;
|
||||||
});
|
});
|
||||||
|
|
||||||
type UsbDriver = Driver<'static, USB>;
|
|
||||||
type Neo = PioWs2812<'static, PIO0, 0, 1, Grb>;
|
|
||||||
type MouseHid = HidWriter<'static, UsbDriver, 5>;
|
|
||||||
type KbdHid = HidWriter<'static, UsbDriver, 8>;
|
|
||||||
|
|
||||||
// ── Lifecycle timing (statechart Durations) ────────────────────────
|
|
||||||
// 4h00m: optimum from the 4-D Monte Carlo (RUN_DURATION × YELLOW_AT ×
|
|
||||||
// RED_AT × FAST_RED_AT) over a typical office workday distribution
|
|
||||||
// with a per-minute press-on-warning user model. Lands the screen-sleep
|
|
||||||
// in the 12:15–12:45 sweet spot on ~52 % of days and somewhere in
|
|
||||||
// lunch on ~74 %. See the README's "Why four hours…" section and
|
|
||||||
// `scripts/tune_runtime.py` for the simulation.
|
|
||||||
const RUN_DURATION: Duration = Duration::from_hours(4);
|
|
||||||
const SHUTDOWN_LEAD: Duration = Duration::from_secs(30);
|
|
||||||
const RUN_BEFORE_SHUTDOWN: Duration = RUN_DURATION.saturating_sub(SHUTDOWN_LEAD);
|
|
||||||
const SHUTDOWN_ANIM_BUDGET: Duration = Duration::from_secs(5);
|
|
||||||
const QUIET_AFTER_ANIM: Duration = SHUTDOWN_LEAD.saturating_sub(SHUTDOWN_ANIM_BUDGET);
|
|
||||||
const JIGGLE_PERIOD: Duration = Duration::from_secs(270);
|
|
||||||
const FLASH_DURATION: Duration = Duration::from_millis(100);
|
|
||||||
|
|
||||||
// Phase boundaries — compared against time *remaining* in Active.
|
|
||||||
// Joint optimum from `scripts/tune_runtime.py`. Yellow and red are
|
|
||||||
// kept deliberately short (5 min each); the long phase is fast-red.
|
|
||||||
// See the README's "Why four hours…" section for the rationale.
|
|
||||||
const YELLOW_AT: EDuration = EDuration::from_secs(30 * 60);
|
|
||||||
const RED_AT: EDuration = EDuration::from_secs(25 * 60);
|
|
||||||
const FAST_RED_AT: EDuration = EDuration::from_secs(20 * 60);
|
|
||||||
|
|
||||||
// LED breathing math
|
|
||||||
const LED_TICK: EDuration = EDuration::from_millis(20);
|
|
||||||
const SLOW_GREEN_PERIOD: EDuration = EDuration::from_secs(4);
|
|
||||||
const YELLOW_PERIOD: EDuration = EDuration::from_secs(3);
|
|
||||||
const RED_PERIOD: EDuration = EDuration::from_secs(2);
|
|
||||||
const FAST_RED_PERIOD: EDuration = EDuration::from_millis(500);
|
|
||||||
|
|
||||||
// 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<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]
|
#[embassy_executor::main]
|
||||||
async fn main(spawner: Spawner) {
|
async fn main(spawner: Spawner) {
|
||||||
let p = embassy_rp::init(Default::default());
|
let p = embassy_rp::init(Default::default());
|
||||||
|
|||||||
+163
@@ -0,0 +1,163 @@
|
|||||||
|
//! Mouse HID: report send + the family of cursor animations
|
||||||
|
//! (wake-shake, spinner, eased spirals).
|
||||||
|
|
||||||
|
use core::f32::consts::PI;
|
||||||
|
|
||||||
|
use embassy_rp::clocks::RoscRng;
|
||||||
|
use embassy_time::{Duration as EDuration, Timer, with_timeout};
|
||||||
|
use embassy_usb::class::hid::HidWriter;
|
||||||
|
use libm::{cosf, powf, roundf, sinf};
|
||||||
|
use usbd_hid::descriptor::MouseReport;
|
||||||
|
|
||||||
|
use crate::chart::Ev;
|
||||||
|
use crate::config::{
|
||||||
|
ANIM_FRAME, EASE_POW, FINAL_SPIRAL_FRAMES, FINAL_SPIRAL_RADIUS_START, FINAL_SPIRAL_TURNS,
|
||||||
|
RUN_CIRCLES, RUN_FRAMES_PER_CIRCLE, RUN_RADIUS, SPIRAL_RADIUS_END, WAKE_AMPLITUDE,
|
||||||
|
WAKE_FRAMES_PER_HALF, WAKE_JITTER, WAKE_OSCILLATIONS, WARN5_FRAMES, WARN5_RADIUS_START,
|
||||||
|
WARN5_TURNS, WARN10_FRAMES, WARN10_RADIUS_START, WARN10_TURNS,
|
||||||
|
};
|
||||||
|
use crate::usb::UsbDriver;
|
||||||
|
|
||||||
|
pub(crate) type MouseHid = HidWriter<'static, UsbDriver, 5>;
|
||||||
|
|
||||||
|
// Frantic horizontal mouse shake. Plain oneshot helper — the action method
|
||||||
|
// that calls this races it against MOUSE_WAKE_DEADLINE.
|
||||||
|
pub(crate) async fn wake_with_mouse(mouse: &mut MouseHid) {
|
||||||
|
let period_frames = (WAKE_FRAMES_PER_HALF * 2) as f32;
|
||||||
|
let total_frames = WAKE_OSCILLATIONS * WAKE_FRAMES_PER_HALF * 2;
|
||||||
|
let mut prev_x: f32 = 0.0;
|
||||||
|
let mut prev_y: f32 = 0.0;
|
||||||
|
let mut acc_x: f32 = 0.0;
|
||||||
|
let mut acc_y: f32 = 0.0;
|
||||||
|
for f in 0..total_frames {
|
||||||
|
let phase = (f as f32) * 2.0 * PI / period_frames;
|
||||||
|
let next_x = WAKE_AMPLITUDE * sinf(phase);
|
||||||
|
let jitter = ((RoscRng::next_u8() as f32) / 255.0 - 0.5) * 2.0 * WAKE_JITTER;
|
||||||
|
let next_y = jitter;
|
||||||
|
let (dx, dy, used_x, used_y) = step_delta(prev_x, prev_y, next_x, next_y, acc_x, acc_y);
|
||||||
|
acc_x = used_x;
|
||||||
|
acc_y = used_y;
|
||||||
|
send_mouse(mouse, dx, dy).await;
|
||||||
|
prev_x = next_x;
|
||||||
|
prev_y = next_y;
|
||||||
|
Timer::after(ANIM_FRAME).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn animate_spinner(mouse: &mut MouseHid) -> Ev {
|
||||||
|
let total_frames = RUN_CIRCLES * RUN_FRAMES_PER_CIRCLE;
|
||||||
|
let period_frames = RUN_FRAMES_PER_CIRCLE as f32;
|
||||||
|
let mut prev_x: f32 = 0.0;
|
||||||
|
let mut prev_y: f32 = 0.0;
|
||||||
|
let mut acc_x: f32 = 0.0;
|
||||||
|
let mut acc_y: f32 = 0.0;
|
||||||
|
for f in 0..total_frames {
|
||||||
|
let angle = (f as f32) * 2.0 * PI / period_frames;
|
||||||
|
let next_x = RUN_RADIUS * sinf(angle);
|
||||||
|
let next_y = RUN_RADIUS * (1.0 - cosf(angle));
|
||||||
|
let (dx, dy, used_x, used_y) = step_delta(prev_x, prev_y, next_x, next_y, acc_x, acc_y);
|
||||||
|
acc_x = used_x;
|
||||||
|
acc_y = used_y;
|
||||||
|
send_mouse(mouse, dx, dy).await;
|
||||||
|
prev_x = next_x;
|
||||||
|
prev_y = next_y;
|
||||||
|
Timer::after(ANIM_FRAME).await;
|
||||||
|
}
|
||||||
|
Ev::SpinDone
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn animate_spiral(
|
||||||
|
mouse: &mut MouseHid,
|
||||||
|
radius_start: f32,
|
||||||
|
radius_end: f32,
|
||||||
|
turns: f32,
|
||||||
|
frames: u32,
|
||||||
|
) {
|
||||||
|
let mut prev_x: f32 = 0.0;
|
||||||
|
let mut prev_y: f32 = 0.0;
|
||||||
|
let mut acc_x: f32 = 0.0;
|
||||||
|
let mut acc_y: f32 = 0.0;
|
||||||
|
for f in 0..frames {
|
||||||
|
let t = (f as f32) / (frames as f32);
|
||||||
|
let u = powf(t, EASE_POW);
|
||||||
|
let angle = u * 2.0 * PI * turns;
|
||||||
|
let radius = radius_start + (radius_end - radius_start) * u;
|
||||||
|
// Subtract starting offset so the spiral begins at the cursor's entry point.
|
||||||
|
let next_x = radius * cosf(angle) - radius_start;
|
||||||
|
let next_y = radius * sinf(angle);
|
||||||
|
let (dx, dy, used_x, used_y) = step_delta(prev_x, prev_y, next_x, next_y, acc_x, acc_y);
|
||||||
|
acc_x = used_x;
|
||||||
|
acc_y = used_y;
|
||||||
|
send_mouse(mouse, dx, dy).await;
|
||||||
|
prev_x = next_x;
|
||||||
|
prev_y = next_y;
|
||||||
|
Timer::after(ANIM_FRAME).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn animate_final_spiral(mouse: &mut MouseHid) -> Ev {
|
||||||
|
animate_spiral(
|
||||||
|
mouse,
|
||||||
|
FINAL_SPIRAL_RADIUS_START,
|
||||||
|
SPIRAL_RADIUS_END,
|
||||||
|
FINAL_SPIRAL_TURNS,
|
||||||
|
FINAL_SPIRAL_FRAMES,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
Ev::SpiralDone
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn animate_warning_5(mouse: &mut MouseHid) -> Ev {
|
||||||
|
animate_spiral(
|
||||||
|
mouse,
|
||||||
|
WARN5_RADIUS_START,
|
||||||
|
SPIRAL_RADIUS_END,
|
||||||
|
WARN5_TURNS,
|
||||||
|
WARN5_FRAMES,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
Ev::WarnDone
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn animate_warning_10(mouse: &mut MouseHid) -> Ev {
|
||||||
|
animate_spiral(
|
||||||
|
mouse,
|
||||||
|
WARN10_RADIUS_START,
|
||||||
|
SPIRAL_RADIUS_END,
|
||||||
|
WARN10_TURNS,
|
||||||
|
WARN10_FRAMES,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
Ev::WarnDone
|
||||||
|
}
|
||||||
|
|
||||||
|
// Translate continuous (next_x, next_y) target into clamped i8 deltas while
|
||||||
|
// carrying sub-pixel residue forward, so a 90-frame circle of radius 50 doesn't
|
||||||
|
// lose ~half its motion to truncation. Returns (dx, dy, residual_x, residual_y).
|
||||||
|
fn step_delta(
|
||||||
|
prev_x: f32,
|
||||||
|
prev_y: f32,
|
||||||
|
next_x: f32,
|
||||||
|
next_y: f32,
|
||||||
|
acc_x: f32,
|
||||||
|
acc_y: f32,
|
||||||
|
) -> (i8, i8, f32, f32) {
|
||||||
|
let want_x = (next_x - prev_x) + acc_x;
|
||||||
|
let want_y = (next_y - prev_y) + acc_y;
|
||||||
|
let dx = roundf(want_x.clamp(-127.0, 127.0)) as i8;
|
||||||
|
let dy = roundf(want_y.clamp(-127.0, 127.0)) as i8;
|
||||||
|
(dx, dy, want_x - dx as f32, want_y - dy as f32)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── HID primitive ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub(crate) async fn send_mouse(mouse: &mut MouseHid, x: i8, y: i8) {
|
||||||
|
let report = MouseReport {
|
||||||
|
buttons: 0,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
wheel: 0,
|
||||||
|
pan: 0,
|
||||||
|
};
|
||||||
|
let _ = with_timeout(EDuration::from_secs(3), mouse.write_serialize(&report)).await;
|
||||||
|
}
|
||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
//! USB driver type aliases, identity helpers, and infra tasks
|
||||||
|
//! (USB device pump + watchdog feeder).
|
||||||
|
|
||||||
|
use embassy_rp::{
|
||||||
|
Peri,
|
||||||
|
flash::Flash,
|
||||||
|
peripherals::{FLASH, USB},
|
||||||
|
usb::Driver,
|
||||||
|
watchdog::Watchdog,
|
||||||
|
};
|
||||||
|
use embassy_time::Timer;
|
||||||
|
use embassy_usb::UsbDevice;
|
||||||
|
use static_cell::StaticCell;
|
||||||
|
|
||||||
|
use crate::config::{WATCHDOG_FEED_INTERVAL, WATCHDOG_TIMEOUT};
|
||||||
|
|
||||||
|
pub(crate) type UsbDriver = Driver<'static, USB>;
|
||||||
|
|
||||||
|
// ── USB serial number from RP2040 unique chip ID ───────────────────
|
||||||
|
// Reads the 64-bit unique ID baked into the on-board SPI flash, formats
|
||||||
|
// it as 16 ASCII hex chars in a static buffer, and returns a `'static`
|
||||||
|
// string suitable for `embassy_usb::Config::serial_number`. This makes
|
||||||
|
// the device's USB identity stable per-board across replugs (so hosts
|
||||||
|
// stop treating each plug as a new device) while still being unique
|
||||||
|
// between different boards.
|
||||||
|
const FLASH_SIZE: usize = 2 * 1024 * 1024; // Xiao RP2040 has 2 MB.
|
||||||
|
|
||||||
|
pub(crate) fn make_serial(flash_periph: Peri<'static, FLASH>) -> &'static str {
|
||||||
|
static SERIAL: StaticCell<[u8; 16]> = StaticCell::new();
|
||||||
|
const HEX: &[u8; 16] = b"0123456789ABCDEF";
|
||||||
|
|
||||||
|
let mut flash = Flash::<_, _, FLASH_SIZE>::new_blocking(flash_periph);
|
||||||
|
let mut id = [0u8; 8];
|
||||||
|
let _ = flash.blocking_unique_id(&mut id);
|
||||||
|
|
||||||
|
let buf = SERIAL.init([0; 16]);
|
||||||
|
for (i, &b) in id.iter().enumerate() {
|
||||||
|
buf[i * 2] = HEX[(b >> 4) as usize];
|
||||||
|
buf[i * 2 + 1] = HEX[(b & 0x0f) as usize];
|
||||||
|
}
|
||||||
|
core::str::from_utf8(buf).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tasks ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[embassy_executor::task]
|
||||||
|
pub(crate) async fn usb_task(mut device: UsbDevice<'static, UsbDriver>) {
|
||||||
|
device.run().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[embassy_executor::task]
|
||||||
|
pub(crate) async fn watchdog_task(mut wd: Watchdog) -> ! {
|
||||||
|
loop {
|
||||||
|
wd.feed(WATCHDOG_TIMEOUT);
|
||||||
|
Timer::after(WATCHDOG_FEED_INTERVAL).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user