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:
@@ -0,0 +1,2 @@
|
||||
/.cargo
|
||||
/target
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+24
@@ -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
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 }}
|
||||
@@ -0,0 +1,5 @@
|
||||
MEMORY {
|
||||
BOOT2 : ORIGIN = 0x10000000, LENGTH = 0x100
|
||||
FLASH : ORIGIN = 0x10000100, LENGTH = 2048K - 0x100
|
||||
RAM : ORIGIN = 0x20000000, LENGTH = 264K
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user