feat: initial Xiao RP2040 mouse jiggler firmware

Embassy-based no_std firmware for the Seeed Studio Xiao RP2040 that
masquerades as a Dell MS116 USB HID Boot Mouse. Each cycle picks a
random axis from the RP2040 ROSC RANDOMBIT register, nudges +1 px,
dwells 25 ms, nudges -1 px, then sleeps 4.5 minutes. After 8 hours
elapsed from boot or RESET press the device idles silently (watchdog
still fed) until the user taps RESET (R) again, aligning a single
press with one workday.

Tooling:
- mise.toml pins rust 1.95.0 with explicit components and the
  thumbv6m-none-eabi target, plus cargo-binutils / uf2conv helpers.
- justfile shell is set to "mise exec -- sh -eu -c" so every recipe
  uses the pinned toolchain without any shell activation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-27 14:59:30 -06:00
co-authored by Claude Opus 4.7
commit 0032d01638
9 changed files with 1951 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
/.cargo
/target
+33
View File
@@ -0,0 +1,33 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [0.1.0] - 2026-04-27
### Added
- Initial Embassy-based `no_std` firmware for the Seeed Studio Xiao RP2040.
- USB HID Boot Mouse, masquerading as a Dell MS116 (VID `0x413c`, PID `0x301a`).
- Jiggle pattern: pick a random axis from the RP2040 ROSC `RANDOMBIT`, nudge
+1 px, dwell 25 ms, nudge 1 px, then sleep 4.5 minutes before the next
cycle.
- 8-hour workday window after boot or `RESET (R)` press, then silent idle
(watchdog still fed) until the next manual reset.
- 8-second hardware watchdog, fed in 5-second chunks during long sleeps.
- `mise.toml` pinning rust 1.95.0 with explicit components
(`cargo`, `clippy`, `llvm-tools`, `rust-src`, `rust-std`, `rustc`,
`rustfmt`) and the `thumbv6m-none-eabi` target, plus cargo helpers
(`cargo-binutils`, `uf2conv`, `cargo-watch`, `cargo-bloat`,
`cargo-expand`).
- `justfile` recipes for `build`, `release`, `check`, `clippy`, `fmt`,
`lint`, `ci`, `bin`, `uf2`, `flash`, `size`, `bloat`, `expand`, and
`bootstrap`. All recipes execute inside `mise exec -- sh -eu -c` so the
pinned toolchain is used regardless of shell activation state.
[Unreleased]: https://github.com/swaits/jiggly/compare/v0.1.0...HEAD
[0.1.0]: https://github.com/swaits/jiggly/releases/tag/v0.1.0
Generated
+1621
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
[package]
name = "jiggly"
version = "0.1.0"
edition = "2024"
[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-usb = "0.6.0"
usbd-hid = "0.10.0"
cortex-m = { version = "0.7.6", features = ["inline-asm"] }
cortex-m-rt = "0.7.5"
panic-reset = "0.1"
static_cell = "2.1"
portable-atomic = { version = "1.13", features = ["critical-section"] }
[profile.release]
lto = true
opt-level = "s"
codegen-units = 1
debug = 2
overflow-checks = false
+12
View File
@@ -0,0 +1,12 @@
use std::{env, fs::File, io::Write, path::PathBuf};
fn main() {
let out = PathBuf::from(env::var_os("OUT_DIR").unwrap());
File::create(out.join("memory.x"))
.unwrap()
.write_all(include_bytes!("memory.x"))
.unwrap();
println!("cargo:rustc-link-search={}", out.display());
println!("cargo:rerun-if-changed=memory.x");
println!("cargo:rerun-if-changed=build.rs");
}
+108
View File
@@ -0,0 +1,108 @@
# Every recipe runs inside `mise exec --`, so cargo, rustup components, and
# cargo helpers (objcopy, uf2conv, …) come from the toolchain pinned in
# mise.toml — no shell activation required.
set shell := ["mise", "exec", "--", "sh", "-eu", "-c"]
firmware := "jiggly"
target := "thumbv6m-none-eabi"
base_addr := "0x10000000"
family_id := "0xE48BFF56"
out_release := "target" / target / "release" / firmware
out_bin := "target" / target / "release" / firmware + ".bin"
out_uf2 := "target" / target / "release" / firmware + ".uf2"
# Show available recipes.
default:
@just --list --unsorted
# Debug build.
build:
cargo build
# Optimised release build.
release:
cargo build --release
# `cargo check` on the firmware binary.
check:
cargo check
# Clippy — treat warnings as errors.
clippy:
cargo clippy -- -D warnings
# Format source in place.
fmt:
cargo fmt --all
# Verify formatting without writing.
fmt-check:
cargo fmt --all -- --check
# All lint gates (fmt-check + clippy).
lint: fmt-check clippy
# Local CI pipeline: check + lint + release build.
ci: check lint release
# Remove build artefacts.
clean:
cargo clean
# Re-run `cargo check` on source changes.
watch:
cargo watch -x check
# Print section sizes of the release ELF.
size: release
cargo size --release -- -A
# Symbol-level size breakdown of the release ELF.
bloat: release
cargo bloat --release -n 30
# Expand macros (helpful for inspecting `bind_interrupts!` / `#[embassy_executor::main]`).
expand:
cargo expand
# Build rustdoc for this crate and dependencies, then open in a browser.
doc:
cargo doc --open
# Strip the release ELF to a raw binary image.
bin: release
cargo objcopy --release -- -O binary {{ out_bin }}
@echo "→ {{ out_bin }}"
# Produce a UF2 image ready to flash onto the XIAO bootloader volume.
uf2: bin
uf2conv {{ out_bin }} -b {{ base_addr }} -f {{ family_id }} -o {{ out_uf2 }}
@echo "→ {{ out_uf2 }}"
# Build UF2 and copy it to the first mounted XIAO UF2 volume (Linux/macOS).
flash: uf2
#!/usr/bin/env bash
set -euo pipefail
echo "Hold BOOT (B) and tap RESET (R) on the XIAO RP2040 to enter bootloader mode…"
for i in {1..60}; do
for mount in /media/*/RPI-RP2* /run/media/*/RPI-RP2* /Volumes/RPI-RP2*; do
if [[ -d "$mount" ]]; then
cp "{{ out_uf2 }}" "$mount/"
sync
echo "→ Copied to $mount"
exit 0
fi
done
sleep 1
done
echo "Timed out waiting for XIAO UF2 volume. Copy {{ out_uf2 }} manually." >&2
exit 1
# Install the toolchain and cargo helpers declared in mise.toml.
bootstrap:
mise install
# Show firmware file size summary.
stats: uf2
@ls -lh {{ out_release }} {{ out_bin }} {{ out_uf2 }}
+5
View File
@@ -0,0 +1,5 @@
MEMORY {
BOOT2 : ORIGIN = 0x10000000, LENGTH = 0x100
FLASH : ORIGIN = 0x10000100, LENGTH = 2048K - 0x100
RAM : ORIGIN = 0x20000000, LENGTH = 264K
}
+16
View File
@@ -0,0 +1,16 @@
[tools]
rust = { version = "1.95.0", profile = "minimal", components = ["cargo", "clippy", "llvm-tools", "rust-src", "rust-std", "rustc", "rustfmt"], targets = ["thumbv6m-none-eabi"] }
just = "latest"
"cargo:cargo-binutils" = "latest"
"cargo:uf2conv" = "latest"
"cargo:cargo-watch" = "latest"
"cargo:cargo-bloat" = "latest"
"cargo:cargo-expand" = "latest"
[env]
CARGO_TARGET_DIR = "target"
DEFMT_LOG = "off"
[settings]
experimental = true
+130
View File
@@ -0,0 +1,130 @@
#![no_std]
#![no_main]
use embassy_executor::Spawner;
use embassy_rp::{
bind_interrupts,
clocks::RoscRng,
peripherals::USB,
usb::{Driver, InterruptHandler},
watchdog::Watchdog,
};
use embassy_time::{Duration, Instant, Timer, with_timeout};
use embassy_usb::{
Builder, Config as UsbConfig, UsbDevice,
class::hid::{Config as HidConfig, HidBootProtocol, HidSubclass, HidWriter, State},
};
use panic_reset as _;
use static_cell::StaticCell;
use usbd_hid::descriptor::{MouseReport, SerializedDescriptor};
bind_interrupts!(struct Irqs {
USBCTRL_IRQ => InterruptHandler<USB>;
});
type UsbDriver = Driver<'static, USB>;
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);
#[embassy_executor::task]
async fn usb_task(mut device: UsbDevice<'static, UsbDriver>) {
device.run().await;
}
async fn send(writer: &mut HidWriter<'static, UsbDriver, 5>, x: i8, y: i8) {
let report = MouseReport {
buttons: 0,
x,
y,
wheel: 0,
pan: 0,
};
let _ = with_timeout(Duration::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;
}
}
#[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));
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");
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 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 hid_config = HidConfig {
report_descriptor: MouseReport::desc(),
request_handler: None,
poll_ms: 60,
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 usb = builder.build();
spawner.spawn(usb_task(usb).unwrap());
let start = Instant::now();
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;
}
}