4 Commits
Author SHA1 Message Date
swaits d1a23814e2 chore: repoint URLs at git.swaits.com and bump for release
Rewrites remaining github.com / git.sr.ht references to git.swaits.com,
translating GitHub URL shapes to Gitea's (/blob/<ref>/ -> /src/branch/<ref>/,
/tree/<tag> -> /src/tag/<tag>, /actions/workflows/X -> /actions?workflow=X,
/edit/ -> /_edit/).

Left pointing at GitHub on purpose: pull-request links (PRs were not migrated)
and GitHub-only surfaces Gitea lacks (security advisories, discussions).
2026-08-31 17:58:29 -06:00
swaits 5de24d44c9 chore: point repository at git.swaits.com 2026-08-31 17:19:32 -06:00
swaits aa6572e104 chore: release 1.1.1 2026-05-11 14:56:43 -06:00
swaits 9c51c10e7e chore: release 1.1.0 2026-04-23 11:24:51 -06:00
6 changed files with 247 additions and 16 deletions

No files matched your search

+34
View File
@@ -1,5 +1,39 @@
# Changelog
## 1.1.1 — 2026-05-11
### Changed
- Refreshed `Cargo.lock` to pull in the latest compatible patch
releases of dependencies:
- `donglora-protocol` 1.0.0 → 1.2.0 (range `"1"` unchanged)
- `tokio` 1.52.1 → 1.52.3
- `libc` 0.2.184 → 0.2.186
- `bitflags` 2.11.0 → 2.11.1
No source changes; public API is unchanged.
## 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
Generated
+11 -11
View File
@@ -25,9 +25,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.11.0"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
[[package]]
name = "byteorder"
@@ -71,7 +71,7 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "donglora-client"
version = "1.0.2"
version = "1.1.1"
dependencies = [
"anyhow",
"donglora-protocol",
@@ -87,9 +87,9 @@ dependencies = [
[[package]]
name = "donglora-protocol"
version = "1.0.0"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8f2743ac7668845500e3686890917cdf5b0a09d17ec099bde2db4d3fae2a4a4"
checksum = "0eea2a87a5ad31629140accc2bae6c931434bf1946706f2184dec9b3d356505d"
dependencies = [
"heapless",
"ucobs",
@@ -232,9 +232,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "libc"
version = "0.2.184"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "log"
@@ -308,7 +308,7 @@ version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
dependencies = [
"bitflags 2.11.0",
"bitflags 2.11.1",
"cfg-if",
"cfg_aliases",
"libc",
@@ -426,7 +426,7 @@ version = "4.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4d91116f97173694f1642263b2ff837f80d933aa837e2314969f6728f661df3"
dependencies = [
"bitflags 2.11.0",
"bitflags 2.11.1",
"cfg-if",
"core-foundation",
"core-foundation-sys",
@@ -517,9 +517,9 @@ dependencies = [
[[package]]
name = "tokio"
version = "1.52.1"
version = "1.52.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6"
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
dependencies = [
"bytes",
"libc",
+3 -3
View File
@@ -1,13 +1,13 @@
[package]
name = "donglora-client"
version = "1.0.2"
version = "1.1.2"
edition = "2024"
rust-version = "1.85"
authors = ["Stephen Waits <steve@waits.net>"]
license = "MIT"
description = "DongLoRa host library — connect, configure, send/receive LoRa packets (DongLoRa Protocol v2)"
repository = "https://github.com/donglora/client-rs"
homepage = "https://github.com/donglora/client-rs"
repository = "https://git.swaits.com/donglora/client-rs"
homepage = "https://git.swaits.com/donglora/client-rs"
keywords = ["lora", "radio", "usb", "serial"]
categories = ["hardware-support"]
exclude = ["CLAUDE_TODO.md", "deny.toml", "justfile", "mise.toml", "rustfmt.toml", ".jj"]
+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}")]