Release-prep work for a publish-worthy 0.2.0: - LICENSE: MIT, © 2026 Stephen Waits. - README.md: tagline, what-it-does, hardware, build/flash recipes, ASCII statechart overview, "Why four hours…" runtime-tuning rationale, USB identity section. - Cargo.toml: 0.1.0 → 0.2.0; description, license, repository, readme, keywords, categories; publish = false (firmware, not a library); release profile tightened (lto = "fat", opt-level = "z", panic = "abort"). Flashed binary stays 47 KB; the size knobs are explicit rather than relying on defaults. - CHANGELOG.md: collapse Unreleased → [0.2.0] - 2026-05-01. - scripts/tune_runtime.py: 4-D Monte Carlo over (RUN_DURATION, YELLOW_AT, RED_AT, FAST_RED_AT). PEP 723 inline deps so `uv run` just works. Runtime + LED thresholds re-derived from a typical office workday distribution with a per-minute press-on-warning user model. Joint optimum: RUN_DURATION: 4h00m YELLOW_AT / RED_AT / FAST_RED_AT (min remaining): 30 / 25 / 20 USB identity: VID/PID: 046d:c07d (G502) → 1209:b0b0 (pid.codes) manufacturer: "Logitech" → "swaits.com" product: "G502 Mouse" → "jiggly" bcdDevice: default 0x0010 → 0x0200 (matches firmware version) serial: (none) → RP2040 chip ID as 16 hex chars Bug fix in the descriptor change: an interim version spoofed the Logitech Unifying Receiver (046d:c52b). On Linux, `hid-logitech-dj` matches that exact PID and tries to talk Logitech's HID++ protocol to enumerate paired wireless devices. The firmware doesn't speak HID++, so the driver waits through ~10–20 s of control-transfer timeouts on every plug before unbinding and letting `hid-generic` actually start polling. macOS has no such driver and was always fast. Moving to a pid.codes VID routes the device straight to `hid-generic`. Wake key: tapped Left Shift in early versions to wake the host. In practice that turned out to be exactly the nightmare scenario it sounds like — if the deadline preempted the loop between a Shift-down report and its Shift-up, the host would silently capitalise every keystroke from the user's real keyboard until the device was unplugged. Switched to F13: still wakes any modern OS, but no mainstream OS maps F13 by default, so a stuck F13 has zero visible effect. Also added an unconditional all-keys-released cleanup report at the end of the wake action, bounded by KBD_RELEASE_DEADLINE = 100 ms, so even a deadline that fires mid-press can't leave anything held. Wake refactor: WakingWithKeyboard and WakingWithMouse use oneshot `entry:` actions that race their work against an internal deadline via `embassy_futures::select`; the chart timer (`on(after KBD_PHASE_DURATION)` / `on(after MOUSE_PHASE_DURATION)`) advances. Removes a class of "slow USB ⇒ chart stalls" failure modes from the wake path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
752 lines
27 KiB
Rust
752 lines
27 KiB
Rust
#![no_std]
|
||
#![no_main]
|
||
|
||
use core::f32::consts::PI;
|
||
|
||
use embassy_executor::Spawner;
|
||
use embassy_rp::{
|
||
Peri, bind_interrupts,
|
||
clocks::RoscRng,
|
||
dma,
|
||
flash::Flash,
|
||
gpio::{Level, Output},
|
||
peripherals::{DMA_CH0, FLASH, PIO0, USB},
|
||
pio::{InterruptHandler as PioInterruptHandler, Pio},
|
||
pio_programs::ws2812::{Grb, 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_usb::{
|
||
Builder, Config as UsbConfig, UsbDevice,
|
||
class::hid::{Config as HidConfig, HidBootProtocol, HidSubclass, HidWriter, State},
|
||
};
|
||
use hsmc::{Duration, statechart};
|
||
use libm::{cosf, powf, roundf, sinf};
|
||
use panic_reset as _;
|
||
use smart_leds::RGB8;
|
||
use static_cell::StaticCell;
|
||
use usbd_hid::descriptor::{KeyboardReport, MouseReport, SerializedDescriptor};
|
||
|
||
bind_interrupts!(struct Irqs {
|
||
USBCTRL_IRQ => UsbInterruptHandler<USB>;
|
||
PIO0_IRQ_0 => PioInterruptHandler<PIO0>;
|
||
DMA_IRQ_0 => dma::InterruptHandler<DMA_CH0>;
|
||
});
|
||
|
||
type UsbDriver = Driver<'static, USB>;
|
||
type Neo = PioWs2812<'static, PIO0, 0, 1, Grb>;
|
||
type MouseHid = HidWriter<'static, UsbDriver, 5>;
|
||
type KbdHid = HidWriter<'static, UsbDriver, 8>;
|
||
|
||
// ── Lifecycle timing (statechart Durations) ────────────────────────
|
||
// 4h00m: optimum from the 4-D Monte Carlo (RUN_DURATION × YELLOW_AT ×
|
||
// RED_AT × FAST_RED_AT) over a typical office workday distribution
|
||
// with a per-minute press-on-warning user model. Lands the screen-sleep
|
||
// in the 12:15–12:45 sweet spot on ~52 % of days and somewhere in
|
||
// lunch on ~74 %. See the README's "Why four hours…" section and
|
||
// `scripts/tune_runtime.py` for the simulation.
|
||
const RUN_DURATION: Duration = Duration::from_hours(4);
|
||
const SHUTDOWN_LEAD: Duration = Duration::from_secs(30);
|
||
const RUN_BEFORE_SHUTDOWN: Duration = RUN_DURATION.saturating_sub(SHUTDOWN_LEAD);
|
||
const SHUTDOWN_ANIM_BUDGET: Duration = Duration::from_secs(5);
|
||
const QUIET_AFTER_ANIM: Duration = SHUTDOWN_LEAD.saturating_sub(SHUTDOWN_ANIM_BUDGET);
|
||
const JIGGLE_PERIOD: Duration = Duration::from_secs(270);
|
||
const FLASH_DURATION: Duration = Duration::from_millis(100);
|
||
|
||
// Phase boundaries — compared against time *remaining* in Active.
|
||
// Joint optimum from `scripts/tune_runtime.py`. Yellow and red are
|
||
// kept deliberately short (5 min each); the long phase is fast-red.
|
||
// See the README's "Why four hours…" section for the rationale.
|
||
const YELLOW_AT: EDuration = EDuration::from_secs(30 * 60);
|
||
const RED_AT: EDuration = EDuration::from_secs(25 * 60);
|
||
const FAST_RED_AT: EDuration = EDuration::from_secs(20 * 60);
|
||
|
||
// LED breathing math
|
||
const LED_TICK: EDuration = EDuration::from_millis(20);
|
||
const SLOW_GREEN_PERIOD: EDuration = EDuration::from_secs(4);
|
||
const YELLOW_PERIOD: EDuration = EDuration::from_secs(3);
|
||
const RED_PERIOD: EDuration = EDuration::from_secs(2);
|
||
const FAST_RED_PERIOD: EDuration = EDuration::from_millis(500);
|
||
|
||
// LED brightness (raw WS2812 PWM, 0..=255).
|
||
const BREATHE_FLOOR: u8 = 1;
|
||
const BREATHE_PEAK: u8 = 16;
|
||
const FLASH_PEAK: u8 = 160;
|
||
|
||
// ── Animation parameters ───────────────────────────────────────────
|
||
// 8ms frames + HID poll_ms=8 means each frame's report actually reaches the
|
||
// host instead of being coalesced — at 16ms frames against a 60ms poll the
|
||
// shake looked sluggish because three reports out of four were dropped on the
|
||
// floor.
|
||
const ANIM_FRAME: EDuration = EDuration::from_millis(8);
|
||
|
||
// Frantic side-to-side, the gesture a person makes to wake a sleeping display.
|
||
// 10 full oscillations × 8 frames of full period = 80 frames × 8 ms = 640 ms,
|
||
// which works out to a ~12 Hz alternation — visibly "shaking", not "sweeping".
|
||
const WAKE_OSCILLATIONS: u32 = 10;
|
||
const WAKE_FRAMES_PER_HALF: u32 = 4;
|
||
const WAKE_AMPLITUDE: f32 = 60.0;
|
||
const WAKE_JITTER: f32 = 1.0;
|
||
|
||
// 2 s pause after the shake so the display has time to actually wake before
|
||
// we draw the spinner. The user wants this delay to live *here*, not before
|
||
// the shake.
|
||
const SETTLING_DELAY: Duration = Duration::from_secs(2);
|
||
|
||
// ── Keyboard wake (host-wake first pass) ───────────────────────────
|
||
// macOS often won't wake from raw HID mouse motion alone, but reliably wakes
|
||
// from any keyboard event. We tap **F13** four times before the mouse shake.
|
||
// F13–F24 are intentionally unmapped on every mainstream OS, so even in the
|
||
// nightmare scenario where the deadline preempts the loop *between* a key-
|
||
// down and key-up report and the host ends up holding F13 forever, nothing
|
||
// visible happens — unlike with Shift, which would silently capitalise every
|
||
// keystroke from the user's real keyboard until they unplug the device.
|
||
// Earlier versions used Left Shift; that turned out to be exactly that
|
||
// nightmare scenario in practice.
|
||
const KBD_WAKE_TAPS: u32 = 4;
|
||
const KBD_TAP_HOLD: EDuration = EDuration::from_millis(30);
|
||
const KBD_TAP_GAP: EDuration = EDuration::from_millis(50);
|
||
// Hard internal deadline on the keyboard-wake entry action. The taps total
|
||
// ~320 ms so they finish well before this; the deadline only kicks in if a
|
||
// USB write blocks (e.g. the host hasn't bound the keyboard endpoint yet).
|
||
const KBD_WAKE_DEADLINE: EDuration = EDuration::from_millis(500);
|
||
// Statechart timer for the WakingWithKeyboard state — chosen above the
|
||
// internal deadline so the chart timer is what drives the transition out.
|
||
const KBD_PHASE_DURATION: Duration = Duration::from_millis(550);
|
||
// HID Keyboard usage page keycode for F13.
|
||
const KBD_KEY_F13: u8 = 0x68;
|
||
// Final-cleanup deadline — the all-keys-released report we send after the
|
||
// main work loop is bounded by this so a misbehaving endpoint can't pin
|
||
// the chart. Best-effort; if it doesn't land we tried.
|
||
const KBD_RELEASE_DEADLINE: EDuration = EDuration::from_millis(100);
|
||
|
||
// ── Mouse wake (host-wake second pass) ─────────────────────────────
|
||
// Internal deadline on the mouse-shake entry action — the shake itself takes
|
||
// ~640 ms; the cap exists so a misbehaving USB endpoint can't pin the chart.
|
||
const MOUSE_WAKE_DEADLINE: EDuration = EDuration::from_millis(1000);
|
||
const MOUSE_PHASE_DURATION: Duration = Duration::from_millis(1050);
|
||
|
||
// Three quick clockwise circles read more clearly as "spinner / running"
|
||
// than one slow lap. 25 frames per circle × 3 × 8 ms = 600 ms total.
|
||
const RUN_RADIUS: f32 = 40.0;
|
||
const RUN_FRAMES_PER_CIRCLE: u32 = 25;
|
||
const RUN_CIRCLES: u32 = 3;
|
||
|
||
// Shared "spin-down" spiral. Both radius and angle are driven by an eased phase
|
||
// u(t) = t^EASE_POW with EASE_POW > 1 — slow at the start, ~EASE_POW× the
|
||
// average rate at the finish. Because radius and angle share u, the inward
|
||
// spiral and the rotation accelerate together: a coin/Euler-disk feel.
|
||
const EASE_POW: f32 = 2.5;
|
||
const SPIRAL_RADIUS_END: f32 = 2.0;
|
||
|
||
// Final spiral — the dramatic full version, 30 s before USB goes silent.
|
||
const FINAL_SPIRAL_RADIUS_START: f32 = 80.0;
|
||
const FINAL_SPIRAL_TURNS: f32 = 5.0;
|
||
const FINAL_SPIRAL_FRAMES: u32 = 625; // 5.0 s @ 8 ms/frame
|
||
|
||
// 5-min warning — medium escalation.
|
||
const WARN5_RADIUS_START: f32 = 50.0;
|
||
const WARN5_TURNS: f32 = 3.0;
|
||
const WARN5_FRAMES: u32 = 312; // ~2.5 s
|
||
|
||
// 10-min warning — small foreshadow.
|
||
const WARN10_RADIUS_START: f32 = 30.0;
|
||
const WARN10_TURNS: f32 = 2.0;
|
||
const WARN10_FRAMES: u32 = 187; // ~1.5 s
|
||
|
||
// Offsets from Active-entry. The hsmc parent timer rule: timers in a parent
|
||
// state start on parent entry and survive sibling-substate transitions, so
|
||
// these three `after`s race concurrently against the same epoch.
|
||
const WARN_10_AT: Duration = RUN_BEFORE_SHUTDOWN.saturating_sub(Duration::from_mins(10));
|
||
const WARN_5_AT: Duration = RUN_BEFORE_SHUTDOWN.saturating_sub(Duration::from_mins(5));
|
||
|
||
// ── Watchdog (independent task) ────────────────────────────────────
|
||
const WATCHDOG_TIMEOUT: EDuration = EDuration::from_secs(8);
|
||
const WATCHDOG_FEED_INTERVAL: EDuration = EDuration::from_secs(5);
|
||
|
||
// ── Jiggle dwell ───────────────────────────────────────────────────
|
||
const PIXEL_DWELL: EDuration = EDuration::from_millis(25);
|
||
|
||
// ── Boot LED sweep ─────────────────────────────────────────────────
|
||
// Brief power-on confirmation. Kept short so the cursor shake — the actual
|
||
// "wake the display" gesture — happens promptly after reset.
|
||
const BOOT_SWEEP_STEP: EDuration = EDuration::from_millis(60);
|
||
|
||
// ── Shutdown LED flashes ───────────────────────────────────────────
|
||
const SHUTDOWN_FLASH_STEP: EDuration = EDuration::from_millis(400);
|
||
|
||
pub struct Ctx {
|
||
pub mouse: MouseHid,
|
||
pub kbd: KbdHid,
|
||
pub neo: Neo,
|
||
pub neo_pwr: Output<'static>,
|
||
pub active_start: Option<Instant>,
|
||
}
|
||
|
||
#[derive(Debug, Clone)]
|
||
pub enum Ev {
|
||
BootDone,
|
||
SpinDone,
|
||
Jiggled,
|
||
WarnDone,
|
||
SpiralDone,
|
||
}
|
||
|
||
statechart! {
|
||
Jiggly {
|
||
context: Ctx;
|
||
events: Ev;
|
||
default(Booting);
|
||
|
||
state Booting {
|
||
during: boot_sweep(neo, neo_pwr);
|
||
on(BootDone) => WakingHost;
|
||
}
|
||
|
||
// Wake the host using whichever input the host actually responds to.
|
||
// Keyboard first (more reliable on macOS), then a mouse shake as
|
||
// belt-and-suspenders. Each substate runs a oneshot entry action with
|
||
// its own internal deadline; the chart timer is what advances the
|
||
// chart. No durings, no events — purely entry + timer.
|
||
state WakingHost {
|
||
default(WakingWithKeyboard);
|
||
|
||
state WakingWithKeyboard {
|
||
entry: keyboard_wake;
|
||
on(after KBD_PHASE_DURATION) => WakingWithMouse;
|
||
}
|
||
|
||
state WakingWithMouse {
|
||
entry: mouse_wake;
|
||
on(after MOUSE_PHASE_DURATION) => Settling;
|
||
}
|
||
}
|
||
|
||
// Quiet pause so the display has time to come out of sleep before the
|
||
// cursor starts drawing the spinner.
|
||
state Settling {
|
||
on(after SETTLING_DELAY) => Spinning;
|
||
}
|
||
|
||
state Spinning {
|
||
during: animate_spinner(mouse);
|
||
on(SpinDone) => Active;
|
||
}
|
||
|
||
state Active {
|
||
entry: capture_active_start;
|
||
on(every JIGGLE_PERIOD) => jiggle_pair;
|
||
on(after WARN_10_AT) => Warning10;
|
||
on(after WARN_5_AT) => Warning5;
|
||
on(after RUN_BEFORE_SHUTDOWN) => Ending;
|
||
default(Breathing);
|
||
|
||
state Breathing {
|
||
during: breathe_color(neo, active_start);
|
||
on(Jiggled) => Flashing;
|
||
}
|
||
|
||
state Flashing {
|
||
entry: paint_white;
|
||
on(after FLASH_DURATION) => Breathing;
|
||
}
|
||
|
||
state Warning10 {
|
||
during: animate_warning_10(mouse);
|
||
on(WarnDone) => Breathing;
|
||
}
|
||
|
||
state Warning5 {
|
||
during: animate_warning_5(mouse);
|
||
on(WarnDone) => Breathing;
|
||
}
|
||
}
|
||
|
||
state Ending {
|
||
during: blink_fast_red(neo);
|
||
default(Spiraling);
|
||
|
||
state Spiraling {
|
||
during: animate_final_spiral(mouse);
|
||
on(SpiralDone) => Quiet;
|
||
}
|
||
|
||
state Quiet {
|
||
on(after QUIET_AFTER_ANIM) => PoweringDown;
|
||
}
|
||
}
|
||
|
||
state PoweringDown {
|
||
entry: shutdown_flashes;
|
||
entry: power_off_neo;
|
||
}
|
||
}
|
||
}
|
||
|
||
impl JigglyActions for JigglyActionContext<'_> {
|
||
async fn capture_active_start(&mut self) {
|
||
self.active_start = Some(Instant::now());
|
||
}
|
||
|
||
async fn keyboard_wake(&mut self) {
|
||
let _ = embassy_futures::select::select(
|
||
wake_with_keyboard(&mut self.kbd),
|
||
Timer::after(KBD_WAKE_DEADLINE),
|
||
)
|
||
.await;
|
||
// Belt-and-suspenders: always send an all-keys-released report,
|
||
// even if the deadline preempted the loop *between* a key-down
|
||
// and its key-up. Without this, a stuck modifier (Shift!) or key
|
||
// on the host side could persist until the user unplugs the
|
||
// device. Bounded by KBD_RELEASE_DEADLINE so a misbehaving
|
||
// endpoint can't pin the chart.
|
||
let _ = embassy_futures::select::select(
|
||
send_kbd(&mut self.kbd, 0, [0; 6]),
|
||
Timer::after(KBD_RELEASE_DEADLINE),
|
||
)
|
||
.await;
|
||
}
|
||
|
||
async fn mouse_wake(&mut self) {
|
||
let _ = embassy_futures::select::select(
|
||
wake_with_mouse(&mut self.mouse),
|
||
Timer::after(MOUSE_WAKE_DEADLINE),
|
||
)
|
||
.await;
|
||
}
|
||
|
||
async fn jiggle_pair(&mut self) {
|
||
let (dx, dy): (i8, i8) = if (RoscRng::next_u8() & 1) == 0 {
|
||
(1, 0)
|
||
} else {
|
||
(0, 1)
|
||
};
|
||
send_mouse(&mut self.mouse, dx, dy).await;
|
||
Timer::after(PIXEL_DWELL).await;
|
||
send_mouse(&mut self.mouse, -dx, -dy).await;
|
||
let _ = self.emit(Ev::Jiggled);
|
||
}
|
||
|
||
async fn paint_white(&mut self) {
|
||
paint(&mut self.neo, FLASH_PEAK, FLASH_PEAK, FLASH_PEAK).await;
|
||
}
|
||
|
||
async fn shutdown_flashes(&mut self) {
|
||
for _ in 0..3 {
|
||
paint(&mut self.neo, 0, BREATHE_PEAK, 0).await;
|
||
Timer::after(SHUTDOWN_FLASH_STEP).await;
|
||
paint(&mut self.neo, 0, 0, 0).await;
|
||
Timer::after(SHUTDOWN_FLASH_STEP).await;
|
||
}
|
||
}
|
||
|
||
async fn power_off_neo(&mut self) {
|
||
self.neo_pwr.set_low();
|
||
}
|
||
}
|
||
|
||
// ── During activities (free async fns) ─────────────────────────────
|
||
|
||
async fn boot_sweep(neo: &mut Neo, neo_pwr: &mut Output<'static>) -> Ev {
|
||
neo_pwr.set_high();
|
||
Timer::after_millis(2).await;
|
||
|
||
paint(neo, BREATHE_PEAK, 0, 0).await;
|
||
Timer::after(BOOT_SWEEP_STEP).await;
|
||
paint(neo, 0, BREATHE_PEAK, 0).await;
|
||
Timer::after(BOOT_SWEEP_STEP).await;
|
||
paint(neo, 0, 0, BREATHE_PEAK).await;
|
||
Timer::after(BOOT_SWEEP_STEP).await;
|
||
paint(neo, 0, 0, 0).await;
|
||
Ev::BootDone
|
||
}
|
||
|
||
// Tap F13 four times. macOS reliably wakes from any keyboard event but is
|
||
// inconsistent about waking from raw mouse motion. Plain oneshot helper —
|
||
// the action method that calls this races it against KBD_WAKE_DEADLINE
|
||
// AND unconditionally sends an all-keys-released cleanup report after,
|
||
// to make sure we never leave a key held on the host.
|
||
async fn wake_with_keyboard(kbd: &mut KbdHid) {
|
||
for _ in 0..KBD_WAKE_TAPS {
|
||
send_kbd(kbd, 0, [KBD_KEY_F13, 0, 0, 0, 0, 0]).await;
|
||
Timer::after(KBD_TAP_HOLD).await;
|
||
send_kbd(kbd, 0, [0; 6]).await;
|
||
Timer::after(KBD_TAP_GAP).await;
|
||
}
|
||
}
|
||
|
||
// Frantic horizontal mouse shake. Plain oneshot helper — the action method
|
||
// that calls this races it against MOUSE_WAKE_DEADLINE.
|
||
async fn wake_with_mouse(mouse: &mut MouseHid) {
|
||
let period_frames = (WAKE_FRAMES_PER_HALF * 2) as f32;
|
||
let total_frames = WAKE_OSCILLATIONS * WAKE_FRAMES_PER_HALF * 2;
|
||
let mut prev_x: f32 = 0.0;
|
||
let mut prev_y: f32 = 0.0;
|
||
let mut acc_x: f32 = 0.0;
|
||
let mut acc_y: f32 = 0.0;
|
||
for f in 0..total_frames {
|
||
let phase = (f as f32) * 2.0 * PI / period_frames;
|
||
let next_x = WAKE_AMPLITUDE * sinf(phase);
|
||
let jitter = ((RoscRng::next_u8() as f32) / 255.0 - 0.5) * 2.0 * WAKE_JITTER;
|
||
let next_y = jitter;
|
||
let (dx, dy, used_x, used_y) = step_delta(prev_x, prev_y, next_x, next_y, acc_x, acc_y);
|
||
acc_x = used_x;
|
||
acc_y = used_y;
|
||
send_mouse(mouse, dx, dy).await;
|
||
prev_x = next_x;
|
||
prev_y = next_y;
|
||
Timer::after(ANIM_FRAME).await;
|
||
}
|
||
}
|
||
|
||
async fn animate_spinner(mouse: &mut MouseHid) -> Ev {
|
||
let total_frames = RUN_CIRCLES * RUN_FRAMES_PER_CIRCLE;
|
||
let period_frames = RUN_FRAMES_PER_CIRCLE as f32;
|
||
let mut prev_x: f32 = 0.0;
|
||
let mut prev_y: f32 = 0.0;
|
||
let mut acc_x: f32 = 0.0;
|
||
let mut acc_y: f32 = 0.0;
|
||
for f in 0..total_frames {
|
||
let angle = (f as f32) * 2.0 * PI / period_frames;
|
||
let next_x = RUN_RADIUS * sinf(angle);
|
||
let next_y = RUN_RADIUS * (1.0 - cosf(angle));
|
||
let (dx, dy, used_x, used_y) = step_delta(prev_x, prev_y, next_x, next_y, acc_x, acc_y);
|
||
acc_x = used_x;
|
||
acc_y = used_y;
|
||
send_mouse(mouse, dx, dy).await;
|
||
prev_x = next_x;
|
||
prev_y = next_y;
|
||
Timer::after(ANIM_FRAME).await;
|
||
}
|
||
Ev::SpinDone
|
||
}
|
||
|
||
async fn animate_spiral(
|
||
mouse: &mut MouseHid,
|
||
radius_start: f32,
|
||
radius_end: f32,
|
||
turns: f32,
|
||
frames: u32,
|
||
) {
|
||
let mut prev_x: f32 = 0.0;
|
||
let mut prev_y: f32 = 0.0;
|
||
let mut acc_x: f32 = 0.0;
|
||
let mut acc_y: f32 = 0.0;
|
||
for f in 0..frames {
|
||
let t = (f as f32) / (frames as f32);
|
||
let u = powf(t, EASE_POW);
|
||
let angle = u * 2.0 * PI * turns;
|
||
let radius = radius_start + (radius_end - radius_start) * u;
|
||
// Subtract starting offset so the spiral begins at the cursor's entry point.
|
||
let next_x = radius * cosf(angle) - radius_start;
|
||
let next_y = radius * sinf(angle);
|
||
let (dx, dy, used_x, used_y) = step_delta(prev_x, prev_y, next_x, next_y, acc_x, acc_y);
|
||
acc_x = used_x;
|
||
acc_y = used_y;
|
||
send_mouse(mouse, dx, dy).await;
|
||
prev_x = next_x;
|
||
prev_y = next_y;
|
||
Timer::after(ANIM_FRAME).await;
|
||
}
|
||
}
|
||
|
||
async fn animate_final_spiral(mouse: &mut MouseHid) -> Ev {
|
||
animate_spiral(
|
||
mouse,
|
||
FINAL_SPIRAL_RADIUS_START,
|
||
SPIRAL_RADIUS_END,
|
||
FINAL_SPIRAL_TURNS,
|
||
FINAL_SPIRAL_FRAMES,
|
||
)
|
||
.await;
|
||
Ev::SpiralDone
|
||
}
|
||
|
||
async fn animate_warning_5(mouse: &mut MouseHid) -> Ev {
|
||
animate_spiral(
|
||
mouse,
|
||
WARN5_RADIUS_START,
|
||
SPIRAL_RADIUS_END,
|
||
WARN5_TURNS,
|
||
WARN5_FRAMES,
|
||
)
|
||
.await;
|
||
Ev::WarnDone
|
||
}
|
||
|
||
async fn animate_warning_10(mouse: &mut MouseHid) -> Ev {
|
||
animate_spiral(
|
||
mouse,
|
||
WARN10_RADIUS_START,
|
||
SPIRAL_RADIUS_END,
|
||
WARN10_TURNS,
|
||
WARN10_FRAMES,
|
||
)
|
||
.await;
|
||
Ev::WarnDone
|
||
}
|
||
|
||
async fn breathe_color(neo: &mut Neo, active_start: &mut Option<Instant>) -> Ev {
|
||
loop {
|
||
let elapsed = active_start.map(|s| s.elapsed()).unwrap_or_default();
|
||
let total_run = run_before_shutdown_e();
|
||
let remaining = total_run
|
||
.checked_sub(elapsed)
|
||
.unwrap_or(EDuration::from_ticks(0));
|
||
let (r, g, b) = breathe_for(remaining, elapsed);
|
||
paint(neo, r, g, b).await;
|
||
Timer::after(LED_TICK).await;
|
||
}
|
||
}
|
||
|
||
async fn blink_fast_red(neo: &mut Neo) -> Ev {
|
||
let start = Instant::now();
|
||
loop {
|
||
let t = start.elapsed();
|
||
let level = if blink_on(FAST_RED_PERIOD, t) {
|
||
BREATHE_PEAK
|
||
} else {
|
||
BREATHE_FLOOR
|
||
};
|
||
paint(neo, level, 0, 0).await;
|
||
Timer::after(LED_TICK).await;
|
||
}
|
||
}
|
||
|
||
// ── Color / breathing helpers ──────────────────────────────────────
|
||
|
||
fn run_before_shutdown_e() -> EDuration {
|
||
// RUN_BEFORE_SHUTDOWN is a `core::time::Duration`; convert to the embassy
|
||
// type once at the call site so the comparator below is apples-to-apples.
|
||
EDuration::from_secs(RUN_BEFORE_SHUTDOWN.as_secs())
|
||
}
|
||
|
||
fn breathe_for(remaining: EDuration, elapsed: EDuration) -> (u8, u8, u8) {
|
||
if remaining > YELLOW_AT {
|
||
let level = sin_breath(SLOW_GREEN_PERIOD, elapsed, BREATHE_FLOOR, BREATHE_PEAK);
|
||
(0, level, 0)
|
||
} else if remaining > RED_AT {
|
||
// Green is perceptually brighter on WS2812 — scale it down for a warm yellow.
|
||
let level = sin_breath(YELLOW_PERIOD, elapsed, BREATHE_FLOOR, BREATHE_PEAK);
|
||
(level, ((level as u16 * 5) / 10) as u8, 0)
|
||
} else if remaining > FAST_RED_AT {
|
||
let level = sin_breath(RED_PERIOD, elapsed, BREATHE_FLOOR, BREATHE_PEAK);
|
||
(level, 0, 0)
|
||
} else {
|
||
let level = if blink_on(FAST_RED_PERIOD, elapsed) {
|
||
BREATHE_PEAK
|
||
} else {
|
||
BREATHE_FLOOR
|
||
};
|
||
(level, 0, 0)
|
||
}
|
||
}
|
||
|
||
fn sin_breath(period: EDuration, t: EDuration, floor: u8, peak: u8) -> u8 {
|
||
let period_ms = period.as_millis() as f32;
|
||
let t_ms = (t.as_millis() % period.as_millis()) as f32;
|
||
let phase = 2.0 * PI * t_ms / period_ms;
|
||
let val = (1.0 - cosf(phase)) * 0.5;
|
||
let span = peak.saturating_sub(floor) as f32;
|
||
floor + (val * span) as u8
|
||
}
|
||
|
||
fn blink_on(period: EDuration, t: EDuration) -> bool {
|
||
let period_ms = period.as_millis();
|
||
(t.as_millis() % period_ms) < (period_ms / 2)
|
||
}
|
||
|
||
// Translate continuous (next_x, next_y) target into clamped i8 deltas while
|
||
// carrying sub-pixel residue forward, so a 90-frame circle of radius 50 doesn't
|
||
// lose ~half its motion to truncation. Returns (dx, dy, residual_x, residual_y).
|
||
fn step_delta(
|
||
prev_x: f32,
|
||
prev_y: f32,
|
||
next_x: f32,
|
||
next_y: f32,
|
||
acc_x: f32,
|
||
acc_y: f32,
|
||
) -> (i8, i8, f32, f32) {
|
||
let want_x = (next_x - prev_x) + acc_x;
|
||
let want_y = (next_y - prev_y) + acc_y;
|
||
let dx = roundf(want_x.clamp(-127.0, 127.0)) as i8;
|
||
let dy = roundf(want_y.clamp(-127.0, 127.0)) as i8;
|
||
(dx, dy, want_x - dx as f32, want_y - dy as f32)
|
||
}
|
||
|
||
// ── HID + LED primitives ───────────────────────────────────────────
|
||
|
||
async fn send_mouse(mouse: &mut MouseHid, x: i8, y: i8) {
|
||
let report = MouseReport {
|
||
buttons: 0,
|
||
x,
|
||
y,
|
||
wheel: 0,
|
||
pan: 0,
|
||
};
|
||
let _ = with_timeout(EDuration::from_secs(3), mouse.write_serialize(&report)).await;
|
||
}
|
||
|
||
async fn send_kbd(kbd: &mut KbdHid, modifier: u8, keycodes: [u8; 6]) {
|
||
let report = KeyboardReport {
|
||
modifier,
|
||
reserved: 0,
|
||
leds: 0,
|
||
keycodes,
|
||
};
|
||
let _ = with_timeout(EDuration::from_secs(3), kbd.write_serialize(&report)).await;
|
||
}
|
||
|
||
async fn paint(neo: &mut Neo, r: u8, g: u8, b: u8) {
|
||
neo.write(&[RGB8 { r, g, b }]).await;
|
||
}
|
||
|
||
// ── USB serial number from RP2040 unique chip ID ───────────────────
|
||
// Reads the 64-bit unique ID baked into the on-board SPI flash, formats
|
||
// it as 16 ASCII hex chars in a static buffer, and returns a `'static`
|
||
// string suitable for `embassy_usb::Config::serial_number`. This makes
|
||
// the device's USB identity stable per-board across replugs (so hosts
|
||
// stop treating each plug as a new device) while still being unique
|
||
// between different boards.
|
||
const FLASH_SIZE: usize = 2 * 1024 * 1024; // Xiao RP2040 has 2 MB.
|
||
|
||
fn make_serial(flash_periph: Peri<'static, FLASH>) -> &'static str {
|
||
static SERIAL: StaticCell<[u8; 16]> = StaticCell::new();
|
||
const HEX: &[u8; 16] = b"0123456789ABCDEF";
|
||
|
||
let mut flash = Flash::<_, _, FLASH_SIZE>::new_blocking(flash_periph);
|
||
let mut id = [0u8; 8];
|
||
let _ = flash.blocking_unique_id(&mut id);
|
||
|
||
let buf = SERIAL.init([0; 16]);
|
||
for (i, &b) in id.iter().enumerate() {
|
||
buf[i * 2] = HEX[(b >> 4) as usize];
|
||
buf[i * 2 + 1] = HEX[(b & 0x0f) as usize];
|
||
}
|
||
core::str::from_utf8(buf).unwrap()
|
||
}
|
||
|
||
// ── Tasks ──────────────────────────────────────────────────────────
|
||
|
||
#[embassy_executor::task]
|
||
async fn usb_task(mut device: UsbDevice<'static, UsbDriver>) {
|
||
device.run().await;
|
||
}
|
||
|
||
#[embassy_executor::task]
|
||
async fn watchdog_task(mut wd: Watchdog) -> ! {
|
||
loop {
|
||
wd.feed(WATCHDOG_TIMEOUT);
|
||
Timer::after(WATCHDOG_FEED_INTERVAL).await;
|
||
}
|
||
}
|
||
|
||
// ── Main ───────────────────────────────────────────────────────────
|
||
|
||
#[embassy_executor::main]
|
||
async fn main(spawner: Spawner) {
|
||
let p = embassy_rp::init(Default::default());
|
||
|
||
let mut watchdog = Watchdog::new(p.WATCHDOG);
|
||
watchdog.start(WATCHDOG_TIMEOUT);
|
||
|
||
// NeoPixel: GPIO11 powers it, GPIO12 is the WS2812 data line driven from
|
||
// PIO0 + DMA_CH0. The user RGB on GPIO16/17/25 stays floating so those
|
||
// LEDs remain dark.
|
||
let neo_pwr = Output::new(p.PIN_11, Level::Low);
|
||
let mut pio = Pio::new(p.PIO0, Irqs);
|
||
let neo_program = PioWs2812Program::new(&mut pio.common);
|
||
let neo: Neo = PioWs2812::new(
|
||
&mut pio.common,
|
||
pio.sm0,
|
||
p.DMA_CH0,
|
||
Irqs,
|
||
p.PIN_12,
|
||
&neo_program,
|
||
);
|
||
|
||
let driver = Driver::new(p.USB, Irqs);
|
||
|
||
// pid.codes community VID with a self-allocated PID — using a real
|
||
// Logitech Unifying Receiver VID/PID was a mistake: Linux has a kernel
|
||
// driver (`hid-logitech-dj`) that special-cases that PID and tries to
|
||
// talk HID++ to enumerate paired wireless devices. We don't speak
|
||
// HID++, so the driver waits through ~10–20 s of timeouts before
|
||
// unbinding and letting `hid-generic` actually start polling our
|
||
// endpoints. Generic VID/PID routes straight to `hid-generic`.
|
||
let mut config = UsbConfig::new(0x1209, 0xb0b0);
|
||
config.manufacturer = Some("swaits.com");
|
||
config.product = Some("jiggly");
|
||
config.serial_number = Some(make_serial(p.FLASH));
|
||
config.device_release = 0x0200; // matches firmware version 0.2.0
|
||
config.max_power = 100;
|
||
config.max_packet_size_0 = 64;
|
||
|
||
static CONFIG_DESCRIPTOR: StaticCell<[u8; 256]> = StaticCell::new();
|
||
static BOS_DESCRIPTOR: StaticCell<[u8; 256]> = StaticCell::new();
|
||
static MSOS_DESCRIPTOR: StaticCell<[u8; 256]> = StaticCell::new();
|
||
static CONTROL_BUF: StaticCell<[u8; 64]> = StaticCell::new();
|
||
static MOUSE_HID_STATE: StaticCell<State> = StaticCell::new();
|
||
static KBD_HID_STATE: StaticCell<State> = StaticCell::new();
|
||
|
||
let mut builder = Builder::new(
|
||
driver,
|
||
config,
|
||
CONFIG_DESCRIPTOR.init([0; 256]),
|
||
BOS_DESCRIPTOR.init([0; 256]),
|
||
MSOS_DESCRIPTOR.init([0; 256]),
|
||
CONTROL_BUF.init([0; 64]),
|
||
);
|
||
|
||
let mouse_config = HidConfig {
|
||
report_descriptor: MouseReport::desc(),
|
||
request_handler: None,
|
||
// 8 ms (125 Hz) — standard for full-speed mice. At the previous 60 ms
|
||
// the host was throwing away ~7 of every 8 animation frames we sent.
|
||
poll_ms: 8,
|
||
max_packet_size: 8,
|
||
hid_subclass: HidSubclass::Boot,
|
||
hid_boot_protocol: HidBootProtocol::Mouse,
|
||
};
|
||
let mouse = HidWriter::<_, 5>::new(
|
||
&mut builder,
|
||
MOUSE_HID_STATE.init(State::new()),
|
||
mouse_config,
|
||
);
|
||
|
||
let kbd_config = HidConfig {
|
||
report_descriptor: KeyboardReport::desc(),
|
||
request_handler: None,
|
||
// The keyboard only fires once at boot; no need for fast polling.
|
||
poll_ms: 10,
|
||
max_packet_size: 8,
|
||
hid_subclass: HidSubclass::Boot,
|
||
hid_boot_protocol: HidBootProtocol::Keyboard,
|
||
};
|
||
let kbd = HidWriter::<_, 8>::new(&mut builder, KBD_HID_STATE.init(State::new()), kbd_config);
|
||
|
||
let usb = builder.build();
|
||
spawner.spawn(usb_task(usb).unwrap());
|
||
spawner.spawn(watchdog_task(watchdog).unwrap());
|
||
|
||
static EVENT_CHAN: Channel<CriticalSectionRawMutex, Ev, 8> = Channel::new();
|
||
|
||
let ctx = Ctx {
|
||
mouse,
|
||
kbd,
|
||
neo,
|
||
neo_pwr,
|
||
active_start: None,
|
||
};
|
||
let mut chart = Jiggly::new(ctx, &EVENT_CHAN);
|
||
let _ = chart.run().await;
|
||
|
||
// Unreachable in practice — the chart parks in PoweringDown forever and
|
||
// run() never returns. The watchdog task keeps the chip alive.
|
||
loop {
|
||
Timer::after(WATCHDOG_FEED_INTERVAL).await;
|
||
}
|
||
}
|