`reconfigure_radio` previously consumed `cfg.sync_word` from the host
in name only — it called `enter_standby` and constructed a fresh set
of LoRa modulation params (which carry sf/bw/cr/freq but not the sync
word), then returned. lora-phy v3 has no public sync-word setter for
SX126x, and there's no codepath in this crate that touches the chip's
LoRaSyncword registers (0x0740/0x0741) after boot. The chip therefore
sat on whatever `LoRa::new(_, false, _)` set at cold init —
`LORAWAN_PRIVATE_SYNCWORD` (byte 0x12, register pair 0x1424) — for the
entire session, regardless of what the host requested.
That happened to work for every existing user because everyone wants
0x1424 (MeshCore's sync byte). It manifested as soon as a host actually
needed a different value: `anon_ping.py` survived because MeshCore
matches, but any custom mesh / non-MeshCore network would get silent
wrong-sync-word TXes that no peer could hear and no host-side diagnostic
could pin down.
This commit applies the host-supplied sync word inside
`reconfigure_radio`, between the existing `enter_standby` and the
mod-params construction:
- `cfg.sync_word` (the protocol's u16, RadioLib-style; e.g.
0x1424 == byte 0x12, 0x3444 == byte 0x34) is decoded back to its
underlying single byte: `byte = (hi & 0xF0) | (lo >> 4)` after
`to_be_bytes()`, the inverse of lora-phy's `convert_sync_word`
nibble-expansion.
- That byte is handed to lora-phy's new
`LoRa<RK,DLY>::set_lora_sync_word` (added in the matching
lora-phy commit), which honours SX1261/2 datasheet §8.3.1 by
driving `ensure_ready` + conditional `set_standby` before
writing the register pair, and caches the new value in the
`LoRa` struct so a later cold-reset NRESET (Recovering state)
resumes with the host's sync word rather than reverting to the
construction-time default.
- Failure of the sync-word write lands the same way `enter_standby`
failure already did: `apply_set_config` wipes `self.config`,
sends `ERR(ERadio)`, and routes the chart to `Recovering` to
NRESET + cold-init. No new error-handling plumbing required.
Cargo.lock bumps lora-phy from `fixed#e8a52ebd` to `fixed#8d66d1c4`,
which is the merge of three pre-existing `fix/*` branches plus a
new `feat/set-sync-word` branch carrying the lora-phy API addition.
Verified on hardware with a two-dongle bench test
(`examples/sync_word_test.py`): one dongle pinned to sync 0x5464,
the other rotating through six sync words. RX hears exactly the
matching cycles with realistic RSSI / SNR; non-matching cycles are
correctly filtered by the SX126x packet engine before reaching the
host.
Rewrite the Operational subtree of the radio chart so that every
cancel-unsafe lora-phy future (lora.rx() / lora.cad() / lora.tx() /
lora.init()) runs to natural completion, honoring lora-phy's documented
'must run to completion' contract.
Architectural pivot: every cancel-unsafe future is the body of a leaf-
state during whose host state declares NO outbound event handlers other
than events the during itself emits on natural completion. Per hsmc spec
§6, the chart cannot drop the during because no transition can fire.
RX subtree (RxSession super-state):
- irq_pump (cancel-safe: wait_for_irq + get_irq_state + clear_irq_status)
runs at RxSession scope, emits Dio1Asserted(IrqState) to chart
- try_start_rx_hw chains prepare_for_rx -> start_rx so the chip is
actually receiving when RxActive is entered (both single SPI ops)
- RxActive's collect_rx_packet action does post-IRQ get_rx_result work
- RxDraining's enter_standby cleanly stops RX before transitions out
TX subtree (TxSession super-state):
- TxCadPrep / TxCadWait / TxBackoff / TxPrep / TxOnAir / TxFanout
leaf states each owning one cancel-safe SPI op or one sandboxed
cancel-unsafe await
- lora.cad() / lora.tx() called AS-IS in states with zero non-self
event handlers
- chart timers (on(after Duration)) set context flags for wedge
detection; the during checks the flag at its next cancel-safe
wakeup and returns cleanly
- mark_pending_reset action defers Reset events until natural
completion (per spec §5 emit() semantics)
Removes lora_setup_with wrappers around lora.cad(), lora.tx(),
lora.init() -- those wrappers used embassy_time::with_timeout which
DROPS the wrapped future on timeout, the proximate cause of every
'lora_setup timeout: do_tx.cad' warn we've seen.
Keeps lora_setup around enter_standby / prepare_for_rx /
prepare_for_cad / prepare_for_tx -- those are short SPI sequences where
drop is documented-safe.
Documented latency budget: chart-cycle overhead <100us; regulatory
LBT (CAD-to-TX <50ms) well within budget.
Add exit: respond_tx_failed on the Transmitting super-state. Per hsmc spec
§6, exit actions fire on every departure from a state, including the
Operational-level on(WedgeDetected) => Recovering transition that bypasses
Transmitting's own on(TransmitFailed) => respond_tx_failed handler.
respond_tx_failed is a clean no-op when last_tx_tag is None (the
normal-completion path consumed it via .take()), so this is a safe
unconditional exit action. Net effect: every CmdTx gets exactly one
tag-correlated TX_DONE/TX_FAILED/CHANNEL_BUSY reply no matter how
Transmitting is exited. The host (mux/ai-bot) stops eating 2s firmware-
timeouts on every CAD wedge mid-TX.
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.
The CAD pre-TX loop previously ran up to 10 retries with a fixed 10 ms
sleep — roughly one max-length packet only at SF7/BW500. At SF10/BW125
the 100 ms total was ~4% of one packet; at SF12/BW125 LBT was a no-op.
Now the per-retry sleep is sized to one time-on-air of an assumed-peer
80-byte packet under the active config, cached on RadioContext and
recomputed on every SET_CONFIG. Retries scale as
clamp(floor(BUDGET/T)+1, 1, 10) with BUDGET=2.5 s, so fast configs
still get up to 10 retries while SF11/12 degrade to single-CAD-and-bail
(logged at config-apply). Sleeps are jittered ±20% per iteration to
decorrelate simultaneously-backing-off contenders.
The ToA math, CR→{1..4} mapping, retry-count derivation, and jitter
helper live in a pure src/lbt.rs module so they're host-testable via
DONGLORA_HOST_TEST=1 cargo test (13 tests).
Validated on-device: 20 iterations of the AI Bot test flow that
previously saw ~2 ChannelBusy errors now see zero at the same SF/BW.
RAK4631 board:
- SX126X_POWER_EN on P1.05 driven HIGH at init (radio is power-gated;
without this the chip has no power and SPI hangs)
- HFXO + LFXO external crystals in embassy_nrf::Config (USB CDC-ACM
needs +/- 500 ppm which the internal RC oscillator can't provide)
- SoftwareVbusDetect(true, true) so USB enumerates without needing a
CLOCK_POWER interrupt handler
- Green LED on P1.03 via SimpleLed (RAK19007 Base LED1, active HIGH)
- driver/mod.rs: open simple_led feature gate for rak_wisblock_4631
- Spim::new pin-order comment on SPI setup (arg order is sck/miso/mosi,
not sck/mosi/miso — easy to get wrong)
debug-checkpoint instrumentation (feature-gated, zero cost in release):
- radio.rs: debug_blink::set(N) at each TX-phase boundary, filling in
the long-aspirational blink-map doc
- debug_blink.rs: checkpoint 19 between prepare_for_cad and cad(), so
future bring-ups can distinguish modem-setup hang from DIO1-wait hang
justfile + mise.toml:
- set shell := ["mise", "exec", "--", "sh", "-c"] so recipes
always resolve tools through mise, not whatever happens to be on PATH
- replace per-board := vars + long if/elif _info chain with a single
case statement — one place per board, two lists (names + metadata)
- new flash-dfu recipe using adafruit-nrfutil via uvx, with VID:PID
(239a:0029) port autodetect for the Adafruit nRF52 bootloader
- mise.toml: rust pins now carry ARM targets + clippy, so 'mise install'
is sufficient bootstrapping (no extra rustup target add steps)
Verified TX on real hardware (RAK19007 Base + RAK4631 Core + RAK1921
OLED, US915 band): ADVERT round-trip with an external receiver.
Extends the v1.1.0 "SET_CONFIG during RX auto-re-arms" invariant to
every failure path that can fire while a client has RX active:
* `Receiving.on(RxFailed)` now transitions to `ResumingRx` instead of
`Idle`. A transient `next_packet` error re-arms the chip rather than
ending the session.
* `TransmittingFromRx.on(TransmitFailed)` now transitions to
`ResumingRx` instead of `Idle`. TX failure during RX still responds
`TX_FAILED` to the caller, then returns to RX — matching the shape
of the adjacent `ChannelBusy` handler, which already did this.
* `ResumingRx.on(StartRxFailed)` is now a bounded retry (5 attempts,
25 ms backoff) rather than an immediate drop to `Idle`. Only after
the retry budget is exhausted does the chip actually land in `Idle`,
at which point it's genuinely wedged and needs reconfiguring.
Observable effect: the OLED dashboard no longer bounces to Splash
when a client (e.g. ai-bot) TXes while bridges are receiving.
Previously any single hiccup in the RX/TX/ResumeRX cycle would
announce `RadioEvent::Idle`, bouncing the OLED, and the radio would
sit in Idle with no client aware enough to re-issue RX_START —
stranding the session. The invariant should have held for the whole
RX window between `CMD_RX_START` and `CMD_RX_STOP`; now it does.
Implementation notes:
* Two new internal `RadioInput` variants: `ResumeRxRetry` drives the
`ResumingRx` self-transition; `ResumeRxExhausted` breaks out to
Idle after too many failed tries.
* New `resume_retries: u8` field on `RadioContext`, reset to 0 on
every successful `StartRxOk`, bumped by `bump_resume_retries`.
* Constants `MAX_RESUME_RETRIES = 5` and `RESUME_RX_RETRY_BACKOFF_MS
= 25` at the top of `radio.rs`.
* Removed the dead `announce_rx_failed` helper — its only caller was
the now-deleted `Receiving.on(RxFailed) => Idle` edge.
No behavior change for the three paths where `Idle` remains the
correct landing state: initial `CmdRxStart` failure in `StartingRx`,
`CmdRxStop` in `Receiving`, and all three `TransmittingFromIdle`
outcomes (where RX wasn't running to begin with).
Inside state Receiving, override the parent Configured state chart handler on(ConfigApplied) => Idle with on(ConfigApplied) => ResumingRx. ResumingRx entry (try_start_rx_hw) re-enables RX on the chip with the new modulation once apply_set_config has finished the SPI write + calibration, and transitions back to Receiving on StartRxOk.
Net effect: SET_CONFIG issued while the radio is in continuous RX no longer kicks the radio (and therefore the OLED) out to idle. The RX ring is still cleared by apply_set_config; callers never need to re-issue RX_START after SET_CONFIG.
This matches donglora-protocol 1.1.0 §3.5 and §6.3 normative behavior.
Wire-incompatible break with the 0.x protocol. Every host-side path (UART, USB CDC, framing, radio dispatcher, display update channel) speaks the v2 wire format defined in PROTOCOL.md.
* Replace the host framing path with streaming FrameDecoder + encode_frame from donglora-protocol, matching the new (type_id, tag, payload) shape.
* Update src/channel.rs and src/host/{uart,usb,framing}.rs for the new frame semantics: per-request tag echo, CRC checked in the decoder, async ERR fan-out on tag 0.
* Rework src/radio.rs to emit RX / TX_DONE / OK / ERR messages in v2 shape, including RxOrigin, TxDonePayload, and SetConfigResult codes.
* Update src/protocol.rs glue to re-export v2 types from donglora-protocol and drop the legacy 0.x shim surface.
* Refresh PROTOCOL.md to the v2 normative spec (kept in sync with the donglora-protocol crate).
* Minor display and main-loop cleanups needed for the new radio event shape.
Finishes the hsmc migration that was staged across the prior two
commits:
- Radio task is now a single `machine.run().await`; all I/O lives in
state-scoped `during:` activities (`next_command`, `next_packet`,
`perform_tx`). The old `push/drain` helpers, the imperative
top-level `select`, `handle_cmd`, `handle_rx_result`, and
`execute_transmit` are gone. `StartingRx` / `ResumingRx` intermediate
states model the host-visible difference between a responding and a
silent RX re-arm.
- Host/USB task is now an hsmc statechart (Disconnected/Connected)
with entry/exit actions handling display wake, radio StopRx, and
display reset — replacing the open-coded `was_connected` edge
detector. The `protocol_loop` keeps its imperative read path because
`read_packet` blocks.
- New `debug-checkpoint` cargo feature ships a dedicated
`debug_blink.rs` task and a shared atomic counter that code along
the boot/dispatch/TX path bumps at notable points. A ladder-latching
write policy preserves the deepest TX phase reached on the first
attempt until reboot — diagnostic output for UART-only boards where
defmt would corrupt the host protocol. Zero footprint when the
feature is off.
- Silence a stray `clippy::unit_arg` on `led_off` / `led_blip` in the
display task for boards with `LedDriver = ()`.
Replaces the imperative Mode enum + if-branches in the radio loop
with a declarative hsmc statechart mirroring the display:
Radio
├── Unconfigured
└── Configured
├── Idle
├── Receiving
└── Transmitting
├── FromIdle
└── FromRx
The `TransmittingFrom{Idle,Rx}` substates encode "where to return after
TX" as state identity, killing the `was_receiving` bookkeeping. Entry
actions announce `RadioEvent`s on the display channel; RF I/O stays in
the task loop because `rx_once` blocks indefinitely and cannot live
inside an action. Radio-init failure runs a Ping-only degraded loop
without ever instantiating the machine.
Replaces the ad-hoc display loop with a declarative hsmc statechart
(Off/On.{Splash.{LearnMore,Info}, Dashboard.{Listening,Transmitting}})
and swaps the Radio→Display channel from a RadioStatus watch to a
typed RadioEvent queue. The radio task now emits discrete mode and
packet events instead of publishing full status snapshots; the
display consumes them through the statechart, which also handles the
5 s splash alternation and 1 s sparkline tick via `on(after …)` /
`on(every …)` transitions.
Also:
- Add hsmc dep and embassy/tokio feature shims (hsmc proc macro
evaluates `cfg(feature = "embassy")` in this crate).
- Fix USB disconnect path in `host/usb.rs` so the DTR branch always
runs on read error (was early-continuing, starving display/radio
reset).
- Set disconnected flag on DisplayCommand::Reset to block stale radio
status updates from re-rendering dashboard over splash screen
- Send StopRx to radio when USB DTR drops so radio stops generating
packets after host disconnects
- Use try_send for unsolicited responses (RxPacket, error recovery,
StopRx Ok) to prevent radio task deadlock on full response channel
- Fall back to LED-only event loop when display init fails instead of
returning early and orphaning the LED
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Flattened radio/ directory to radio.rs (was just one file)
- Flattened display/ to display.rs (combined task + rendering as inline module)
- Moved SH1106 display driver reference from display/ to driver/ (done in Phase 2)
- Suppressed dead_code warnings for conditional USB/UART types in hal
- All boards: zero warnings, zero cfg in application code