2 Commits
Author SHA1 Message Date
swaits 9c51c10e7e chore: release 1.1.0 2026-04-23 11:24:51 -06:00
swaits 2b3d353c7a chore: release 1.0.2 2026-04-22 20:51:17 -06:00
6 changed files with 235 additions and 6 deletions

No files matched your search

+32
View File
@@ -1,5 +1,37 @@
# Changelog
## 1.1.0 — 2026-04-23
### Added
- `connect()` now validates and auto-adjusts the requested LoRa
config against the device's advertised caps (`GET_INFO`), matching
the behaviour just landed in client-py 1.1.0:
- `tx_power_dbm` is silently clamped into
`[tx_power_min_dbm, tx_power_max_dbm]`. Clamps are logged at
INFO via `tracing`. `ConnectOptions::config(cfg)` now "just works"
on boards with lower PA ceilings (e.g. SX1276 @ 20 dBm vs
SX1262 @ 22 dBm) with no per-board bookkeeping.
- `freq_hz` outside the device's range, `sf` not in
`supported_sf_bitmap`, or `bw` not in `supported_bw_bitmap` now
return `ClientError::ConfigNotSupported` *before* `SET_CONFIG`
hits the wire. Silent shifts here would cross regulatory
boundaries or change airtime/sensitivity without the caller
noticing.
- `Dongle::config()` continues to reflect `SetConfigResult.current`
— the modulation the device actually stored, post-clamp.
- New `ClientError::ConfigNotSupported { reason }` variant.
## 1.0.2 — 2026-04-23
### Changed
- `donglora-protocol` dependency spec loosened from `"1.0.0"` to `"1"`.
The resolver already accepted any `1.x.y` (both specs mean `^1.0.0`),
but the `"1"` form signals intent: any 1.x release of the protocol
crate is accepted without requiring a fresh `donglora-client`
publication. Behavior unchanged.
## 1.0.1 — 2026-04-22
### Fixed
Generated
+1 -1
View File
@@ -71,7 +71,7 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "donglora-client"
version = "1.0.1"
version = "1.1.0"
dependencies = [
"anyhow",
"donglora-protocol",
+3 -3
View File
@@ -1,6 +1,6 @@
[package]
name = "donglora-client"
version = "1.0.1"
version = "1.1.0"
edition = "2024"
rust-version = "1.85"
authors = ["Stephen Waits <steve@waits.net>"]
@@ -13,7 +13,7 @@ categories = ["hardware-support"]
exclude = ["CLAUDE_TODO.md", "deny.toml", "justfile", "mise.toml", "rustfmt.toml", ".jj"]
[dependencies]
donglora-protocol = { version = "1.0.0", default-features = false, features = ["std"] }
donglora-protocol = { version = "1", default-features = false, features = ["std"] }
thiserror = "2"
tokio = { version = "1", features = ["rt", "rt-multi-thread", "sync", "time", "macros", "io-util", "net"] }
tokio-serial = "5"
@@ -27,7 +27,7 @@ anyhow = "1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# Direct access to raw wire primitives for tests (frame encoder/decoder
# helpers that are not part of the donglora-client public API).
donglora-protocol = { version = "1.0.0", default-features = false, features = ["std"] }
donglora-protocol = { version = "1", default-features = false, features = ["std"] }
heapless = "0.9"
[[example]]
+190 -2
View File
@@ -23,7 +23,7 @@ use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use donglora_protocol::Modulation;
use donglora_protocol::{Info, LoRaConfig, Modulation};
use tracing::{debug, info};
use crate::discovery;
@@ -380,7 +380,8 @@ async fn finalize<T: Transport>(
let applied = if opts.auto_configure {
match opts.config {
Some(m) => {
let result = session.set_config(m, timeout).await?;
let prepared = prepare_config(&info, m)?;
let result = session.set_config(prepared, timeout).await?;
Some(result.current)
}
None => None,
@@ -391,3 +392,190 @@ async fn finalize<T: Transport>(
Ok(Dongle::new(session, info, kind, applied, opts.keepalive))
}
/// Validate and auto-adjust `modulation` against the device's advertised caps.
///
/// Per-field policy:
///
/// * `tx_power_dbm`: clamped into `[tx_power_min_dbm, tx_power_max_dbm]`.
/// A clamp is logged at INFO — "give me max power" quietly returning
/// less is the universally-expected behaviour and not worth a hard error.
/// * `freq_hz`: rejected with [`ClientError::ConfigNotSupported`] when
/// outside `[freq_min_hz, freq_max_hz]`. Silently shifting a 915 MHz
/// request to 868 MHz (or vice versa) would cross regulatory boundaries.
/// * `sf`, `bw`: rejected with [`ClientError::ConfigNotSupported`] when
/// the corresponding capability bit isn't set. These change airtime and
/// sensitivity dramatically; silent substitution is more confusing
/// than helpful.
///
/// Non-LoRa modulations pass through untouched — the firmware rejects
/// unsupported modulation IDs with `EMODULATION` on its own.
pub(crate) fn prepare_config(info: &Info, modulation: Modulation) -> ClientResult<Modulation> {
let Modulation::LoRa(cfg) = modulation else {
return Ok(modulation);
};
Ok(Modulation::LoRa(prepare_lora_config(info, cfg)?))
}
fn prepare_lora_config(info: &Info, mut cfg: LoRaConfig) -> ClientResult<LoRaConfig> {
if cfg.freq_hz < info.freq_min_hz || cfg.freq_hz > info.freq_max_hz {
return Err(ClientError::ConfigNotSupported {
reason: format!(
"frequency {} Hz outside device range [{}, {}] Hz",
cfg.freq_hz, info.freq_min_hz, info.freq_max_hz
),
});
}
if info.supported_sf_bitmap & (1u16 << cfg.sf) == 0 {
let supported: Vec<u8> = (0u8..16).filter(|i| info.supported_sf_bitmap & (1u16 << i) != 0).collect();
return Err(ClientError::ConfigNotSupported {
reason: format!("SF{} not supported by this device (supports SF{:?})", cfg.sf, supported),
});
}
let bw_bit = cfg.bw.as_u8();
if info.supported_bw_bitmap & (1u16 << bw_bit) == 0 {
return Err(ClientError::ConfigNotSupported {
reason: format!(
"bandwidth {:?} (bit {}) not in supported_bw_bitmap 0x{:04X}",
cfg.bw, bw_bit, info.supported_bw_bitmap
),
});
}
if cfg.tx_power_dbm > info.tx_power_max_dbm {
info!(requested = cfg.tx_power_dbm, device_max = info.tx_power_max_dbm, "clamping tx_power_dbm to device max");
cfg.tx_power_dbm = info.tx_power_max_dbm;
} else if cfg.tx_power_dbm < info.tx_power_min_dbm {
info!(requested = cfg.tx_power_dbm, device_min = info.tx_power_min_dbm, "clamping tx_power_dbm to device min");
cfg.tx_power_dbm = info.tx_power_min_dbm;
}
Ok(cfg)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::panic)]
mod tests {
use super::*;
use donglora_protocol::{
FskConfig, LoRaBandwidth, LoRaCodingRate, LoRaHeaderMode, MAX_MCU_UID_LEN, MAX_RADIO_UID_LEN, RadioChipId,
};
fn info(tx_min: i8, tx_max: i8, freq_min: u32, freq_max: u32, sf_bm: u16, bw_bm: u16) -> Info {
Info {
proto_major: 1,
proto_minor: 0,
fw_major: 0,
fw_minor: 0,
fw_patch: 0,
radio_chip_id: RadioChipId::Sx1262.as_u16(),
capability_bitmap: donglora_protocol::cap::LORA,
supported_sf_bitmap: sf_bm,
supported_bw_bitmap: bw_bm,
max_payload_bytes: 255,
rx_queue_capacity: 32,
tx_queue_capacity: 1,
freq_min_hz: freq_min,
freq_max_hz: freq_max,
tx_power_min_dbm: tx_min,
tx_power_max_dbm: tx_max,
mcu_uid_len: 0,
mcu_uid: [0u8; MAX_MCU_UID_LEN],
radio_uid_len: 0,
radio_uid: [0u8; MAX_RADIO_UID_LEN],
}
}
fn lora(freq_hz: u32, sf: u8, bw: LoRaBandwidth, tx_power_dbm: i8) -> LoRaConfig {
LoRaConfig {
freq_hz,
sf,
bw,
cr: LoRaCodingRate::Cr4_5,
preamble_len: 8,
sync_word: 0x3444,
tx_power_dbm,
header_mode: LoRaHeaderMode::Explicit,
payload_crc: true,
iq_invert: false,
}
}
const SUB_GHZ_ALL_SF: u16 = 0x1FE0; // SF5..SF12
const SX127X_SF: u16 = 0x1FC0; // SF6..SF12
const SUB_GHZ_BW: u16 = 0x03FF; // BW 0..9
#[test]
fn tx_power_above_max_clamps_down() {
let i = info(-9, 20, 863_000_000, 928_000_000, SUB_GHZ_ALL_SF, SUB_GHZ_BW);
let cfg = lora(915_000_000, 7, LoRaBandwidth::Khz125, 30);
let Modulation::LoRa(out) = prepare_config(&i, Modulation::LoRa(cfg)).unwrap() else {
panic!("expected LoRa");
};
assert_eq!(out.tx_power_dbm, 20);
assert_eq!(out.freq_hz, 915_000_000);
}
#[test]
fn tx_power_below_min_clamps_up() {
let i = info(2, 20, 863_000_000, 928_000_000, SUB_GHZ_ALL_SF, SUB_GHZ_BW);
let cfg = lora(915_000_000, 7, LoRaBandwidth::Khz125, -30);
let Modulation::LoRa(out) = prepare_config(&i, Modulation::LoRa(cfg)).unwrap() else {
panic!("expected LoRa");
};
assert_eq!(out.tx_power_dbm, 2);
}
#[test]
fn tx_power_in_range_unchanged() {
let i = info(-9, 22, 863_000_000, 928_000_000, SUB_GHZ_ALL_SF, SUB_GHZ_BW);
let cfg = lora(915_000_000, 7, LoRaBandwidth::Khz125, 17);
let Modulation::LoRa(out) = prepare_config(&i, Modulation::LoRa(cfg)).unwrap() else {
panic!("expected LoRa");
};
assert_eq!(out.tx_power_dbm, 17);
}
#[test]
fn freq_out_of_range_rejected() {
let i = info(-9, 22, 863_000_000, 928_000_000, SUB_GHZ_ALL_SF, SUB_GHZ_BW);
let cfg = lora(300_000_000, 7, LoRaBandwidth::Khz125, 14);
let err = prepare_config(&i, Modulation::LoRa(cfg)).unwrap_err();
assert!(matches!(err, ClientError::ConfigNotSupported { ref reason } if reason.contains("frequency")));
}
#[test]
fn sf5_rejected_on_sx127x_bitmap() {
let i = info(2, 20, 863_000_000, 1_020_000_000, SX127X_SF, SUB_GHZ_BW);
let cfg = lora(915_000_000, 5, LoRaBandwidth::Khz125, 14);
let err = prepare_config(&i, Modulation::LoRa(cfg)).unwrap_err();
assert!(matches!(err, ClientError::ConfigNotSupported { ref reason } if reason.contains("SF5")));
}
#[test]
fn bw_not_in_bitmap_rejected() {
let i = info(-9, 22, 863_000_000, 928_000_000, SUB_GHZ_ALL_SF, SUB_GHZ_BW);
// Bw200 is bit 10 — SX128x only. Sub-GHz bitmap rejects it.
let cfg = lora(915_000_000, 7, LoRaBandwidth::Khz200, 14);
let err = prepare_config(&i, Modulation::LoRa(cfg)).unwrap_err();
assert!(matches!(err, ClientError::ConfigNotSupported { ref reason } if reason.contains("bandwidth")));
}
#[test]
fn non_lora_modulation_passes_through() {
let i = info(-9, 22, 863_000_000, 928_000_000, SUB_GHZ_ALL_SF, SUB_GHZ_BW);
let fsk = FskConfig {
freq_hz: 50_000_000, // well outside info range — must not be inspected
bitrate_bps: 50_000,
freq_dev_hz: 25_000,
rx_bw: 0,
preamble_len: 16,
sync_word_len: 0,
sync_word: [0u8; 8],
};
let out = prepare_config(&i, Modulation::FskGfsk(fsk)).unwrap();
assert!(matches!(out, Modulation::FskGfsk(_)));
}
}
+1
View File
@@ -400,6 +400,7 @@ impl ClientError {
Self::BadFrame(s) => Self::BadFrame(s.clone()),
Self::EncodeFailed(s) => Self::EncodeFailed(s.clone()),
Self::Io(e) => Self::Other(format!("io: {e}")),
Self::ConfigNotSupported { reason } => Self::ConfigNotSupported { reason: reason.clone() },
Self::Other(s) => Self::Other(s.clone()),
}
}
+8
View File
@@ -106,6 +106,14 @@ pub enum ClientError {
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
/// The requested config contains a field the device cannot satisfy.
/// Raised client-side, before `SET_CONFIG` hits the wire, for fields
/// that are *not* safe to silently auto-adjust (`freq_hz`, `sf`, `bw`).
/// `tx_power_dbm` is clamped transparently instead — "give me max"
/// returning less is universally expected and not worth a hard error.
#[error("config not supported by device: {reason}")]
ConfigNotSupported { reason: String },
/// Catch-all for transport initialisation issues that don't have a
/// dedicated variant.
#[error("{0}")]