feat: defmt-over-RTT debug build + probe-rs flow

Add an opt-in `defmt` feature that swaps panic-reset for panic-probe,
links defmt-rtt, and enables hsmc's `trace-defmt` so chart transitions
print over RTT. Default builds are unchanged (still panic-reset, no
defmt overhead).

- Cargo.toml: `defmt` feature pulls defmt + defmt-rtt + panic-probe
  and forwards `trace-defmt` to hsmc; mirror-flag pattern matches
  the existing `embassy` feature.
- build.rs: link `defmt.x` only when the feature is on.
- src/main.rs: cfg-gate the panic handler swap, log boot, USB
  serial, kbd/mouse wake completion vs. deadline, and jiggle dx/dy.
- main.rs: watchdog.pause_on_debug(true) so probe-rs halts don't
  trip the 8 s reset.
- justfile: build-debug, debug, attach, probes recipes
  (release mode — debug builds overflow flash with defmt+panic-probe).
- mise.toml: add cargo:probe-rs-tools.
This commit is contained in:
2026-05-04 12:57:31 -06:00
parent d0d9c451aa
commit 8cb071b132
6 changed files with 197 additions and 7 deletions
Generated
+93 -2
View File
@@ -86,6 +86,12 @@ version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d7e60934ceec538daadb9d8432424ed043a904d8e0243f3c6446bce549a46ac"
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.11.1"
@@ -262,6 +268,57 @@ version = "0.3.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f578e8e2c440e7297e008bb5486a3a8a194775224bbc23729b0dbdfaeebf162e"
[[package]]
name = "defmt"
version = "0.3.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0963443817029b2024136fc4dd07a5107eb8f977eaf18fcd1fdeb11306b64ad"
dependencies = [
"defmt 1.0.1",
]
[[package]]
name = "defmt"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "548d977b6da32fa1d1fda2876453da1e7df63ad0304c8b3dae4dbe7b96f39b78"
dependencies = [
"bitflags 1.3.2",
"defmt-macros",
]
[[package]]
name = "defmt-macros"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d4fc12a85bcf441cfe44344c4b72d58493178ce635338a3f3b78943aceb258e"
dependencies = [
"defmt-parser",
"proc-macro-error2",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "defmt-parser"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
dependencies = [
"thiserror",
]
[[package]]
name = "defmt-rtt"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6eca0aae8aa2cf8333200ecbd236274697bc0a394765c858b3d9372eb1abcfa"
dependencies = [
"critical-section",
"defmt 0.3.100",
]
[[package]]
name = "digest"
version = "0.10.7"
@@ -472,7 +529,7 @@ version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a25746d8b152b72fbf2a217f489a083dbbe243f281f09184a1f2cfbe9bbb245f"
dependencies = [
"bitflags",
"bitflags 2.11.1",
"embassy-futures",
"embassy-net-driver-channel",
"embassy-sync",
@@ -722,6 +779,7 @@ version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "497ed9593e4baf6ef120b24aff80cb898f6832d32cdf01be9d90be0c06624e77"
dependencies = [
"defmt 0.3.100",
"embassy-futures",
"embassy-sync",
"embassy-time",
@@ -771,6 +829,8 @@ version = "0.2.0"
dependencies = [
"cortex-m",
"cortex-m-rt",
"defmt 0.3.100",
"defmt-rtt",
"embassy-executor",
"embassy-futures",
"embassy-rp",
@@ -779,6 +839,7 @@ dependencies = [
"embassy-usb",
"hsmc",
"libm",
"panic-probe",
"panic-reset",
"portable-atomic",
"smart-leds",
@@ -960,6 +1021,16 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "panic-probe"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4047d9235d1423d66cc97da7d07eddb54d4f154d6c13805c6d0793956f4f25b0"
dependencies = [
"cortex-m",
"defmt 0.3.100",
]
[[package]]
name = "panic-reset"
version = "0.1.1"
@@ -1150,7 +1221,7 @@ version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags",
"bitflags 2.11.1",
]
[[package]]
@@ -1420,6 +1491,26 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "thread_local"
version = "1.1.9"
+27 -2
View File
@@ -12,11 +12,31 @@ categories = ["embedded", "no-std"]
publish = false # firmware binary; not a crates.io library
[features]
default = ["embassy"]
default = ["embassy", "panic-reset"]
# `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"]
# Default panic handler — silently resets the chip. Mutually exclusive with
# `defmt` (which uses panic-probe). Build a defmt firmware with
# `cargo build --no-default-features --features embassy,defmt`.
panic-reset = ["dep:panic-reset"]
# Same trick as `embassy` above: `hsmc` emits `#[cfg(feature = "trace-defmt")]`
# from inside `__chart_observe!` (re-exported via `statechart!`), and that
# cfg is evaluated in *this* crate's feature set, not hsmc's. So we mirror
# the flag here and forward it to `hsmc/trace-defmt`. Without this mirror,
# `--features defmt` would compile cleanly but emit zero state-transition
# logs, because every defmt arm in __chart_observe! would be cfg'd out.
trace-defmt = ["hsmc/trace-defmt"]
# Debug build over RTT. Pulls in defmt + defmt-rtt, swaps the panic handler
# for panic-probe (so panics get printed instead of silently resetting), and
# turns on hsmc's chart/state/transition tracing.
defmt = [
"dep:defmt",
"dep:defmt-rtt",
"dep:panic-probe",
"trace-defmt",
]
[dependencies]
embassy-executor = { version = "0.10.0", features = ["platform-cortex-m", "executor-thread"] }
@@ -32,11 +52,16 @@ libm = "0.2"
cortex-m = { version = "0.7.6", features = ["inline-asm"] }
cortex-m-rt = "0.7.5"
panic-reset = "0.1"
panic-reset = { version = "0.1", optional = true }
static_cell = "2.1"
# Cortex-M0+ has no hardware CAS; static_cell + heapless need this shim.
portable-atomic = { version = "1.13", features = ["critical-section"] }
# Defmt stack — only linked when the `defmt` feature is enabled.
defmt = { version = "0.3", optional = true }
defmt-rtt = { version = "0.4", optional = true }
panic-probe = { version = "0.3", features = ["print-defmt"], optional = true }
[lints.rust]
unexpected_cfgs = { level = "allow", check-cfg = ['cfg(feature, values("tokio", "embassy"))'] }
+7
View File
@@ -9,4 +9,11 @@ fn main() {
println!("cargo:rustc-link-search={}", out.display());
println!("cargo:rerun-if-changed=memory.x");
println!("cargo:rerun-if-changed=build.rs");
// defmt ships its own linker script (`defmt.x`) that defines the
// `_defmt_*` interner sections. Only link it when the feature is on —
// otherwise the symbols don't exist and `ld` will refuse the build.
if env::var_os("CARGO_FEATURE_DEFMT").is_some() {
println!("cargo:rustc-link-arg=-Tdefmt.x");
}
}
+26
View File
@@ -7,6 +7,7 @@ firmware := "jiggly"
target := "thumbv6m-none-eabi"
base_addr := "0x10000000"
family_id := "0xE48BFF56"
chip := "RP2040"
out_release := "target" / target / "release" / firmware
out_bin := "target" / target / "release" / firmware + ".bin"
@@ -99,6 +100,31 @@ flash: uf2
echo "Timed out waiting for XIAO UF2 volume. Copy {{ out_uf2 }} manually." >&2
exit 1
# Build the firmware with defmt-over-RTT enabled. Mutually exclusive with
# the default `panic-reset` feature, so we drop default features.
# DEFMT_LOG=trace overrides the `off` default in mise.toml so the macros
# actually emit. Built in release mode because thumbv6m debug builds plus
# defmt-rtt + panic-probe overflow flash quickly.
build-debug:
DEFMT_LOG=trace cargo build --release --no-default-features --features embassy,defmt
# Flash the defmt firmware via the attached SWD probe and tail RTT logs.
# Plug the probe into the four SWD pads on the back of the Xiao
# (SWCLK + SWDIO + GND — leave 3V3 disconnected so USB-C powers the board).
# The Xiao's own USB-C can stay plugged into a separate host the whole time;
# the SWD path and the USB path are independent.
debug: build-debug
probe-rs run --chip {{ chip }} {{ out_release }}
# Just tail RTT from a board that's already running the defmt firmware
# (no flash). Useful for rejoining a session after Ctrl-C without a reset.
attach:
probe-rs attach --chip {{ chip }} {{ out_release }}
# List attached debug probes — sanity check that the DAPLink shows up.
probes:
probe-rs list
# Install the toolchain and cargo helpers declared in mise.toml.
bootstrap:
mise install
+1
View File
@@ -7,6 +7,7 @@ just = "latest"
"cargo:cargo-watch" = "latest"
"cargo:cargo-bloat" = "latest"
"cargo:cargo-expand" = "latest"
"cargo:probe-rs-tools" = "latest"
[env]
CARGO_TARGET_DIR = "target"
+43 -3
View File
@@ -24,10 +24,13 @@ use embassy_usb::{
};
use hsmc::{Duration, statechart};
use libm::{cosf, powf, roundf, sinf};
#[cfg(not(feature = "defmt"))]
use panic_reset as _;
use smart_leds::RGB8;
use static_cell::StaticCell;
use usbd_hid::descriptor::{KeyboardReport, MouseReport, SerializedDescriptor};
#[cfg(feature = "defmt")]
use {defmt_rtt as _, panic_probe as _};
bind_interrupts!(struct Irqs {
USBCTRL_IRQ => UsbInterruptHandler<USB>;
@@ -291,11 +294,23 @@ impl JigglyActions for JigglyActionContext<'_> {
}
async fn keyboard_wake(&mut self) {
let _ = embassy_futures::select::select(
let result = embassy_futures::select::select(
wake_with_keyboard(&mut self.kbd),
Timer::after(KBD_WAKE_DEADLINE),
)
.await;
#[cfg(feature = "defmt")]
match result {
embassy_futures::select::Either::First(_) => defmt::info!("kbd wake: completed"),
embassy_futures::select::Either::Second(_) => {
defmt::warn!(
"kbd wake: deadline hit ({} ms)",
KBD_WAKE_DEADLINE.as_millis()
)
}
}
#[cfg(not(feature = "defmt"))]
let _ = result;
// Belt-and-suspenders: always send an all-keys-released report,
// even if the deadline preempted the loop *between* a key-down
// and its key-up. Without this, a stuck modifier (Shift!) or key
@@ -310,11 +325,21 @@ impl JigglyActions for JigglyActionContext<'_> {
}
async fn mouse_wake(&mut self) {
let _ = embassy_futures::select::select(
let result = embassy_futures::select::select(
wake_with_mouse(&mut self.mouse),
Timer::after(MOUSE_WAKE_DEADLINE),
)
.await;
#[cfg(feature = "defmt")]
match result {
embassy_futures::select::Either::First(_) => defmt::info!("mouse wake: completed"),
embassy_futures::select::Either::Second(_) => defmt::warn!(
"mouse wake: deadline hit ({} ms)",
MOUSE_WAKE_DEADLINE.as_millis()
),
}
#[cfg(not(feature = "defmt"))]
let _ = result;
}
async fn jiggle_pair(&mut self) {
@@ -323,6 +348,8 @@ impl JigglyActions for JigglyActionContext<'_> {
} else {
(0, 1)
};
#[cfg(feature = "defmt")]
defmt::info!("jiggle: dx={} dy={}", dx, dy);
send_mouse(&mut self.mouse, dx, dy).await;
Timer::after(PIXEL_DWELL).await;
send_mouse(&mut self.mouse, -dx, -dy).await;
@@ -649,7 +676,14 @@ async fn watchdog_task(mut wd: Watchdog) -> ! {
async fn main(spawner: Spawner) {
let p = embassy_rp::init(Default::default());
#[cfg(feature = "defmt")]
defmt::info!("jiggly v{} boot", env!("CARGO_PKG_VERSION"));
let mut watchdog = Watchdog::new(p.WATCHDOG);
// Pause the countdown while a debugger has the core halted, so
// breakpoints and single-stepping under `probe-rs` don't trip the
// 8 s reset. Cheap and always-correct, so leave it on for release too.
watchdog.pause_on_debug(true);
watchdog.start(WATCHDOG_TIMEOUT);
// NeoPixel: GPIO11 powers it, GPIO12 is the WS2812 data line driven from
@@ -679,7 +713,10 @@ async fn main(spawner: Spawner) {
let mut config = UsbConfig::new(0x1209, 0xb0b0);
config.manufacturer = Some("swaits.com");
config.product = Some("jiggly");
config.serial_number = Some(make_serial(p.FLASH));
let serial = make_serial(p.FLASH);
#[cfg(feature = "defmt")]
defmt::info!("usb serial: {}", serial);
config.serial_number = Some(serial);
config.device_release = 0x0200; // matches firmware version 0.2.0
config.max_power = 100;
config.max_packet_size_0 = 64;
@@ -731,6 +768,9 @@ async fn main(spawner: Spawner) {
spawner.spawn(usb_task(usb).unwrap());
spawner.spawn(watchdog_task(watchdog).unwrap());
#[cfg(feature = "defmt")]
defmt::info!("usb + watchdog tasks spawned, starting statechart");
static EVENT_CHAN: Channel<CriticalSectionRawMutex, Ev, 8> = Channel::new();
let ctx = Ctx {