From 276f941fbbd45a8de7bc857a20f090d1ec8618d6 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Sun, 26 Apr 2026 00:19:34 -0600 Subject: [PATCH] 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. --- Cargo.lock | 30 +- Cargo.toml | 58 +- config.toml | 6 + docs/semtech_lora_cad_lbt_guidebook.md | 1041 ++++++++++++++++++++++++ justfile | 38 +- ld/nrf52840-memory.x | 13 +- src/board/rak_wisblock_4631.rs | 52 +- src/channel.rs | 70 +- src/display.rs | 2 + src/driver/mod.rs | 6 +- src/driver/sh1106.rs | 18 + src/hal/nrf52840.rs | 70 ++ src/host/framing.rs | 31 +- src/host/usb.rs | 245 +++++- src/main.rs | 9 + src/radio.rs | 990 ++++++++++++++++++---- 16 files changed, 2417 insertions(+), 262 deletions(-) create mode 100644 config.toml create mode 100644 docs/semtech_lora_cad_lbt_guidebook.md diff --git a/Cargo.lock b/Cargo.lock index 6ad2de1..7e8e4a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 63a3b71..e4cc414 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 diff --git a/config.toml b/config.toml new file mode 100644 index 0000000..67dd8f0 --- /dev/null +++ b/config.toml @@ -0,0 +1,6 @@ +workers = ["app.routes"] + +[settings] +cache = ".script" +address = "127.0.0.1" +port = 3500 diff --git a/docs/semtech_lora_cad_lbt_guidebook.md b/docs/semtech_lora_cad_lbt_guidebook.md new file mode 100644 index 0000000..5e64cde --- /dev/null +++ b/docs/semtech_lora_cad_lbt_guidebook.md @@ -0,0 +1,1041 @@ +# Semtech LoRa CAD and LBT Guidebook + +**How to use Channel Activity Detection (CAD), RSSI, and CAD-to-TX modes on Semtech LoRa radios** + +Revision: 1.0 +Prepared: 2026-04-27 +Audience: embedded firmware engineers implementing carrier sense, channel assessment, or listen-before-talk behavior on Semtech LoRa transceivers. + +--- + +## 1. Executive summary + +Do not implement LoRa listen-before-talk (LBT) as a blind two-command sequence such as: + +```text +SetCAD(); +SetTx(); // or Send(), Radio.Send(), start TX, etc. +``` + +That is not a reliable LBT implementation. It either ignores the CAD result, races the radio state machine, or cancels the CAD decision before the host has observed it. + +There are only two defensible implementation patterns: + +1. **Host-controlled CAD then TX** + + Start CAD, wait for the `CadDone` indication, inspect whether `CadDetected` was asserted, clear the IRQ flags, optionally perform an RSSI or energy check, and only then transmit. + +2. **Documented CAD_LBT / CAD exit-to-TX** + + On radio families and driver stacks that explicitly support a CAD exit mode that enters TX when CAD finds no activity, configure that mode, preload the TX payload, and start CAD. In this model, the CAD operation substitutes for the normal TX command. + +The important distinction is this: + +```text +Correct: configure CAD exit mode = TX/LBT, preload payload, then SetCAD() +Incorrect: SetCAD(), then immediately SetTx()/Send() +``` + +CAD is not a general-purpose RF energy detector. CAD answers a narrower question: whether the radio detected LoRa-like activity under its current LoRa and CAD configuration. For regulatory LBT or coexistence with non-LoRa interferers, CAD is often not sufficient by itself; combine it with RSSI or energy CCA when the applicable rule or product behavior requires that. + +--- + +## 2. Scope and assumptions + +This guide covers practical CAD and LBT sequencing for the following Semtech LoRa radio families: + +- SX1272/SX1276/SX1277/SX1278/SX1279 style register-based LoRa radios +- SX1261/SX1262/SX1268 class command-based radios +- LR1110/LR1120/LR1121 / LR11xx family radios +- SX1280/SX1281 2.4 GHz LoRa radios +- LLCC68, at a high level, because it is SX126x-like but should be verified against the exact datasheet and driver in use + +This document is not a regulatory certification opinion. For legal LBT compliance, use the current regional standard and your test lab's interpretation for threshold, observation time, channel bandwidth, timing between assessment and TX, and duty-cycle or dwell-time constraints. + +--- + +## 3. Key definitions + +### CAD: Channel Activity Detection + +CAD is a LoRa modem feature that attempts to detect LoRa activity with less energy and latency than fully entering continuous receive. Its exact behavior and configurability vary by radio family. + +In most LoRa use cases, CAD is used to decide whether there appears to be another LoRa signal on the configured channel before entering RX or TX. + +### `CadDone` + +`CadDone` means the CAD operation has finished. It does not by itself mean the channel is clear or busy. + +### `CadDetected` + +`CadDetected` means the radio detected activity according to its CAD algorithm and current configuration. + +A useful firmware rule is: + +```text +CadDone && CadDetected -> LoRa activity was detected +CadDone && !CadDetected -> CAD did not detect LoRa activity +``` + +The second case does **not** prove that the RF channel is free of all energy or interference. + +### LBT: Listen Before Talk + +LBT is a policy or regulatory behavior: assess a channel before transmitting, transmit only when the channel is considered clear, and back off or retry when it is busy. + +CAD can be part of an LBT implementation, but CAD alone is not always a complete LBT implementation. + +### CCA: Clear Channel Assessment + +CCA is a broader channel-clear decision. It may be based on RSSI, energy detection, CAD, packet detection, or a combination of these. + +### CAD exit modes + +Some Semtech command-based radios let CAD automatically transition to another mode after the CAD decision: + +- `CAD_ONLY`: perform CAD and return to standby +- `CAD_RX`: enter RX if CAD detects activity +- `CAD_LBT`, `CAD_TX`, or `CAD_EXIT_MODE_TX`: enter TX if CAD does **not** detect activity + +Names vary by radio family and driver. + +--- + +## 4. The two correct CAD/LBT patterns + +## 4.1 Pattern A: host-controlled CAD then TX + +This is the portable pattern and the safe default. + +Use it on: + +- SX127x-class radios +- SX1280/SX1281 +- SX126x when you are using only the CAD behavior documented in the public SX1261/2 Rev. 2.2 datasheet +- Any driver stack where CAD-to-TX support is uncertain + +Sequence: + +```text +1. Configure LoRa PHY and packet settings. +2. Configure CAD settings, if the radio exposes them. +3. Clear stale CAD IRQ flags. +4. Start CAD. +5. Wait for CadDone. +6. Read IRQ/status. +7. Clear CAD IRQ flags. +8. If CadDetected is set: + treat the channel as LoRa-busy; + back off, retry later, or enter RX. + Else: + optionally perform RSSI/energy CCA; + transmit only if the final CCA policy says clear. +``` + +Generic pseudocode: + +```c +configure_lora_phy(); +configure_packet_params(); +configure_cad_params_if_available(); + +clear_irq(CAD_DONE | CAD_DETECTED); +start_cad(); + +wait_for_irq(CAD_DONE); +irq = get_irq_status(); +clear_irq(CAD_DONE | CAD_DETECTED); + +if (irq & CAD_DETECTED) { + backoff_or_enter_rx(); +} else { + if (energy_cca_required()) { + if (energy_is_clear()) { + transmit(); + } else { + backoff(); + } + } else { + transmit(); + } +} +``` + +## 4.2 Pattern B: documented CAD_LBT / CAD exit-to-TX + +Use this only when the exact radio and driver document a CAD exit mode that enters TX after a clear CAD decision. + +Sequence: + +```text +1. Configure LoRa PHY. +2. Configure TX parameters. +3. Configure packet parameters. +4. Configure buffer base addresses, where applicable. +5. Write the TX payload into the radio buffer. +6. Configure CAD exit mode = TX/LBT. +7. Start CAD. +8. Wait for TX done, timeout, or CAD-related IRQs according to the driver. +``` + +Generic pseudocode: + +```c +configure_lora_phy(); +configure_tx_params(); +configure_packet_params(); +configure_buffer_base_addresses(); +write_tx_payload(payload, len); + +set_cad_params( + cad_symbols, + cad_det_peak, + cad_det_min, + CAD_EXIT_MODE_TX, // name depends on radio/driver + tx_timeout +); + +start_cad(); // this substitutes for transmit() + +wait_for_completion_irq(); +handle_tx_done_timeout_or_cad_status(); +``` + +Do not call the normal transmit command immediately after starting CAD in this mode. The CAD operation is the transmit gate. + +--- + +## 5. Device-family decision matrix + +| Radio family | Is blind `CAD; SEND` valid? | Documented CAD-to-TX? | Correct method | +| ---------------------------------------- | --------------------------: | ----------------------------------------------------------------------------------------------------------------: | ------------------------------------------------------------------------------------------------------------- | +| SX1272/SX1276/SX1277/SX1278/SX1279 style | No | No documented CAD_LBT in the SX1276/77/78/79 register model | Host-controlled CAD, wait `CadDone`, inspect `CadDetected`, then TX only if clear | +| SX1261/SX1262/SX1268 class | No | Public SX1261/2 Rev. 2.2 datasheet documents `CAD_ONLY` and `CAD_RX`; Semtech SDK also documents `SX126X_CAD_LBT` | Use host-controlled CAD for datasheet-portable code; use SDK `CAD_LBT` only when your exact stack supports it | +| LR1110/LR1120/LR1121 / LR11xx | No | LR1121 manual documents `CAD_LBT`; LR11xx SDK documents TX-exit CAD mode | Use documented CAD exit-to-TX with payload preloaded, or host-controlled CAD | +| SX1280/SX1281 | No | No CAD_LBT in the SX1280/81 datasheet flow | Host-controlled CAD, wait `CadDone`, inspect `CadDetected`, then TX only if clear | +| LLCC68 | No | Verify exact datasheet/driver | Treat as SX126x-like, but confirm supported CAD exit modes before using CAD-to-TX | + +--- + +## 6. CAD is not the same as RF energy detection + +A common implementation error is treating this condition as a complete channel-clear proof: + +```text +CadDone && !CadDetected +``` + +That condition only means CAD did not detect LoRa activity according to the radio's current CAD and LoRa configuration. + +It does not guarantee that: + +- no other RF signal is present; +- no FSK, BLE, Wi-Fi leakage, CW interferer, jammer, or broadband noise is present; +- another LoRa signal using a different configuration is absent; +- the channel satisfies a regional energy-based LBT rule; +- the channel will remain clear by the time TX begins. + +A robust CCA policy is often: + +```text +busy if CAD detects LoRa activity +busy if RSSI/energy is above the chosen threshold +clear only if both CAD and energy checks pass +``` + +For purely LoRa-network collision avoidance, CAD alone may be acceptable in some products. For regulatory LBT, do not assume CAD alone is enough unless your standard, certification plan, and test lab agree. + +--- + +## 7. SX127x guide + +This section applies to SX1272/SX1276/SX1277/SX1278/SX1279 style register-based LoRa radios. The exact register map differs somewhat by part, but the CAD control model is the same general style. + +## 7.1 What the radio supports + +SX127x-class radios expose CAD as a radio operating mode, separate from TX and RX. In the SX1276/77/78/79 datasheet model, TX, RX single, RX continuous, and CAD are different operating modes selected through the operation-mode register. + +CAD completion is reported through CAD-related IRQ flags: + +- `CadDone`: CAD operation finished +- `CadDetected`: CAD found activity + +The CAD operation returns the radio to standby after completion. + +There is no documented hardware CAD-to-TX exit mode in the SX1276/77/78/79 register model. Therefore, LBT must be host-controlled. + +## 7.2 Correct SX127x host-controlled CAD/LBT sequence + +```c +// SX127x-style pseudocode. +// Symbol names are illustrative. Use the definitions from your driver. + +// 1. Configure LoRa mode and modem parameters. +set_lora_mode(); +set_frequency(freq_hz); +set_spreading_factor(sf); +set_bandwidth(bw); +set_coding_rate(cr); +set_preamble_length(preamble_len); +set_payload_params(payload_len, crc_on, explicit_header); + +// 2. Optionally preload TX FIFO to reduce the gap between clear CAD and TX. +load_tx_fifo(payload, payload_len); + +// 3. Clear stale CAD IRQ flags. +write_reg(REG_IRQ_FLAGS, IRQ_CAD_DONE | IRQ_CAD_DETECTED); + +// 4. Start CAD. +write_reg(REG_OP_MODE, MODE_LORA | MODE_CAD); + +// 5. Wait for CAD completion. +while ((read_reg(REG_IRQ_FLAGS) & IRQ_CAD_DONE) == 0) { + // Poll, sleep until DIO interrupt, or service other tasks. +} + +uint8_t irq = read_reg(REG_IRQ_FLAGS); + +// 6. Clear CAD flags by writing 1s to those bits. +write_reg(REG_IRQ_FLAGS, IRQ_CAD_DONE | IRQ_CAD_DETECTED); + +// 7. Make the LBT decision. +if (irq & IRQ_CAD_DETECTED) { + // LoRa activity was detected. + schedule_random_backoff(); + // or enter RX to receive the packet, depending on your MAC. +} else { + if (rssi_cca_required() && !rssi_energy_clear()) { + schedule_random_backoff(); + } else { + // FIFO was preloaded above; otherwise load it here. + write_reg(REG_OP_MODE, MODE_LORA | MODE_TX); + } +} +``` + +## 7.3 SX127x notes + +- Clear stale CAD IRQ flags before starting a new CAD operation. +- Do not read `CadDetected` before `CadDone` and treat it as final. +- Do not issue TX until CAD has completed and the host has evaluated the result. +- Preloading the FIFO before CAD can reduce the time between clear CAD and TX, but it does not remove the need to wait for `CadDone`. +- If your LBT policy must detect non-LoRa interferers, add RSSI or energy CCA. + +--- + +## 8. SX126x guide + +This section applies to SX1261/SX1262/SX1268 style command-based radios. + +## 8.1 Datasheet-portable behavior + +The public SX1261/2 Rev. 2.2 datasheet documents CAD as a LoRa-only operation that searches for LoRa preamble activity. CAD reports: + +- `CADdone` +- `CadDetected`, if a valid signal was detected + +`SetCadParams` includes parameters such as: + +- number of CAD symbols +- detection peak threshold +- detection minimum threshold +- CAD exit mode +- CAD timeout + +The public Rev. 2.2 datasheet table documents at least these exit modes: + +- `CAD_ONLY`: perform CAD and return to standby +- `CAD_RX`: if activity is detected, enter RX + +For code that must be portable to the behavior described in that datasheet, use host-controlled CAD or `CAD_RX`, not an assumed CAD-to-TX path. + +## 8.2 SX126x host-controlled LBT sequence + +```c +// SX126x-style pseudocode. +// Function names vary across drivers. + +wait_while_busy(); +SetStandby(STDBY_RC); + +wait_while_busy(); +SetPacketType(PACKET_TYPE_LORA); + +wait_while_busy(); +SetRfFrequency(freq_hz); + +wait_while_busy(); +SetModulationParams(sf, bw, cr, ldro); + +wait_while_busy(); +SetPacketParams(preamble_len, + header_type, + payload_len, + crc_on, + iq_inverted); + +wait_while_busy(); +SetTxParams(tx_power_dbm, ramp_time); + +wait_while_busy(); +SetBufferBaseAddress(tx_base, rx_base); + +wait_while_busy(); +WriteBuffer(tx_base, payload, payload_len); // optional preload + +wait_while_busy(); +SetDioIrqParams(IRQ_CAD_DONE | IRQ_CAD_DETECTED | IRQ_TX_DONE | IRQ_TIMEOUT, + dio1_mask, + dio2_mask, + dio3_mask); + +wait_while_busy(); +ClearIrqStatus(IRQ_CAD_DONE | IRQ_CAD_DETECTED); + +wait_while_busy(); +SetCadParams(cad_symbols, + cad_det_peak, + cad_det_min, + CAD_ONLY, + 0); + +wait_while_busy(); +SetCAD(); + +wait_for_irq(IRQ_CAD_DONE); + +wait_while_busy(); +uint16_t irq = GetIrqStatus(); + +wait_while_busy(); +ClearIrqStatus(IRQ_CAD_DONE | IRQ_CAD_DETECTED); + +if (irq & IRQ_CAD_DETECTED) { + backoff_or_enter_rx(); +} else if (energy_cca_required() && !energy_is_clear()) { + backoff(); +} else { + wait_while_busy(); + SetTx(tx_timeout); +} +``` + +## 8.3 SX126x SDK `CAD_LBT` behavior + +Semtech's SWSD003 SX126x CAD example documents `SX126X_CAD_LBT`. In that mode, if no activity is detected, the chip goes to TX. The same example states that this mode is a substitute for TX and that payload data should be preloaded before entering the CAD LBT mode. + +Use this only when your exact driver, chip, and integration expose and support it. + +```c +// SX126x SDK-style CAD_LBT pseudocode. + +wait_while_busy(); +SetStandby(STDBY_RC); + +wait_while_busy(); +SetPacketType(PACKET_TYPE_LORA); + +wait_while_busy(); +SetRfFrequency(freq_hz); + +wait_while_busy(); +SetModulationParams(sf, bw, cr, ldro); + +wait_while_busy(); +SetPacketParams(preamble_len, + header_type, + payload_len, + crc_on, + iq_inverted); + +wait_while_busy(); +SetTxParams(tx_power_dbm, ramp_time); + +wait_while_busy(); +SetBufferBaseAddress(tx_base, rx_base); + +// Required before CAD_LBT, because CAD_LBT substitutes for SetTx(). +wait_while_busy(); +WriteBuffer(tx_base, payload, payload_len); + +wait_while_busy(); +SetDioIrqParams(IRQ_CAD_DONE | IRQ_CAD_DETECTED | IRQ_TX_DONE | IRQ_TIMEOUT, + dio1_mask, + dio2_mask, + dio3_mask); + +wait_while_busy(); +ClearIrqStatus(IRQ_CAD_DONE | IRQ_CAD_DETECTED | IRQ_TX_DONE | IRQ_TIMEOUT); + +wait_while_busy(); +SetCadParams(cad_symbols, + cad_det_peak, + cad_det_min, + SX126X_CAD_LBT, + tx_timeout); + +wait_while_busy(); +SetCAD(); // This is the gated TX attempt. + +// Do not immediately call SetTx() here. +// Wait for the completion indication defined by your driver. +``` + +## 8.4 SX126x notes + +- Always respect the `BUSY` line before sending commands. +- Treat `cadDetPeak` and `cadDetMin` as parameters requiring validation for your SF, bandwidth, packet timing, and false-detection tolerance. +- If using `CAD_LBT`, preload the payload and TX configuration before starting CAD. +- If using only the public Rev. 2.2 datasheet behavior, do not assume `CAD_LBT` exists. +- For regulatory LBT, add RSSI or energy assessment when required. + +--- + +## 9. LR11xx / LR1121 guide + +This section applies to LR1110/LR1120/LR1121 family radios at a high level, with the strongest direct documentation in this guide coming from the LR1121 user manual and the Semtech LR11xx SDK example. + +## 9.1 What the radio supports + +The LR1121 manual documents CAD as a LoRa operation that reports: + +- `CADdone` +- `CadDetected`, when valid activity is found + +Its CAD parameters include: + +- number of symbols +- detection peak threshold +- detection minimum threshold +- CAD exit mode +- timeout + +The LR1121 manual documents these CAD exit modes: + +- `CAD_ONLY`: after CAD, return to standby +- `CAD_RX`: if activity is detected, stay in RX until packet or timeout +- `CAD_LBT`: if no activity is detected, enter TX mode + +For `CAD_LBT`, the timeout is interpreted as the TX timeout. + +Semtech's LR11xx SDK CAD example similarly documents a TX-exit CAD mode and states that the payload should be preloaded because this mode substitutes for TX. + +## 9.2 LR11xx CAD_LBT sequence + +```c +// LR11xx/LR1121-style pseudocode. +// Function and enum names vary by driver. + +SetPacketType(PACKET_TYPE_LORA); +SetRfFrequency(freq_hz); +SetModulationParams(sf, bw, cr, ldro); +SetPacketParams(preamble_len, + header_type, + payload_len, + crc_on, + iq_inverted); + +SetTxParams(tx_power_dbm, ramp_time); +SetBufferBaseAddress(tx_base, rx_base); +WriteBuffer(tx_base, payload, payload_len); // Required before CAD-to-TX + +SetDioIrqParams(IRQ_CAD_DONE | IRQ_CAD_DETECTED | IRQ_TX_DONE | IRQ_TIMEOUT, + dio1_mask, + dio2_mask, + dio3_mask); + +ClearIrqStatus(IRQ_CAD_DONE | IRQ_CAD_DETECTED | IRQ_TX_DONE | IRQ_TIMEOUT); + +SetCadParams(cad_symbols, + cad_det_peak, + cad_det_min, + CAD_LBT, + tx_timeout); + +SetCAD(); // If CAD is clear, the radio enters TX. + +wait_for_completion_irq(); +``` + +## 9.3 LR11xx notes + +- Do not use `SetCAD(); SetTx();` as a substitute for `CAD_LBT`. +- Preload the payload before starting CAD_LBT. +- Verify the exact enum names and behavior for LR1110/LR1120/LR1121 in the driver version you ship. +- If certification matters, verify the behavior against the exact firmware, radio revision, and regional rules. + +--- + +## 10. SX1280/SX1281 guide + +This section applies to SX1280/SX1281 2.4 GHz LoRa radios. + +## 10.1 What the radio supports + +The SX1280/81 datasheet describes `SetCAD()` as a LoRa operation that searches for a LoRa signal. It returns to standby after the search and reports: + +- `CadDone` +- `CadDetected`, if a valid signal was found + +`SetCadParams()` for SX1280/SX1281 selects the number of CAD symbols. Supported values include 1, 2, 4, 8, and 16 symbols. The datasheet warns that using very small symbol counts, especially 1 or 2 symbols, increases false-detection risk. + +The SX1280/81 datasheet flow does not document a CAD-to-TX exit mode analogous to LR1121 `CAD_LBT`. + +## 10.2 Correct SX128x host-controlled sequence + +```c +// SX1280/SX1281-style pseudocode. + +SetPacketType(PACKET_TYPE_LORA); +SetRfFrequency(freq_hz); +SetModulationParams(sf, bw, cr); +SetPacketParams(preamble_len, + header_type, + payload_len, + crc_on, + iq_inverted); +SetTxParams(tx_power_dbm, ramp_time); + +SetBufferBaseAddress(tx_base, rx_base); +WriteBuffer(tx_base, payload, payload_len); // optional preload + +SetCadParams(LORA_CAD_04_SYMBOLS); // choose based on robustness/latency + +SetDioIrqParams(IRQ_CAD_DONE | IRQ_CAD_DETECTED | IRQ_TX_DONE | IRQ_TIMEOUT, + dio1_mask, + dio2_mask, + dio3_mask); + +ClearIrqStatus(IRQ_CAD_DONE | IRQ_CAD_DETECTED); + +SetCAD(); + +wait_for_irq(IRQ_CAD_DONE); +irq = GetIrqStatus(); +ClearIrqStatus(IRQ_CAD_DONE | IRQ_CAD_DETECTED); + +if (irq & IRQ_CAD_DETECTED) { + backoff_or_enter_rx(); +} else if (energy_cca_required() && !energy_is_clear()) { + backoff(); +} else { + SetTx(tx_timeout); +} +``` + +## 10.3 SX128x notes + +- Use host-controlled CAD for LBT. +- Avoid relying on one-symbol or two-symbol CAD unless your testing shows the false-detection behavior is acceptable. +- Add an energy check when non-LoRa interferers or regulatory LBT matter. + +--- + +## 11. LLCC68 note + +LLCC68 is SX126x-like in its command structure, but this guide does not assert a universal LLCC68 CAD-to-TX behavior. Verify the exact LLCC68 datasheet and driver you are using. + +Safe default: + +```text +Use host-controlled CAD: + SetCadParams(...) + SetCAD() + wait CadDone + inspect CadDetected + TX only if clear +``` + +Use CAD-to-TX only if your exact LLCC68 documentation and driver explicitly define it. + +--- + +## 12. RSSI plus CAD + +## 12.1 Why RSSI may still be needed + +CAD is useful because LoRa signals can be difficult to assess with simple RSSI, especially when they are near or below the apparent receiver noise floor. However, CAD is not an energy detector for every possible interferer. + +Use RSSI or energy CCA when you need to detect: + +- non-LoRa transmitters; +- strong out-of-network interferers; +- continuous wave or broadband noise; +- signals using configurations your CAD settings may not detect; +- energy-based regulatory occupancy. + +## 12.2 Conservative CCA decision + +A conservative channel decision is: + +```c +bool channel_busy = false; + +if (cad_detected) { + channel_busy = true; +} + +if (energy_cca_required() && rssi_or_energy_above_threshold()) { + channel_busy = true; +} + +if (channel_busy) { + backoff(); +} else { + transmit(); +} +``` + +Or, as a truth table: + +| CAD result | RSSI/energy result | Conservative decision | +| ------------ | ------------------- | --------------------------------------------- | +| CAD detected | Any | Busy | +| CAD clear | Energy high | Busy | +| CAD clear | Energy low | Clear | +| CAD clear | Energy not measured | Clear only if your policy allows CAD-only CCA | + +## 12.3 RSSI during CAD + +Semtech application note AN1200.21 describes reading RSSI during CAD on SX127x/SX1272-era radios. The idea is to start CAD, wait for the appropriate CAD startup and symbol timing point, read RSSI while the radio is effectively observing the channel, then wait for CAD completion. + +This can combine a LoRa CAD decision and an RSSI sample with relatively low energy cost. Treat the timing and thresholds as implementation details that must be validated for the target radio, driver, bandwidth, spreading factor, and regional rule. + +--- + +## 13. CAD parameter selection + +## 13.1 CAD symbol count + +Increasing the number of CAD symbols generally improves detection confidence and reduces false decisions, at the cost of more time and energy. + +Lower symbol counts reduce latency and power but increase risk: + +- missed detection; +- false detection; +- less reliable behavior at low SNR; +- poorer LBT decisions under marginal conditions. + +For radios that expose symbol count, validate the chosen value under realistic network traffic and interference. + +## 13.2 Detection thresholds + +SX126x and LR11xx-style radios expose CAD detection threshold parameters such as detection peak and detection minimum. + +Do not blindly copy threshold values across products. The correct operating point can depend on: + +- spreading factor; +- bandwidth; +- expected SNR; +- preamble length; +- traffic timing; +- antenna and RF front-end behavior; +- false-alarm tolerance; +- missed-detection tolerance; +- whether CAD is used for RX wakeup, collision avoidance, or regulatory LBT support. + +## 13.3 Time gap between CAD clear and TX + +Host-controlled CAD always has a gap between CAD completion and transmit start. During that gap, another node may begin transmitting. Reduce the gap by: + +- preloading the TX buffer before CAD; +- preconfiguring TX parameters before CAD; +- servicing the CAD IRQ promptly; +- using a documented CAD-to-TX mode where available; +- applying random backoff in multi-node networks. + +Even CAD_LBT does not make collisions impossible. It only reduces the assessment-to-TX gap and lets the radio perform the transition according to its documented state machine. + +--- + +## 14. Recommended implementation checklist + +Before CAD: + +- [ ] Put the radio in the required standby or command-ready state. +- [ ] Configure packet type to LoRa. +- [ ] Configure RF frequency. +- [ ] Configure LoRa modulation parameters: spreading factor, bandwidth, coding rate, and low-data-rate optimization where applicable. +- [ ] Configure packet parameters needed for the subsequent RX/TX path. +- [ ] Configure TX power, ramp, and PA settings if TX may follow CAD. +- [ ] Configure buffer base addresses where applicable. +- [ ] Preload TX payload if using CAD_LBT or if minimizing CAD-to-TX delay. +- [ ] Configure CAD parameters. +- [ ] Configure IRQ routing for `CadDone`, `CadDetected`, `TxDone`, and timeout as needed. +- [ ] Clear stale IRQ flags. +- [ ] Respect the `BUSY` line on command-based radios. + +During CAD: + +- [ ] Do not issue TX until CAD is complete, unless the radio is in a documented CAD_LBT mode. +- [ ] Wait for `CadDone` or the driver's completion callback. +- [ ] Do not treat intermediate IRQ state as the final CAD decision. + +After CAD: + +- [ ] Read IRQ/status. +- [ ] Clear CAD IRQ flags. +- [ ] If `CadDetected`, back off or enter RX. +- [ ] If not detected, optionally perform RSSI/energy CCA. +- [ ] Transmit only when your final CCA/LBT policy says clear. +- [ ] Wait for `TxDone` or timeout. +- [ ] Clear TX-related IRQs. + +For CAD_LBT: + +- [ ] Confirm the exact chip and driver support TX-exit CAD. +- [ ] Preload the payload before starting CAD. +- [ ] Configure the CAD exit timeout as the TX timeout if required by the radio. +- [ ] Do not immediately call `SetTx()` after `SetCAD()`. +- [ ] Handle the completion IRQs exactly as specified by the driver. + +--- + +## 15. Common incorrect implementations + +## 15.1 Blind CAD then send + +Incorrect: + +```c +SetCAD(); +Send(payload); +``` + +Why it is wrong: + +- It does not wait for `CadDone`. +- It does not inspect `CadDetected`. +- It may cancel or override CAD. +- It does not implement channel assessment. + +## 15.2 Polling `CadDetected` without waiting for `CadDone` + +Incorrect: + +```c +SetCAD(); +if (!(GetIrqStatus() & IRQ_CAD_DETECTED)) { + SetTx(timeout); +} +``` + +Why it is wrong: + +- `CadDetected` is meaningful as a CAD result only after CAD has completed. +- Before `CadDone`, absence of `CadDetected` is not a final clear-channel indication. + +## 15.3 Using CAD as a universal energy detector + +Incorrect assumption: + +```text +No CadDetected means no RF energy is present. +``` + +Why it is wrong: + +- CAD is LoRa-specific and configuration-dependent. +- Non-LoRa interferers may not be detected by CAD. +- Regulatory LBT may require energy measurement rather than LoRa activity detection. + +## 15.4 Using CAD_LBT without preloading the payload + +Incorrect: + +```c +SetCadParams(..., CAD_LBT, timeout); +SetCAD(); +WriteBuffer(payload); // too late +``` + +Why it is wrong: + +- In CAD-to-TX modes, CAD is the gated transmit attempt. +- The radio may enter TX immediately after a clear CAD decision. +- The payload and TX parameters must already be ready. + +## 15.5 Assuming SX126x datasheet and SDK expose identical CAD modes + +Incorrect assumption: + +```text +Every SX126x stack has CAD_LBT because a Semtech SDK example mentions it. +``` + +Why it is risky: + +- The public SX1261/2 Rev. 2.2 datasheet table documents `CAD_ONLY` and `CAD_RX`. +- Semtech's SDK documents `SX126X_CAD_LBT`. +- Your product should verify the exact driver, silicon, and documentation you rely on. + +--- + +## 16. Testing and validation plan + +## 16.1 Functional tests + +Test at minimum: + +- CAD clear channel -> TX occurs +- CAD busy channel -> TX is suppressed +- CAD busy channel with random backoff -> retry later +- stale CAD IRQ flags cleared before CAD start +- `CadDone` without `CadDetected` handled correctly +- `CadDone` with `CadDetected` handled correctly +- TX timeout handled correctly +- TX done handled correctly +- BUSY-line handling on command-based radios + +## 16.2 RF tests + +Test with: + +- same SF/BW LoRa interferer; +- different SF or bandwidth LoRa interferer; +- short preamble packets; +- long preamble packets; +- packets already in progress; +- weak signals near sensitivity; +- strong adjacent-channel signals; +- non-LoRa interferers; +- noise or CW interference; +- multiple nodes attempting CAD/LBT simultaneously. + +## 16.3 Parameter sweep + +Sweep: + +- CAD symbol count; +- `cadDetPeak` and `cadDetMin`, where available; +- RSSI threshold; +- RSSI observation time; +- random backoff window; +- time between CAD clear and TX; +- payload length and preamble length. + +Measure: + +- missed detection rate; +- false detection rate; +- collision rate; +- channel access latency; +- energy consumption; +- throughput; +- fairness between nodes. + +## 16.4 Regulatory validation + +For regulatory LBT, record: + +- applicable region and rule version; +- required channel observation time; +- required energy threshold; +- bandwidth over which energy is assessed; +- maximum allowed delay between clear assessment and TX; +- random backoff requirements; +- maximum channel occupancy time; +- test-lab interpretation; +- final firmware version and radio driver commit. + +--- + +## 17. Practical decision tree + +```text +Do you need to transmit only if LoRa activity is absent? + Yes: + Does your exact radio/driver document CAD_LBT or CAD exit-to-TX? + Yes: + Preload TX payload. + Configure CAD exit mode = TX/LBT. + Start CAD. + Do not call SetTx immediately after SetCAD. + No: + Start CAD. + Wait CadDone. + If CadDetected, back off. + If not CadDetected, transmit. + +Do you need to satisfy energy-based LBT or detect non-LoRa interferers? + Yes: + Add RSSI/energy CCA. + Treat channel as busy if either CAD detects activity or energy is high. + No: + CAD-only may be acceptable for LoRa collision avoidance, after testing. +``` + +--- + +## 18. Final rules of thumb + +1. **Never treat `SetCAD(); SetTx();` as LBT.** +2. **Wait for `CadDone` before using the CAD result.** +3. **Treat `CadDetected` as LoRa activity, not generic RF energy.** +4. **Use RSSI/energy CCA when non-LoRa interference or regulation matters.** +5. **Preload the payload before using CAD_LBT or CAD exit-to-TX.** +6. **Use CAD_LBT only when the exact chip and driver document it.** +7. **Tune CAD thresholds and symbol counts with RF testing, not assumptions.** +8. **Clear stale IRQs before and after CAD.** +9. **Respect the command-radio `BUSY` line.** +10. **Validate timing from clear assessment to TX.** + +--- + +## Appendix A: Diffs from the initial informal answer + +The initial informal answer was directionally correct but needed tightening. The final guidebook makes these changes: + +- Replaces the broad statement "Semtech radios do not support single CAD then SEND" with the precise rule: blind `CAD; SEND` is wrong, but documented CAD exit-to-TX modes exist on some families and stacks. +- Separates host-controlled CAD from hardware/driver CAD_LBT. +- Clarifies that SX1261/2 Rev. 2.2 datasheet documentation and Semtech SDK examples are not identical: the datasheet table documents `CAD_ONLY` and `CAD_RX`; the SDK documents `SX126X_CAD_LBT`. +- Clarifies that LR1121 explicitly documents `CAD_LBT`, and LR11xx SDK examples document TX-exit CAD modes. +- Removes the implication that configuring a sync word is part of CAD detection itself. Configure packet and sync settings for the RX/TX path, but CAD is not a full packet validation step. +- Strengthens the distinction between CAD and RSSI/energy CCA. +- Adds implementation checklists, wrong-pattern examples, and validation tests. + +--- + +## Appendix B: References + +1. [Semtech SX1276/77/78/79 datasheet PDF mirror](https://cdn.sparkfun.com/assets/7/7/3/2/2/SX1276_Datasheet.pdf) +2. [Semtech SX1261/2 Rev. 2.2 datasheet PDF mirror](https://resource.heltec.cn/download/WiFi_LoRa_32_V4/datasheet/SX1261_2%20V2-2.pdf) +3. [Semtech SWSD003 SX126x CAD example README](https://github.com/Lora-net/SWSD003/blob/master/sx126x/apps/cad/README.md) +4. [Semtech LR1121 user manual mirror](https://manualzz.com/doc/89606071/semtech-lr1121-lora%C2%AE-transceiver-user-manual) +5. [Semtech SWSD003 LR11xx CAD example README](https://github.com/Lora-net/SWSD003/blob/master/lr11xx/apps/cad/README.md) +6. [Semtech SX1280/SX1281 Rev. 3.2 datasheet PDF mirror](https://media.digikey.com/pdf/Data%20Sheets/Semtech%20PDFs/SX1280-81_Rev3.2_Mar2020.pdf) +7. [Semtech AN1200.21, Reading Channel RSSI During a CAD](https://www.dessy.ru/include/images/ware/pdf/a/AN1200.21_STD.pdf) +8. [Semtech LLCC68 datasheet section mirror: SetCadParams](https://manualzz.com/doc/o/15k4mx/semtech-llcc68-data-sheet-lora%C2%AE-transceiver-datasheet-13.4.7-setcadparams) + +--- + +## Appendix C: Minimal one-page implementation recipe + +Use this when you need the shortest practical firmware rule. + +```text +For SX127x/SX128x or portable SX126x code: + configure LoRa PHY and TX packet + optionally preload TX buffer + clear CAD IRQs + start CAD + wait CadDone + read IRQs + clear CAD IRQs + if CadDetected: + backoff or RX + else: + if RSSI/energy CCA required and energy is high: + backoff + else: + TX + +For LR1121/LR11xx or supported SX126x CAD_LBT: + confirm exact driver/chip supports CAD exit-to-TX + configure LoRa PHY and TX packet + preload TX payload + configure CAD exit mode = TX/LBT + start CAD + do not issue SetTx immediately + let radio enter TX only if CAD is clear +``` diff --git a/justfile b/justfile index f0b5005..74cb0ea 100644 --- a/justfile +++ b/justfile @@ -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" diff --git a/ld/nrf52840-memory.x b/ld/nrf52840-memory.x index 8de0314..bdd70d7 100644 --- a/ld/nrf52840-memory.x +++ b/ld/nrf52840-memory.x @@ -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 } diff --git a/src/board/rak_wisblock_4631.rs b/src/board/rak_wisblock_4631.rs index 3db461b..46dcaad 100644 --- a/src/board/rak_wisblock_4631.rs +++ b/src/board/rak_wisblock_4631.rs @@ -22,11 +22,18 @@ pub type UsbDriver = mcu::UsbNrfDriver; pub type DisplayI2c = mcu::I2cBus; pub type LedDriver = crate::driver::simple_led::SimpleLed>; -pub type DisplayDriver = ssd1306::Ssd1306Async< - ssd1306::prelude::I2CInterface, - ssd1306::size::DisplaySize128x64, - ssd1306::mode::BufferedGraphicsModeAsync, ->; +// 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; // ── Peripheral bundles ────────────────────────────────────────────── @@ -46,22 +53,29 @@ pub struct DisplayParts { // ── Display init ──────────────────────────────────────────────────── pub async fn create_display(i2c: DisplayI2c) -> Option { - 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 ────────────────────────────────────────────────────── diff --git a/src/channel.rs b/src/channel.rs index 26662bb..8ac9f8d 100644 --- a/src/channel.rs +++ b/src/channel.rs @@ -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; +/// 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; // ── Internal: display commands (no longer wire-visible) ──────────── diff --git a/src/display.rs b/src/display.rs index 708ddeb..b76efa9 100644 --- a/src/display.rs +++ b/src/display.rs @@ -237,6 +237,8 @@ statechart! { Display { context: DisplayContext; events: DisplayEvent; + // Auto-trace — backend selected by `hsmc/trace-*` cargo feature. + trace; default(On); state Off { diff --git a/src/driver/mod.rs b/src/driver/mod.rs index cf7eb0d..8901b5e 100644 --- a/src/driver/mod.rs +++ b/src/driver/mod.rs @@ -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 DisplayBrightness for sh1106::Sh1106 where diff --git a/src/driver/sh1106.rs b/src/driver/sh1106.rs index ed2743e..d31bff7 100644 --- a/src/driver/sh1106.rs +++ b/src/driver/sh1106.rs @@ -37,6 +37,12 @@ impl Sh1106 { /// 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 Sh1106 { /// 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 Sh1106 { 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 Sh1106 { 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 Sh1106 { /// 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 } } diff --git a/src/hal/nrf52840.rs b/src/hal/nrf52840.rs index b07b006..b16eb77 100644 --- a/src/hal/nrf52840.rs +++ b/src/hal/nrf52840.rs @@ -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. diff --git a/src/host/framing.rs b/src/host/framing.rs index 8abbb83..fd77216 100644 --- a/src/host/framing.rs +++ b/src/host/framing.rs @@ -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 } } diff --git a/src/host/usb.rs b/src/host/usb.rs index bdff9ce..533dc1a 100644 --- a/src/host/usb.rs +++ b/src/host/usb.rs @@ -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 = 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; - } } } diff --git a/src/main.rs b/src/main.rs index d911b4c..0e9c077 100644 --- a/src/main.rs +++ b/src/main.rs @@ -84,6 +84,15 @@ cfg_if::cfg_if! { #[cfg(not(test))] async fn run(spawner: Spawner) { let board = ::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 diff --git a/src/radio.rs b/src/radio.rs index 0eaad1f..834cec7 100644 --- a/src/radio.rs +++ b/src/radio.rs @@ -2,18 +2,40 @@ //! //! ```text //! Radio -//! ├── Unconfigured (default) no config cached → RF ops rejected -//! └── Configured -//! ├── Idle (default) ready, waiting -//! ├── StartingRx brief: HW init in response to host -//! │ RxStart; responds Ok/Err then → -//! │ Receiving (Ok) or Idle (Err) -//! ├── Receiving continuous RX -//! ├── ResumingRx brief: post-TX HW re-arm; silent → -//! │ Receiving (Ok) or Idle (Err) -//! └── Transmitting -//! ├── FromIdle (default) TxDone → Idle -//! └── FromRx TxDone → ResumingRx +//! ├── Booting (default) cold LoRa::new with timeout. NO event +//! │ handlers — cold_init runs uninterruptible. +//! │ → Operational on success, → Dead on fail. +//! ├── Operational super-state owning all chip-touching +//! │ │ lifecycle. PING/GET_INFO/Reset/ +//! │ │ WedgeDetected handlers live here so +//! │ │ they're STRUCTURALLY unreachable in +//! │ │ Booting and Dead by event bubbling. +//! │ ├── Unconfigured (default) no config cached → RF ops rejected +//! │ ├── Configured +//! │ │ ├── Idle (default) ready, waiting +//! │ │ ├── StartingRx brief: HW init in response to host +//! │ │ │ RxStart; responds Ok/Err then → +//! │ │ │ Receiving (Ok) or Idle (Err) +//! │ │ ├── Receiving continuous RX +//! │ │ ├── ResumingRx brief: post-TX HW re-arm; silent → +//! │ │ │ Receiving (Ok) or Idle (Err) +//! │ │ └── Transmitting +//! │ │ ├── FromIdle (default) TxDone → Idle +//! │ │ └── FromRx TxDone → ResumingRx +//! │ ├── Recovering in-place NRESET pulse + lora.init(); +//! │ │ on success → Unconfigured, on failure +//! │ │ → CheckRecoveryBudget +//! │ └── CheckRecoveryBudget bounded-retry decision: → Recovering +//! │ while under MAX_RECOVERY_ATTEMPTS, +//! │ → Dead when exhausted +//! └── Dead terminal: cold init failed OR recovery +//! exhausted. dead_drain consumes commands +//! inline replying ERadio (PING/INFO still +//! answer); only cold POR (USB unplug) +//! escapes. Per the Harel rule, the chart +//! MUST be non-terminating — radio_task's +//! run() call is followed by panic, since +//! reaching past it implies a chart bug. //! ``` //! //! The task body is a single `machine.run().await`. Host commands flow in @@ -115,6 +137,35 @@ pub enum RadioInput { /// `MAX_RESUME_RETRIES` attempts have failed — the chip is genuinely /// wedged; fall through to `Idle` so the host can reconfigure. ResumeRxExhausted, + + // ── Cold-boot / Wedge / Recovery / Dead lifecycle (chart-modeled) ── + /// Internal: emitted by `Booting`'s `during:` after `LoRa::new` + /// succeeded. Drives `Booting → Unconfigured`. + BootOk, + /// Internal: emitted by `Booting`'s `during:` after `LoRa::new` + /// failed or timed out. Drives `Booting → Dead`. + BootFailed, + /// Emitted by an action / `during:` activity when it observes a + /// `lora_setup*` timeout (i.e. `take_wedge_flag()` returned true on + /// the failure path). Drives the chart from any operational state + /// into `Recovering` for an in-place NRESET pulse + `lora.init()`. + WedgeDetected, + /// Internal: emitted by `Recovering`'s `during:` when `lora.init()` + /// succeeded. Drives `Recovering → Unconfigured` (host re-issues + /// SET_CONFIG to re-arm). + RecoveryOk, + /// Internal: emitted by `Recovering`'s `during:` when `lora.init()` + /// failed (or itself wedged). Drives `Recovering → + /// CheckRecoveryBudget` so the chart decides to retry or give up. + RecoveryFailed, + /// Internal: emitted by `assess_recovery_budget` when the recovery + /// counter is still under `MAX_RECOVERY_ATTEMPTS`. Drives + /// `CheckRecoveryBudget → Recovering` for another attempt. + RetryRecovery, + /// Internal: emitted by `assess_recovery_budget` when the recovery + /// counter has reached `MAX_RECOVERY_ATTEMPTS`. Drives + /// `CheckRecoveryBudget → Dead`. + RecoveryExhausted, } /// How many back-to-back `try_start_rx_hw` failures to tolerate in @@ -126,6 +177,28 @@ const MAX_RESUME_RETRIES: u8 = 5; /// Backoff between `ResumingRx` retry attempts. const RESUME_RX_RETRY_BACKOFF_MS: u64 = 25; +/// How many back-to-back `Recovering` (NRESET-pulse + `lora.init()`) +/// attempts to tolerate before declaring the chip permanently dead and +/// parking the chart in `Dead`. Each attempt is bounded to ~2 s by the +/// `lora_setup_with` budget in `try_lora_init`, so 4 attempts give the +/// chart ~8 s of recovery effort before going terminal — long enough +/// to ride out a transient hardware glitch without burning CPU on a +/// chip that is genuinely dead. +const MAX_RECOVERY_ATTEMPTS: u8 = 4; + +/// Wall-clock budget for `lora.init()` in the `Recovering` state. Cold +/// init normally completes in well under 100 ms, but the NRESET pulse +/// adds chip wake-up latency. 2 s is ~20× headroom for the documented +/// path; if it blows, the chip is unrecoverable and we fall through to +/// `CheckRecoveryBudget`. +const LORA_INIT_RECOVERY_TIMEOUT_MS: u64 = 2000; + +/// Wall-clock budget for cold `LoRa::new` in the `Booting` state. Same +/// 1.5 s headroom we gave the imperative cold-init path before this +/// moved into the chart — covers a stuck-BUSY chip on first power-on. +/// On timeout the chart parks in `Dead`; user power-cycles to escape. +const LORA_COLD_INIT_TIMEOUT_MS: u64 = 1500; + // ── Context ───────────────────────────────────────────────────────── pub struct RadioContext { @@ -133,7 +206,18 @@ pub struct RadioContext { pub outbound: &'static OutboundChannel, pub events: &'static RadioEventChannel, pub info: &'static Info, - pub lora: LoRaDriver, + /// Cold-init parts (driver pins + delay). `Some` at boot; consumed + /// by `Booting`'s `during:` (`cold_init`) which calls `LoRa::new`. + /// `None` afterwards — pin ownership has moved into `lora`. + pub parts: Option, + /// Live LoRa driver. `None` while in `Booting` (before `LoRa::new` + /// returns) or after `Booting → Dead` (cold init failed). `Some` + /// in every operational state. Action paths invariant: chart never + /// dispatches operational actions when `lora` is `None`, so an + /// `Option::expect` there would indicate a chart bug — actions + /// instead defensive-unwrap and emit a failure event so the chart + /// self-recovers rather than panicking. + pub lora: Option, pub rx_buf: [u8; MAX_OTA_PAYLOAD], /// Active LoRa config. `None` in `Unconfigured`; `Some(cfg)` in /// `Configured` (LoRa is the only modulation the firmware drives @@ -145,17 +229,31 @@ pub struct RadioContext { /// Tag of the command currently being handled. Written by /// `next_command` just before emitting the event; read by action /// handlers to construct tag-echoed responses. - pub incoming_tag: u16, + /// + /// Type-level guard: `None` means "no command is currently being + /// handled" — actions that read this MUST be reachable only as a + /// consequence of a chart event dispatched by `next_command`. If an + /// action reads `None`, the chart's invariant ("commands always go + /// through next_command first") was violated; we `.expect()` with a + /// clear panic message rather than silently continuing with tag=0. + /// `NonZeroU16` because protocol §2.2 reserves tag=0 for async-only + /// frames; tag-correlated commands are NEVER 0. + pub incoming_tag: Option, /// Tag of the TX currently on the wire. Captured on entry to /// `Transmitting`; consumed by `respond_tx_*` handlers. - pub last_tx_tag: Option, + pub last_tx_tag: Option, /// Tag of a pending `RX_START`, pulled by `respond_ok_start_rx` / /// `respond_err_start_rx` once the chip reports back. - pub pending_rx_start_tag: Option, + pub pending_rx_start_tag: Option, /// Number of consecutive `try_start_rx_hw` failures while in /// `ResumingRx`. Reset on any `StartRxOk`. See `MAX_RESUME_RETRIES` /// for the limit before falling through to `Idle`. pub resume_retries: u8, + /// Number of `Recovering` attempts the chart has burned this wedge + /// session. Bumped on `Recovering` entry; cleared on `RecoveryOk` + /// (chip came back). When it reaches `MAX_RECOVERY_ATTEMPTS` the + /// chart transitions to `Dead` (terminal until cold POR). + pub recovery_attempts: u8, /// Per-CAD-retry sleep in microseconds: time-on-air for a /// `CAD_ASSUMED_PEER_PAYLOAD_B`-byte packet under the current /// config. Recomputed on every successful `SET_CONFIG`; `0` while @@ -181,131 +279,328 @@ statechart! { Radio { context: RadioContext; events: RadioInput; - default(Unconfigured); + // Auto-trace every state entry/exit, action invocation, and + // transition. Backend (defmt / log / tracing / no-op) is picked + // by which `hsmc/trace-*` cargo feature is enabled — see + // `Cargo.toml`. When no backend feature is enabled this line + // expands to zero code at every emission site. + trace; + default(Booting); - // Globals: PING always answers; GET_INFO always answers; Reset - // forces Unconfigured. - on(CmdPing) => respond_pong; - on(CmdGetInfo) => respond_info; - on(Reset) => reset_to_unconfigured, Unconfigured; + // ── Top-level: three states. Booting (cold init), Operational + // (everything that touches the chip), Dead (terminal until + // cold POR). Operational is a super-state so that PING / + // GET_INFO / Reset / WedgeDetected are STRUCTURALLY + // UNREACHABLE in Booting — cold_init's `during:` cannot be + // cancelled mid-`LoRa::new` by a stray host event, so the + // cancel-safety hazard of `parts.take()` before the await + // is unreachable by chart shape, not by defensive code. + // Dead handles its own commands inline via `dead_drain`. - state Unconfigured { - during: next_command(commands, incoming_tag, pending_tx); - on(CmdSetConfig(m: Modulation)) => apply_set_config; - on(ConfigApplied) => Configured; - on(CmdTx) => reject_tx_not_configured; - on(CmdRxStart) => reject_not_configured; - on(CmdRxStop) => reject_not_configured; + // Cold init lives in the chart, not in `radio_task`. The chart + // takes ownership of `RadioParts` from context, calls + // `LoRa::new` with a wall-clock budget, and routes the outcome: + // success → `Operational/Unconfigured`; failure → `Dead`. No + // `sys_reset`, no GPREGRET retry. Booting has NO event handlers + // on purpose: nothing can interrupt cold_init. + state Booting { + during: cold_init(parts, lora); + on(BootOk) => Operational; + on(BootFailed) => Dead; } - state Configured { - on(CmdSetConfig(m: Modulation)) => apply_set_config; - on(ConfigFailedRadio) => Unconfigured; - // Default post-reconfigure landing is Idle. The RX ring is - // always cleared as part of apply_set_config. Receiving - // overrides this handler to auto-resume RX with the new - // parameters (spec §3.5) so callers do not need to re-issue - // RX_START after SET_CONFIG. - on(ConfigApplied) => Idle; - default(Idle); + // Operational: every state where the chart is allowed to touch + // the radio chip. Handlers that only make sense AFTER cold init + // succeeded live here, not at root, so they cannot fire in + // Booting or Dead by event bubbling. Per hsmc README rule §3 + // (event bubbling, leaf-first), an event with no handler in + // Booting bubbles to root and silently drops — the right + // semantics for "host sent something while we were starting up." + state Operational { + // PING / GET_INFO at this level: always answer while + // operational. In Booting they drop (host retries). In + // Dead, dead_drain answers them inline. + on(CmdPing) => respond_pong; + on(CmdGetInfo) => respond_info; + // Reset at this level: drives the Operational subtree back + // to Unconfigured. Resets in Booting drop (cold init must + // complete first). Resets in Dead drop (chart's terminal). + on(Reset) => reset_to_unconfigured, Unconfigured; + // Wedge detected mid-operation: enter Recovering for + // NRESET-pulse + lora.init() retry. Same reachability + // guards as Reset. + on(WedgeDetected) => Recovering; + default(Unconfigured); - state Idle { - entry: announce_idle; + state Unconfigured { during: next_command(commands, incoming_tag, pending_tx); - on(CmdRxStart) => stage_rx_start; - on(CmdRxStart) => StartingRx; - on(CmdTx) => TransmittingFromIdle; - on(CmdRxStop) => respond_ok; + on(CmdSetConfig(m: Modulation)) => apply_set_config; + on(ConfigApplied) => Configured; + on(CmdTx) => reject_tx_not_configured; + on(CmdRxStart) => reject_not_configured; + on(CmdRxStop) => reject_not_configured; } - state StartingRx { - entry: try_start_rx_hw; - on(StartRxOk) => respond_ok_start_rx, Receiving; - on(StartRxFailed) => respond_err_start_rx, Idle; - } + state Configured { + on(CmdSetConfig(m: Modulation)) => apply_set_config; + on(ConfigFailedRadio) => Unconfigured; + // Default post-reconfigure landing is Idle. The RX ring + // is always cleared as part of apply_set_config. + // Receiving overrides this handler to auto-resume RX + // with the new parameters (spec §3.5) so callers do not + // need to re-issue RX_START after SET_CONFIG. + on(ConfigApplied) => Idle; + default(Idle); - state Receiving { - entry: announce_entered_rx; - during: next_command(commands, incoming_tag, pending_tx); - during: next_packet(lora, rx_buf, config); - on(CmdRxStart) => respond_ok; - on(PacketReceived(rx: RxPayload)) => record_packet; - // Transient driver error during `next_packet` — re-arm - // the chip via ResumingRx rather than parking in Idle - // (which would bounce the OLED and leave the radio - // waiting for a client-initiated RX_START that will - // never come because clients already think RX is up). - on(RxFailed) => ResumingRx; - on(CmdRxStop) => stop_rx_hw, respond_ok; - on(CmdRxStop) => Idle; - on(CmdTx) => TransmittingFromRx; - // Spec §3.5: a SET_CONFIG issued while receiving clears - // the RX ring (already handled by apply_set_config) but - // auto-rearms RX with the new parameters — the caller - // does not need to send a fresh RX_START. Override the - // parent's `on(ConfigApplied) => Idle` by landing in - // ResumingRx, whose entry kicks the chip back into RX. - on(ConfigApplied) => ResumingRx; - } - - // Bounded-retry RX re-arm. Entered after every TX while - // already receiving, on RX driver errors, and on mid-session - // SET_CONFIG. If `try_start_rx_hw` succeeds we land cleanly - // in Receiving; if it fails we back off and retry, only - // conceding to Idle after MAX_RESUME_RETRIES attempts (chip - // is genuinely wedged at that point). - state ResumingRx { - entry: try_start_rx_hw; - on(StartRxOk) => reset_resume_retries, Receiving; - on(StartRxFailed) => bump_resume_retries; - on(ResumeRxRetry) => ResumingRx; - on(ResumeRxExhausted) => Idle; - } - - state Transmitting { - entry: announce_entered_tx; - exit: announce_packet_tx; - default(TransmittingFromIdle); - - state TransmittingFromIdle { - during: perform_tx(lora, pending_tx, cad_retry_delay_us); - on(TransmitDone { airtime_us: u32 }) => respond_tx_done; - on(TransmitDone) => Idle; - on(ChannelBusy) => respond_tx_channel_busy, Idle; - on(TransmitFailed) => respond_tx_failed, Idle; + state Idle { + entry: announce_idle; + during: next_command(commands, incoming_tag, pending_tx); + on(CmdRxStart) => stage_rx_start; + on(CmdRxStart) => StartingRx; + on(CmdTx) => TransmittingFromIdle; + on(CmdRxStop) => respond_ok; } - state TransmittingFromRx { - during: perform_tx(lora, pending_tx, cad_retry_delay_us); - on(TransmitDone { airtime_us: u32 }) => respond_tx_done; - on(TransmitDone) => ResumingRx; - // Even on failure, we were receiving before the TX — - // return to RX (the client still gets a TX_FAILED - // reply via respond_tx_failed). Dropping to Idle here - // would break the "RX session stays up" invariant. - on(ChannelBusy) => respond_tx_channel_busy, ResumingRx; - on(TransmitFailed) => respond_tx_failed, ResumingRx; + state StartingRx { + entry: try_start_rx_hw; + // Per hsmc README rule §1: leaf states need an + // active during somewhere on the path so the run + // loop has something to await. `pending()` blocks + // forever — the chart can only leave via + // StartRxOk/Failed. + during: pending(); + on(StartRxOk) => respond_ok_start_rx, Receiving; + on(StartRxFailed) => respond_err_start_rx, Idle; + } + + state Receiving { + entry: announce_entered_rx; + during: next_command(commands, incoming_tag, pending_tx); + during: next_packet(lora, rx_buf, config); + on(CmdRxStart) => respond_ok; + on(PacketReceived(rx: RxPayload)) => record_packet; + // Transient driver error during `next_packet` — + // re-arm the chip via ResumingRx rather than + // parking in Idle (which would bounce the OLED and + // leave the radio waiting for a client-initiated + // RX_START that will never come because clients + // already think RX is up). + on(RxFailed) => ResumingRx; + on(CmdRxStop) => stop_rx_hw, respond_ok; + on(CmdRxStop) => Idle; + on(CmdTx) => TransmittingFromRx; + // Spec §3.5: a SET_CONFIG issued while receiving + // clears the RX ring (already handled by + // apply_set_config) but auto-rearms RX with the new + // parameters — the caller does not need to send a + // fresh RX_START. Override the parent's + // `on(ConfigApplied) => Idle` by landing in + // ResumingRx, whose entry kicks the chip back into + // RX. + on(ConfigApplied) => ResumingRx; + } + + // Bounded-retry RX re-arm. Entered after every TX while + // already receiving, on RX driver errors, and on + // mid-session SET_CONFIG. If `try_start_rx_hw` succeeds + // we land cleanly in Receiving; if it fails we back off + // and retry, only conceding to Idle after + // MAX_RESUME_RETRIES attempts (chip is genuinely wedged + // at that point). + state ResumingRx { + entry: try_start_rx_hw; + during: pending(); + on(StartRxOk) => reset_resume_retries, Receiving; + on(StartRxFailed) => bump_resume_retries; + on(ResumeRxRetry) => ResumingRx; + on(ResumeRxExhausted) => Idle; + } + + state Transmitting { + entry: announce_entered_tx; + exit: announce_packet_tx; + default(TransmittingFromIdle); + + state TransmittingFromIdle { + during: perform_tx(lora, pending_tx, cad_retry_delay_us); + on(TransmitDone { airtime_us: u32 }) => respond_tx_done; + on(TransmitDone) => Idle; + on(ChannelBusy) => respond_tx_channel_busy, Idle; + on(TransmitFailed) => respond_tx_failed, Idle; + } + + state TransmittingFromRx { + during: perform_tx(lora, pending_tx, cad_retry_delay_us); + on(TransmitDone { airtime_us: u32 }) => respond_tx_done; + on(TransmitDone) => ResumingRx; + // Even on failure, we were receiving before the + // TX — return to RX (the client still gets a + // TX_FAILED reply via respond_tx_failed). + // Dropping to Idle here would break the "RX + // session stays up" invariant. + on(ChannelBusy) => respond_tx_channel_busy, ResumingRx; + on(TransmitFailed) => respond_tx_failed, ResumingRx; + } } } + + // In-place wedge recovery: NRESET pulse + lora.init() + // retry. Entered from any Operational sub-state via the + // Operational-level `on(WedgeDetected)` handler when a + // `lora_setup*` op blew past its wall-clock budget (chip + // BUSY stuck high). NOT a sys_reset — we keep the embassy + // task alive, just kick the chip. STRUCTURALLY UNREACHABLE + // from Booting (Booting can't emit WedgeDetected; cold_init + // doesn't go through lora_setup). + state Recovering { + entry: bump_recovery_attempt; + during: try_lora_init(lora); + on(RecoveryOk) => clear_recovery_attempts, Unconfigured; + on(RecoveryFailed) => CheckRecoveryBudget; + } + + // Decide whether to attempt another recovery or give up. + // Lives as its own state (rather than inline guard logic) + // so the budget policy is readable from the chart shape — + // see `assess_recovery_budget` for the threshold check. + state CheckRecoveryBudget { + entry: assess_recovery_budget; + during: pending(); + on(RetryRecovery) => Recovering; + on(RecoveryExhausted) => Dead; + } + } + + // Terminal: cold init failed OR chart-internal recovery + // exhausted. Stays here until cold POR (USB unplug → fresh + // boot, fresh chart). Drains the commands channel inline + // replying ERadio for any radio-touching command and Ok for + // PING/GET_INFO so the host protocol stays alive even though + // the chip is unreachable. No event handlers — `dead_drain` + // owns the entire command-response loop and never returns. + state Dead { + entry: announce_dead; + during: dead_drain(commands, outbound, info); } } } +// ── Lora setup-call wedge protection ────────────────────────────────── +// +// Background: lora-phy's IV calls `wait_on_busy().await` before every +// SPI command. That's a GPIO sense interrupt with NO TIMEOUT in the +// upstream-clean version of lora-phy (which we keep upstreamable and +// thus un-modified here). If the SX1262's BUSY pin is ever stuck high +// — sticky chip state from a prior wedge, hardware glitch, etc. — any +// lora-phy SPI op blocks the radio_task forever. +// +// `lora_setup` wraps a SHORT-DURATION lora-phy op (one that should +// complete in well under 100 ms per datasheet §13) with a generous wall- +// clock bound. On timeout it returns `Err(RadioError::Busy)` — the same +// shape the existing `?` chains in chart actions already handle, so the +// chart's failure events (TxFailed, ConfigFailedRadio, RxFailed, +// StartRxFailed) carry the firmware back to a recoverable state. +// +// Budget rationale: 500 ms = ~5× the worst documented BUSY-high window +// per SX1262 datasheet §13, with slack for cold-boot calibration variance, +// post-TX hot-PA settling, and SPI/DMA arbitration jitter under embassy +// executor load. Genuine wedge (BUSY stuck high for hardware reasons) is +// the only thing this rejects. +// +// Do NOT wrap `lora.rx(&pkt, rx_buf)` — single-packet RX legitimately +// waits indefinitely for an incoming packet. The wedge we're guarding +// against is in setup paths, not the await-IRQ path. + +const LORA_SETUP_TIMEOUT_MS: u64 = 500; + +async fn lora_setup(label: &'static str, fut: F) -> Result +where + F: core::future::Future>, +{ + match embassy_time::with_timeout( + embassy_time::Duration::from_millis(LORA_SETUP_TIMEOUT_MS), + fut, + ) + .await + { + Ok(r) => r, + Err(_) => { + warn!("lora_setup timeout: {}", label); + crate::channel::LORA_WEDGED.store(true, portable_atomic::Ordering::Release); + Err(RadioError::Busy) + } + } +} + +/// Variant for ops with a known-larger budget (TX with long ToA, etc.). +async fn lora_setup_with( + label: &'static str, + timeout_ms: u64, + fut: F, +) -> Result +where + F: core::future::Future>, +{ + match embassy_time::with_timeout( + embassy_time::Duration::from_millis(timeout_ms), + fut, + ) + .await + { + Ok(r) => r, + Err(_) => { + warn!("lora_setup timeout: {} ({=u64} ms)", label, timeout_ms); + crate::channel::LORA_WEDGED.store(true, portable_atomic::Ordering::Release); + Err(RadioError::Busy) + } + } +} + +/// True if a `lora_setup*` timeout fired since the last call. Consumes +/// the flag (clears on read) so the next call sees a fresh signal. +/// Use immediately after a lora helper returned `Err(_)` to decide +/// whether to emit `WedgeDetected` (chart enters `Recovering`) or the +/// per-action failure event (chart self-recovers in place). +fn take_wedge_flag() -> bool { + crate::channel::LORA_WEDGED.swap(false, portable_atomic::Ordering::AcqRel) +} + // ── during: activities ────────────────────────────────────────────── +/// Forever-pending activity. Used as a `during:` for chart states that +/// have no other concurrent activity, to defeat hsmc's run-loop exit +/// condition (`next.is_none() && !has_any_during`). The chart can only +/// leave the state via an explicit transition triggered by an emitted +/// event. Spec property: the radio chart never exits silently. +async fn pending() -> RadioInput { + core::future::pending::().await +} + /// Read the next host command, stash its tag into context, and (for TX) /// stage a placeholder pending-TX struct before emitting the corresponding /// event. The `apply_set_config` / `try_start_rx_hw` / TX-staging actions /// patch in the real config from ctx when they run. async fn next_command( commands: &mut &'static CommandChannel, - incoming_tag: &mut u16, + incoming_tag: &mut Option, pending_tx: &mut Option, ) -> RadioInput { match commands.receive().await { - InboundEvent::Reset => RadioInput::Reset, + InboundEvent::Reset => { + // Reset isn't tag-correlated — clear the slot so any stray + // action that reads incoming_tag panics with a clear msg + // rather than echoing a stale tag from a prior command. + *incoming_tag = None; + RadioInput::Reset + } InboundEvent::Command { tag, cmd } => { - *incoming_tag = tag; + // Protocol §2.2: tag=0 is rejected by the host framing + // layer before reaching the commands channel. If we see 0 + // here it's a serious framing-layer bug — fail loudly. + *incoming_tag = Some( + core::num::NonZeroU16::new(tag) + .expect("protocol §2.2: command tag must be non-zero (host framing bug)"), + ); match cmd { Command::Ping => RadioInput::CmdPing, Command::GetInfo => RadioInput::CmdGetInfo, @@ -330,11 +625,91 @@ async fn next_command( } } +/// `during:` for the `Booting` state. Owns the cold `LoRa::new` call: +/// takes the consumed-once driver pins out of context, hands them to +/// lora-phy with a wall-clock budget, and stores the result back in +/// context. Emits `BootOk` on success (chart proceeds to `Unconfigured` +/// where the host can `SET_CONFIG`); `BootFailed` on any failure +/// (chart parks in `Dead`; user power-cycles the dongle to escape). +/// +/// This replaces the imperative `radio_dead_loop` / +/// `handle_radio_init_failure` / GPREGRET-retry machinery that used to +/// live in `radio_task`. Cold init is now a chart state, not a function. +async fn cold_init( + parts: &mut Option, + lora: &mut Option, +) -> RadioInput { + // ── DIAGNOSTIC PROBE 1 ── + // Per hsmc README rule §6, a state's `during:` activity STARTS after + // the state's entry actions finish. So if this fires, the chart MUST + // have already emitted `[Radio] enter __Root` and `[Radio] enter + // Booting` from `enter_state_no_descent`'s trace call. If chart + // traces are missing from the log but THIS line appears, the + // problem is specifically in the `__chart_trace!` macro's defmt + // path — not chart reachability and not the chart engine itself. + info!("[probe] cold_init enter (chart trace path SHOULD have fired BEFORE this)"); + if lora.is_some() { + // Already initialized — re-entry into Booting (e.g., user + // injected a Reset before we even left Booting on first boot). + // Just acknowledge what's already true. + return RadioInput::BootOk; + } + let Some(p) = parts.take() else { + // No parts AND no lora — we already failed cold init in a + // previous Booting visit (parts was consumed by `take()` then). + // Chart should have transitioned to Dead; if we're here again + // something forced us back. Stay Failed. + return RadioInput::BootFailed; + }; + match embassy_time::with_timeout( + embassy_time::Duration::from_millis(LORA_COLD_INIT_TIMEOUT_MS), + LoRa::new(p.driver, false, p.delay), + ) + .await + { + Ok(Ok(l)) => { + *lora = Some(l); + // Successful cold init clears any stale wedge flag carried + // from a prior Recovering attempt — fresh chip, fresh state. + let _ = take_wedge_flag(); + info!("radio: cold init OK"); + // ── DIAGNOSTIC PROBE 2 ── + // After this, we return BootOk; the chart's run loop pushes + // it into the queue and processes it on the next iteration: + // Booting → Operational. Expected trace lines AFTER this: + // [Radio] exit Booting + // [Radio] Booting -> Operational + // [Radio] enter Operational + // [Radio] enter Unconfigured + // If those don't appear, the trace path is broken. + info!("[probe] cold_init OK — about to return BootOk (chart should now exit Booting → Operational)"); + RadioInput::BootOk + } + Ok(Err(e)) => { + error!("radio: cold init failed: {:?}", defmt::Debug2Format(&e)); + RadioInput::BootFailed + } + Err(_) => { + error!( + "radio: cold init TIMED OUT after {=u64} ms — chip BUSY likely stuck", + LORA_COLD_INIT_TIMEOUT_MS, + ); + RadioInput::BootFailed + } + } +} + async fn next_packet( - lora: &mut LoRaDriver, + lora: &mut Option, rx_buf: &mut [u8; MAX_OTA_PAYLOAD], config: &mut Option, ) -> RadioInput { + // Chart invariant: Receiving is unreachable from Booting/Dead, so + // lora is Some here. Defensive bail-out instead of panic so a chart + // bug self-recovers via RxFailed → ResumingRx rather than dying. + let Some(lora) = lora.as_mut() else { + return RadioInput::RxFailed; + }; let Some(cfg) = config.as_ref() else { return RadioInput::RxFailed; }; @@ -373,17 +748,35 @@ async fn next_packet( data, }) } - Err(_) => RadioInput::RxFailed, + // RX path is the long-await IRQ path that lora_setup is NOT + // wrapped around (RX legitimately waits indefinitely for a + // packet). But the helpers in to_lora_mod_params / + // create_rx_packet_params above DO go through lora_setup-style + // SPI calls — if any of THOSE wedged earlier in this poll, the + // flag is set and we should escalate to Recovering rather than + // bounce through the per-action ResumingRx loop with a still- + // wedged chip. + Err(_) => { + if take_wedge_flag() { + RadioInput::WedgeDetected + } else { + RadioInput::RxFailed + } + } } } async fn perform_tx( - lora: &mut LoRaDriver, + lora: &mut Option, pending: &mut Option, cad_retry_delay_us: &mut u32, ) -> RadioInput { #[cfg(feature = "debug-checkpoint")] crate::debug_blink::set(6); + // Same defensive-bail rationale as `next_packet`. + let Some(lora) = lora.as_mut() else { + return RadioInput::TransmitFailed; + }; let Some(tx) = pending.take() else { return RadioInput::TransmitFailed; }; @@ -401,23 +794,113 @@ async fn perform_tx( Err(_) => { #[cfg(feature = "debug-checkpoint")] crate::debug_blink::set(15); - RadioInput::TransmitFailed + // Wedge takes precedence over per-action TransmitFailed so + // the chart escalates to Recovering instead of bouncing + // back to Idle/ResumingRx with a still-wedged chip. + if take_wedge_flag() { + RadioInput::WedgeDetected + } else { + RadioInput::TransmitFailed + } + } + } +} + +/// `during:` for `Recovering`. Pulses NRESET and re-runs lora-phy's cold +/// init sequence (radio_kind.reset → ensure_ready → set_standby → +/// init_lora → set_tx_power → set_irq_params). Wraps with a generous +/// 2 s budget — `lora.init()` itself contains multiple SPI commands +/// that each can wedge if the chip is genuinely dead. +/// +/// Emits `RecoveryOk` on success, `RecoveryFailed` on any error +/// (including a fresh wedge inside this attempt). The chart counts +/// attempts via `bump_recovery_attempt` (entry) and gives up to `Dead` +/// after `MAX_RECOVERY_ATTEMPTS`. +async fn try_lora_init(lora: &mut Option) -> RadioInput { + let Some(lora) = lora.as_mut() else { + // Recovering reachable only from operational states (which had + // a live driver) — None here means the chart is in a bad place. + // Treat as RecoveryFailed; CheckRecoveryBudget will route us + // toward Dead which is the right resting state. + return RadioInput::RecoveryFailed; + }; + match lora_setup_with( + "Recovering.lora.init", + LORA_INIT_RECOVERY_TIMEOUT_MS, + lora.init(), + ) + .await + { + Ok(()) => { + // Successful re-init clears any wedge flag the prior failure + // had set — we know the chip is responsive again. + let _ = take_wedge_flag(); + RadioInput::RecoveryOk + } + Err(_) => RadioInput::RecoveryFailed, + } +} + +/// `during:` for `Dead`. Drains the commands channel forever, replying +/// `Ok` to PING / GET_INFO and `ERadio` to anything else. Never returns +/// — the chart stays in `Dead` until cold POR (USB unplug clears +/// GPREGRET; next boot re-runs cold init from scratch). +/// +/// Mirrors the imperative `radio_dead_loop` semantics so the host-side +/// protocol stays alive whether the chip died at cold init (handled by +/// `radio_dead_loop`) or mid-operation (handled here). +async fn dead_drain( + commands: &mut &'static CommandChannel, + outbound: &mut &'static OutboundChannel, + info: &mut &'static Info, +) -> RadioInput { + loop { + match commands.receive().await { + InboundEvent::Reset => { + // Reset in Dead is a no-op: there's no chip-state to + // park. Clear the dedup flag so the host chart can + // queue a fresh Reset later if it wants to. + crate::channel::RESET_PENDING + .store(false, portable_atomic::Ordering::Release); + } + InboundEvent::Command { tag, cmd } => { + let msg = match cmd { + Command::Ping => DeviceMessage::Ok(OkPayload::Empty), + Command::GetInfo => DeviceMessage::Ok(OkPayload::Info(**info)), + _ => DeviceMessage::Err(ErrorCode::ERadio), + }; + outbound.send(OutboundFrame { tag, msg }).await; + } } } } // ── Actions ───────────────────────────────────────────────────────── +/// Unwrap an `Option` slot (incoming_tag, last_tx_tag, etc.) +/// to a raw u16 for sending over the wire, panicking with a clear +/// message if the slot is `None`. Reaching `None` here means a chart +/// invariant was violated — an action that constructs a tag-echoed +/// response ran without a preceding `next_command` having stashed the +/// tag, OR the tag-echo path read a slot before the producer wrote it. +#[inline] +fn tag_or_panic(t: Option, slot: &'static str) -> u16 { + t.unwrap_or_else(|| { + defmt::panic!("chart invariant violated: tag slot `{}` was None", slot); + }) + .get() +} + impl RadioActions for RadioActionContext<'_> { // ── PING / GET_INFO ─────────────────────────────────────────────── async fn respond_pong(&mut self) { - let tag = self.incoming_tag; + let tag = tag_or_panic(self.incoming_tag, "respond_pong"); self.send(tag, DeviceMessage::Ok(OkPayload::Empty)).await; } async fn respond_info(&mut self) { - let tag = self.incoming_tag; + let tag = tag_or_panic(self.incoming_tag, "respond_info"); let info = *self.info; self.send(tag, DeviceMessage::Ok(OkPayload::Info(info))) .await; @@ -426,7 +909,8 @@ impl RadioActions for RadioActionContext<'_> { // ── SET_CONFIG ──────────────────────────────────────────────────── async fn apply_set_config(&mut self, m: Modulation) { - let tag = self.incoming_tag; + let tag = tag_or_panic(self.incoming_tag, "apply_set_config"); + info!("apply_set_config: tag={=u16}", tag); match m { Modulation::LoRa(cfg) => { if let Err(code) = validate_lora(&cfg, self.info) { @@ -442,15 +926,34 @@ impl RadioActions for RadioActionContext<'_> { }; self.send(cancelled.tag, DeviceMessage::TxDone(td)).await; } - if let Err(_e) = reconfigure_radio(&mut self.lora, &cfg).await { + let recfg_result = match self.lora.as_mut() { + Some(lora) => reconfigure_radio(lora, &cfg).await, + None => Err(RadioError::Busy), + }; + if recfg_result.is_err() { // Hardware failure: drop to Unconfigured (spec §6.3). + // Clear all per-session state — including any in-flight + // RxStart tag — so the next CmdRxStart in Unconfigured + // is rejected cleanly (rather than racing a stale tag + // through respond_err_start_rx on an unrelated path). self.config = None; self.cad_retry_delay_us = 0; + self.pending_rx_start_tag = None; + info!("config cleared (apply_set_config failure tag={=u16})", tag); self.send(tag, DeviceMessage::Err(ErrorCode::ERadio)).await; - let _ = self.emit(RadioInput::ConfigFailedRadio); + // Wedge takes precedence over ConfigFailedRadio so the + // chart enters Recovering (NRESET + lora.init()) instead + // of bouncing back to Unconfigured with a still-wedged + // chip that the next SET_CONFIG would also fail on. + if take_wedge_flag() { + let _ = self.emit(RadioInput::WedgeDetected); + } else { + let _ = self.emit(RadioInput::ConfigFailedRadio); + } return; } self.config = Some(cfg); + info!("config set (apply_set_config success tag={=u16})", tag); self.cad_retry_delay_us = toa_us(&cfg, CAD_ASSUMED_PEER_PAYLOAD_B); if self.cad_retry_delay_us > CAD_BUDGET_US { defmt::debug!( @@ -494,7 +997,7 @@ impl RadioActions for RadioActionContext<'_> { result: TxResult::Transmitted, airtime_us, }; - self.send(tag, DeviceMessage::TxDone(td)).await; + self.send(tag.get(), DeviceMessage::TxDone(td)).await; } } @@ -504,35 +1007,65 @@ impl RadioActions for RadioActionContext<'_> { result: TxResult::ChannelBusy, airtime_us: 0, }; - self.send(tag, DeviceMessage::TxDone(td)).await; + self.send(tag.get(), DeviceMessage::TxDone(td)).await; } } async fn respond_tx_failed(&mut self) { if let Some(tag) = self.last_tx_tag.take() { - self.send(tag, DeviceMessage::Err(ErrorCode::ERadio)).await; + self.send(tag.get(), DeviceMessage::Err(ErrorCode::ERadio)) + .await; } } // ── RX_START / RX_STOP ──────────────────────────────────────────── async fn stage_rx_start(&mut self) { - self.pending_rx_start_tag = Some(self.incoming_tag); + // incoming_tag is already Option; copy directly. + // If None, the chart invariant was violated (CmdRxStart fired + // without next_command) — propagate the None so the eventual + // respond_*_start_rx site silently does nothing rather than + // echoing a stale tag. + self.pending_rx_start_tag = self.incoming_tag; } async fn try_start_rx_hw(&mut self) { let Some(cfg) = self.config else { - error!("try_start_rx_hw: no config cached — state invariant broken"); + // Defensive: shouldn't happen on the documented chart paths + // (StartingRx is only entered from Idle which requires + // Configured; ResumingRx is only entered from Receiving or + // self-loop, both of which had a valid config when reached). + // If it does fire, the chart self-recovers via StartRxFailed + // → respond_err_start_rx (replies ERadio to host) → Idle. + // Logged at WARN with diagnostic tag context so the next soak + // log captures the chart-state at the moment of the violation. + warn!( + "try_start_rx_hw: no config; incoming_tag={=u16}, pending_rx_start_tag={=u16}, last_tx_tag={=u16} (0 = None); emitting StartRxFailed (chart will recover to Idle)", + self.incoming_tag.map(|t| t.get()).unwrap_or(0), + self.pending_rx_start_tag.map(|t| t.get()).unwrap_or(0), + self.last_tx_tag.map(|t| t.get()).unwrap_or(0), + ); let _ = self.emit(RadioInput::StartRxFailed); return; }; - match start_rx(&mut self.lora, &cfg).await { + let result = match self.lora.as_mut() { + Some(lora) => start_rx(lora, &cfg).await, + None => Err(RadioError::Busy), + }; + match result { Ok(()) => { let _ = self.emit(RadioInput::StartRxOk); } Err(e) => { warn!("start_rx failed: {}", e); - let _ = self.emit(RadioInput::StartRxFailed); + // Wedge → Recovering takes precedence over the per-action + // self-recover-to-Idle path, so a stuck chip doesn't get + // re-armed for RX with the still-stuck SPI bus. + if take_wedge_flag() { + let _ = self.emit(RadioInput::WedgeDetected); + } else { + let _ = self.emit(RadioInput::StartRxFailed); + } } } } @@ -560,41 +1093,72 @@ impl RadioActions for RadioActionContext<'_> { } async fn respond_ok(&mut self) { - let tag = self.incoming_tag; + let tag = tag_or_panic(self.incoming_tag, "respond_ok"); self.send(tag, DeviceMessage::Ok(OkPayload::Empty)).await; } async fn respond_ok_start_rx(&mut self) { if let Some(tag) = self.pending_rx_start_tag.take() { - self.send(tag, DeviceMessage::Ok(OkPayload::Empty)).await; + self.send(tag.get(), DeviceMessage::Ok(OkPayload::Empty)) + .await; } } async fn respond_err_start_rx(&mut self) { if let Some(tag) = self.pending_rx_start_tag.take() { - self.send(tag, DeviceMessage::Err(ErrorCode::ERadio)).await; + self.send(tag.get(), DeviceMessage::Err(ErrorCode::ERadio)) + .await; } } async fn reject_not_configured(&mut self) { - let tag = self.incoming_tag; + let tag = tag_or_panic(self.incoming_tag, "reject_not_configured"); self.send(tag, DeviceMessage::Err(ErrorCode::ENotConfigured)) .await; } async fn stop_rx_hw(&mut self) { - let _ = self.lora.enter_standby().await; + if let Some(lora) = self.lora.as_mut() { + let _ = lora_setup("stop_rx_hw.enter_standby", lora.enter_standby()).await; + } + // If the call wedged the chip, escalate to Recovering (chart + // root handler `on(WedgeDetected) => Recovering`) so the next + // operational action doesn't run against a stuck SPI bus. + if take_wedge_flag() { + let _ = self.emit(RadioInput::WedgeDetected); + } } // ── Reset (disconnect / inactivity timeout) ────────────────────── async fn reset_to_unconfigured(&mut self) { + info!("config cleared (reset_to_unconfigured)"); + // Clear the dedup flag — host_task can now queue another Reset + // when needed. Done at the START of this action so even if the + // chip-side enter_standby below times out / errors, future + // Resets aren't blocked. + crate::channel::RESET_PENDING + .store(false, portable_atomic::Ordering::Release); self.pending_tx = None; self.last_tx_tag = None; self.pending_rx_start_tag = None; self.config = None; self.packets_dropped = 0; - let _ = self.lora.enter_standby().await; + // Start each Reset cycle from a clean wedge state — old timeout + // signal from a prior session shouldn't auto-escalate this one. + let _ = take_wedge_flag(); + if let Some(lora) = self.lora.as_mut() { + let _ = + lora_setup("reset_to_unconfigured.enter_standby", lora.enter_standby()).await; + } + // If THIS standby attempt wedged, escalate to Recovering so the + // chart doesn't loop reset_to_unconfigured every host-inactivity + // cycle against a chip that's stuck. The root WedgeDetected + // handler routes us into NRESET-pulse + lora.init() recovery + // before we re-enter Unconfigured. + if take_wedge_flag() { + let _ = self.emit(RadioInput::WedgeDetected); + } self.announce(RadioEvent::Idle); } @@ -615,7 +1179,12 @@ impl RadioActions for RadioActionContext<'_> { pt.config = cfg; } if let Some(pt) = self.pending_tx.as_ref() { - self.last_tx_tag = Some(pt.tag); + // PendingTx.tag came from next_command, which already + // enforced non-zero — fail loudly if that invariant slipped. + self.last_tx_tag = Some( + core::num::NonZeroU16::new(pt.tag) + .expect("PendingTx.tag was 0 — next_command should have rejected"), + ); } self.announce(RadioEvent::EnteredTx); } @@ -624,6 +1193,63 @@ impl RadioActions for RadioActionContext<'_> { self.announce(RadioEvent::PacketTx); } + // ── Recovery / Dead lifecycle ───────────────────────────────────── + + async fn bump_recovery_attempt(&mut self) { + self.recovery_attempts = self.recovery_attempts.saturating_add(1); + info!( + "Recovering: attempt {=u8}/{=u8}", + self.recovery_attempts, MAX_RECOVERY_ATTEMPTS, + ); + } + + async fn clear_recovery_attempts(&mut self) { + if self.recovery_attempts > 0 { + info!( + "Recovering: success after {=u8} attempt(s); chip back online", + self.recovery_attempts, + ); + } + self.recovery_attempts = 0; + // A successful recovery means the prior config no longer applies + // to the chip — clear it so the chart's Unconfigured invariant + // ("config is None") holds when we land there. + self.config = None; + self.cad_retry_delay_us = 0; + self.pending_tx = None; + self.last_tx_tag = None; + self.pending_rx_start_tag = None; + self.announce(RadioEvent::Idle); + } + + async fn assess_recovery_budget(&mut self) { + if self.recovery_attempts >= MAX_RECOVERY_ATTEMPTS { + error!( + "Recovering: exhausted ({=u8} attempts); transitioning to Dead", + self.recovery_attempts, + ); + let _ = self.emit(RadioInput::RecoveryExhausted); + } else { + let _ = self.emit(RadioInput::RetryRecovery); + } + } + + async fn announce_dead(&mut self) { + error!( + "radio chart entered Dead state — chip permanently wedged after {=u8} recovery attempts. Power-cycle the dongle (USB unplug) to reset.", + self.recovery_attempts, + ); + // Mirror the cold-init dead path: park display + flush any + // session state so subsequent host commands get clean ERadio + // replies (handled by the dead_drain `during:`). + self.config = None; + self.cad_retry_delay_us = 0; + self.pending_tx = None; + self.last_tx_tag = None; + self.pending_rx_start_tag = None; + self.announce(RadioEvent::Idle); + } + // ── Packet bookkeeping ──────────────────────────────────────────── async fn record_packet(&mut self, rx: RxPayload) { @@ -633,6 +1259,16 @@ impl RadioActions for RadioActionContext<'_> { rssi: rssi_dbm, snr: snr_db, }); + // Backpressure: if the host transport's IN endpoint is observed- + // dead (host_task set RADIO_THROTTLED on a write_packet failure), + // skip the outbound push entirely. Otherwise we'd just fill the + // queue and the frame would fall out the bottom of the depth-64 + // ring — the drop is the same outcome with extra log noise. + // Cleared by host_task on the first successful write_packet. + if crate::channel::RADIO_THROTTLED.load(portable_atomic::Ordering::Acquire) { + self.packets_dropped = self.packets_dropped.saturating_add(1); + return; + } let mut rx = rx; rx.packets_dropped = self.packets_dropped; let delivered = self @@ -649,6 +1285,7 @@ impl RadioActions for RadioActionContext<'_> { self.packets_dropped = self.packets_dropped.saturating_add(1); } } + } // ── LoRa helpers ──────────────────────────────────────────────────── @@ -723,11 +1360,18 @@ async fn start_rx(lora: &mut LoRaDriver, cfg: &LoRaConfig) -> Result<(), RadioEr cfg.iq_invert, &mdltn, )?; - lora.prepare_for_rx(RxMode::Continuous, &mdltn, &pkt).await + // RX SETUP — wraps with a setup-budget timeout. The ACTUAL receive + // (lora.rx call in next_packet activity) is INTENTIONALLY un-wrapped + // because RX-continuous legitimately waits indefinitely for a packet. + lora_setup( + "start_rx.prepare_for_rx", + lora.prepare_for_rx(RxMode::Continuous, &mdltn, &pkt), + ) + .await } async fn reconfigure_radio(lora: &mut LoRaDriver, cfg: &LoRaConfig) -> Result<(), RadioError> { - lora.enter_standby().await?; + lora_setup("reconfigure_radio.enter_standby", lora.enter_standby()).await?; // Calibrate image etc. via a throwaway create_modulation_params call // — inside the SET_CONFIG OK window so first TX/RX is fast. let _ = to_lora_mod_params(lora, cfg)?; @@ -745,14 +1389,16 @@ async fn do_tx( if !tx.skip_cad { #[cfg(feature = "debug-checkpoint")] crate::debug_blink::set(7); - lora.prepare_for_cad(&mdltn).await?; + lora_setup("do_tx.prepare_for_cad", lora.prepare_for_cad(&mdltn)).await?; // Reaches here → prepare_for_cad (SPI setup) completed. // Hang past here means lora.cad() is waiting on DIO1. #[cfg(feature = "debug-checkpoint")] crate::debug_blink::set(19); let mut saw_activity = false; for i in 0..retries { - if !lora.cad(&mdltn).await? { + // CAD with 8 symbols at SF12/BW125 is ~262 ms; 1500 ms covers + // worst-case configs + chip post-processing slack. + if !lora_setup_with("do_tx.cad", 1500, lora.cad(&mdltn)).await? { saw_activity = false; break; } @@ -767,7 +1413,7 @@ async fn do_tx( let jittered_us = jittered_sleep_us(sleep_us, entropy); embassy_time::Timer::after(embassy_time::Duration::from_micros(jittered_us as u64)) .await; - lora.prepare_for_cad(&mdltn).await?; + lora_setup("do_tx.prepare_for_cad(retry)", lora.prepare_for_cad(&mdltn)).await?; } if saw_activity { return Ok(TxOutcome::ChannelBusy); @@ -783,11 +1429,19 @@ async fn do_tx( )?; #[cfg(feature = "debug-checkpoint")] crate::debug_blink::set(8); - lora.prepare_for_tx(&mdltn, &mut tx_pkt, tx.config.tx_power_dbm as i32, &tx.data) - .await?; + lora_setup( + "do_tx.prepare_for_tx", + lora.prepare_for_tx(&mdltn, &mut tx_pkt, tx.config.tx_power_dbm as i32, &tx.data), + ) + .await?; #[cfg(feature = "debug-checkpoint")] crate::debug_blink::set(9); - lora.tx().await?; + // TX budget = on-air ToA + 1000 ms slack for PA ramp, IRQ delivery, + // chip overhead. Skips the wedge-protection if data is huge / config + // is at SF12/BW125 — but ToA is bounded by config and known up front. + let payload_len = tx.data.len().min(u16::MAX as usize) as u16; + let toa_ms = (toa_us(&tx.config, payload_len) / 1000) as u64; + lora_setup_with("do_tx.tx", toa_ms.saturating_add(1000), lora.tx()).await?; #[cfg(feature = "debug-checkpoint")] crate::debug_blink::set(10); Ok(TxOutcome::Transmitted) @@ -840,45 +1494,57 @@ pub async fn radio_task( events: &'static RadioEventChannel, info: &'static Info, ) { + // The chart owns everything: cold init, operational lifecycle, + // wedge recovery, dead-loop. radio_task is just a thin shim that + // hands `parts` (consumable pin ownership) into context and runs + // the chart. See `Radio` chart's `Booting` / `Recovering` / `Dead` + // states for the lifecycle policy. #[cfg(feature = "debug-checkpoint")] crate::debug_blink::set(1); - let lora = match LoRa::new(parts.driver, false, parts.delay).await { - Ok(l) => { - info!("radio initialized"); - #[cfg(feature = "debug-checkpoint")] - crate::debug_blink::set(2); - l - } - Err(e) => { - error!("radio init failed: {}", e); - #[cfg(feature = "debug-checkpoint")] - crate::debug_blink::set(16); - return; - } - }; let ctx = RadioContext { commands, outbound, events, info, - lora, + parts: Some(parts), + lora: None, rx_buf: [0u8; MAX_OTA_PAYLOAD], config: None, pending_tx: None, packets_dropped: 0, - incoming_tag: 0, + incoming_tag: None, last_tx_tag: None, pending_rx_start_tag: None, resume_retries: 0, + recovery_attempts: 0, cad_retry_delay_us: 0, }; + // Per hsmc 0.3 README: channel capacity MUST match the machine's + // internal event-deque capacity. Both are 32 here (default is 8): + // action chains can emit a transient burst (e.g. apply_set_config + // emits ConfigApplied + possibly WedgeDetected; reset_to_unconfigured + // may emit WedgeDetected; etc.). 32 is comfortable headroom before + // hsmc's `take_overflow()` would surface Err(QueueFull) out of run(). static RADIO_INTERNAL_CH: embassy_sync::channel::Channel< embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex, RadioInput, - 8, + 32, > = embassy_sync::channel::Channel::new(); - let mut machine = Radio::new(ctx, &RADIO_INTERNAL_CH); - let _ = machine.run().await; + let mut machine = + Radio::with_queue_capacity::<_, 32>(ctx, &RADIO_INTERNAL_CH); + // Per the chart's Harel structure: the radio chart is a non- + // terminating reactive system. There is no `terminate:` event + // declared, and Booting/Operational/Dead all have `during:` + // activities that prevent the run loop's silent-exit path. The + // ONLY way `run()` can return is `Err(QueueFull)` — which would + // be a chart-design bug, not a runtime condition to recover from. + // Halt loud so the bug is visible at the source rather than + // letting the task end silently and leaving the dongle limping. + let result = machine.run().await; + defmt::panic!( + "radio chart returned: {:?} — this is structurally impossible per chart design", + defmt::Debug2Format(&result), + ); }