4 Commits
Author SHA1 Message Date
swaits e9360c28ba chore: release 1.5.1 2026-05-11 11:38:38 -06:00
swaits d7a1ee86d0 fix(radio): cache mod params on SET_CONFIG so TX works without prior RX
The TX state machine's `prep_for_cad_act` (TxCadPrep entry) and
`prep_for_tx_act` (TxPrep entry) both read `ctx.mdltn` and emit
`TransmitFailed` → `ERR(ERadio)` if it's None. But `ctx.mdltn` was
only ever assigned on the RX path — `try_start_rx_hw` (RxArming entry)
and `cache_rx_pkt` (RxSession entry). `reconfigure_radio` already
called `to_lora_mod_params(lora, cfg)` during SET_CONFIG but discarded
the result via `let _ = …?;` (comment claimed image calibration; the
function is pure compute on SX126x).

Result: a freshly-booted dongle that issues TX before ever calling
RX_START gets `ERR(ERadio)` on the first CmdTx because the chart's
TX-entry actions find `ctx.mdltn = None`. The bug was hidden in
practice by any prior RX_START (ai-bot, ping_pong --role rx, etc.)
leaving `ctx.mdltn` populated across sessions until the next reflash.

Fix: thread the `ModulationParams` out of `reconfigure_radio` as its
return value, and have `apply_set_config` cache it in `self.mdltn`
alongside `self.config`. The failure path clears `self.mdltn` along
with `self.config` so a SET_CONFIG that fails leaves a clean
Unconfigured state.

Surfaced while testing the matching sync-word fix on freshly-flashed
dongles via `examples/sync_word_test.py` — the first TX failed with
`ERR(ERadio)` until this path was wired up.
2026-05-11 11:15:30 -06:00
swaits 0b9f9ee882 fix(radio): apply LoRaConfig.sync_word to the chip on SET_CONFIG
`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.
2026-05-11 11:10:47 -06:00
swaits 04dc58c943 chore(deps): bump hsmc 0.5 -> 0.6
0.6.0 is a perf pass + bench harness migration; chart syntax and
public API are unchanged. All 10 board features check clean.
2026-05-11 10:49:32 -06:00
4 changed files with 116 additions and 38 deletions

No files matched your search

+58
View File
@@ -7,6 +7,64 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [1.5.1] - 2026-05-11
### Fixed
- **`LoRaConfig.sync_word` is now actually applied to the SX126x.**
`reconfigure_radio` accepted the host's `sync_word` field in name
only — the value was stored in `self.config` but never written to
the chip's `LoRaSyncwordMSB`/`LoRaSyncwordLSB` register pair
(0x0740/0x0741, SX1261/2 datasheet Rev 2.2 §13.4.4). The chip sat
on whatever `LoRa::new(_, false, _)` set at cold init —
`LORAWAN_PRIVATE_SYNCWORD` (byte `0x12`, register pair `0x1424`).
Everyone got byte `0x12` regardless of the requested value. Worked
by accident for MeshCore (which uses byte `0x12`); silently broke
any host trying to talk to a non-MeshCore network. Fix wires
`cfg.sync_word` through to `lora.set_lora_sync_word(byte)` after
`enter_standby`, using the new lora-phy API from
[lora-rs/lora-rs#433](https://github.com/lora-rs/lora-rs/pull/433).
The `u16` protocol field is decoded back to its underlying byte
(`byte = (hi & 0xF0) | (lo >> 4)` after `to_be_bytes()`), the
inverse of lora-phy's `convert_sync_word` nibble-expansion. Honors
SX1261/2 §8.3.1 (BUSY-low precondition) via the existing
`ensure_ready + conditional set_standby` preamble in
`LoRa::set_lora_sync_word`.
- **First TX after `SET_CONFIG` no longer fails with `ERR(ERadio)` on
a freshly-booted chip.** The chart's `prep_for_cad_act`
(`TxCadPrep` entry) and `prep_for_tx_act` (`TxPrep` entry) both
read `ctx.mdltn` and emit `TransmitFailed` → `ERR(ERadio)` if it's
`None`. But `ctx.mdltn` was only ever assigned on the RX path —
`try_start_rx_hw` (`RxArming` entry) and `cache_rx_pkt`
(`RxSession` entry). `reconfigure_radio` already called
`to_lora_mod_params(lora, cfg)` during `SET_CONFIG` but discarded
the result via `let _ = …?;` (the dropped comment claimed image
calibration; the function is pure compute on SX126x). Hidden in
practice by any prior `RX_START` (ai-bot, `ping_pong --role rx`,
etc.) leaving `ctx.mdltn` populated until the next reflash. Fix
threads the `ModulationParams` out of `reconfigure_radio` as its
return value, and `apply_set_config` caches it in `self.mdltn`
alongside `self.config`. First TX on a fresh boot now works
without a preceding `RX_START`.
### Changed
- **`lora-phy` `swaits/lora-rs#fixed` bumped from `e8a52ebd` →
`043d7659`.** The fork's `fixed` merge now includes a new
`feat/set-sync-word` branch alongside the three pre-existing
`fix/*` branches.
[lora-rs/lora-rs#433](https://github.com/lora-rs/lora-rs/pull/433)
adds the runtime `LoRa::set_lora_sync_word(u8)` API the radio fix
above depends on (was unavailable in stock lora-phy; the only
public sync-word entry points were `with_syncword` /
`new(_, public_network: bool, _)` at construction time). Strictly
additive in lora-phy: pure addition, no behavioral change to
`init_lora` / `with_syncword` / any existing path.
- **`hsmc` 0.5 → 0.6.** Patch bump tracked via `cargo update -p
hsmc`; no behavior change in this crate's usage. Pulled in
separately from the radio fixes so the dep churn doesn't blur the
fix commits.
## [1.5.0] - 2026-04-30
### Added
Generated
+10 -10
View File
@@ -586,7 +586,7 @@ dependencies = [
[[package]]
name = "donglora"
version = "1.5.0"
version = "1.5.1"
dependencies = [
"cfg-if",
"cortex-m",
@@ -1592,9 +1592,9 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hsmc"
version = "0.5.1"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "497ed9593e4baf6ef120b24aff80cb898f6832d32cdf01be9d90be0c06624e77"
checksum = "18bb06fda289e60e17bc7483a222fc6d9d6865b92648f836242b7a79115cc91d"
dependencies = [
"defmt 0.3.100",
"embassy-futures",
@@ -1606,9 +1606,9 @@ dependencies = [
[[package]]
name = "hsmc-macros"
version = "0.5.1"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49d0b1413ed69ba168f9d0edbf4a8ea21569eaf352edf4e11335ae3d765ba004"
checksum = "289a7c9f1b677168229c8ad4e992380c3951d0908b52b1d2bfae8ff3c980c35d"
dependencies = [
"proc-macro2",
"quote",
@@ -1812,7 +1812,7 @@ dependencies = [
[[package]]
name = "lora-modulation"
version = "0.1.5"
source = "git+https://github.com/swaits/lora-rs.git?branch=fixed#e8a52ebd35352ce0267148fd392a0b1956d7735f"
source = "git+https://github.com/swaits/lora-rs.git?branch=fixed#043d7659dfe2148913f16b2f7ea3e9241642b770"
dependencies = [
"defmt 0.3.100",
]
@@ -1820,7 +1820,7 @@ dependencies = [
[[package]]
name = "lora-phy"
version = "3.0.2-alpha"
source = "git+https://github.com/swaits/lora-rs.git?branch=fixed#e8a52ebd35352ce0267148fd392a0b1956d7735f"
source = "git+https://github.com/swaits/lora-rs.git?branch=fixed#043d7659dfe2148913f16b2f7ea3e9241642b770"
dependencies = [
"defmt 0.3.100",
"document-features",
@@ -1834,7 +1834,7 @@ dependencies = [
[[package]]
name = "lorawan"
version = "0.9.1"
source = "git+https://github.com/swaits/lora-rs.git?branch=fixed#e8a52ebd35352ce0267148fd392a0b1956d7735f"
source = "git+https://github.com/swaits/lora-rs.git?branch=fixed#043d7659dfe2148913f16b2f7ea3e9241642b770"
dependencies = [
"aes",
"cmac",
@@ -1846,7 +1846,7 @@ dependencies = [
[[package]]
name = "lorawan-device"
version = "0.12.2"
source = "git+https://github.com/swaits/lora-rs.git?branch=fixed#e8a52ebd35352ce0267148fd392a0b1956d7735f"
source = "git+https://github.com/swaits/lora-rs.git?branch=fixed#043d7659dfe2148913f16b2f7ea3e9241642b770"
dependencies = [
"defmt 0.3.100",
"document-features",
@@ -1861,7 +1861,7 @@ dependencies = [
[[package]]
name = "lorawan-macros"
version = "0.1.0"
source = "git+https://github.com/swaits/lora-rs.git?branch=fixed#e8a52ebd35352ce0267148fd392a0b1956d7735f"
source = "git+https://github.com/swaits/lora-rs.git?branch=fixed#043d7659dfe2148913f16b2f7ea3e9241642b770"
dependencies = [
"proc-macro2",
"quote",
+2 -2
View File
@@ -3,7 +3,7 @@ members = ["."]
[package]
name = "donglora"
version = "1.5.0"
version = "1.5.1"
edition = "2021"
build = "build.rs"
authors = ["Stephen Waits <steve@waits.net>"]
@@ -29,7 +29,7 @@ embassy-usb-driver = "0.2"
ucobs = { version = "0.3", features = ["legacy-msrv"] }
heapless = "0.9"
donglora-protocol = "1"
hsmc = { version = "0.5", default-features = false, features = ["embassy", "trace-defmt"] }
hsmc = { version = "0.6", default-features = false, features = ["embassy", "trace-defmt"] }
# Fork branch carrying CAD IRQ-clear / standby-after-CAD fixes awaiting upstream PRs.
# Switch back to crates.io once changes are upstreamed.
lora-phy = { git = "https://github.com/swaits/lora-rs.git", branch = "fixed", features = ["defmt-03"] }
+46 -26
View File
@@ -1252,29 +1252,33 @@ impl RadioActions for RadioActionContext<'_> {
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;
// 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);
let mdltn = match recfg_result {
Ok(m) => m,
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;
// 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;
}
return;
}
};
self.config = Some(cfg);
self.mdltn = Some(mdltn);
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 {
@@ -2094,12 +2098,28 @@ fn to_lora_mod_params(
lora.create_modulation_params(sf, bw, cr, cfg.freq_hz)
}
async fn reconfigure_radio(lora: &mut LoRaDriver, cfg: &LoRaConfig) -> Result<(), RadioError> {
async fn reconfigure_radio(
lora: &mut LoRaDriver,
cfg: &LoRaConfig,
) -> Result<lora_phy::mod_params::ModulationParams, RadioError> {
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)?;
Ok(())
// Apply the host-supplied LoRa sync word. The protocol field is a
// u16 holding the SX126x register pair in big-endian order
// (RadioLib convention: 0x1424 == byte 0x12 == LoRa "private" /
// MeshCore; 0x3444 == byte 0x34 == LoRaWAN "public"). lora-phy's
// set_lora_sync_word takes the underlying single-byte form, so
// undo the nibble encoding: convert_sync_word(byte) puts the
// chip's mandatory 0x4 in the low nibble of each register byte,
// and the high nibble of each register byte holds the original
// byte's high/low nibble respectively.
let [hi, lo] = cfg.sync_word.to_be_bytes();
let sync_byte = (hi & 0xF0) | (lo >> 4);
lora_setup(
"reconfigure_radio.set_lora_sync_word",
lora.set_lora_sync_word(sync_byte),
)
.await?;
to_lora_mod_params(lora, cfg)
}
fn validate_lora(cfg: &LoRaConfig, info: &Info) -> Result<(), ErrorCode> {