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