2 Commits
Author SHA1 Message Date
swaits 155c641012 chore: release 1.1.0
CI / lint (push) Canceled after 0s
CI / test (3.10) (push) Canceled after 0s
CI / test (3.11) (push) Canceled after 0s
CI / test (3.12) (push) Canceled after 0s
CI / test (3.13) (push) Canceled after 0s
CI / publish (push) Canceled after 0s
2026-04-23 11:24:09 -06:00
swaits a338d929ec chore: release 1.0.1
CI / lint (push) Canceled after 0s
CI / test (3.10) (push) Canceled after 0s
CI / test (3.11) (push) Canceled after 0s
CI / test (3.12) (push) Canceled after 0s
CI / test (3.13) (push) Canceled after 0s
CI / publish (push) Canceled after 0s
2026-04-22 19:35:07 -06:00
9 changed files with 353 additions and 13 deletions

No files matched your search

+32
View File
@@ -1,5 +1,37 @@
# Changelog # Changelog
## 1.1.0 — 2026-04-23
### Added
- `connect()` now validates and auto-adjusts the requested
`LoRaConfig` against the device's advertised caps (`GET_INFO`):
- `tx_power_dbm` is silently clamped into
`[tx_power_min_dbm, tx_power_max_dbm]`. A clamp is logged at
INFO. This keeps `dl.connect(config=my_config)` "just work" on
boards with lower PA ceilings (e.g. SX1276 @ 20 dBm vs SX1262
@ 22 dBm) without any per-board bookkeeping by the caller.
- `freq_hz` outside the device's range, `sf` not in
`supported_sf_bitmap`, or `bw` not in `supported_bw_bitmap` now
raise :class:`ConfigNotSupported` *before* `SET_CONFIG` hits
the wire. Silent shifts here would cross regulatory boundaries
or change airtime/sensitivity without the caller noticing.
- `Dongle.config` is now populated from `SetConfigResult.current`
— the modulation the device actually stored, post-clamp — so
callers can inspect what landed.
- New `ConfigNotSupported` exception (subclass of `DongloraError`),
exported at the package root.
## 1.0.1 — 2026-04-22
### Fixed
- `discovery.find_port` / `wait_for_device` now match the WCH CH340K
USB-UART bridge (`1a86:7522`) in `BRIDGE_VID_PIDS`. The Elecrow
ThinkNode-M2 board ships with a CH340K bridge; the previous set
covered CP2102, CH9102, standard CH340, and FTDI but missed this
specific revision, so `discovery` returned `None` on that board.
## 1.0.0 — 2026-04-22 ## 1.0.0 — 2026-04-22
The 1.0 Python client, mirroring the Rust client 1.0 API and the The 1.0 Python client, mirroring the Rust client 1.0 API and the
+2
View File
@@ -66,6 +66,7 @@ from donglora.errors import (
BusyError, BusyError,
Cancelled, Cancelled,
ChannelBusy, ChannelBusy,
ConfigNotSupported,
DeviceError, DeviceError,
DongloraError, DongloraError,
ErrorCode, ErrorCode,
@@ -131,6 +132,7 @@ __all__ = [
"Cancelled", "Cancelled",
"Capability", "Capability",
"ChannelBusy", "ChannelBusy",
"ConfigNotSupported",
"DeviceError", "DeviceError",
"Dongle", "Dongle",
"DongloraError", "DongloraError",
+90 -9
View File
@@ -6,15 +6,21 @@ form it:
1. Finds a DongLoRa USB device (or mux socket if one is available). 1. Finds a DongLoRa USB device (or mux socket if one is available).
2. Opens the transport at 115200 baud. 2. Opens the transport at 115200 baud.
3. Sends ``GET_INFO`` to confirm we're talking DongLoRa Protocol v2. 3. Sends ``GET_INFO`` to confirm we're talking DongLoRa Protocol v2.
4. Applies a sensible default :class:`LoRaConfig` via ``SET_CONFIG``. 4. Applies a sensible default :class:`LoRaConfig` via ``SET_CONFIG``,
auto-clamping ``tx_power_dbm`` against the device's advertised cap
and raising :class:`ConfigNotSupported` for out-of-range frequency,
spreading factor, or bandwidth.
5. Spawns a keepalive thread to keep the session alive. 5. Spawns a keepalive thread to keep the session alive.
6. Returns a :class:`Dongle` ready for ``.tx()`` / ``.rx()``. 6. Returns a :class:`Dongle` ready for ``.tx()`` / ``.rx()``. The
actually-applied config (post-clamp) is exposed on
:attr:`Dongle.config`.
Every step is overrideable via keyword arguments. See ``connect``'s docstring. Every step is overrideable via keyword arguments. See ``connect``'s docstring.
""" """
from __future__ import annotations from __future__ import annotations
import dataclasses
import logging import logging
import os import os
import socket import socket
@@ -25,7 +31,7 @@ import serial
from donglora.discovery import find_port, wait_for_device from donglora.discovery import find_port, wait_for_device
from donglora.dongle import Dongle from donglora.dongle import Dongle
from donglora.errors import DongloraError from donglora.errors import ConfigNotSupported, DongloraError
from donglora.info import Info from donglora.info import Info
from donglora.modulation import LoRaConfig, Modulation from donglora.modulation import LoRaConfig, Modulation
from donglora.session import Session from donglora.session import Session
@@ -131,6 +137,67 @@ def _open_transport(port: str | None, timeout: float) -> Any:
return ser return ser
def _prepare_config(info: Info, config: Modulation) -> Modulation:
"""Validate and auto-adjust *config* 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 behavior and not worth a hard error.
* ``freq_hz``: rejected with :class:`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 :class:`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.
"""
if not isinstance(config, LoRaConfig):
return config
cfg: LoRaConfig = config
if not (info.freq_min_hz <= cfg.freq_hz <= info.freq_max_hz):
raise ConfigNotSupported(
f"frequency {cfg.freq_hz} Hz outside device range "
f"[{info.freq_min_hz}, {info.freq_max_hz}] Hz"
)
if not (info.supported_sf_bitmap & (1 << cfg.sf)):
supported = [i for i in range(16) if info.supported_sf_bitmap & (1 << i)]
raise ConfigNotSupported(
f"SF{cfg.sf} not supported by this device (supports SF{supported})"
)
bw_bit = int(cfg.bw)
if not (info.supported_bw_bitmap & (1 << bw_bit)):
raise ConfigNotSupported(
f"bandwidth {cfg.bw.name} (bit {bw_bit}) not in "
f"supported_bw_bitmap 0x{info.supported_bw_bitmap:04X}"
)
if cfg.tx_power_dbm > info.tx_power_max_dbm:
log.info(
"clamping tx_power_dbm: requested %d dBm, device max %d dBm",
cfg.tx_power_dbm,
info.tx_power_max_dbm,
)
cfg = dataclasses.replace(cfg, tx_power_dbm=info.tx_power_max_dbm)
elif cfg.tx_power_dbm < info.tx_power_min_dbm:
log.info(
"clamping tx_power_dbm: requested %d dBm, device min %d dBm",
cfg.tx_power_dbm,
info.tx_power_min_dbm,
)
cfg = dataclasses.replace(cfg, tx_power_dbm=info.tx_power_min_dbm)
return cfg
def connect( def connect(
port: str | None = None, port: str | None = None,
*, *,
@@ -158,9 +225,16 @@ def connect(
Modulation to apply at connect-time. Default: Modulation to apply at connect-time. Default:
:meth:`LoRaConfig.default`. Pass ``None`` with :meth:`LoRaConfig.default`. Pass ``None`` with
``auto_configure=False`` to skip configuration entirely. ``auto_configure=False`` to skip configuration entirely.
With ``auto_configure=True`` (default), ``tx_power_dbm`` is
silently clamped to the device's ``tx_power_max_dbm`` /
``tx_power_min_dbm``; out-of-range ``freq_hz``, ``sf``, or
``bw`` raise :class:`ConfigNotSupported`. Inspect
:attr:`Dongle.config` after connect to see what the device
actually stored.
auto_configure auto_configure
If False, skip the ``SET_CONFIG`` step. The caller is then If False, skip the ``SET_CONFIG`` step entirely — no
responsible for calling :meth:`Dongle.set_config` before TX/RX. validation, no clamping. The caller is then responsible for
calling :meth:`Dongle.set_config` before TX/RX.
keepalive keepalive
If False, skip the keepalive daemon thread. The caller is If False, skip the keepalive daemon thread. The caller is
responsible for periodic :meth:`Dongle.ping` calls to stay responsible for periodic :meth:`Dongle.ping` calls to stay
@@ -195,12 +269,18 @@ def connect(
applied: Modulation | None = None applied: Modulation | None = None
if auto_configure: if auto_configure:
applied = config if config is not None else LoRaConfig.default() requested: Modulation = config if config is not None else LoRaConfig.default()
try: try:
session.set_config(applied, timeout=max(timeout, 2.0)) prepared = _prepare_config(info, requested)
result = session.set_config(prepared, timeout=max(timeout, 2.0))
except DongloraError: except DongloraError:
session.close() session.close()
raise raise
# Firmware echoes back the modulation it actually stored. Trust
# that rather than our own `prepared` — it's the same value
# today, but stays correct if firmware ever adds its own
# normalization.
applied = result.current if result is not None else prepared
return session, info, applied return session, info, applied
session, info, applied = _open_and_init() session, info, applied = _open_and_init()
@@ -315,10 +395,11 @@ def _session_on(transport: Any) -> Dongle:
raise DongloraError( raise DongloraError(
f"device speaks DongLoRa Protocol v{info.proto_major}.{info.proto_minor}; this client requires v1.x", f"device speaks DongLoRa Protocol v{info.proto_major}.{info.proto_minor}; this client requires v1.x",
) )
applied = LoRaConfig.default()
try: try:
session.set_config(applied) prepared = _prepare_config(info, LoRaConfig.default())
result = session.set_config(prepared)
except DongloraError: except DongloraError:
session.close() session.close()
raise raise
applied = result.current if result is not None else prepared
return Dongle(session, info, applied_config=applied, keepalive=True) return Dongle(session, info, applied_config=applied, keepalive=True)
+7 -1
View File
@@ -18,7 +18,13 @@ USB_VID: int = 0x1209
USB_PID: int = 0x5741 USB_PID: int = 0x5741
USB_VID_PID: str = "1209:5741" USB_VID_PID: str = "1209:5741"
BRIDGE_VID_PIDS: set[str] = {"10c4:ea60", "1a86:55d4", "1a86:7523", "0403:6001"} BRIDGE_VID_PIDS: set[str] = {
"10c4:ea60", # CP2102 (Silicon Labs)
"1a86:55d4", # CH9102
"1a86:7522", # CH340K — Elecrow ThinkNode-M2 ships with this
"1a86:7523", # CH340
"0403:6001", # FT232R (FTDI)
}
"""Known USB-UART bridge VID:PIDs found on some board revisions.""" """Known USB-UART bridge VID:PIDs found on some board revisions."""
+5 -1
View File
@@ -99,7 +99,11 @@ class Dongle:
@property @property
def config(self) -> Modulation | None: def config(self) -> Modulation | None:
"""The most-recently-applied radio config, or ``None`` if none.""" """The radio config the device actually stored on the last
``SET_CONFIG`` (including any post-clamp tx_power adjustment
that :func:`donglora.connect` performed). ``None`` if no
``SET_CONFIG`` has landed yet.
"""
return self._applied_config return self._applied_config
def tx(self, data: bytes, *, skip_cad: bool = False, timeout: float = 10.0) -> TxDone: def tx(self, data: bytes, *, skip_cad: bool = False, timeout: float = 10.0) -> TxDone:
+19
View File
@@ -42,6 +42,25 @@ class DongloraError(Exception):
"""Base class for all DongLoRa client errors.""" """Base class for all DongLoRa client errors."""
class ConfigNotSupported(DongloraError):
"""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`` outside the device's reported
``freq_min_hz..freq_max_hz`` — silently shifting 915 MHz to
868 MHz (or vice versa) crosses regulatory boundaries.
* ``sf`` not in ``supported_sf_bitmap`` — changes airtime and
sensitivity in ways the caller needs to know about.
* ``bw`` not in ``supported_bw_bitmap`` — same as SF.
``tx_power_dbm`` is *not* in this list: it's clamped transparently
because "give me max power" returning less than requested is the
universally expected behavior.
"""
class FrameError(DongloraError): class FrameError(DongloraError):
"""COBS decode, CRC check, or length check failed on an inbound frame.""" """COBS decode, CRC check, or length check failed on an inbound frame."""
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "donglora" name = "donglora"
version = "1.0.0" version = "1.1.0"
description = "DongLoRa host library — talk LoRa from Python over USB" description = "DongLoRa host library — talk LoRa from Python over USB"
license = "MIT" license = "MIT"
requires-python = ">=3.10" requires-python = ">=3.10"
+196
View File
@@ -0,0 +1,196 @@
"""Unit tests for the client-side config preparation done by ``connect()``.
Validates the auto_configure contract:
* ``tx_power_dbm`` clamps silently (both high and low sides).
* ``freq_hz`` / ``sf`` / ``bw`` raise :class:`ConfigNotSupported` when
outside the device's advertised caps — silent substitution would hide
regulatory or airtime-changing surprises from the caller.
* Non-LoRa modulations pass through untouched.
"""
from __future__ import annotations
import pytest
import donglora as dl
from donglora.connect import _prepare_config
from donglora.info import Info
def _info(
*,
freq_min_hz: int = 863_000_000,
freq_max_hz: int = 928_000_000,
supported_sf_bitmap: int = 0x1FE0, # SF5..SF12
supported_bw_bitmap: int = 0x03FF, # sub-GHz BWs 0..9
tx_power_min_dbm: int = -9,
tx_power_max_dbm: int = 22,
) -> Info:
"""Build an Info with only the caps-related fields that matter here."""
return Info(
proto_major=1,
proto_minor=0,
fw_major=0,
fw_minor=0,
fw_patch=0,
radio_chip_id=int(dl.RadioChipId.SX1262),
capability_bitmap=dl.Capability.LORA,
supported_sf_bitmap=supported_sf_bitmap,
supported_bw_bitmap=supported_bw_bitmap,
max_payload_bytes=255,
rx_queue_capacity=32,
tx_queue_capacity=1,
freq_min_hz=freq_min_hz,
freq_max_hz=freq_max_hz,
tx_power_min_dbm=tx_power_min_dbm,
tx_power_max_dbm=tx_power_max_dbm,
mcu_uid=b"\x00" * 6,
radio_uid=b"",
)
# ── tx_power: clamp silently ──────────────────────────────────────
def test_tx_power_above_max_is_clamped_down():
cfg = dl.LoRaConfig.default()
cfg = dl.LoRaConfig(
freq_hz=cfg.freq_hz,
bw=cfg.bw,
sf=cfg.sf,
cr=cfg.cr,
sync_word=cfg.sync_word,
tx_power_dbm=30,
preamble_len=cfg.preamble_len,
header_mode=cfg.header_mode,
payload_crc=cfg.payload_crc,
iq_invert=cfg.iq_invert,
)
out = _prepare_config(_info(tx_power_max_dbm=20), cfg)
assert isinstance(out, dl.LoRaConfig)
assert out.tx_power_dbm == 20
# Other fields unchanged.
assert out.freq_hz == cfg.freq_hz
assert out.sf == cfg.sf
def test_tx_power_below_min_is_clamped_up():
cfg = dl.LoRaConfig.default()
cfg = dl.LoRaConfig(
freq_hz=cfg.freq_hz,
bw=cfg.bw,
sf=cfg.sf,
cr=cfg.cr,
sync_word=cfg.sync_word,
tx_power_dbm=-30,
preamble_len=cfg.preamble_len,
header_mode=cfg.header_mode,
payload_crc=cfg.payload_crc,
iq_invert=cfg.iq_invert,
)
out = _prepare_config(_info(tx_power_min_dbm=2), cfg)
assert isinstance(out, dl.LoRaConfig)
assert out.tx_power_dbm == 2
def test_tx_power_inside_range_is_untouched():
cfg = dl.LoRaConfig.default()
out = _prepare_config(_info(), cfg)
assert out is cfg # same object, no replace() called
# ── freq_hz: reject (regulatory boundaries) ───────────────────────
def test_freq_below_min_raises():
cfg = dl.LoRaConfig(
freq_hz=300_000_000,
bw=dl.LoRaBandwidth.KHZ_125,
sf=7,
cr=dl.LoRaCodingRate.CR_4_5,
sync_word=0x3444,
tx_power_dbm=14,
preamble_len=8,
header_mode=dl.LoRaHeaderMode.EXPLICIT,
payload_crc=True,
iq_invert=False,
)
with pytest.raises(dl.ConfigNotSupported, match="frequency"):
_prepare_config(_info(freq_min_hz=863_000_000, freq_max_hz=928_000_000), cfg)
def test_freq_above_max_raises():
cfg = dl.LoRaConfig(
freq_hz=2_400_000_000,
bw=dl.LoRaBandwidth.KHZ_125,
sf=7,
cr=dl.LoRaCodingRate.CR_4_5,
sync_word=0x3444,
tx_power_dbm=14,
preamble_len=8,
header_mode=dl.LoRaHeaderMode.EXPLICIT,
payload_crc=True,
iq_invert=False,
)
with pytest.raises(dl.ConfigNotSupported, match="frequency"):
_prepare_config(_info(freq_min_hz=137_000_000, freq_max_hz=1_020_000_000), cfg)
# ── sf: reject (airtime / sensitivity change) ─────────────────────
def test_sf5_rejected_on_sx1276_bitmap():
"""SX1276 advertises SF6-SF12 (0x1FC0). SF5 must not silently upgrade."""
cfg = dl.LoRaConfig(
freq_hz=915_000_000,
bw=dl.LoRaBandwidth.KHZ_125,
sf=5,
cr=dl.LoRaCodingRate.CR_4_5,
sync_word=0x3444,
tx_power_dbm=14,
preamble_len=8,
header_mode=dl.LoRaHeaderMode.EXPLICIT,
payload_crc=True,
iq_invert=False,
)
with pytest.raises(dl.ConfigNotSupported, match="SF5"):
_prepare_config(_info(supported_sf_bitmap=0x1FC0), cfg)
# ── bw: reject (airtime change) ────────────────────────────────────
def test_bw_not_in_bitmap_raises():
"""SX128x-only 200 kHz BW on a sub-GHz-only device must be rejected."""
cfg = dl.LoRaConfig(
freq_hz=915_000_000,
bw=dl.LoRaBandwidth.KHZ_200, # bit 10 — SX128x only
sf=7,
cr=dl.LoRaCodingRate.CR_4_5,
sync_word=0x3444,
tx_power_dbm=14,
preamble_len=8,
header_mode=dl.LoRaHeaderMode.EXPLICIT,
payload_crc=True,
iq_invert=False,
)
with pytest.raises(dl.ConfigNotSupported, match="bandwidth"):
_prepare_config(_info(supported_bw_bitmap=0x03FF), cfg)
# ── Non-LoRa: pass through ─────────────────────────────────────────
def test_non_lora_modulation_passes_through():
# Frequency deliberately outside the info's range; _prepare_config
# must not inspect it for non-LoRa modulations (firmware rejects
# unsupported modulation IDs with EMODULATION on its own).
fsk = dl.FskConfig(
freq_hz=50_000_000,
bitrate_bps=50_000,
freq_dev_hz=25_000,
rx_bw=0,
)
out = _prepare_config(_info(), fsk)
assert out is fsk
Generated
+1 -1
View File
@@ -296,7 +296,7 @@ wheels = [
[[package]] [[package]]
name = "donglora" name = "donglora"
version = "1.0.0" version = "1.1.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "cobs" }, { name = "cobs" },