refactor(radio): chart-first restructure on hsmc 0.3 + soak hardening

Migrate from hsmc 0.2 (local-patched) to hsmc 0.3.0 (crates.io, with
embassy + trace-defmt features). Restructure radio chart and bundle
several adjacent stability changes that need to land together for
soak runs.

Radio chart:
- Booting (default, no handlers) -> Operational (default Unconfigured;
  super-state owning CmdPing/CmdGetInfo/Reset/WedgeDetected handlers
  + Recovering + CheckRecoveryBudget) -> Dead (terminal).
- Cold init's during is structurally un-cancellable.
- radio_task is a 30-line shim that panics if run() returns (chart is
  non-terminating).
- Internal queue capacity bumped 8 -> 32 to cover action-emit bursts.

Host chart:
- Connected splits into WaitingForFirstFrame (default, no timer) +
  Active (chart-owned 1s inactivity timer via on(after) + self-
  transition on FrameReceived). Manual Timer::after + last_frame_at
  removed.

Soak hardening:
- ld/nrf52840-memory.x: app start 0x1000 -> 0x26000 to leave the
  factory s140 SoftDevice dormant in flash.
- hal/nrf52840.rs: dump POWER.RESETREAS on boot -- WARN on DOG/LOCKUP,
  INFO on RESETPIN/SREQ/wake-from-OFF.
- host/usb.rs: read_packet errors discriminated -- Disabled is debug
  (normal during USB enum), BufferOverflow stays warn.
- COBS encode warns now carry type/tag/payload-length for diagnosis.
- RADIO_THROTTLED static initializes to true (no proof of host drain
  at boot).
- driver/sh1106.rs: init retries 3x with 100ms backoff (intermittent
  first-write race on power-up).
- Cargo.toml: unexpected_cfgs allowlist for hsmc's journal/trace-*
  features.

docs/semtech_lora_cad_lbt_guidebook.md: vendor the Semtech CAD/LBT
reference for offline lookup.
This commit is contained in:
swaits committed 2026-04-28 10:59:28 -06:00
1 parent 481712930c
commit 276f941fbb
16 files changed
+2417 -262

No files matched your search

Generated
+13 -17
View File
@@ -506,12 +506,12 @@ dependencies = [
[[package]]
name = "defmt-rtt"
version = "1.1.0"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93d5a25c99d89c40f5676bec8cefe0614f17f0f40e916f98e345dae941807f9e"
checksum = "c6eca0aae8aa2cf8333200ecbd236274697bc0a394765c858b3d9372eb1abcfa"
dependencies = [
"critical-section",
"defmt 1.0.1",
"defmt 0.3.100",
]
[[package]]
@@ -592,7 +592,7 @@ dependencies = [
"cortex-m",
"cortex-m-rt",
"critical-section",
"defmt 1.0.1",
"defmt 0.3.100",
"defmt-rtt",
"display-interface",
"donglora-protocol",
@@ -1550,10 +1550,11 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hsmc"
version = "0.2.0"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960effec6ded897c013d5cde8fb7f09bf0137e783efc4c2e0346092a2941c46e"
checksum = "ebed0d04633829a1e8319efcb038684dff8c01d06e4e50a4669691fb7c706f9e"
dependencies = [
"defmt 0.3.100",
"embassy-futures",
"embassy-sync 0.8.0",
"embassy-time",
@@ -1563,9 +1564,9 @@ dependencies = [
[[package]]
name = "hsmc-macros"
version = "0.2.0"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e1933eea1e95edb4fa03688f969f0a1ffabc4f16fc396b40ead6bedd5c4bdff"
checksum = "e4de168c4459a45a99c71d51be8af79d94eb09ecd62d6f25baee6ec174297db2"
dependencies = [
"proc-macro2",
"quote",
@@ -1574,9 +1575,9 @@ dependencies = [
[[package]]
name = "hybrid-array"
version = "0.4.10"
version = "0.4.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214"
checksum = "08d46837a0ed51fe95bd3b05de33cd64a1ee88fc797477ca48446872504507c5"
dependencies = [
"typenum",
]
@@ -1733,7 +1734,6 @@ dependencies = [
[[package]]
name = "lora-modulation"
version = "0.1.5"
source = "git+https://github.com/swaits/lora-rs.git?branch=fix%2Fsx126x-busy-race#f9ebb303a7029cbc3f19f0760ae164946000b152"
dependencies = [
"defmt 0.3.100",
]
@@ -1741,7 +1741,6 @@ dependencies = [
[[package]]
name = "lora-phy"
version = "3.0.2-alpha"
source = "git+https://github.com/swaits/lora-rs.git?branch=fix%2Fsx126x-busy-race#f9ebb303a7029cbc3f19f0760ae164946000b152"
dependencies = [
"defmt 0.3.100",
"document-features",
@@ -1755,7 +1754,6 @@ dependencies = [
[[package]]
name = "lorawan"
version = "0.9.1"
source = "git+https://github.com/swaits/lora-rs.git?branch=fix%2Fsx126x-busy-race#f9ebb303a7029cbc3f19f0760ae164946000b152"
dependencies = [
"aes",
"cmac",
@@ -1767,7 +1765,6 @@ dependencies = [
[[package]]
name = "lorawan-device"
version = "0.12.2"
source = "git+https://github.com/swaits/lora-rs.git?branch=fix%2Fsx126x-busy-race#f9ebb303a7029cbc3f19f0760ae164946000b152"
dependencies = [
"defmt 0.3.100",
"document-features",
@@ -1782,7 +1779,6 @@ dependencies = [
[[package]]
name = "lorawan-macros"
version = "0.1.0"
source = "git+https://github.com/swaits/lora-rs.git?branch=fix%2Fsx126x-busy-race#f9ebb303a7029cbc3f19f0760ae164946000b152"
dependencies = [
"proc-macro2",
"quote",
@@ -2747,9 +2743,9 @@ dependencies = [
[[package]]
name = "typenum"
version = "1.19.0"
version = "1.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
[[package]]
name = "ucobs"
+48 -10
View File
@@ -19,7 +19,7 @@ categories = ["embedded", "hardware-support", "no-std"]
[dependencies]
cfg-if = "1.0"
critical-section = "1.2"
defmt = "1.0"
defmt = "0.3"
embedded-hal = "1.0"
embedded-hal-async = "1.0"
embassy-embedded-hal = "0.6"
@@ -31,12 +31,11 @@ embassy-usb-driver = "0.2"
ucobs = { version = "0.3", features = ["legacy-msrv"] }
heapless = "0.9"
donglora-protocol = "1"
hsmc = { version = "0.2", default-features = false, features = ["embassy"] }
# Pulled from swaits/lora-rs fork until lora-rs/lora-rs#428 (pre-command
# BUSY check) is merged + released on crates.io. Switch back to a normal
# crates.io version then.
lora-phy = { git = "https://github.com/swaits/lora-rs.git", branch = "fix/sx126x-busy-race", features = ["defmt-03"] }
portable-atomic = { version = "1", optional = true, default-features = false, features = ["critical-section"] }
hsmc = { version = "0.3", default-features = false, features = ["embassy", "trace-defmt"] }
# Local path while iterating on CAD IRQ-clear / standby-after-CAD fixes.
# Switch back to git fork (then crates.io) once changes are upstreamed.
lora-phy = { path = "../../lora-rs/lora-phy", features = ["defmt-03"] }
portable-atomic = { version = "1", default-features = false, features = ["critical-section"] }
static_cell = "2.1"
# Display rendering
@@ -70,12 +69,22 @@ cortex-m = { version = "0.7", optional = true, features = [
"critical-section-single-core",
] }
cortex-m-rt = { version = "0.7", optional = true }
defmt-rtt = { version = "1.0", optional = true }
defmt-rtt = { version = "0.4", optional = true }
embassy-nrf = { version = "0.10", optional = true, features = [
"nrf52840",
"gpiote",
"time-driver-rtc1",
"defmt",
# UICR pin-config flags — must match what the Adafruit nRF52 bootloader
# actually programmed into UICR on this board. Verified empirically on
# RAK4631 via `probe-rs read b32 0x10001200 4`:
# PSELRESET[0/1] = 0x12 (P0.18 connected as RESET — NOT gpio)
# NFCPINS = 0xFFFFFFFE (bit 0 = 0 → NFC pins as GPIO)
# So `nfc-pins-as-gpio` matches; `reset-pin-as-gpio` would NOT match
# (would request GPIO when UICR has RESET enabled). The bootloader
# uses NRESET for its physical button-reset path, so leaving the pin
# as RESET is the right state.
"nfc-pins-as-gpio",
] }
panic-probe = { version = "1.0", optional = true, features = ["print-defmt"] }
@@ -105,6 +114,21 @@ tokio = []
# a human watches the pattern and reports N to determine where execution
# reached / stopped.
debug-checkpoint = []
# NOTE: chart auto-tracing is wired in unconditionally via the
# `trace;` keyword in each `statechart!` and the `hsmc/trace-defmt`
# feature on the dependency. Whether the trace lines actually emit at
# runtime is controlled by defmt's standard compile-time level filter
# (`DEFMT_LOG` env var). Release/shipping builds with `DEFMT_LOG`
# unset (or `off`) compile every chart trace call out to nothing —
# zero overhead, no separate cargo feature needed.
# When enabled, the SH1106 OLED driver emits a defmt::info! line for
# every I2C transaction it issues — bytes, lengths, addresses. Used to
# debug the embassy_nrf::twim assertion panic at twim.rs:541 (cannot
# happen via the documented call path; instrumenting the SH1106 path
# captures the actual transaction sequence preceding the panic so we
# can root-cause). Off by default; opt in via
# `just flash-attach rak_wisblock_4631 debug-twim`.
debug-twim = []
heltec_v3 = [
"embassy",
"dep:esp-backtrace",
@@ -200,7 +224,6 @@ waveshare_rp2040_lora = [
"dep:defmt-rtt",
"dep:embassy-rp",
"dep:panic-probe",
"dep:portable-atomic",
"embassy-executor/platform-cortex-m",
"embassy-executor/executor-thread",
]
@@ -215,6 +238,17 @@ esp-rtos = { git = "https://github.com/esp-rs/esp-hal.git", branch = "main"
esp-backtrace = { git = "https://github.com/esp-rs/esp-hal.git", branch = "main" }
esp-println = { git = "https://github.com/esp-rs/esp-hal.git", branch = "main" }
[lints.rust]
# hsmc 0.3's `statechart!` macro emits `#[cfg(feature = "journal")]`
# attributes (and others). check-cfg evaluates those in the CALLER's
# crate context, so unless donglora declares the same feature names
# (we don't — they're hsmc's, not ours), rustc lints them. Per hsmc's
# README, the documented fix is to allow the lint with an explicit
# check-cfg list so other unexpected cfgs still surface as warnings.
unexpected_cfgs = { level = "allow", check-cfg = [
'cfg(feature, values("tokio", "embassy", "journal", "trace-defmt", "trace-log", "trace-tracing"))',
] }
[lints.clippy]
undocumented_unsafe_blocks = "deny"
@@ -223,4 +257,8 @@ codegen-units = 1
lto = "fat"
opt-level = 3 # "s"/"z" break Xtensa (l32r literal range exceeded)
panic = "abort"
strip = true
# `strip = true` was killing defmt's `_defmt_version_` / `_defmt_encoding_`
# symbols, breaking RTT log decoding via probe-rs. Keep symbols + DWARF
# so probe-rs can show source locations for log lines.
strip = false
debug = 2
+6
View File
@@ -0,0 +1,6 @@
workers = ["app.routes"]
[settings]
cache = ".script"
address = "127.0.0.1"
port = 3500
File diff suppressed because it is too large. Load diff
+37 -1
View File
@@ -82,12 +82,48 @@ flash board extra_features="":
# Flash ARM board via debug probe (requires J-Link or similar).
# `extra_features` is passed through to the build.
# Sets DEFMT_LOG at build time so defmt::info!/warn!/error! are actually
# compiled in (default filter is ERROR-only — strips info logs).
# `embassy_usb=error` silences the benign Linux modem-manager
# `SET_INTERFACE: alt setting out of range` WARN that fires once per
# enumeration; it's a normal STALL response (correct USB behavior),
# the WARN is just embassy_usb noise. Other crates inherit the `info`
# default so chart traces, our own info!/warn!/error! all still fire.
flash-probe board extra_features="":
@just _ensure_tools
@just build {{board}} release "{{extra_features}}"
@DEFMT_LOG=info,embassy_usb=error just build {{board}} release "{{extra_features}}"
@set -- $(just _info {{board}}); feat=$1; target=$2; chip=$3; \
probe-rs run --chip $chip "{{builds_dir}}/donglora-{{board}}-v{{version}}.elf"
# UF2-flash via the bootloader, then probe-rs attach for RTT log streaming.
# Required on boards (e.g. RAK4631) where the bootloader gates the app
# behind a "valid app flag" in its settings page that only the bootloader
# itself can set — `probe-rs run`'s direct SWD writes bypass that flag and
# leave the bootloader stuck in DFU mode. UF2 path goes through the
# bootloader, sets the flag, then we attach for RTT (no reset, no reflash).
flash-attach board extra_features="":
@just _ensure_tools
@DEFMT_LOG=info,embassy_usb=error just build {{board}} release "{{extra_features}}"
@just _flash_uf2 {{board}}
@set -- $(just _info {{board}}); feat=$1; target=$2; chip=$3; \
sleep 1; \
probe-rs attach --chip $chip --no-catch-reset \
"{{builds_dir}}/donglora-{{board}}-v{{version}}.elf"
# Attach probe-rs for live RTT/defmt streaming WITHOUT flashing or
# resetting the chip. Logs are tee'd to a UTC-timestamped file in /tmp
# so soak captures don't clobber each other across runs. Use this with
# the dongle already running the desired firmware (flash via `just flash`
# or `just flash-attach` first).
attach board:
@just _ensure_tools
@set -- $(just _info {{board}}); feat=$1; target=$2; chip=$3; \
log="/tmp/donglora-soak-$(date -u +%Y%m%dT%H%M%SZ).log"; \
echo "Logging to $log"; \
probe-rs attach --chip $chip --no-catch-reset \
"{{builds_dir}}/donglora-{{board}}-v{{version}}.elf" 2>&1 \
| tee "$log"
# Show binary size for a release build
size board:
@just _cargo {{board}} "size --release"
+9 -4
View File
@@ -1,16 +1,21 @@
/* nRF52840 memory layout — Adafruit UF2 bootloader
/* nRF52840 memory layout — Adafruit UF2 bootloader + s140 SoftDevice
*
* 0x00000 – 0x01000 MBR (Master Boot Record)
* 0x01000 – 0xEA000 Application ← we live here
* 0x01000 – 0x26000 s140 SoftDevice (dormant — we don't use BLE)
* 0x26000 – 0xEA000 Application ← we live here
* 0xEA000 – 0xF4000 Reserved (app data)
* 0xF4000 – 0xFC000 UF2 Bootloader
* 0xFF000 – 0x100000 Bootloader settings
*
* RAM: first 8 bytes reserved for MBR interrupt forwarding.
* RAM: first 8 bytes reserved for MBR interrupt forwarding. SoftDevice
* RAM (typically 0x20000008–0x20002000) is NOT reserved here because we
* never call sd_softdevice_enable — SD sits dormant in flash and never
* touches RAM. cortex-m-rt's set-vtor feature handles relocating VTOR
* to the app's vector table at 0x26000 at runtime.
*/
MEMORY
{
FLASH : ORIGIN = 0x00001000, LENGTH = 0xE9000
FLASH : ORIGIN = 0x00026000, LENGTH = 0xCE000
RAM : ORIGIN = 0x20000008, LENGTH = 255K
}
+33 -19
View File
@@ -22,11 +22,18 @@ pub type UsbDriver = mcu::UsbNrfDriver;
pub type DisplayI2c = mcu::I2cBus;
pub type LedDriver = crate::driver::simple_led::SimpleLed<Output<'static>>;
pub type DisplayDriver = ssd1306::Ssd1306Async<
ssd1306::prelude::I2CInterface<DisplayI2c>,
ssd1306::size::DisplaySize128x64,
ssd1306::mode::BufferedGraphicsModeAsync<ssd1306::size::DisplaySize128x64>,
>;
// SH1106 hand-rolled driver instead of the ssd1306 crate. The RAK1921
// OLED ships under various Chinese-OEM packaging (e.g. JMD0.96C) that
// can be either SSD1306 or SH1106 silicon — physically and pin
// identical, but use different addressing modes. The bigger reason
// though: ssd1306 0.10's display-interface-i2c emits I2C transaction
// patterns (consecutive writes, 3+ ops) that embassy_nrf::twim's
// `unreachable!()` rejects, so even on real SSD1306 silicon the panic
// would fire. Our hand-rolled SH1106 driver issues only single writes
// per logical op and is twim-safe. SH1106 init opcodes are similar
// enough that real SSD1306 silicon mostly works (worst case: mirrored
// display until proper runtime detection lands).
pub type DisplayDriver = crate::driver::sh1106::Sh1106<DisplayI2c>;
// ── Peripheral bundles ──────────────────────────────────────────────
@@ -46,22 +53,29 @@ pub struct DisplayParts {
// ── Display init ────────────────────────────────────────────────────
pub async fn create_display(i2c: DisplayI2c) -> Option<DisplayDriver> {
use ssd1306::mode::DisplayConfigAsync;
use ssd1306::prelude::{Brightness, DisplayRotation};
use ssd1306::size::DisplaySize128x64;
use ssd1306::{I2CDisplayInterface, Ssd1306Async};
let mut display = crate::driver::sh1106::Sh1106::new(i2c, 0x3C);
let interface = I2CDisplayInterface::new(i2c);
let mut display = Ssd1306Async::new(interface, DisplaySize128x64, DisplayRotation::Rotate0)
.into_buffered_graphics_mode();
embassy_time::Timer::after_millis(100).await;
if display.init().await.is_err() {
defmt::error!("SSD1306 display init failed");
return None;
// Retry 3× with 100ms backoff. The SH1106's first transaction
// after power-on can return Err if the chip's internal voltage
// converter hasn't fully settled — observed empirically on
// RAK4631/RAK1921. Each attempt is independent (full init
// sequence). Display works visibly even if the FIRST attempt
// failed, because subsequent attempts succeed.
for attempt in 0..3u8 {
embassy_time::Timer::after_millis(100).await;
if display.init().await.is_ok() {
let _ = display.set_brightness(0xFF).await;
return Some(display);
}
if attempt < 2 {
defmt::warn!(
"SH1106 display init attempt {=u8}/3 failed; retrying",
attempt + 1
);
}
}
let _ = display.set_brightness(Brightness::BRIGHTEST).await;
Some(display)
defmt::error!("SH1106 display init failed after 3 attempts");
None
}
// ── Board init ──────────────────────────────────────────────────────
+67 -3
View File
@@ -9,9 +9,66 @@
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
use embassy_sync::channel::Channel;
use portable_atomic::AtomicBool;
use crate::protocol::{Command, DeviceMessage, LoRaConfig};
/// True while a Reset has been queued in `CommandChannel` but not yet
/// consumed by the radio task. Dedupes injections from any host chart
/// (USB DTR-driven OR UART inactivity-driven) that fires `reset_radio`
/// repeatedly during quiet/inactive periods. Without this dedup, a slow
/// or wedged radio causes the host chart's exit action to inject Reset
/// every cycle, filling the commands channel and producing a "queue
/// full" warn flood. Cleared by the radio chart's Reset handler
/// (`reset_to_unconfigured`).
///
/// Uses `portable_atomic::AtomicBool` (not `core::sync::atomic`) because
/// Cortex-M0+ targets (RP2040) lack native CAS for `swap`. portable-atomic
/// transparently uses native atomics on M3+/M4 and critical-section
/// emulation on M0/M0+.
pub static RESET_PENDING: AtomicBool = AtomicBool::new(false);
/// True while the host transport's USB IN endpoint is observed-dead
/// (write_packet returned Err). Set by `host_task::protocol_loop` on the
/// first failed write; cleared on the next successful write. Producers of
/// **async** D→H events — `radio_task::record_packet` (RX) and
/// `host::framing::emit_async_err` — check this flag before pushing into
/// `OutboundChannel` and silently skip when set, so the queue does not
/// fill while the host is gone.
///
/// Tag-correlated responses (OK / sync ERR / TX_DONE) are NOT gated by
/// this flag: they're a small, bounded volume and the protocol contract
/// requires them to be delivered if we ever reach the host again.
///
/// Same `portable_atomic::AtomicBool` rationale as `RESET_PENDING`.
///
/// **Initial value `true`**: at boot we have no proof the host can
/// drain the outbound queue. Until host_task observes its first
/// successful `write_packet` (and clears this), async-RX producers
/// silently skip pushing. Otherwise — at boot, with no client
/// attached or with mux-rs idle — RX events would fill the queue
/// faster than host_task could drain it (which it can't: `write_packet`
/// blocks until the host actually polls), and the depth-64 ring would
/// flood `[WARN] RX dropped: outbound queue full` hundreds of times
/// per second. Pre-throttle is the structural fix.
pub static RADIO_THROTTLED: AtomicBool = AtomicBool::new(true);
/// Set by `radio::lora_setup` / `lora_setup_with` when a SHORT-DURATION
/// lora-phy op blew through its wall-clock budget (almost always BUSY
/// stuck high — chip is wedged). Read by chart actions and `during:`
/// activities that called the helper: when they observe a downstream
/// `Err(_)` AND see this flag set, they emit `WedgeDetected` (which
/// drives the radio chart into `Recovering` for an in-place NRESET +
/// `lora.init()` retry) instead of the per-action failure event. The
/// flag is consumed (`swap(false)`) by whoever reads it so the next
/// independent op gets a fresh signal.
///
/// This is the cleanest plumbing path for "wedge timeout vs ordinary
/// driver error" given the helper sits below the chart-action layer
/// (free function, no chart-emit access). One atomic, one concern,
/// matching the established `RESET_PENDING` / `RADIO_THROTTLED` pattern.
pub static LORA_WEDGED: AtomicBool = AtomicBool::new(false);
// ── Host → Radio: tag-correlated commands ───────────────────────────
/// An event flowing from the host task into the radio task.
@@ -42,9 +99,16 @@ pub struct OutboundFrame {
pub msg: DeviceMessage,
}
/// Radio-to-host outbound channel (depth 32: enough to hold a burst of
/// RX events + queued command responses without stalling the radio ISR).
pub type OutboundChannel = Channel<CriticalSectionRawMutex, OutboundFrame, 32>;
/// Radio-to-host outbound channel.
///
/// Depth 64: absorbs bursts of RX events plus queued command responses
/// plus async ERRs while the host transport drains. Each slot is ~280 B
/// (max RxPayload + envelope), so 64 slots is ~18 KB — well within RAM
/// budget on every supported MCU. The previous depth-32 was provoking
/// "outbound queue full" floods during host stalls; the larger queue
/// plus `RADIO_THROTTLED` backpressure flag (set by host_task on a
/// failed write_packet) together stop the cascade.
pub type OutboundChannel = Channel<CriticalSectionRawMutex, OutboundFrame, 64>;
// ── Internal: display commands (no longer wire-visible) ────────────
+2
View File
@@ -237,6 +237,8 @@ statechart! {
Display {
context: DisplayContext;
events: DisplayEvent;
// Auto-trace — backend selected by `hsmc/trace-*` cargo feature.
trace;
default(On);
state Off {
+4 -2
View File
@@ -1,7 +1,8 @@
#[cfg(any(
feature = "wio_tracker_l1",
feature = "elecrow_thinknode_m2",
feature = "lilygo_tbeam_supreme"
feature = "lilygo_tbeam_supreme",
feature = "rak_wisblock_4631"
))]
pub mod sh1106;
@@ -67,7 +68,8 @@ where
#[cfg(any(
feature = "wio_tracker_l1",
feature = "elecrow_thinknode_m2",
feature = "lilygo_tbeam_supreme"
feature = "lilygo_tbeam_supreme",
feature = "rak_wisblock_4631"
))]
impl<I> DisplayBrightness for sh1106::Sh1106<I>
where
+18
View File
@@ -37,6 +37,12 @@ impl<I: I2c> Sh1106<I> {
/// Send a sequence of commands (each prefixed with the I2C command byte 0x00).
async fn cmd(&mut self, commands: &[u8]) -> Result<(), I::Error> {
for &c in commands {
#[cfg(feature = "debug-twim")]
defmt::info!(
"[sh1106] cmd write: addr=0x{=u8:02X} bytes=[0x00, 0x{=u8:02X}]",
self.addr,
c,
);
self.i2c.write(self.addr, &[0x00, c]).await?;
}
Ok(())
@@ -44,6 +50,8 @@ impl<I: I2c> Sh1106<I> {
/// Initialize the display with sensible defaults.
pub async fn init(&mut self) -> Result<(), I::Error> {
#[cfg(feature = "debug-twim")]
defmt::info!("[sh1106] init: starting");
self.cmd(&[
0xAE, // display off
0xD5, 0x80, // clock div: default
@@ -71,6 +79,8 @@ impl<I: I2c> Sh1106<I> {
page_buf[0] = 0x40;
for page in 0..PAGES {
#[cfg(feature = "debug-twim")]
defmt::info!("[sh1106] flush page {=usize}: addr cmds (3 bytes)", page);
// Set page address and column start (with SH1106 2-column offset).
self.cmd(&[
0xB0 | page as u8, // page address
@@ -81,6 +91,12 @@ impl<I: I2c> Sh1106<I> {
let start = page * WIDTH;
page_buf[1..].copy_from_slice(&self.buffer[start..start + WIDTH]);
#[cfg(feature = "debug-twim")]
defmt::info!(
"[sh1106] flush page {=usize}: data write {=usize} bytes (0x40 prefix + 128 pixels)",
page,
page_buf.len(),
);
self.i2c.write(self.addr, &page_buf).await?;
}
Ok(())
@@ -88,6 +104,8 @@ impl<I: I2c> Sh1106<I> {
/// Set display brightness (contrast).
pub async fn set_brightness(&mut self, value: u8) -> Result<(), I::Error> {
#[cfg(feature = "debug-twim")]
defmt::info!("[sh1106] set_brightness: 0x{=u8:02X}", value);
self.cmd(&[0x81, value]).await
}
}
+70
View File
@@ -56,6 +56,76 @@ pub fn alloc_vbus_detect(
))
}
// ── Reset reason ────────────────────────────────────────────────────
/// Read the POWER.RESETREAS register, log a human-readable summary,
/// then clear it so the next boot reflects only the next reset cause.
///
/// This is the post-mortem diagnostic for soak runs: if the firmware
/// crashes and the chip resets, the next boot's RESETREAS tells us
/// what happened (cortex-m lockup, watchdog, soft reset, debugger,
/// pin reset, etc.). Without this, a self-rebooting firmware just
/// looks like log restarts with no signal as to why.
///
/// nRF52840 `POWER.RESETREAS` (PS §15.4.1): bits are write-1-to-clear.
/// The register accumulates causes across resets until cleared, so
/// always clear after reading.
pub fn dump_reset_reason() {
// POWER.RESETREAS lives at 0x40000400 (POWER base 0x40000000 +
// offset 0x400). Direct memory access avoids requiring embassy_nrf's
// `unstable-pac` feature. Bit layout per nRF52840 PS §15.4.1:
// 0 RESETPIN - external NRESET pin asserted
// 1 DOG - watchdog reset
// 2 SREQ - software-requested reset (cortex SYSRESETREQ)
// 3 LOCKUP - cortex-m core lockup
// 16 OFF - wake from System OFF via DETECT
// 17 LPCOMP - wake from System OFF via ANADETECT
// 18 DIF - wake from System OFF via debug interface
// 24 NFC - wake from System OFF via NFC field
const RESETREAS_ADDR: *mut u32 = 0x4000_0400 as *mut u32;
// SAFETY: 0x40000400 is the POWER.RESETREAS register on nRF52840;
// read/write_volatile is correct for memory-mapped hardware.
let raw = unsafe { core::ptr::read_volatile(RESETREAS_ADDR) };
let dog = (raw & (1 << 1)) != 0;
let lockup = (raw & (1 << 3)) != 0;
if raw == 0 {
defmt::info!("boot: cold POR (RESETREAS=0)");
} else if dog || lockup {
// Real problems: watchdog timeout (hung task) OR CPU lockup
// (HardFault → escalated). Worth a WARN — these are bugs.
defmt::warn!(
"boot: ABNORMAL reset RESETREAS=0x{=u32:08x} resetpin={=bool} dog={=bool} sreq={=bool} lockup={=bool} off={=bool} lpcomp={=bool} dif={=bool} nfc={=bool}",
raw,
(raw & (1 << 0)) != 0,
dog,
(raw & (1 << 2)) != 0,
lockup,
(raw & (1 << 16)) != 0,
(raw & (1 << 17)) != 0,
(raw & (1 << 18)) != 0,
(raw & (1 << 24)) != 0,
);
} else {
// Normal reset paths: RESETPIN (UF2 bootloader pulses NRESET on
// app start; user reset button), SREQ (deliberate sys_reset),
// OFF/LPCOMP/DIF/NFC (wake from System OFF). All expected
// operational reasons; INFO not WARN.
defmt::info!(
"boot: RESETREAS=0x{=u32:08x} resetpin={=bool} sreq={=bool} off={=bool} lpcomp={=bool} dif={=bool} nfc={=bool}",
raw,
(raw & (1 << 0)) != 0,
(raw & (1 << 2)) != 0,
(raw & (1 << 16)) != 0,
(raw & (1 << 17)) != 0,
(raw & (1 << 18)) != 0,
(raw & (1 << 24)) != 0,
);
}
// Clear all bits — write-1-to-clear per nRF52840 PS §15.4.1.
// SAFETY: same justification as the read above.
unsafe { core::ptr::write_volatile(RESETREAS_ADDR, 0xFFFF_FFFF) };
}
// ── MAC address ─────────────────────────────────────────────────────
/// Read the factory-programmed device address from FICR registers.
+28 -3
View File
@@ -54,7 +54,16 @@ pub async fn dispatch_frame(
}
/// Emit an async framing error (tag = 0x0000) per spec §2.5.
///
/// Backpressure: if the host transport's IN endpoint is observed-dead
/// (RADIO_THROTTLED set by host_task), drop silently. Without this gate,
/// a bad-frame burst from a wedged host fills `OutboundChannel` faster
/// than host_task can drain it (because it can't drain it at all when
/// write_packet keeps failing), producing the warn flood seen in soak.
pub async fn emit_async_err(outbound: &OutboundChannel, code: ErrorCode) {
if crate::channel::RADIO_THROTTLED.load(portable_atomic::Ordering::Acquire) {
return;
}
let frame = OutboundFrame {
tag: 0,
msg: DeviceMessage::Err(code),
@@ -106,15 +115,31 @@ pub fn encode_outbound(frame: &OutboundFrame, out: &mut [u8; MAX_WIRE_FRAME]) ->
) {
Ok(n) => Some(n),
Err(FrameEncodeError::BufferTooSmall) => {
warn!("encode_frame: output buffer too small");
warn!(
"encode_frame: output buffer too small (type={=u8}, tag={=u16}, payload={=usize}B, out_buf={=usize}B)",
frame.msg.type_id(),
frame.tag,
payload_len,
out.len(),
);
None
}
Err(FrameEncodeError::PayloadTooLarge) => {
warn!("encode_frame: payload exceeds MAX_PAYLOAD_FIELD");
warn!(
"encode_frame: payload exceeds MAX_PAYLOAD_FIELD (type={=u8}, tag={=u16}, payload={=usize}B)",
frame.msg.type_id(),
frame.tag,
payload_len,
);
None
}
Err(FrameEncodeError::CobsEncode) => {
warn!("encode_frame: COBS encode refused");
warn!(
"encode_frame: COBS encode refused (type={=u8}, tag={=u16}, payload={=usize}B)",
frame.msg.type_id(),
frame.tag,
payload_len,
);
None
}
}
+204 -41
View File
@@ -2,24 +2,35 @@
//!
//! ```text
//! Host
//! ├── Disconnected (default) no DTR, or inactivity timer fired
//! └── Connected host attached, liveness OK
//! entry: wake_display, start_liveness_timer
//! exit: reset_radio, reset_display, stop_liveness_timer
//! ├── Disconnected (default) no DTR
//! └── Connected host attached
//! ├── WaitingForFirstFrame (default) DTR up, no traffic yet
//! │ NO inactivity timer (boot quiet
//! │ is normal — chart structurally
//! │ blocks the spurious 1s fire).
//! └── Active ≥1 frame seen since connect
//! inactivity timer (1s) armed and
//! restarted by every frame via
//! self-transition.
//! ```
//!
//! The statechart tracks one state bit: "is a host present?". DTR drives
//! connect edges; the 1-second inactivity watchdog (spec §3.4) drives
//! the disconnect edge when DTR is held high but no bytes arrive.
//! Two chart-level invariants made structurally impossible by the shape:
//! 1. **No spurious inactivity at boot.** The 1-second timer only exists
//! in `Active`, which requires a `FrameReceived` event to enter.
//! A freshly-connected host that holds DTR high but sends nothing
//! sits in `WaitingForFirstFrame` indefinitely — no timer, no warn,
//! no Reset cascade.
//! 2. **Chart-owned timer scope.** Per hsmc README rule §7 (timers
//! armed by state lifecycle), the inactivity timer is cancelled on
//! every exit of `Active`, restarted on every entry. No global
//! `last_frame_at` instant, no manual elapsed check in the loop.
//!
//! The data plane (frame decode, command dispatch, frame encode) stays
//! imperative in `protocol_loop` for the same reason the radio's
//! `rx_once` stayed out of its chart: `read_packet` blocks.
//! imperative in `protocol_loop` because `read_packet` blocks.
use defmt::warn;
use embassy_executor::task;
use embassy_futures::join::join;
use embassy_time::{Duration, Instant};
use embassy_time::Duration;
use embassy_usb::class::cdc_acm::{CdcAcmClass, Receiver, Sender, State};
use embassy_usb::Builder;
use hsmc::{statechart, Duration as HsmcDuration};
@@ -35,17 +46,24 @@ const MAX_USB_PACKET: usize = 64;
/// Spec §3.4: inactivity timer period. The device must see at least one
/// frame (good or bad) every 1000 ms or the session is considered lost.
/// Declared in the chart on `Active` — chart owns timer lifecycle.
const INACTIVITY_MS: u64 = 1000;
/// Poll interval for the DTR edge detector / inactivity check.
/// Poll interval for the DTR edge detector. Chart owns the inactivity
/// timer; this is only the cadence at which protocol_loop wakes when
/// no other event is pending, to re-check DTR.
const POLL_INTERVAL_MS: u64 = 100;
#[derive(Debug, Clone)]
pub enum HostInput {
DtrRaised,
DtrDropped,
/// Host went silent for >1 s; kick the radio back to Unconfigured.
InactivityTimeout,
/// A USB read returned ≥1 byte. Drives `WaitingForFirstFrame →
/// Active` (first frame after connect) and the `Active → Active`
/// self-transition that restarts the inactivity timer per
/// hsmc README rule §4 (self-transition exits + re-enters target,
/// re-arming its timer).
FrameReceived,
}
pub struct HostContext {
@@ -68,8 +86,22 @@ impl HostContext {
}
fn send_radio_reset(&self) {
use portable_atomic::Ordering;
// Dedupe: if a Reset is already queued (and not yet consumed by
// the radio task), don't queue another. Without this, a slow or
// wedged radio + repeated chart exits (DTR drop, inactivity,
// both transports) fill the commands channel and produce the
// "queue full" warn flood. The radio chart's reset_to_unconfigured
// clears the flag once it processes the queued Reset.
if crate::channel::RESET_PENDING.swap(true, Ordering::AcqRel) {
return;
}
if self.commands.try_send(InboundEvent::Reset).is_err() {
warn!("radio Reset inject: command queue full");
// Channel genuinely full of non-Reset commands; this is the
// edge case worth knowing about. Clear the flag so future
// Reset attempts can re-try once the channel drains.
defmt::warn!("radio Reset inject: command queue full");
crate::channel::RESET_PENDING.store(false, Ordering::Release);
}
}
}
@@ -78,6 +110,8 @@ statechart! {
Host {
context: HostContext;
events: HostInput;
// Auto-trace — backend selected by `hsmc/trace-*` cargo feature.
trace;
default(Disconnected);
state Disconnected {
@@ -88,7 +122,31 @@ statechart! {
entry: wake_display;
exit: reset_radio, reset_display;
on(DtrDropped) => Disconnected;
on(InactivityTimeout) => Disconnected;
default(WaitingForFirstFrame);
// Default child of Connected. No inactivity timer yet —
// a freshly-connected host that hasn't sent its first byte
// is normal (mux-rs holds DTR high before any client
// attaches). Stays here indefinitely until the first
// frame arrives.
state WaitingForFirstFrame {
on(FrameReceived) => Active;
}
// Inactivity timer is chart-scoped: armed on every entry to
// Active (whether from WaitingForFirstFrame or self-loop
// after a frame), cancelled on every exit (per hsmc README
// rule §7). The self-transition `on(FrameReceived) =>
// Active` re-enters Active per rule §4, re-arming the
// timer — equivalent to the "reset last_frame_at on each
// byte" pattern but expressed in chart shape.
//
// hsmc's `after` triggers want `hsmc::Duration` (re-export
// of `core::time::Duration`), not `embassy_time::Duration`.
state Active {
on(after HsmcDuration::from_millis(INACTIVITY_MS)) => Disconnected;
on(FrameReceived) => Active;
}
}
}
}
@@ -133,20 +191,34 @@ pub async fn host_task(
}
config.serial_number = Some(core::str::from_utf8(serial_buf).expect("MAC hex is valid UTF-8"));
// CTRL_BUF was 64 bytes; bumped to 128 because the previous size
// matches the panic site "control buffer too small" in
// embassy_usb/src/lib.rs:758. CdcAcmClass only requires >= 7 for
// SET_LINE_CODING, but composite descriptor handlers and string
// descriptor responses may overrun a 64 B buffer for some host
// enumerations. 128 is safely above the largest CDC-ACM control
// request and well within RAM budget.
static DESC_BUF: StaticCell<[u8; 256]> = StaticCell::new();
static CONF_BUF: StaticCell<[u8; 256]> = StaticCell::new();
static BOS_BUF: StaticCell<[u8; 256]> = StaticCell::new();
static CTRL_BUF: StaticCell<[u8; 64]> = StaticCell::new();
static CTRL_BUF: StaticCell<[u8; 128]> = StaticCell::new();
static CDC_STATE: StaticCell<State> = StaticCell::new();
let desc_buf = DESC_BUF.init([0; 256]);
let conf_buf = CONF_BUF.init([0; 256]);
let bos_buf = BOS_BUF.init([0; 256]);
let ctrl_buf = CTRL_BUF.init([0; 64]);
let ctrl_buf = CTRL_BUF.init([0; 128]);
let cdc_state = CDC_STATE.init(State::new());
let mut builder = Builder::new(parts.driver, config, desc_buf, conf_buf, bos_buf, ctrl_buf);
// Note: when this device enumerates on Linux, modem-manager's
// CDC-ACM probe issues SET_INTERFACE with alt settings beyond what
// we declare, triggering a "SET_INTERFACE: alt setting out of
// range" WARN from embassy_usb. The probe is normal Linux
// behavior; embassy_usb's STALL response is correct USB negotiation.
// The WARN is benign noise — do NOT add dummy alt settings just to
// silence it (risks breaking Windows/macOS enumeration).
let class = CdcAcmClass::new(&mut builder, cdc_state, MAX_USB_PACKET as u16);
let mut usb_dev = builder.build();
let (sender, receiver) = class.split();
@@ -186,8 +258,6 @@ async fn protocol_loop<'d, D: embassy_usb_driver::Driver<'d>>(
let mut write_buf: [u8; MAX_WIRE_FRAME] = [0u8; MAX_WIRE_FRAME];
let mut decoder = FrameDecoder::new();
let mut last_frame_at = Instant::now();
loop {
match select3(
receiver.read_packet(&mut read_buf),
@@ -196,52 +266,145 @@ async fn protocol_loop<'d, D: embassy_usb_driver::Driver<'d>>(
)
.await
{
Either3::First(Ok(0)) => {}
Either3::First(Ok(0)) => {
defmt::info!("host_task: read_packet returned 0 bytes");
}
Either3::First(Ok(n)) => {
last_frame_at = Instant::now();
defmt::info!(
"host_task: read_packet got {=usize}B (DTR={=bool})",
n,
receiver.dtr(),
);
// Drives WaitingForFirstFrame → Active on first byte
// and Active → Active (timer-restart) on every byte
// after. The chart owns inactivity from here.
let _ = machine.dispatch(HostInput::FrameReceived).await;
dispatch_bytes(&mut decoder, &read_buf[..n], commands, outbound).await;
}
Either3::First(Err(_)) => {
Either3::First(Err(e)) => {
// Per embassy_usb_driver::EndpointError, `read_packet`
// returns one of two errors:
// - `Disabled`: OUT endpoint not enabled by the host
// (USB enumeration in progress, host
// suspended, or interface not yet
// configured). Completely normal during
// bring-up + every host disconnect.
// - `BufferOverflow`: incoming packet larger than read_buf.
// read_buf is sized to MAX_USB_PACKET
// (64) which matches the CDC-ACM EP
// max packet size, so this should
// never happen in practice.
//
// `Disabled` floods at boot before USB enumeration finishes
// (the OS hasn't sent SET_CONFIGURATION yet) — was previously
// logging dozens of "read_packet error" lines per second.
// Demote to debug-level since it's expected; reserve WARN
// for BufferOverflow which IS a real bug.
use embassy_usb_driver::EndpointError;
match e {
EndpointError::Disabled => {
defmt::debug!(
"host_task: read_packet returned Disabled (USB endpoint not yet enabled by host — normal during enumeration)"
);
}
EndpointError::BufferOverflow => {
defmt::warn!(
"host_task: read_packet BufferOverflow — host sent a packet larger than {=usize}B (max EP packet size); decoder reset",
MAX_USB_PACKET,
);
}
}
decoder.reset();
embassy_time::Timer::after_millis(100).await;
}
Either3::Second(frame) => {
let frame_tag = frame.tag;
if let Some(n) = encode_outbound(&frame, &mut write_buf) {
for chunk in write_buf[..n].chunks(MAX_USB_PACKET) {
defmt::info!(
"host_task: sending {=usize}B frame tag={=u16}",
n,
frame_tag,
);
// Per embassy_usb 0.6: write_packet only returns
// EndpointError::Disabled when the IN endpoint is
// dead (host gone or USB suspended). If a chunk
// mid-frame fails, the partial bytes already sent
// are unrecoverable — host's framing layer will see
// corruption until a clean frame arrives. Bail on
// the rest of THIS frame and set RADIO_THROTTLED so
// the radio task stops generating async events
// until the host comes back. Without the throttle,
// every subsequent frame would also fail mid-write
// and produce more wire corruption.
let mut frame_failed = false;
for (chunk_idx, chunk) in
write_buf[..n].chunks(MAX_USB_PACKET).enumerate()
{
defmt::info!(
"host_task: write_packet chunk {=usize} ({=usize}B) tag={=u16}",
chunk_idx,
chunk.len(),
frame_tag,
);
if sender.write_packet(chunk).await.is_err() {
warn!("USB write failed, frame dropped");
defmt::warn!(
"USB write failed (host stalled or gone); frame dropped tag={=u16}, throttling radio",
frame_tag,
);
crate::channel::RADIO_THROTTLED
.store(true, portable_atomic::Ordering::Release);
frame_failed = true;
break;
}
defmt::info!(
"host_task: write_packet OK chunk {=usize} tag={=u16}",
chunk_idx,
frame_tag,
);
}
if !frame_failed
&& crate::channel::RADIO_THROTTLED
.load(portable_atomic::Ordering::Acquire)
{
defmt::info!(
"USB write recovered (tag={=u16}); clearing radio throttle",
frame_tag,
);
crate::channel::RADIO_THROTTLED
.store(false, portable_atomic::Ordering::Release);
}
defmt::info!("host_task: frame done tag={=u16}", frame_tag);
}
}
Either3::Third(()) => {}
Either3::Third(()) => {
// Heartbeat tick — drives DTR edge polling + inactivity
// watchdog below. No log: it would fire every
// POLL_INTERVAL_MS and bury the interesting traffic.
}
}
// DTR edge detection — drives Connected / Disconnected transitions.
let connected = receiver.dtr();
let was_connected = matches!(machine.current_state(), HostState::Connected);
match (was_connected, connected) {
// DTR edge detection — drives Connected / Disconnected
// transitions. The chart's `current_state()` may be
// Disconnected, Connected/WaitingForFirstFrame, or
// Connected/Active per the new hierarchy. We're connected if
// current state is anywhere inside Connected.
let dtr_high = receiver.dtr();
let was_connected = !matches!(
machine.current_state(),
HostState::Disconnected
);
match (was_connected, dtr_high) {
(false, true) => {
defmt::info!("host_task: DTR raised");
let _ = machine.dispatch(HostInput::DtrRaised).await;
last_frame_at = Instant::now();
}
(true, false) => {
defmt::info!("host_task: DTR dropped");
decoder.reset();
let _ = machine.dispatch(HostInput::DtrDropped).await;
}
_ => {}
}
// Inactivity watchdog (spec §3.4): while Connected, if no frame
// arrived in the last INACTIVITY_MS, drive the session back to
// Disconnected.
if matches!(machine.current_state(), HostState::Connected)
&& last_frame_at.elapsed() >= Duration::from_millis(INACTIVITY_MS)
{
warn!("host inactivity timeout fired");
let _ = machine.dispatch(HostInput::InactivityTimeout).await;
}
}
}
+9
View File
@@ -84,6 +84,15 @@ cfg_if::cfg_if! {
#[cfg(not(test))]
async fn run(spawner: Spawner) {
let board = <board::Board as LoRaBoard>::init();
// Read + log + clear POWER.RESETREAS before any other boot output so
// the cause of the *previous* reset (if any) lands at the very top
// of the RTT log. Critical for soak-debugging silent crash-reboots:
// a self-rebooting firmware otherwise looks like the log just
// restarted with no explanation. nRF52840-only.
#[cfg(any(feature = "rak_wisblock_4631", feature = "wio_tracker_l1"))]
crate::hal::nrf52840::dump_reset_reason();
let parts = board.into_parts();
// Build the GET_INFO struct once at boot. This is the authoritative
+828 -162
View File
File diff suppressed because it is too large. Load diff