From 9c51c10e7ee1cfaa29ce88224b318c77bd986539 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Thu, 23 Apr 2026 11:14:09 -0600 Subject: [PATCH] chore: release 1.1.0 --- CHANGELOG.md | 22 ++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- src/connect.rs | 192 ++++++++++++++++++++++++++++++++++++++++++++++++- src/dongle.rs | 1 + src/errors.rs | 8 +++ 6 files changed, 223 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index db2eae7..957816b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # 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 diff --git a/Cargo.lock b/Cargo.lock index c4b9064..7b770fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -71,7 +71,7 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "donglora-client" -version = "1.0.2" +version = "1.1.0" dependencies = [ "anyhow", "donglora-protocol", diff --git a/Cargo.toml b/Cargo.toml index 0ca0cd5..f12c9e5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "donglora-client" -version = "1.0.2" +version = "1.1.0" edition = "2024" rust-version = "1.85" authors = ["Stephen Waits "] diff --git a/src/connect.rs b/src/connect.rs index 557d929..1565c26 100644 --- a/src/connect.rs +++ b/src/connect.rs @@ -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( 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( 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 { + 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 { + 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 = (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(_))); + } +} diff --git a/src/dongle.rs b/src/dongle.rs index 1072e0e..9630ae5 100644 --- a/src/dongle.rs +++ b/src/dongle.rs @@ -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()), } } diff --git a/src/errors.rs b/src/errors.rs index bc9c8c5..82795fc 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -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}")]