diff --git a/CHANGELOG.md b/CHANGELOG.md index a94e514..8e6a0fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,56 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `hsmc` 0.5.1 statechart drives the entire device lifecycle. The control + flow (boot → wake-up animation → settle → running animation → active + jiggle/flash → end-of-day shutdown spiral → power-down) is expressed + declaratively in one `statechart!` block; no more atomic flags, no more + per-tick `Phase` enum, no hand-rolled main loop. +- Mouse animations driven by the chart's `during:` activities: + - **Wake-up** (`WakingDisplay`): 10 frantic horizontal sin-shaped sweeps + (~12 Hz, ±60 px, ~640 ms) — simulates "shake the mouse to wake the + display." + - **Running** (`ShowingRunning`): 3 quick clockwise circles + (radius 40 px, ~600 ms) — universal "spinner / in progress" cue. + - **Pre-shutdown** (`Ending → ShutdownAnim`): inward spiral 50 → 5 px + over 3 turns (~2 s), 30 s before lifetime end — cursor visibly winds + down. +- 2 s `Settling` state between wake-up and running animations so the + display has time to come out of sleep before the spinner draws. +- All animations carry sub-pixel residue forward across `i8` HID delta + reports so the rendered shape matches the intended geometry rather than + losing ~half its motion to truncation. + +### Changed + +- **`RUN_DURATION` from 8 hours to 3 h 50 m**. Math-optimal under the + user's workday distribution (start `triangular(8.0, mode=8.5, 9.5)`, + lunch 12:00–13:00, end `triangular(16.0, mode=17.5, 19.0)`) for "screen + goes to sleep during the lunch hour on most days." See + `/tmp/jiggly_runtime_v3.py` for the simulation. +- HID `poll_ms` 60 → 8 (125 Hz). At the previous 60 ms poll the host + was discarding ~7 of every 8 animation frames the firmware emitted. +- Boot LED `R→G→B` step 180 ms → 60 ms; 1.5 s pre-animation USB-settle + delay removed. Reset → wake-shake latency dropped from ~2 s to ~240 ms. +- Watchdog feeding moved out of the imperative main loop into its own + `#[embassy_executor::task]`; the chart owns user-visible state, the + watchdog task owns the periodic kick. +- USB descriptor unchanged (still spoofs Logitech G502 VID `0x046d` / + PID `0xc07d`). + +### Removed + +- `JIGGLE_FLAG: AtomicBool` and all `swap`/`store` calls. +- `Phase` enum and the per-tick `phase_for(remaining)` switch — the + breathing color is now read directly from elapsed time inside the + `breathe_color` `during:`. +- `led_task` and `sleep_chunked` helper — both subsumed by the chart. +- `RUN_DURATION = Duration::from_secs(8 * 60 * 60)` and similar + raw-integer-millisecond constants. All timing is now `Duration`-typed + with named constructors (`from_hours`, `from_mins`, `from_millis`). + ## [0.1.0] - 2026-04-27 ### Added diff --git a/Cargo.lock b/Cargo.lock index 305fc8e..63ca5d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -716,6 +716,30 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "hsmc" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "497ed9593e4baf6ef120b24aff80cb898f6832d32cdf01be9d90be0c06624e77" +dependencies = [ + "embassy-futures", + "embassy-sync", + "embassy-time", + "heapless 0.8.0", + "hsmc-macros", +] + +[[package]] +name = "hsmc-macros" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49d0b1413ed69ba168f9d0edbf4a8ea21569eaf352edf4e11335ae3d765ba004" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "ident_case" version = "1.0.1" @@ -748,11 +772,16 @@ dependencies = [ "cortex-m", "cortex-m-rt", "embassy-executor", + "embassy-futures", "embassy-rp", + "embassy-sync", "embassy-time", "embassy-usb", + "hsmc", + "libm", "panic-reset", "portable-atomic", + "smart-leds", "static_cell", "usbd-hid", ] @@ -810,6 +839,12 @@ version = "0.2.185" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "litrs" version = "1.0.0" diff --git a/Cargo.toml b/Cargo.toml index 74d2d53..3224bb1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,19 +3,35 @@ name = "jiggly" version = "0.1.0" edition = "2024" +[features] +default = ["embassy"] +# `hsmc` emits `#[cfg(feature = "embassy")]` from inside `statechart!` and +# the cfg is evaluated in this crate's context, so we mirror the flag here +# and forward it to `hsmc/embassy`. +embassy = ["hsmc/embassy"] + [dependencies] embassy-executor = { version = "0.10.0", features = ["platform-cortex-m", "executor-thread"] } embassy-time = { version = "0.5.1" } embassy-rp = { version = "0.10.0", features = ["rp2040", "time-driver", "critical-section-impl", "unstable-pac"] } +embassy-sync = "0.8" +embassy-futures = "0.1" embassy-usb = "0.6.0" +hsmc = { version = "0.5.1", default-features = false } usbd-hid = "0.10.0" +smart-leds = "0.4" +libm = "0.2" cortex-m = { version = "0.7.6", features = ["inline-asm"] } cortex-m-rt = "0.7.5" panic-reset = "0.1" static_cell = "2.1" +# Cortex-M0+ has no hardware CAS; static_cell + heapless need this shim. portable-atomic = { version = "1.13", features = ["critical-section"] } +[lints.rust] +unexpected_cfgs = { level = "allow", check-cfg = ['cfg(feature, values("tokio", "embassy"))'] } + [profile.release] lto = true opt-level = "s" diff --git a/src/main.rs b/src/main.rs index 751f4a9..79fe547 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,41 +1,409 @@ #![no_std] #![no_main] +use core::f32::consts::PI; + use embassy_executor::Spawner; use embassy_rp::{ bind_interrupts, clocks::RoscRng, - peripherals::USB, - usb::{Driver, InterruptHandler}, + dma, + gpio::{Level, Output}, + peripherals::{DMA_CH0, PIO0, USB}, + pio::{InterruptHandler as PioInterruptHandler, Pio}, + pio_programs::ws2812::{Grb, PioWs2812, PioWs2812Program}, + usb::{Driver, InterruptHandler as UsbInterruptHandler}, watchdog::Watchdog, }; -use embassy_time::{Duration, Instant, Timer, with_timeout}; +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, roundf, sinf}; use panic_reset as _; +use smart_leds::RGB8; use static_cell::StaticCell; use usbd_hid::descriptor::{MouseReport, SerializedDescriptor}; bind_interrupts!(struct Irqs { - USBCTRL_IRQ => InterruptHandler; + USBCTRL_IRQ => UsbInterruptHandler; + PIO0_IRQ_0 => PioInterruptHandler; + DMA_IRQ_0 => dma::InterruptHandler; }); type UsbDriver = Driver<'static, USB>; +type Neo = PioWs2812<'static, PIO0, 0, 1, Grb>; +type Writer = HidWriter<'static, UsbDriver, 5>; -const RUN_DURATION: Duration = Duration::from_secs(8 * 60 * 60); -const IDLE_BETWEEN: Duration = Duration::from_secs(270); -const PIXEL_DWELL_MS: u64 = 25; -const WATCHDOG_FEED: Duration = Duration::from_secs(8); -const SLEEP_CHUNK: Duration = Duration::from_secs(5); +// ── Lifecycle timing (statechart Durations) ──────────────────────── +// 3h50m: math-optimal under the user's schedule for "screen sleeps during +// lunch on most days". Balances early-start re-tap risk against late-start +// alive-through-lunch risk. See /tmp/jiggly_runtime_v3.py. +const RUN_DURATION: Duration = Duration::from_hours(3).saturating_add(Duration::from_mins(50)); +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(2); +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); -#[embassy_executor::task] -async fn usb_task(mut device: UsbDevice<'static, UsbDriver>) { - device.run().await; +// Phase boundaries — compared against time *remaining* in Active. +const YELLOW_AT: EDuration = EDuration::from_secs(60 * 60); +const RED_AT: EDuration = EDuration::from_secs(30 * 60); +const FAST_RED_AT: EDuration = EDuration::from_secs(10 * 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 "running" circles. The user wants this delay to live *here*, +// not before the shake. +const SETTLING_DELAY: Duration = Duration::from_secs(2); + +// 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; + +const SHUTDOWN_RADIUS_START: f32 = 50.0; +const SHUTDOWN_RADIUS_END: f32 = 5.0; +const SHUTDOWN_TURNS: u32 = 3; +const SHUTDOWN_FRAMES: u32 = 120; + +// ── 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 writer: Writer, + pub neo: Neo, + pub neo_pwr: Output<'static>, + pub active_start: Option, } -async fn send(writer: &mut HidWriter<'static, UsbDriver, 5>, x: i8, y: i8) { +#[derive(Debug, Clone)] +pub enum Ev { + SweepDone, + WakeDone, + RunDone, + Jiggled, + ShutdownAnimDone, +} + +statechart! { +Jiggly { + context: Ctx; + events: Ev; + default(BootSweep); + + state BootSweep { + during: led_rgb_sweep(neo, neo_pwr); + on(SweepDone) => WakingDisplay; + } + + state WakingDisplay { + during: animate_wake(writer); + on(WakeDone) => Settling; + } + + // Quiet pause so the display has time to come out of sleep before the + // cursor starts drawing the "running" circles. + state Settling { + on(after SETTLING_DELAY) => ShowingRunning; + } + + state ShowingRunning { + during: animate_running(writer); + on(RunDone) => Active; + } + + state Active { + entry: capture_active_start; + on(every JIGGLE_PERIOD) => jiggle_pair; + 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 Ending { + during: blink_fast_red(neo); + default(ShutdownAnim); + + state ShutdownAnim { + during: animate_shutdown(writer); + on(ShutdownAnimDone) => 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 jiggle_pair(&mut self) { + let (dx, dy): (i8, i8) = if (RoscRng::next_u8() & 1) == 0 { + (1, 0) + } else { + (0, 1) + }; + send(&mut self.writer, dx, dy).await; + Timer::after(PIXEL_DWELL).await; + send(&mut self.writer, -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 led_rgb_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::SweepDone +} + +async fn animate_wake(writer: &mut Writer) -> Ev { + 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(writer, dx, dy).await; + prev_x = next_x; + prev_y = next_y; + Timer::after(ANIM_FRAME).await; + } + Ev::WakeDone +} + +async fn animate_running(writer: &mut Writer) -> 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(writer, dx, dy).await; + prev_x = next_x; + prev_y = next_y; + Timer::after(ANIM_FRAME).await; + } + Ev::RunDone +} + +async fn animate_shutdown(writer: &mut Writer) -> Ev { + 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..SHUTDOWN_FRAMES { + let t = (f as f32) / (SHUTDOWN_FRAMES as f32); + let angle = t * 2.0 * PI * (SHUTDOWN_TURNS as f32); + let radius = SHUTDOWN_RADIUS_START + (SHUTDOWN_RADIUS_END - SHUTDOWN_RADIUS_START) * t; + // Subtract starting offset so the spiral begins at the cursor's entry point. + let next_x = radius * cosf(angle) - SHUTDOWN_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(writer, dx, dy).await; + prev_x = next_x; + prev_y = next_y; + Timer::after(ANIM_FRAME).await; + } + Ev::ShutdownAnimDone +} + +async fn breathe_color(neo: &mut Neo, active_start: &mut Option) -> Ev { + loop { + let elapsed = active_start.map(|s| s.elapsed()).unwrap_or_default(); + let total_run = run_before_shutdown_e(); + let remaining = total_run + .checked_sub(elapsed) + .unwrap_or(EDuration::from_ticks(0)); + let (r, g, b) = breathe_for(remaining, elapsed); + paint(neo, r, g, b).await; + Timer::after(LED_TICK).await; + } +} + +async fn blink_fast_red(neo: &mut Neo) -> Ev { + let start = Instant::now(); + loop { + let t = start.elapsed(); + let level = if blink_on(FAST_RED_PERIOD, t) { + BREATHE_PEAK + } else { + BREATHE_FLOOR + }; + paint(neo, level, 0, 0).await; + Timer::after(LED_TICK).await; + } +} + +// ── 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(writer: &mut Writer, x: i8, y: i8) { let report = MouseReport { buttons: 0, x, @@ -43,35 +411,57 @@ async fn send(writer: &mut HidWriter<'static, UsbDriver, 5>, x: i8, y: i8) { wheel: 0, pan: 0, }; - let _ = with_timeout(Duration::from_secs(3), writer.write_serialize(&report)).await; + let _ = with_timeout(EDuration::from_secs(3), writer.write_serialize(&report)).await; } -async fn sleep_chunked(watchdog: &mut Watchdog, total: Duration) { - let mut remaining = total; - while remaining > Duration::from_ticks(0) { - watchdog.feed(WATCHDOG_FEED); - let step = if remaining > SLEEP_CHUNK { - SLEEP_CHUNK - } else { - remaining - }; - Timer::after(step).await; - remaining -= step; +async fn paint(neo: &mut Neo, r: u8, g: u8, b: u8) { + neo.write(&[RGB8 { r, g, b }]).await; +} + +// ── 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(Duration::from_secs(8)); + 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); - let mut config = UsbConfig::new(0x413c, 0x301a); - config.manufacturer = Some("Dell"); - config.product = Some("Dell MS116 USB Optical Mouse"); + let mut config = UsbConfig::new(0x046d, 0xc07d); + config.manufacturer = Some("Logitech"); + config.product = Some("G502 Mouse"); config.max_power = 100; config.max_packet_size_0 = 64; @@ -93,38 +483,33 @@ async fn main(spawner: Spawner) { let hid_config = HidConfig { report_descriptor: MouseReport::desc(), request_handler: None, - poll_ms: 60, + // 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 mut writer = HidWriter::<_, 5>::new(&mut builder, HID_STATE.init(State::new()), hid_config); + let writer = HidWriter::<_, 5>::new(&mut builder, HID_STATE.init(State::new()), hid_config); let usb = builder.build(); spawner.spawn(usb_task(usb).unwrap()); + spawner.spawn(watchdog_task(watchdog).unwrap()); - let start = Instant::now(); + static EVENT_CHAN: Channel = Channel::new(); + + let ctx = Ctx { + writer, + 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 { - if start.elapsed() >= RUN_DURATION { - break; - } - - let (dx, dy): (i8, i8) = if (RoscRng::next_u8() & 1) == 0 { - (1, 0) - } else { - (0, 1) - }; - - watchdog.feed(WATCHDOG_FEED); - send(&mut writer, dx, dy).await; - Timer::after_millis(PIXEL_DWELL_MS).await; - send(&mut writer, -dx, -dy).await; - - sleep_chunked(&mut watchdog, IDLE_BETWEEN).await; - } - - loop { - watchdog.feed(WATCHDOG_FEED); - Timer::after(SLEEP_CHUNK).await; + Timer::after(WATCHDOG_FEED_INTERVAL).await; } }