Files
client-py/donglora/modulation.py
T
swaits e9c4bb3e93 feat!: Python client Protocol v2 rewrite with async Dongle surface
The 1.0 Python client, mirroring the Rust client 1.0 API and the DongLoRa Protocol v2 wire format.

* Module split: monolithic protocol.py / client.py replaced with dongle.py (high-level async Dongle), session.py (reader task + tag routing), frame.py (streaming FrameDecoder + encode_frame), commands.py / events.py / errors.py / modulation.py / info.py / crc.py / chip_id (re-export) matching the donglora-protocol Rust crate.

* Async API throughout. Serial I/O via asyncio-serial; mux connections via asyncio streams. The blocking client surface is gone.

* thiserror-equivalent error hierarchy (DongloraError base with Timeout, TransportClosed, ReaderExited, BadFrame, ErrClientError variants mirroring the Rust ClientError).

* Tag-aware dispatch with concurrent in-flight commands: TagAllocator hands out 16-bit device tags, Session routes each response back to the originating coroutine.

* Auto-recovery from ERR(ENOTCONFIGURED): the Dongle caches the last SET_CONFIG and silently re-applies on timeout.

* 500 ms background keepalive task per PROTOCOL.md §3.4.

* Tests rewritten: test_dongle, test_session, test_frame, test_events, test_modulation, test_crc, test_ergonomics; old test_client / test_protocol removed.

* Sticky mux reconnect (from the prior fix commit) is preserved: once a mux socket is in use, reconnects commit to it instead of falling back to USB.
2026-04-22 09:53:35 -06:00

407 lines
12 KiB
Python

"""Per-modulation config dataclasses for ``SET_CONFIG`` payloads.
Mirrors the Rust crate's ``modulation`` module byte-for-byte. Users
build a :class:`LoRaConfig` (or FSK/LR-FHSS/FLRC variant) and pass it
to :meth:`donglora.dongle.Dongle.set_config` or to :func:`connect` as
an override.
The `.default()` classmethod on each returns a sensible starting point
— for LoRa that's the Appendix C.2.3 canonical EU868 config, which is
also what :func:`connect()` auto-applies.
"""
from __future__ import annotations
import enum
import struct
from dataclasses import dataclass, field
# ── Modulation ID (u8 prefix byte on every SET_CONFIG payload) ────
class ModulationId(enum.IntEnum):
LORA = 0x01
FSK_GFSK = 0x02
LR_FHSS = 0x03
FLRC = 0x04
# ── LoRa enums ─────────────────────────────────────────────────────
class LoRaBandwidth(enum.IntEnum):
"""LoRa bandwidth enum — integer is the wire value, not kHz."""
KHZ_7_81 = 0
KHZ_10_42 = 1
KHZ_15_63 = 2
KHZ_20_83 = 3
KHZ_31_25 = 4
KHZ_41_67 = 5
KHZ_62_5 = 6
KHZ_125 = 7
KHZ_250 = 8
KHZ_500 = 9
KHZ_200 = 10 # SX128x
KHZ_400 = 11 # SX128x
KHZ_800 = 12 # SX128x
KHZ_1600 = 13 # SX128x
@property
def khz(self) -> float:
"""Nominal bandwidth in kHz. Use with ``:g`` or ``:.1f`` for display."""
return _BW_KHZ[self]
_BW_KHZ: dict[LoRaBandwidth, float] = {
LoRaBandwidth.KHZ_7_81: 7.81,
LoRaBandwidth.KHZ_10_42: 10.42,
LoRaBandwidth.KHZ_15_63: 15.63,
LoRaBandwidth.KHZ_20_83: 20.83,
LoRaBandwidth.KHZ_31_25: 31.25,
LoRaBandwidth.KHZ_41_67: 41.67,
LoRaBandwidth.KHZ_62_5: 62.5,
LoRaBandwidth.KHZ_125: 125.0,
LoRaBandwidth.KHZ_250: 250.0,
LoRaBandwidth.KHZ_500: 500.0,
LoRaBandwidth.KHZ_200: 200.0,
LoRaBandwidth.KHZ_400: 400.0,
LoRaBandwidth.KHZ_800: 800.0,
LoRaBandwidth.KHZ_1600: 1600.0,
}
class LoRaCodingRate(enum.IntEnum):
CR_4_5 = 0
CR_4_6 = 1
CR_4_7 = 2
CR_4_8 = 3
class LoRaHeaderMode(enum.IntEnum):
EXPLICIT = 0
IMPLICIT = 1
# ── LoRa config ────────────────────────────────────────────────────
@dataclass(frozen=True)
class LoRaConfig:
"""LoRa modulation parameters. 15-byte wire struct."""
freq_hz: int
sf: int # 5-12
bw: LoRaBandwidth
cr: LoRaCodingRate
preamble_len: int = 8
sync_word: int = 0x1424
tx_power_dbm: int = 14
header_mode: LoRaHeaderMode = LoRaHeaderMode.EXPLICIT
payload_crc: bool = True
iq_invert: bool = False
WIRE_SIZE: int = field(default=15, init=False, repr=False, compare=False)
@classmethod
def default(cls) -> LoRaConfig:
"""PROTOCOL.md §C.2.3 canonical EU868 config — also what
:func:`donglora.connect` auto-applies.
"""
return cls(
freq_hz=868_100_000,
sf=7,
bw=LoRaBandwidth.KHZ_125,
cr=LoRaCodingRate.CR_4_5,
preamble_len=8,
sync_word=0x1424,
tx_power_dbm=14,
header_mode=LoRaHeaderMode.EXPLICIT,
payload_crc=True,
iq_invert=False,
)
def encode(self) -> bytes:
return (
struct.pack("<I", self.freq_hz)
+ bytes([self.sf, int(self.bw), int(self.cr)])
+ struct.pack("<H", self.preamble_len)
+ struct.pack("<H", self.sync_word)
+ struct.pack("<b", self.tx_power_dbm)
+ bytes(
[
int(self.header_mode),
1 if self.payload_crc else 0,
1 if self.iq_invert else 0,
],
)
)
@classmethod
def decode(cls, data: bytes) -> LoRaConfig:
if len(data) != 15:
raise ValueError(f"LoRaConfig wire size must be 15 bytes, got {len(data)}")
(freq_hz,) = struct.unpack_from("<I", data, 0)
sf = data[4]
bw = LoRaBandwidth(data[5])
cr = LoRaCodingRate(data[6])
(preamble_len,) = struct.unpack_from("<H", data, 7)
(sync_word,) = struct.unpack_from("<H", data, 9)
(tx_power_dbm,) = struct.unpack_from("<b", data, 11)
header_mode = LoRaHeaderMode(data[12])
if data[13] not in (0, 1) or data[14] not in (0, 1):
raise ValueError("LoRaConfig payload_crc / iq_invert must be 0 or 1")
return cls(
freq_hz=freq_hz,
sf=sf,
bw=bw,
cr=cr,
preamble_len=preamble_len,
sync_word=sync_word,
tx_power_dbm=tx_power_dbm,
header_mode=header_mode,
payload_crc=bool(data[13]),
iq_invert=bool(data[14]),
)
# ── FSK / GFSK config ──────────────────────────────────────────────
@dataclass(frozen=True)
class FskConfig:
"""FSK / GFSK modulation parameters. Wire size ``16 + sync_word_len``."""
freq_hz: int
bitrate_bps: int
freq_dev_hz: int
rx_bw: int # chip-specific enum index
preamble_len: int = 16
sync_word: bytes = b"" # 0-8 bytes
def encode(self) -> bytes:
if len(self.sync_word) > 8:
raise ValueError("FSK sync word max 8 bytes")
return (
struct.pack("<I", self.freq_hz)
+ struct.pack("<I", self.bitrate_bps)
+ struct.pack("<I", self.freq_dev_hz)
+ bytes([self.rx_bw])
+ struct.pack("<H", self.preamble_len)
+ bytes([len(self.sync_word)])
+ self.sync_word
)
@classmethod
def decode(cls, data: bytes) -> FskConfig:
if len(data) < 16:
raise ValueError(f"FskConfig too short: {len(data)}")
(freq_hz,) = struct.unpack_from("<I", data, 0)
(bitrate_bps,) = struct.unpack_from("<I", data, 4)
(freq_dev_hz,) = struct.unpack_from("<I", data, 8)
rx_bw = data[12]
(preamble_len,) = struct.unpack_from("<H", data, 13)
sync_word_len = data[15]
if sync_word_len > 8:
raise ValueError(f"FSK sync_word_len out of range: {sync_word_len}")
if len(data) != 16 + sync_word_len:
raise ValueError(f"FskConfig wrong total length: {len(data)}")
return cls(
freq_hz=freq_hz,
bitrate_bps=bitrate_bps,
freq_dev_hz=freq_dev_hz,
rx_bw=rx_bw,
preamble_len=preamble_len,
sync_word=bytes(data[16 : 16 + sync_word_len]),
)
# ── LR-FHSS config ─────────────────────────────────────────────────
class LrFhssBandwidth(enum.IntEnum):
KHZ_39 = 0
KHZ_86 = 1
KHZ_137 = 2
KHZ_184 = 3
KHZ_336 = 4
KHZ_387 = 5
KHZ_723 = 6
KHZ_1523 = 7
class LrFhssCodingRate(enum.IntEnum):
CR_5_6 = 0
CR_2_3 = 1
CR_1_2 = 2
CR_1_3 = 3
class LrFhssGrid(enum.IntEnum):
KHZ_25 = 0
KHZ_3_9 = 1
@dataclass(frozen=True)
class LrFhssConfig:
"""LR-FHSS modulation parameters. 10-byte wire struct. TX-only on all
current Semtech silicon.
"""
freq_hz: int
bw: LrFhssBandwidth
cr: LrFhssCodingRate
grid: LrFhssGrid
hopping: bool = True
tx_power_dbm: int = 14
def encode(self) -> bytes:
return (
struct.pack("<I", self.freq_hz)
+ bytes([int(self.bw), int(self.cr), int(self.grid)])
+ bytes([1 if self.hopping else 0])
+ struct.pack("<b", self.tx_power_dbm)
+ b"\x00" # reserved
)
@classmethod
def decode(cls, data: bytes) -> LrFhssConfig:
if len(data) != 10:
raise ValueError(f"LrFhssConfig wire size must be 10 bytes, got {len(data)}")
(freq_hz,) = struct.unpack_from("<I", data, 0)
bw = LrFhssBandwidth(data[4])
cr = LrFhssCodingRate(data[5])
grid = LrFhssGrid(data[6])
if data[7] not in (0, 1):
raise ValueError("hopping must be 0 or 1")
(tx_power_dbm,) = struct.unpack_from("<b", data, 8)
if data[9] != 0:
raise ValueError("LR-FHSS reserved byte must be 0")
return cls(
freq_hz=freq_hz,
bw=bw,
cr=cr,
grid=grid,
hopping=bool(data[7]),
tx_power_dbm=tx_power_dbm,
)
# ── FLRC config ────────────────────────────────────────────────────
class FlrcBitrate(enum.IntEnum):
KBPS_2600 = 0
KBPS_2080 = 1
KBPS_1300 = 2
KBPS_1040 = 3
KBPS_650 = 4
KBPS_520 = 5
KBPS_325 = 6
KBPS_260 = 7
class FlrcCodingRate(enum.IntEnum):
CR_1_2 = 0
CR_3_4 = 1
CR_1_1 = 2
class FlrcBt(enum.IntEnum):
OFF = 0
BT_0_5 = 1
BT_1_0 = 2
class FlrcPreambleLen(enum.IntEnum):
BITS_8 = 0
BITS_12 = 1
BITS_16 = 2
BITS_20 = 3
BITS_24 = 4
BITS_28 = 5
BITS_32 = 6
@dataclass(frozen=True)
class FlrcConfig:
"""FLRC modulation parameters. 13-byte wire struct. SX128x / LR2021 only."""
freq_hz: int
bitrate: FlrcBitrate
cr: FlrcCodingRate
bt: FlrcBt
preamble_len: FlrcPreambleLen
sync_word: int # 32-bit, transmitted MSB-first on air
tx_power_dbm: int = 14
def encode(self) -> bytes:
return (
struct.pack("<I", self.freq_hz)
+ bytes(
[
int(self.bitrate),
int(self.cr),
int(self.bt),
int(self.preamble_len),
],
)
+ struct.pack("<I", self.sync_word)
+ struct.pack("<b", self.tx_power_dbm)
)
@classmethod
def decode(cls, data: bytes) -> FlrcConfig:
if len(data) != 13:
raise ValueError(f"FlrcConfig wire size must be 13 bytes, got {len(data)}")
(freq_hz,) = struct.unpack_from("<I", data, 0)
bitrate = FlrcBitrate(data[4])
cr = FlrcCodingRate(data[5])
bt = FlrcBt(data[6])
preamble_len = FlrcPreambleLen(data[7])
(sync_word,) = struct.unpack_from("<I", data, 8)
(tx_power_dbm,) = struct.unpack_from("<b", data, 12)
return cls(
freq_hz=freq_hz,
bitrate=bitrate,
cr=cr,
bt=bt,
preamble_len=preamble_len,
sync_word=sync_word,
tx_power_dbm=tx_power_dbm,
)
# ── Sum type for SET_CONFIG payloads ───────────────────────────────
Modulation = LoRaConfig | FskConfig | LrFhssConfig | FlrcConfig
def encode_modulation(mod: Modulation) -> bytes:
"""Encode a modulation-agnostic ``[modulation_id || params]`` payload."""
if isinstance(mod, LoRaConfig):
return bytes([ModulationId.LORA]) + mod.encode()
if isinstance(mod, FskConfig):
return bytes([ModulationId.FSK_GFSK]) + mod.encode()
if isinstance(mod, LrFhssConfig):
return bytes([ModulationId.LR_FHSS]) + mod.encode()
if isinstance(mod, FlrcConfig):
return bytes([ModulationId.FLRC]) + mod.encode()
raise TypeError(f"unknown modulation type: {type(mod)}")
def decode_modulation(data: bytes) -> Modulation:
if not data:
raise ValueError("modulation payload is empty")
mod_id = data[0]
params = data[1:]
if mod_id == ModulationId.LORA:
return LoRaConfig.decode(params)
if mod_id == ModulationId.FSK_GFSK:
return FskConfig.decode(params)
if mod_id == ModulationId.LR_FHSS:
return LrFhssConfig.decode(params)
if mod_id == ModulationId.FLRC:
return FlrcConfig.decode(params)
raise ValueError(f"unknown modulation_id: 0x{mod_id:02X}")