feat: composite HID mouse+keyboard with WakingHost statechart

Refactor the boot wake sequence into a WakingHost parent state with
WakingWithKeyboard (default, taps Left Shift 4×) timing out after 500 ms
into WakingWithMouse (existing shake). macOS does not reliably wake from
raw HID mouse motion; a keyboard event does — Shift is a no-op modifier
so it has no character side effect. Parent catches WakeDone (emitted by
the mouse path) and exits to Settling.

USB identity: 046d:c07d "G502 Mouse" → 046d:c52b "USB Receiver"
(Logitech Unifying Receiver — a real composite mouse+keyboard device).
Implementation uses two HidWriters on one embassy_usb::Builder; standard
USB composite, no hub simulation.

Naming cleanup throughout the statechart:
- BootSweep → Booting; SweepDone → BootDone; led_rgb_sweep → boot_sweep
- WakingDisplay → WakingHost{WakingWithKeyboard, WakingWithMouse}
- ShowingRunning → Spinning; RunDone → SpinDone; animate_running → animate_spinner
- Ending::ShutdownAnim → Ending::Spiraling; ShutdownAnimDone → SpiralDone
- animate_shutdown → animate_final_spiral; SHUTDOWN_* → FINAL_SPIRAL_*
- Ctx.writer/Writer → Ctx.mouse/MouseHid (+ new Ctx.kbd/KbdHid)
- send → send_mouse (+ new send_kbd)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-01 12:14:47 -06:00
co-authored by Claude Opus 4.7
parent cb95dadc2a
commit 01b96bdbf6
2 changed files with 171 additions and 65 deletions
+36 -7
View File
@@ -9,11 +9,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- **Composite USB device — mouse + keyboard HID** under one VID/PID. A new
`WakingHost` parent state wraps two substates: `WakingWithKeyboard`
(`default`) taps Left Shift 4× then idles, and parent's
`KBD_WAKE_DURATION` timeout drives the transition to `WakingWithMouse`
which runs the existing shake animation. Reason: macOS does not reliably
wake from raw HID mouse motion alone; a keyboard event does. Shift is a
modifier with no character side effect, so it's safe even with focus on a
text field at the moment of wake. Adds a second `HidWriter` against the
same `embassy_usb::Builder` (no hub simulation; standard USB composite),
plus `KbdHid`/`KBD_HID_STATE` machinery and a `send_kbd` helper.
- `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.
flow (boot → host wake (kbd → mouse) → settle → spinner → active
jiggle/flash → end-of-day 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
@@ -38,6 +48,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- **USB descriptor: Logitech G502 (`046d:c07d`, mouse-only) → Logitech
Unifying Receiver (`046d:c52b`, real composite mouse+keyboard).** Product
string `"G502 Mouse"``"USB Receiver"`. Bumping the PID also invalidates
the host's cached HID descriptor on first plug after reflash.
- **Statechart names cleaned up for symmetry**:
- `BootSweep``Booting`; `Ev::SweepDone``Ev::BootDone`;
`led_rgb_sweep``boot_sweep`.
- `WakingDisplay` (flat) → `WakingHost` parent with `WakingWithKeyboard`
+ `WakingWithMouse` substates; `animate_wake``wake_with_mouse`,
matched by new `wake_with_keyboard`.
- `ShowingRunning``Spinning`; `Ev::RunDone``Ev::SpinDone`;
`animate_running``animate_spinner`.
- `Ending::ShutdownAnim``Ending::Spiraling`;
`Ev::ShutdownAnimDone``Ev::SpiralDone`;
`animate_shutdown``animate_final_spiral`;
`SHUTDOWN_RADIUS_START`/`SHUTDOWN_TURNS`/`SHUTDOWN_FRAMES`
`FINAL_SPIRAL_*`.
- `Ctx.writer: Writer``Ctx.mouse: MouseHid` (parallel to new
`Ctx.kbd: KbdHid`); `send``send_mouse`.
- **`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:0013:00, end `triangular(16.0, mode=17.5, 19.0)`) for "screen
@@ -46,12 +75,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- 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.
delay removed. Reset → wake-shake latency dropped from ~2 s to ~240 ms
(mouse-shake now ~740 ms after reset since 500 ms of keyboard-wake taps
precede it).
- 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
+135 -58
View File
@@ -26,7 +26,7 @@ use libm::{cosf, powf, roundf, sinf};
use panic_reset as _;
use smart_leds::RGB8;
use static_cell::StaticCell;
use usbd_hid::descriptor::{MouseReport, SerializedDescriptor};
use usbd_hid::descriptor::{KeyboardReport, MouseReport, SerializedDescriptor};
bind_interrupts!(struct Irqs {
USBCTRL_IRQ => UsbInterruptHandler<USB>;
@@ -36,7 +36,8 @@ bind_interrupts!(struct Irqs {
type UsbDriver = Driver<'static, USB>;
type Neo = PioWs2812<'static, PIO0, 0, 1, Grb>;
type Writer = HidWriter<'static, UsbDriver, 5>;
type MouseHid = HidWriter<'static, UsbDriver, 5>;
type KbdHid = HidWriter<'static, UsbDriver, 8>;
// ── Lifecycle timing (statechart Durations) ────────────────────────
// 3h50m: math-optimal under the user's schedule for "screen sleeps during
@@ -83,10 +84,24 @@ 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.
// 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 a keyboard event. Tap Left Shift four times before the mouse shake;
// Shift is a modifier, so it produces no visible character even if focus is
// on a text field at the moment of wake.
const KBD_WAKE_TAPS: u32 = 4;
const KBD_TAP_HOLD: EDuration = EDuration::from_millis(30);
const KBD_TAP_GAP: EDuration = EDuration::from_millis(50);
// Total parent dwell: 4 × (30 + 50) = 320 ms of taps + ~180 ms slack so the
// host has a chance to start coming up before the mouse shake begins.
const KBD_WAKE_DURATION: Duration = Duration::from_millis(500);
// USB HID Boot Keyboard modifier byte: bit 1 = Left Shift.
const KBD_MOD_LEFT_SHIFT: u8 = 0x02;
// 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;
@@ -100,10 +115,10 @@ const RUN_CIRCLES: u32 = 3;
const EASE_POW: f32 = 2.5;
const SPIRAL_RADIUS_END: f32 = 2.0;
// Final shutdown — the dramatic full version, 30 s before USB goes silent.
const SHUTDOWN_RADIUS_START: f32 = 80.0;
const SHUTDOWN_TURNS: f32 = 5.0;
const SHUTDOWN_FRAMES: u32 = 625; // 5.0 s @ 8 ms/frame
// 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;
@@ -137,7 +152,8 @@ const BOOT_SWEEP_STEP: EDuration = EDuration::from_millis(60);
const SHUTDOWN_FLASH_STEP: EDuration = EDuration::from_millis(400);
pub struct Ctx {
pub writer: Writer,
pub mouse: MouseHid,
pub kbd: KbdHid,
pub neo: Neo,
pub neo_pwr: Output<'static>,
pub active_start: Option<Instant>,
@@ -145,39 +161,52 @@ pub struct Ctx {
#[derive(Debug, Clone)]
pub enum Ev {
SweepDone,
BootDone,
WakeDone,
RunDone,
SpinDone,
Jiggled,
WarnDone,
ShutdownAnimDone,
SpiralDone,
}
statechart! {
Jiggly {
context: Ctx;
events: Ev;
default(BootSweep);
default(Booting);
state BootSweep {
during: led_rgb_sweep(neo, neo_pwr);
on(SweepDone) => WakingDisplay;
state Booting {
during: boot_sweep(neo, neo_pwr);
on(BootDone) => WakingHost;
}
state WakingDisplay {
during: animate_wake(writer);
// 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. The parent state catches WakeDone (emitted by the
// mouse path) and exits to Settling.
state WakingHost {
default(WakingWithKeyboard);
on(WakeDone) => Settling;
state WakingWithKeyboard {
during: wake_with_keyboard(kbd);
on(after KBD_WAKE_DURATION) => WakingWithMouse;
}
state WakingWithMouse {
during: wake_with_mouse(mouse);
}
}
// Quiet pause so the display has time to come out of sleep before the
// cursor starts drawing the "running" circles.
// cursor starts drawing the spinner.
state Settling {
on(after SETTLING_DELAY) => ShowingRunning;
on(after SETTLING_DELAY) => Spinning;
}
state ShowingRunning {
during: animate_running(writer);
on(RunDone) => Active;
state Spinning {
during: animate_spinner(mouse);
on(SpinDone) => Active;
}
state Active {
@@ -199,23 +228,23 @@ Jiggly {
}
state Warning10 {
during: animate_warning_10(writer);
during: animate_warning_10(mouse);
on(WarnDone) => Breathing;
}
state Warning5 {
during: animate_warning_5(writer);
during: animate_warning_5(mouse);
on(WarnDone) => Breathing;
}
}
state Ending {
during: blink_fast_red(neo);
default(ShutdownAnim);
default(Spiraling);
state ShutdownAnim {
during: animate_shutdown(writer);
on(ShutdownAnimDone) => Quiet;
state Spiraling {
during: animate_final_spiral(mouse);
on(SpiralDone) => Quiet;
}
state Quiet {
@@ -241,9 +270,9 @@ impl JigglyActions for JigglyActionContext<'_> {
} else {
(0, 1)
};
send(&mut self.writer, dx, dy).await;
send_mouse(&mut self.mouse, dx, dy).await;
Timer::after(PIXEL_DWELL).await;
send(&mut self.writer, -dx, -dy).await;
send_mouse(&mut self.mouse, -dx, -dy).await;
let _ = self.emit(Ev::Jiggled);
}
@@ -267,7 +296,7 @@ impl JigglyActions for JigglyActionContext<'_> {
// ── During activities (free async fns) ─────────────────────────────
async fn led_rgb_sweep(neo: &mut Neo, neo_pwr: &mut Output<'static>) -> Ev {
async fn boot_sweep(neo: &mut Neo, neo_pwr: &mut Output<'static>) -> Ev {
neo_pwr.set_high();
Timer::after_millis(2).await;
@@ -278,10 +307,28 @@ async fn led_rgb_sweep(neo: &mut Neo, neo_pwr: &mut Output<'static>) -> Ev {
paint(neo, 0, 0, BREATHE_PEAK).await;
Timer::after(BOOT_SWEEP_STEP).await;
paint(neo, 0, 0, 0).await;
Ev::SweepDone
Ev::BootDone
}
async fn animate_wake(writer: &mut Writer) -> Ev {
// Tap Left Shift 4 times — macOS reliably wakes from a keyboard event but is
// inconsistent about waking from raw mouse motion. The during loop ends after
// the taps and idles in a long sleep; the parent state's KBD_WAKE_DURATION
// timeout drives the transition out, not a completion event from this fn.
async fn wake_with_keyboard(kbd: &mut KbdHid) -> Ev {
for _ in 0..KBD_WAKE_TAPS {
send_kbd(kbd, KBD_MOD_LEFT_SHIFT, [0; 6]).await;
Timer::after(KBD_TAP_HOLD).await;
send_kbd(kbd, 0, [0; 6]).await;
Timer::after(KBD_TAP_GAP).await;
}
// Idle until the parent timeout fires. The hsmc runtime cancels this
// future on transition, so the long sleep just parks us.
loop {
Timer::after(EDuration::from_secs(60)).await;
}
}
async fn wake_with_mouse(mouse: &mut MouseHid) -> 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;
@@ -296,7 +343,7 @@ async fn animate_wake(writer: &mut Writer) -> Ev {
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;
send_mouse(mouse, dx, dy).await;
prev_x = next_x;
prev_y = next_y;
Timer::after(ANIM_FRAME).await;
@@ -304,7 +351,7 @@ async fn animate_wake(writer: &mut Writer) -> Ev {
Ev::WakeDone
}
async fn animate_running(writer: &mut Writer) -> Ev {
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;
@@ -318,16 +365,16 @@ async fn animate_running(writer: &mut Writer) -> Ev {
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;
send_mouse(mouse, dx, dy).await;
prev_x = next_x;
prev_y = next_y;
Timer::after(ANIM_FRAME).await;
}
Ev::RunDone
Ev::SpinDone
}
async fn animate_spiral(
writer: &mut Writer,
mouse: &mut MouseHid,
radius_start: f32,
radius_end: f32,
turns: f32,
@@ -348,28 +395,28 @@ async fn animate_spiral(
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;
send_mouse(mouse, dx, dy).await;
prev_x = next_x;
prev_y = next_y;
Timer::after(ANIM_FRAME).await;
}
}
async fn animate_shutdown(writer: &mut Writer) -> Ev {
async fn animate_final_spiral(mouse: &mut MouseHid) -> Ev {
animate_spiral(
writer,
SHUTDOWN_RADIUS_START,
mouse,
FINAL_SPIRAL_RADIUS_START,
SPIRAL_RADIUS_END,
SHUTDOWN_TURNS,
SHUTDOWN_FRAMES,
FINAL_SPIRAL_TURNS,
FINAL_SPIRAL_FRAMES,
)
.await;
Ev::ShutdownAnimDone
Ev::SpiralDone
}
async fn animate_warning_5(writer: &mut Writer) -> Ev {
async fn animate_warning_5(mouse: &mut MouseHid) -> Ev {
animate_spiral(
writer,
mouse,
WARN5_RADIUS_START,
SPIRAL_RADIUS_END,
WARN5_TURNS,
@@ -379,9 +426,9 @@ async fn animate_warning_5(writer: &mut Writer) -> Ev {
Ev::WarnDone
}
async fn animate_warning_10(writer: &mut Writer) -> Ev {
async fn animate_warning_10(mouse: &mut MouseHid) -> Ev {
animate_spiral(
writer,
mouse,
WARN10_RADIUS_START,
SPIRAL_RADIUS_END,
WARN10_TURNS,
@@ -481,7 +528,7 @@ fn step_delta(
// ── HID + LED primitives ───────────────────────────────────────────
async fn send(writer: &mut Writer, x: i8, y: i8) {
async fn send_mouse(mouse: &mut MouseHid, x: i8, y: i8) {
let report = MouseReport {
buttons: 0,
x,
@@ -489,7 +536,17 @@ async fn send(writer: &mut Writer, x: i8, y: i8) {
wheel: 0,
pan: 0,
};
let _ = with_timeout(EDuration::from_secs(3), writer.write_serialize(&report)).await;
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) {
@@ -537,9 +594,12 @@ async fn main(spawner: Spawner) {
let driver = Driver::new(p.USB, Irqs);
let mut config = UsbConfig::new(0x046d, 0xc07d);
// Spoof a Logitech Unifying Receiver — a real-world composite device that
// exposes both mouse and keyboard HID interfaces under one VID/PID, which
// matches what this firmware now does.
let mut config = UsbConfig::new(0x046d, 0xc52b);
config.manufacturer = Some("Logitech");
config.product = Some("G502 Mouse");
config.product = Some("USB Receiver");
config.max_power = 100;
config.max_packet_size_0 = 64;
@@ -547,7 +607,8 @@ async fn main(spawner: Spawner) {
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 HID_STATE: StaticCell<State> = StaticCell::new();
static MOUSE_HID_STATE: StaticCell<State> = StaticCell::new();
static KBD_HID_STATE: StaticCell<State> = StaticCell::new();
let mut builder = Builder::new(
driver,
@@ -558,7 +619,7 @@ async fn main(spawner: Spawner) {
CONTROL_BUF.init([0; 64]),
);
let hid_config = HidConfig {
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
@@ -568,7 +629,22 @@ async fn main(spawner: Spawner) {
hid_subclass: HidSubclass::Boot,
hid_boot_protocol: HidBootProtocol::Mouse,
};
let writer = HidWriter::<_, 5>::new(&mut builder, HID_STATE.init(State::new()), hid_config);
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());
@@ -577,7 +653,8 @@ async fn main(spawner: Spawner) {
static EVENT_CHAN: Channel<CriticalSectionRawMutex, Ev, 8> = Channel::new();
let ctx = Ctx {
writer,
mouse,
kbd,
neo,
neo_pwr,
active_start: None,