Files
client-py/donglora/info.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

197 lines
6.1 KiB
Python

"""``GET_INFO`` response payload + capability bitmap constants."""
from __future__ import annotations
import enum
import struct
from dataclasses import dataclass, field
MAX_MCU_UID_LEN: int = 32
MAX_RADIO_UID_LEN: int = 16
# ── Capability bits (PROTOCOL.md §9) ──────────────────────────────
class Capability:
"""Bit masks for ``Info.capability_bitmap``.
Use like: ``if info.supports(Capability.LORA | Capability.CAD_BEFORE_TX): ...``.
"""
# Modulations
LORA = 1 << 0
FSK = 1 << 1
GFSK = 1 << 2
LR_FHSS = 1 << 3
FLRC = 1 << 4
MSK = 1 << 5
GMSK = 1 << 6
BLE_COMPATIBLE = 1 << 7
# Radio features
CAD_BEFORE_TX = 1 << 16
IQ_INVERSION = 1 << 17
RANGING = 1 << 18
GNSS_SCAN = 1 << 19
WIFI_MAC_SCAN = 1 << 20
SPECTRAL_SCAN = 1 << 21
FULL_DUPLEX = 1 << 22
# Protocol features
MULTI_CLIENT = 1 << 32
# ── Radio chip IDs (PROTOCOL.md §8) ───────────────────────────────
class RadioChipId(enum.IntEnum):
UNKNOWN = 0x0000
SX1261 = 0x0001
SX1262 = 0x0002
SX1268 = 0x0003
LLCC68 = 0x0004
SX1272 = 0x0010
SX1276 = 0x0011
SX1277 = 0x0012
SX1278 = 0x0013
SX1279 = 0x0014
SX1280 = 0x0020
SX1281 = 0x0021
LR1110 = 0x0030
LR1120 = 0x0031
LR1121 = 0x0032
LR2021 = 0x0040
# ── Info struct ───────────────────────────────────────────────────
@dataclass(frozen=True)
class Info:
"""``GET_INFO`` response payload. Stable for the lifetime of a session."""
proto_major: int
proto_minor: int
fw_major: int
fw_minor: int
fw_patch: int
radio_chip_id: int
capability_bitmap: int
supported_sf_bitmap: int
supported_bw_bitmap: int
max_payload_bytes: int
rx_queue_capacity: int
tx_queue_capacity: int
freq_min_hz: int
freq_max_hz: int
tx_power_min_dbm: int
tx_power_max_dbm: int
mcu_uid: bytes
radio_uid: bytes
MIN_WIRE_SIZE: int = field(default=37, init=False, repr=False, compare=False)
@property
def chip(self) -> RadioChipId | int:
"""Project ``radio_chip_id`` into the :class:`RadioChipId` enum if
recognised; otherwise return the raw u16 so nothing is lost for
forward-compat with future chips.
"""
try:
return RadioChipId(self.radio_chip_id)
except ValueError:
return self.radio_chip_id
def supports(self, mask: int) -> bool:
"""Return True if all bits in *mask* are set in the capability bitmap."""
return (self.capability_bitmap & mask) == mask
def supported_sf(self) -> list[int]:
"""List of spreading factors (5..12) the device supports."""
return [sf for sf in range(16) if self.supported_sf_bitmap & (1 << sf)]
def encode(self) -> bytes:
if len(self.mcu_uid) > MAX_MCU_UID_LEN:
raise ValueError(f"mcu_uid too long: {len(self.mcu_uid)}")
if len(self.radio_uid) > MAX_RADIO_UID_LEN:
raise ValueError(f"radio_uid too long: {len(self.radio_uid)}")
header = struct.pack(
"<BBBBBHQHHHHHIIbbB",
self.proto_major,
self.proto_minor,
self.fw_major,
self.fw_minor,
self.fw_patch,
self.radio_chip_id,
self.capability_bitmap,
self.supported_sf_bitmap,
self.supported_bw_bitmap,
self.max_payload_bytes,
self.rx_queue_capacity,
self.tx_queue_capacity,
self.freq_min_hz,
self.freq_max_hz,
self.tx_power_min_dbm,
self.tx_power_max_dbm,
len(self.mcu_uid),
)
return header + self.mcu_uid + bytes([len(self.radio_uid)]) + self.radio_uid
@classmethod
def decode(cls, data: bytes) -> Info:
if len(data) < 37:
raise ValueError(f"Info payload too short: {len(data)}")
(
proto_major,
proto_minor,
fw_major,
fw_minor,
fw_patch,
radio_chip_id,
capability_bitmap,
supported_sf_bitmap,
supported_bw_bitmap,
max_payload_bytes,
rx_queue_capacity,
tx_queue_capacity,
freq_min_hz,
freq_max_hz,
tx_power_min_dbm,
tx_power_max_dbm,
mcu_uid_len,
) = struct.unpack_from("<BBBBBHQHHHHHIIbbB", data, 0)
if mcu_uid_len > MAX_MCU_UID_LEN:
raise ValueError(f"mcu_uid_len out of range: {mcu_uid_len}")
if len(data) < 36 + mcu_uid_len + 1:
raise ValueError(f"Info payload truncated after mcu_uid: {len(data)}")
mcu_uid = bytes(data[36 : 36 + mcu_uid_len])
radio_len_idx = 36 + mcu_uid_len
radio_uid_len = data[radio_len_idx]
if radio_uid_len > MAX_RADIO_UID_LEN:
raise ValueError(f"radio_uid_len out of range: {radio_uid_len}")
radio_start = radio_len_idx + 1
if len(data) < radio_start + radio_uid_len:
raise ValueError(f"Info payload truncated after radio_uid: {len(data)}")
radio_uid = bytes(data[radio_start : radio_start + radio_uid_len])
return cls(
proto_major=proto_major,
proto_minor=proto_minor,
fw_major=fw_major,
fw_minor=fw_minor,
fw_patch=fw_patch,
radio_chip_id=radio_chip_id,
capability_bitmap=capability_bitmap,
supported_sf_bitmap=supported_sf_bitmap,
supported_bw_bitmap=supported_bw_bitmap,
max_payload_bytes=max_payload_bytes,
rx_queue_capacity=rx_queue_capacity,
tx_queue_capacity=tx_queue_capacity,
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=mcu_uid,
radio_uid=radio_uid,
)