5 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
swaits c3f3c21df1 chore: release 1.0.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-22 09:54:18 -06:00
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
swaits c82c5a7e94 fix: prevent client from stealing serial port when mux is restarting
Track mux usage with a module-level flag. Once a mux connection
succeeds, all future connect() calls in the same process only try
the mux. connect() blocks waiting for the socket to reappear;
try_connect() raises immediately so callers can retry with backoff.
2026-04-11 02:48:04 -06:00
30 changed files with 3986 additions and 1003 deletions

No files matched your search

+76
View File
@@ -1,5 +1,81 @@
# 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
The 1.0 Python client, mirroring the Rust client 1.0 API and the
DongLoRa Protocol v2 wire format.
### Breaking
- **DongLoRa Protocol v2 wire format** (wire-incompatible with 0.x
firmware and clients). All message types, tag correlation, and
framing now follow `PROTOCOL.md` v1.0.
- **Module split.** Monolithic `protocol.py` / `client.py` are replaced
with: `dongle.py` (high-level async `Dongle`), `session.py` (reader
task, tag routing, keepalive), `frame.py` (streaming `FrameDecoder`
+ `encode_frame`), `commands.py` / `events.py` / `errors.py` /
`modulation.py` / `info.py` / `crc.py`, matching the
`donglora-protocol` Rust crate module-for-module.
- **Async API throughout.** Serial I/O via `asyncio-serial`; mux
connections via `asyncio` streams. The blocking client surface is
gone.
- **New error hierarchy.** `DongloraError` base with `Timeout`,
`TransportClosed`, `ReaderExited`, `BadFrame`, and `ErrClient...`
variants mirroring the Rust client's `ClientError`.
### Added
- Tag-aware dispatch with concurrent in-flight commands. The
`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 it on timeout, retrying
once.
- 500 ms background keepalive task per `PROTOCOL.md §3.4`.
- Sticky mux reconnect: once a mux connection succeeds, all future
`connect()` calls in the same process only try the mux (waiting
for it to reappear if needed). `try_connect()` raises immediately
so callers can retry with backoff. Prevents clients from stealing
the serial port during mux restarts.
### Test suite
- New tests: `test_dongle`, `test_session`, `test_frame`, `test_events`,
`test_modulation`, `test_crc`, `test_ergonomics`.
- Removed: `test_client`, `test_protocol` (superseded).
## 0.2.0 — 2026-04-08
### Features
+140 -93
View File
@@ -1,30 +1,48 @@
"""DongLoRa host library — connect, configure, send/receive LoRa packets.
Implements the DongLoRa USB protocol (COBS-framed fixed-size LE).
See firmware/PROTOCOL.md for the full specification.
"""DongLoRa host library — talk LoRa from Python in three lines.
Quick start::
import donglora as dl
ser = dl.connect()
dl.send(ser, "SetConfig", config=dl.DEFAULT_CONFIG)
dl.send(ser, "StartRx")
d = dl.connect() # auto-discover, auto-configure, auto-keepalive
d.tx(b"Hello") # blocks until TX_DONE
for pkt in d.rx(): # continuous receive
print(pkt.rssi_dbm, pkt.data)
while True:
pkt = dl.recv(ser)
if pkt:
print(pkt["rssi"], pkt["payload"].hex())
For trivial scripts, skip the handle::
import donglora as dl
dl.tx(b"hi")
for pkt in dl.rx():
...
Customise anything via :func:`connect` kwargs:
* ``port="/dev/ttyUSB0"`` — explicit serial device
* ``config=LoRaConfig(...)`` — override the default radio config
* ``auto_configure=False`` — skip automatic SET_CONFIG
* ``keepalive=False`` — run your own session-liveness cadence
The full DongLoRa Protocol v2 surface is available via :class:`Dongle` methods
(:meth:`Dongle.tx` / :meth:`Dongle.rx` / :meth:`Dongle.set_config` /
:meth:`Dongle.ping` / :attr:`Dongle.info`) and the underlying
:mod:`donglora.frame` / :mod:`donglora.events` / :mod:`donglora.info`
modules if you need to speak the wire directly.
See ``firmware/PROTOCOL.md`` for the wire-level specification.
"""
from donglora.client import (
HANDSHAKE_TIMEOUT,
drain_rx,
recv,
send,
validate,
from __future__ import annotations
from donglora._top_level import close, recv, rx, tx
from donglora.commands import (
TYPE_GET_INFO,
TYPE_PING,
TYPE_RX_START,
TYPE_RX_STOP,
TYPE_SET_CONFIG,
TYPE_TX,
)
from donglora.codec import cobs_encode, read_frame
from donglora.connect import (
DEFAULT_TIMEOUT,
connect,
@@ -43,101 +61,130 @@ from donglora.discovery import (
find_port,
wait_for_device,
)
from donglora.protocol import (
CMD_TAG_GET_CONFIG,
CMD_TAG_GET_MAC,
CMD_TAG_PING,
CMD_TAG_SET_CONFIG,
CMD_TAG_START_RX,
CMD_TAG_STOP_RX,
CMD_TAG_TRANSMIT,
DEFAULT_CONFIG,
ERROR_INVALID_CONFIG,
ERROR_NO_DISPLAY,
ERROR_NOT_CONFIGURED,
ERROR_RADIO_BUSY,
ERROR_TX_TIMEOUT,
MAX_PAYLOAD,
PREAMBLE_DEFAULT,
RADIO_CONFIG_SIZE,
RESP_TAG_CONFIG,
RESP_TAG_ERROR,
RESP_TAG_MAC_ADDRESS,
RESP_TAG_OK,
RESP_TAG_PONG,
RESP_TAG_RX_PACKET,
RESP_TAG_TX_DONE,
TX_POWER_MAX,
Bandwidth,
from donglora.dongle import Dongle
from donglora.errors import (
BusyError,
Cancelled,
ChannelBusy,
ConfigNotSupported,
DeviceError,
DongloraError,
ErrorCode,
RadioConfig,
decode_config,
decode_response,
encode_command,
encode_config,
FrameError,
InternalError,
LengthError,
ModulationError,
NotConfiguredError,
ParamError,
RadioError,
TimeoutError_,
UnknownCommandError,
)
from donglora.events import (
Owner,
RxEvent,
RxOrigin,
SetConfigResult,
SetConfigResultCode,
TxDone,
TxResult,
)
from donglora.frame import MAX_OTA_PAYLOAD, MAX_PAYLOAD_FIELD, MAX_WIRE_FRAME, Frame, encode_frame
from donglora.info import MAX_MCU_UID_LEN, MAX_RADIO_UID_LEN, Capability, Info, RadioChipId
from donglora.modulation import (
FlrcBitrate,
FlrcBt,
FlrcCodingRate,
FlrcConfig,
FlrcPreambleLen,
FskConfig,
LoRaBandwidth,
LoRaCodingRate,
LoRaConfig,
LoRaHeaderMode,
LrFhssBandwidth,
LrFhssCodingRate,
LrFhssConfig,
LrFhssGrid,
Modulation,
ModulationId,
)
from donglora.transport import MuxConnection
__all__ = [
"BRIDGE_VID_PIDS",
"CMD_TAG_GET_CONFIG",
"CMD_TAG_GET_MAC",
# tag constants
"CMD_TAG_PING",
"CMD_TAG_SET_CONFIG",
"CMD_TAG_START_RX",
"CMD_TAG_STOP_RX",
"CMD_TAG_TRANSMIT",
"DEFAULT_CONFIG",
# connect
"DEFAULT_TIMEOUT",
# error constants
"ERROR_INVALID_CONFIG",
"ERROR_NOT_CONFIGURED",
"ERROR_NO_DISPLAY",
"ERROR_RADIO_BUSY",
"ERROR_TX_TIMEOUT",
# client
"HANDSHAKE_TIMEOUT",
# protocol
"MAX_PAYLOAD",
"PREAMBLE_DEFAULT",
"RADIO_CONFIG_SIZE",
"RESP_TAG_CONFIG",
"RESP_TAG_ERROR",
"RESP_TAG_MAC_ADDRESS",
"RESP_TAG_OK",
"RESP_TAG_PONG",
"RESP_TAG_RX_PACKET",
"RESP_TAG_TX_DONE",
"TX_POWER_MAX",
"MAX_MCU_UID_LEN",
"MAX_OTA_PAYLOAD",
"MAX_PAYLOAD_FIELD",
"MAX_RADIO_UID_LEN",
"MAX_WIRE_FRAME",
"TYPE_GET_INFO",
"TYPE_PING",
"TYPE_RX_START",
"TYPE_RX_STOP",
"TYPE_SET_CONFIG",
"TYPE_TX",
"USB_PID",
# discovery
"USB_VID",
"USB_VID_PID",
"Bandwidth",
"BusyError",
"Cancelled",
"Capability",
"ChannelBusy",
"ConfigNotSupported",
"DeviceError",
"Dongle",
"DongloraError",
"ErrorCode",
# transport
"FlrcBitrate",
"FlrcBt",
"FlrcCodingRate",
"FlrcConfig",
"FlrcPreambleLen",
"Frame",
"FrameError",
"FskConfig",
"Info",
"InternalError",
"LengthError",
"LoRaBandwidth",
"LoRaCodingRate",
"LoRaConfig",
"LoRaHeaderMode",
"LrFhssBandwidth",
"LrFhssCodingRate",
"LrFhssConfig",
"LrFhssGrid",
"Modulation",
"ModulationError",
"ModulationId",
"MuxConnection",
"RadioConfig",
# codec
"cobs_encode",
"NotConfiguredError",
"Owner",
"ParamError",
"RadioChipId",
"RadioError",
"RxEvent",
"RxOrigin",
"SetConfigResult",
"SetConfigResultCode",
"TimeoutError_",
"TxDone",
"TxResult",
"UnknownCommandError",
"close",
"connect",
"connect_default",
"connect_mux_auto",
"decode_config",
"decode_response",
"default_socket_path",
"drain_rx",
"encode_command",
"encode_config",
"encode_frame",
"find_port",
"mux_connect",
"mux_tcp_connect",
"read_frame",
"recv",
"send",
"rx",
"try_connect",
"validate",
"tx",
"wait_for_device",
]
+70
View File
@@ -0,0 +1,70 @@
"""Module-level convenience singletons so ``import donglora as dl`` gives
one-line TX / RX scripts a Dongle without any explicit setup.
.. code-block:: python
import donglora as dl
dl.tx(b"hello") # lazy auto-connect on first call
for pkt in dl.rx(): # same — lazy auto-connect
print(pkt.rssi_dbm, pkt.data)
Users who need multiple connections, explicit configs, or context-manager
lifetimes should call :func:`donglora.connect` directly and use the
returned :class:`Dongle`.
"""
from __future__ import annotations
import atexit
import contextlib
import threading
from collections.abc import Iterator
from donglora.dongle import Dongle
from donglora.events import RxEvent, TxDone
_default: Dongle | None = None
_lock = threading.Lock()
def _get_default() -> Dongle:
"""Return (lazily creating) the process-wide default :class:`Dongle`."""
global _default
with _lock:
if _default is None:
from donglora.connect import connect
_default = connect()
atexit.register(_cleanup)
return _default
def _cleanup() -> None:
global _default
with _lock:
d = _default
_default = None
if d is not None:
with contextlib.suppress(Exception):
d.close()
def tx(data: bytes, *, skip_cad: bool = False, timeout: float = 10.0) -> TxDone:
"""One-line transmit. Creates the default dongle on first call."""
return _get_default().tx(data, skip_cad=skip_cad, timeout=timeout)
def rx(timeout: float | None = None) -> Iterator[RxEvent]:
"""One-line continuous receive. Lazy-connects on first call."""
return iter(_get_default().rx(timeout))
def recv(timeout: float | None = None) -> RxEvent | None:
"""One packet. Lazy-connects on first call."""
return _get_default().recv(timeout)
def close() -> None:
"""Close the default dongle, if any. Safe to call multiple times."""
_cleanup()
-108
View File
@@ -1,108 +0,0 @@
"""High-level DongLoRa client helpers.
Mirrors the Rust client's ``client.rs``. Provides :func:`send`,
:func:`recv`, :func:`drain_rx`, and :func:`validate` as module-level
functions that operate on any transport (serial port or mux connection).
"""
from __future__ import annotations
import collections
from typing import Any
from donglora.codec import cobs_encode, read_frame
from donglora.protocol import decode_response, encode_command
RX_BUFFER_CAP: int = 256
"""Maximum number of buffered RxPackets before oldest are dropped."""
MAX_UNSOLICITED_BEFORE_TIMEOUT: int = 50
"""Safety bound: max frames to read while waiting for a solicited response."""
HANDSHAKE_TIMEOUT: float = 0.2
"""Timeout (seconds) for the ping-on-connect handshake."""
_rx_queue: collections.deque[dict[str, Any]] = collections.deque(maxlen=RX_BUFFER_CAP)
"""Buffer for RxPacket frames encountered while waiting for solicited responses."""
def validate(ser: Any) -> None:
"""Ping the device and verify the Pong response.
Uses a short timeout to fail fast on non-DongLoRa devices. Called
automatically by the ``connect`` family of functions.
Raises :class:`ConnectionError` if the device does not respond.
"""
saved = ser.timeout
ser.timeout = HANDSHAKE_TIMEOUT
try:
resp = send(ser, "Ping")
if resp["type"] != "Pong":
raise ConnectionError(f"device did not respond to ping — got {resp['type']}")
except ConnectionError:
raise
except Exception as exc:
raise ConnectionError("device did not respond to ping — not a DongLoRa device") from exc
finally:
ser.timeout = saved
def send(ser: Any, cmd: str, **kwargs: Any) -> dict[str, Any]:
"""Send a command and return the solicited response.
Any unsolicited ``RxPacket`` frames encountered while waiting are
buffered in the module-level receive queue (retrievable via
:func:`recv` / :func:`drain_rx`).
"""
ser.write(cobs_encode(encode_command(cmd, **kwargs)))
ser.flush()
for _ in range(MAX_UNSOLICITED_BEFORE_TIMEOUT):
data = read_frame(ser)
if data is None:
return {"type": "Timeout"}
resp = decode_response(data)
if resp["type"] == "RxPacket":
_rx_queue.append(resp)
continue
return resp
return {"type": "Timeout"}
def recv(ser: Any) -> dict[str, Any] | None:
"""Return the next RxPacket from the buffer or the wire.
Returns ``None`` on timeout (no packet available).
"""
if _rx_queue:
return _rx_queue.popleft()
data = read_frame(ser)
if data is None:
return None
resp = decode_response(data)
if resp["type"] == "RxPacket":
return resp
return None
def drain_rx(ser: Any) -> list[dict[str, Any]]:
"""Drain all buffered and pending RxPacket frames.
Temporarily reduces the read timeout to quickly drain any frames
still in flight, then restores the original timeout.
"""
packets: list[dict[str, Any]] = list(_rx_queue)
_rx_queue.clear()
old_timeout = ser.timeout
ser.timeout = 0.01
try:
while True:
data = read_frame(ser)
if data is None:
break
resp = decode_response(data)
if resp["type"] == "RxPacket":
packets.append(resp)
finally:
ser.timeout = old_timeout
return packets
+9 -32
View File
@@ -1,44 +1,21 @@
"""COBS framing for the DongLoRa USB protocol.
"""Backwards-compatible COBS codec helpers.
Mirrors the Rust client's ``codec.rs``. Frames are COBS-encoded with a
``0x00`` sentinel byte.
The v1.0 wire layer lives in :mod:`donglora.frame`. This module keeps
the thin helpers that the old client exposed (``cobs_encode``,
``read_frame``) so unrelated transport glue keeps working.
"""
from __future__ import annotations
from typing import Protocol, runtime_checkable
from cobs import cobs
@runtime_checkable
class Readable(Protocol):
"""Anything with a blocking ``read(n)`` method (serial port, socket wrapper, etc.)."""
def read(self, n: int = 1) -> bytes: ...
from donglora.frame import Frame, Readable, read_frame # noqa: F401 — re-export
def cobs_encode(data: bytes) -> bytes:
"""COBS-encode *data* and append the ``0x00`` sentinel."""
return cobs.encode(data) + b"\x00"
"""COBS-encode *data* and append the ``0x00`` sentinel.
def read_frame(reader: Readable) -> bytes | None:
"""Read one COBS frame from *reader*.
Returns decoded payload bytes, or ``None`` on timeout (zero-length read).
Exposed for transport-level pass-through; regular callers should go
through :func:`donglora.frame.encode_frame` instead.
"""
buf = b""
while True:
b = reader.read(1)
if not b:
return None
if b == b"\x00":
break
buf += b
if not buf:
return None
try:
return cobs.decode(buf)
except cobs.DecodeError:
return None
return cobs.encode(data) + b"\x00"
+50
View File
@@ -0,0 +1,50 @@
"""Host-to-device command payload encoders and type constants."""
from __future__ import annotations
import struct
from dataclasses import dataclass
from donglora.modulation import Modulation, encode_modulation
# Message type identifiers (PROTOCOL.md §4)
TYPE_PING: int = 0x01
TYPE_GET_INFO: int = 0x02
TYPE_SET_CONFIG: int = 0x03
TYPE_TX: int = 0x04
TYPE_RX_START: int = 0x05
TYPE_RX_STOP: int = 0x06
@dataclass(frozen=True)
class TxFlags:
skip_cad: bool = False
def as_byte(self) -> int:
return 0b0000_0001 if self.skip_cad else 0
def encode_ping_payload() -> bytes:
return b""
def encode_get_info_payload() -> bytes:
return b""
def encode_set_config_payload(modulation: Modulation) -> bytes:
return encode_modulation(modulation)
def encode_tx_payload(data: bytes, *, skip_cad: bool = False) -> bytes:
if not data:
raise ValueError("TX payload must be non-empty")
return struct.pack("<B", TxFlags(skip_cad).as_byte()) + data
def encode_rx_start_payload() -> bytes:
return b""
def encode_rx_stop_payload() -> bytes:
return b""
+292 -100
View File
@@ -1,37 +1,51 @@
"""Connection auto-detection and mux client helpers.
"""Auto-detect and connect to a DongLoRa device.
Mirrors the Rust client's ``connect.rs``. The :func:`connect` function
tries mux connections first (TCP via env var, then Unix socket), falling
back to direct USB serial. This matches the Rust client's ``connect()``
behaviour.
``connect()`` is the "60-seconds-to-hacking" entry point. In its zero-argument
form it:
1. Finds a DongLoRa USB device (or mux socket if one is available).
2. Opens the transport at 115200 baud.
3. Sends ``GET_INFO`` to confirm we're talking DongLoRa Protocol v2.
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.
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.
"""
from __future__ import annotations
import dataclasses
import logging
import os
import socket
import time
from typing import Any
import serial
from donglora.client import validate
from donglora.discovery import find_port, wait_for_device
from donglora.dongle import Dongle
from donglora.errors import ConfigNotSupported, DongloraError
from donglora.info import Info
from donglora.modulation import LoRaConfig, Modulation
from donglora.session import Session
from donglora.transport import MuxConnection
log = logging.getLogger("donglora")
log = logging.getLogger("donglora.connect")
DEFAULT_TIMEOUT: float = 2.0
"""Default read timeout for connections (seconds)."""
"""Default transport read timeout (seconds). This is the *poll* interval
— overall command deadlines are per-call kwargs on the Dongle API."""
def default_socket_path() -> str:
"""Resolve the mux socket path in priority order.
1. ``$DONGLORA_MUX`` environment variable
2. ``$XDG_RUNTIME_DIR/donglora/mux.sock``
3. ``/tmp/donglora-mux.sock``
"""
"""Resolve the mux socket path in priority order."""
env = os.environ.get("DONGLORA_MUX")
if env:
return env
@@ -42,7 +56,6 @@ def default_socket_path() -> str:
def _find_mux_socket() -> str | None:
"""Find an existing mux socket path, or ``None`` if no socket file exists."""
env = os.environ.get("DONGLORA_MUX")
if env:
return env if os.path.exists(env) else None
@@ -55,11 +68,10 @@ def _find_mux_socket() -> str | None:
return p if os.path.exists(p) else None
def _try_tcp_mux(addr: str, timeout: float) -> MuxConnection | None:
"""Try to connect to a TCP mux at *addr* (``[host:]port``).
_used_mux = False
Returns ``None`` on failure instead of raising.
"""
def _try_tcp_mux(addr: str, timeout: float) -> MuxConnection | None:
if ":" in addr:
host, _, port_str = addr.rpartition(":")
host = host or "localhost"
@@ -71,143 +83,323 @@ def _try_tcp_mux(addr: str, timeout: float) -> MuxConnection | None:
except ValueError:
return None
try:
return mux_tcp_connect(host, port, timeout)
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect((host, port))
return MuxConnection(sock, timeout)
except (ConnectionRefusedError, OSError):
return None
# ── Public connection functions ───────────────────────────────────
def _open_transport(port: str | None, timeout: float) -> Any:
"""Bottom-level transport opener. Returns a serial/socket-like object.
def mux_connect(path: str | None = None, timeout: float = DEFAULT_TIMEOUT) -> MuxConnection:
"""Connect to the DongLoRa mux daemon via Unix domain socket."""
if path is None:
path = _find_mux_socket()
if path is None:
raise FileNotFoundError("no mux socket found")
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.connect(path)
conn = MuxConnection(sock, timeout)
validate(conn)
return conn
def mux_tcp_connect(host: str, port: int, timeout: float = DEFAULT_TIMEOUT) -> MuxConnection:
"""Connect to the DongLoRa mux daemon via TCP."""
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect((host, port))
conn = MuxConnection(sock, timeout)
validate(conn)
return conn
def connect(port: str | None = None, timeout: float = DEFAULT_TIMEOUT) -> Any:
"""Auto-detect and connect to a DongLoRa device.
Priority:
1. ``DONGLORA_MUX_TCP`` env var -> TCP mux connection
2. Unix socket mux (if socket file exists)
3. Direct USB serial (auto-detect by VID:PID, blocks until device appears)
If *port* is given, skips mux detection and connects directly.
.. note::
If a mux socket file exists, this function **commits** to the mux
and will raise on failure rather than falling through to USB serial.
This prevents port-stealing race conditions (matches Rust v0.2.1).
Priority: explicit port → sticky mux → TCP mux env → Unix mux socket → USB auto-detect.
"""
# Explicit port — go direct
global _used_mux
if port is not None:
log.debug("opening serial port %s", port)
ser = serial.Serial(port, baudrate=115200, timeout=timeout)
ser.reset_input_buffer()
validate(ser)
return ser
# Try TCP mux via environment variable
if _used_mux:
mux_path = default_socket_path()
while not os.path.exists(mux_path):
log.info("Waiting for mux at %s ...", mux_path)
time.sleep(0.5)
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.connect(mux_path)
return MuxConnection(sock, timeout)
tcp = os.environ.get("DONGLORA_MUX_TCP")
if tcp:
conn = _try_tcp_mux(tcp, timeout)
if conn is not None:
log.debug("connected to TCP mux at %s", tcp)
_used_mux = True
return conn
# Try Unix socket mux — commit if socket exists (no fall-through)
sock_path = _find_mux_socket()
if sock_path is not None:
log.debug("mux socket found at %s — connecting via mux only", sock_path)
return mux_connect(sock_path, timeout)
log.debug("mux socket found at %s", sock_path)
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.connect(sock_path)
_used_mux = True
return MuxConnection(sock, timeout)
# Direct USB serial — only reached when no mux socket exists at all
port_path = find_port()
if port_path is None:
port_path = wait_for_device()
log.debug("opening serial port %s", port_path)
ser = serial.Serial(port_path, baudrate=115200, timeout=timeout)
ser.reset_input_buffer()
validate(ser)
return ser
def connect_default() -> Any:
"""Connect with the default timeout. Convenience wrapper for ``connect(None)``."""
return connect(timeout=DEFAULT_TIMEOUT)
def _prepare_config(info: Info, config: Modulation) -> Modulation:
"""Validate and auto-adjust *config* against the device's advertised caps.
Per-field policy:
def connect_mux_auto(timeout: float = DEFAULT_TIMEOUT) -> Any:
"""Connect to a mux daemon only (TCP via env var, then Unix socket).
* ``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.
Unlike :func:`connect`, this **never** falls back to direct USB serial.
Returns an error if no mux is reachable — the caller can retry with
backoff.
This is the Python equivalent of the Rust client's "sticky mux" behaviour:
once you decide to use the mux, you stay on the mux.
Non-LoRa modulations pass through untouched — the firmware rejects
unsupported modulation IDs with ``EMODULATION`` on its own.
"""
# Try TCP mux via environment variable
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(
port: str | None = None,
*,
timeout: float = DEFAULT_TIMEOUT,
config: Modulation | None = None,
auto_configure: bool = True,
keepalive: bool = True,
) -> Dongle:
"""Connect to a DongLoRa device and return a ready-to-use :class:`Dongle`.
The default (zero-argument) invocation:
* Auto-discovers a USB device (or mux socket).
* Sends ``GET_INFO`` to confirm DongLoRa Protocol v2.
* Applies ``LoRaConfig.default()`` (EU868 / SF7 / BW125 / CR4/5).
* Starts a background keepalive thread.
Parameters
----------
port
Explicit serial device path. Skips auto-discovery.
timeout
Transport read timeout (polling interval). Default 2 s.
config
Modulation to apply at connect-time. Default:
:meth:`LoRaConfig.default`. Pass ``None`` with
``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
If False, skip the ``SET_CONFIG`` step entirely — no
validation, no clamping. The caller is then responsible for
calling :meth:`Dongle.set_config` before TX/RX.
keepalive
If False, skip the keepalive daemon thread. The caller is
responsible for periodic :meth:`Dongle.ping` calls to stay
under the 1 s inactivity timer.
"""
def _open_and_init() -> tuple[Session, Info, Modulation | None]:
"""Open the transport, bring up a Session, and verify+configure.
Used for both the initial connect and any later transparent
reconnect driven by :meth:`Dongle._recover_session`. Respects
the sticky-mux state at call time — so a mux-first first call
followed by a reconnect will stay on mux even if USB came back.
"""
transport = _open_transport(port, timeout)
session = Session(transport)
try:
info = session.get_info(timeout=max(timeout, 2.0))
except DongloraError:
session.close()
raise
if not isinstance(info, Info):
session.close()
raise DongloraError(f"GET_INFO returned unexpected payload: {info!r}")
if info.proto_major != 1:
session.close()
raise DongloraError(
f"device speaks DongLoRa Protocol v{info.proto_major}.{info.proto_minor}; "
"this client requires v1.x",
)
applied: Modulation | None = None
if auto_configure:
requested: Modulation = config if config is not None else LoRaConfig.default()
try:
prepared = _prepare_config(info, requested)
result = session.set_config(prepared, timeout=max(timeout, 2.0))
except DongloraError:
session.close()
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
session, info, applied = _open_and_init()
return Dongle(
session,
info,
applied_config=applied,
keepalive=keepalive,
_reopener=_open_and_init,
)
def connect_default() -> Dongle:
"""Convenience wrapper for ``connect()`` with all defaults."""
return connect()
def connect_mux_auto(timeout: float = DEFAULT_TIMEOUT) -> Dongle:
"""Connect via mux only — never falls through to direct USB serial.
Sets the sticky-mux flag on success so any later :func:`connect`
call also stays on the mux.
"""
global _used_mux
tcp = os.environ.get("DONGLORA_MUX_TCP")
if tcp:
conn = _try_tcp_mux(tcp, timeout)
if conn is not None:
log.debug("connected to TCP mux at %s", tcp)
return conn
_used_mux = True
return _session_on(conn)
# Try Unix socket mux
sock_path = _find_mux_socket()
if sock_path is None:
raise FileNotFoundError("no mux socket found")
return mux_connect(sock_path, timeout)
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.connect(sock_path)
_used_mux = True
return _session_on(MuxConnection(sock, timeout))
def try_connect(timeout: float = DEFAULT_TIMEOUT) -> Any:
"""Like :func:`connect` but non-blocking when no USB device is present.
def try_connect(timeout: float = DEFAULT_TIMEOUT) -> Dongle:
"""Like :func:`connect` but single-scan USB (no blocking wait)."""
global _used_mux
if _used_mux:
return connect_mux_auto(timeout)
Runs the same fallback chain (TCP mux, Unix socket mux, direct USB
serial) but uses a single non-blocking scan instead of polling
indefinitely for a USB device. Raises if nothing is found.
"""
# Try TCP mux via environment variable
tcp = os.environ.get("DONGLORA_MUX_TCP")
if tcp:
conn = _try_tcp_mux(tcp, timeout)
if conn is not None:
log.debug("connected to TCP mux at %s", tcp)
return conn
_used_mux = True
return _session_on(conn)
# Try Unix socket mux — commit if socket exists
sock_path = _find_mux_socket()
if sock_path is not None:
log.debug("mux socket found at %s — connecting via mux only", sock_path)
return mux_connect(sock_path, timeout)
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.connect(sock_path)
_used_mux = True
return _session_on(MuxConnection(sock, timeout))
# Direct USB serial — single non-blocking scan
port_path = find_port()
if port_path is None:
raise FileNotFoundError("no DongLoRa device found (no mux, no USB device)")
log.debug("opening serial port %s", port_path)
ser = serial.Serial(port_path, baudrate=115200, timeout=timeout)
ser.reset_input_buffer()
validate(ser)
return ser
return _session_on(ser)
def mux_connect(path: str | None = None, timeout: float = DEFAULT_TIMEOUT) -> Dongle:
"""Connect to the mux daemon via Unix domain socket.
Sets the sticky-mux flag on success so any later :func:`connect`
call also stays on the mux.
"""
global _used_mux
if path is None:
path = _find_mux_socket()
if path is None:
raise FileNotFoundError("no mux socket found")
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.connect(path)
_used_mux = True
return _session_on(MuxConnection(sock, timeout))
def mux_tcp_connect(host: str, port: int, timeout: float = DEFAULT_TIMEOUT) -> Dongle:
"""Connect to the mux daemon via TCP.
Sets the sticky-mux flag on success so any later :func:`connect`
call also stays on the mux.
"""
global _used_mux
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect((host, port))
_used_mux = True
return _session_on(MuxConnection(sock, timeout))
def _session_on(transport: Any) -> Dongle:
"""Bring up a :class:`Dongle` on an already-open transport."""
session = Session(transport)
try:
info = session.get_info()
except DongloraError:
session.close()
raise
if not isinstance(info, Info):
session.close()
raise DongloraError(f"GET_INFO returned unexpected payload: {info!r}")
if info.proto_major != 1:
session.close()
raise DongloraError(
f"device speaks DongLoRa Protocol v{info.proto_major}.{info.proto_minor}; this client requires v1.x",
)
try:
prepared = _prepare_config(info, LoRaConfig.default())
result = session.set_config(prepared)
except DongloraError:
session.close()
raise
applied = result.current if result is not None else prepared
return Dongle(session, info, applied_config=applied, keepalive=True)
+27
View File
@@ -0,0 +1,27 @@
"""CRC-16/CCITT-FALSE — the wire-level integrity check used by DongLoRa Protocol v2.
Polynomial ``0x1021``, initial value ``0xFFFF``, no reflection,
XOR-out ``0x0000``. Also known as CRC-16/AUTOSAR or CRC-16/IBM-3740.
**Not** the same as CRC-16/KERMIT, CRC-16/XMODEM, or plain
"CRC-16/CCITT" — those use different initial values or reflect input.
Check value: ``crc16(b"123456789") == 0x29B1``. The module-level
assertion below fails at import if the implementation drifts.
"""
from __future__ import annotations
def crc16(data: bytes) -> int:
"""Compute CRC-16/CCITT-FALSE over *data*. Returns a u16."""
crc = 0xFFFF
for byte in data:
crc ^= byte << 8
for _ in range(8):
crc = ((crc << 1) ^ 0x1021) & 0xFFFF if crc & 0x8000 else (crc << 1) & 0xFFFF
return crc
# Self-check: the check value from PROTOCOL.md Appendix B.
assert crc16(b"123456789") == 0x29B1, "CRC-16/CCITT-FALSE check value drift"
+7 -1
View File
@@ -18,7 +18,13 @@ USB_VID: int = 0x1209
USB_PID: int = 0x5741
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."""
+364
View File
@@ -0,0 +1,364 @@
"""The user-facing :class:`Dongle` — a connected, configured, ready-to-TX radio.
Designed for "stupidly easy" ergonomics: once you have a ``Dongle`` from
:func:`donglora.connect`, you call ``.tx(data)`` and ``.rx()`` and that's
it. The keepalive daemon, inactivity-timeout auto-recovery, tag
bookkeeping, CRC, COBS — all hidden.
"""
from __future__ import annotations
import logging
import threading
from collections.abc import Callable, Iterator
from donglora.errors import DongloraError, NotConfiguredError
from donglora.events import RxEvent, TxDone
from donglora.info import Info
from donglora.modulation import LoRaConfig, Modulation
from donglora.session import Session
log = logging.getLogger("donglora.dongle")
# Type alias for the reopener closure. Returns (new session, info,
# applied config) — the same triple ``connect()`` produces initially.
Reopener = Callable[[], tuple[Session, Info, "Modulation | None"]]
_KEEPALIVE_INTERVAL_S: float = 0.5
"""Default keepalive cadence. Spec §3.4 sets the inactivity timer to
1000 ms — we ping at 500 ms for 2x margin. Hosts that send commands
naturally won't trigger keepalive PINGs."""
class Dongle:
"""Live DongLoRa radio session.
Normally constructed via :func:`donglora.connect`. Users who need
full control can pass in a prebuilt transport + :class:`Session`,
but the happy path is:
.. code-block:: python
import donglora as dl
d = dl.connect()
d.tx(b"Hello")
for pkt in d.rx():
print(pkt.rssi_dbm, pkt.data)
Thread-safe: multiple threads can call ``tx`` / ``rx`` concurrently.
"""
def __init__(
self,
session: Session,
info: Info,
*,
applied_config: Modulation | None,
keepalive: bool = True,
_reopener: Reopener | None = None,
):
self._session = session
self._info = info
self._applied_config: Modulation | None = applied_config
self._rx_started = False
self._closed = False
# Reopener closure supplied by :func:`donglora.connect`. When
# the session's reader thread dies (e.g. mux restart, USB blip)
# and the caller invokes any method, the Dongle transparently
# calls this to build a fresh session. ``None`` disables
# transparent reconnect (users who constructed a Dongle
# manually from an existing Session keep the old behaviour).
self._reopener: Reopener | None = _reopener
# Serialises reconnect work across concurrent method calls, so
# multiple threads hitting a dead session only drive one
# reconnect attempt.
self._recover_lock = threading.Lock()
# Keepalive daemon: sends PING every ~500 ms unless the caller
# issued other traffic more recently. We track "time of last
# transmit" as a simple monotonic counter.
self._last_write_lock = threading.Lock()
self._last_write_time = _now()
self._keepalive_stop = threading.Event()
self._keepalive_thread: threading.Thread | None = None
if keepalive:
self._keepalive_thread = threading.Thread(
target=self._keepalive_loop,
name="donglora-keepalive",
daemon=True,
)
self._keepalive_thread.start()
# ── User-facing API ─────────────────────────────────────────────
@property
def info(self) -> Info:
"""Cached ``GET_INFO`` snapshot from connect-time."""
return self._info
@property
def config(self) -> Modulation | 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
def tx(self, data: bytes, *, skip_cad: bool = False, timeout: float = 10.0) -> TxDone:
"""Transmit *data*. Blocks until TX_DONE arrives.
Raises :class:`donglora.errors.ChannelBusy` if CAD detected
activity, :class:`donglora.errors.Cancelled` if SET_CONFIG
cancelled a queued TX, or other :class:`DongloraError`
subclasses for device errors.
"""
self._check_open()
self._mark_write()
return self._with_recovery(
lambda: self._session.transmit(data, skip_cad=skip_cad, timeout=timeout),
)
def rx(self, timeout: float | None = None) -> _RxIterator:
"""Return an iterator over incoming packets.
On first call, lazily sends ``RX_START`` to put the radio into
continuous receive. ``timeout`` is the per-packet deadline; set
to ``None`` to block forever (the common case for listening
scripts).
"""
self._check_open()
if not self._rx_started:
self._mark_write()
self._with_recovery(lambda: self._session.rx_start())
self._rx_started = True
return _RxIterator(self, timeout)
def recv(self, timeout: float | None = None) -> RxEvent | None:
"""Block on a single RX event. Convenience for ``next(iter(d.rx(timeout)))``.
Returns ``None`` on timeout. Lazily starts continuous RX on
first call. If the session dies (mux restart, transport blip)
while waiting, transparently reconnects and returns ``None`` —
the caller just loops and calls ``recv()`` again.
"""
self._check_open()
self._ensure_session_alive()
if not self._rx_started:
self._mark_write()
self._with_recovery(lambda: self._session.rx_start())
self._rx_started = True
pkt = self._session.next_rx(timeout)
if pkt is None and not self._session.is_alive:
# Session died during the wait (reader posted the dead
# sentinel which next_rx filtered to None). Reconnect now
# so the *next* recv() sees a live session.
self._ensure_session_alive()
return pkt
def rx_stop(self) -> None:
"""Stop continuous RX. Rare; most programs just close the dongle."""
self._check_open()
self._ensure_session_alive()
self._mark_write()
self._session.rx_stop()
self._rx_started = False
def set_config(self, config: Modulation) -> None:
"""Apply a new radio configuration. Blocks on the ``OK`` response."""
self._check_open()
self._ensure_session_alive()
self._mark_write()
self._session.set_config(config)
self._applied_config = config
# SET_CONFIG aborts any continuous RX on the device side.
self._rx_started = False
def ping(self) -> None:
"""Send a PING. Rarely needed directly — the keepalive daemon
handles session-liveness automatically.
"""
self._check_open()
self._ensure_session_alive()
self._mark_write()
self._session.ping()
def close(self) -> None:
"""Close the session. Idempotent."""
if self._closed:
return
self._closed = True
self._keepalive_stop.set()
self._session.close()
# Context manager
def __enter__(self) -> Dongle:
return self
def __exit__(self, *exc: object) -> None:
self.close()
# ── Internals ───────────────────────────────────────────────────
def _check_open(self) -> None:
if self._closed:
raise DongloraError("dongle is closed")
def _mark_write(self) -> None:
with self._last_write_lock:
self._last_write_time = _now()
def _ensure_session_alive(self) -> None:
"""If the session's reader thread has died, rebuild the session.
Blocks until reconnect succeeds or the Dongle is closed.
No-op when the session is healthy. Safe to call concurrently —
only one thread drives the actual reconnect, the rest wait on
the shared lock.
"""
if self._session.is_alive:
return
if self._reopener is None:
# Manually-constructed Dongle without a reopener — surface
# the death as an error rather than silently swallowing it.
raise DongloraError("session closed; no reopener configured")
with self._recover_lock:
# Another thread may have already reconnected by the time
# we got the lock.
if self._session.is_alive:
return
self._recover_session_locked()
def _recover_session_locked(self) -> None:
"""Replace ``self._session`` with a fresh one.
Called under ``_recover_lock``. Retries the reopener with a
backoff until success or the Dongle is closed. Re-applies the
cached config (if any) and restarts RX (if it was running)
before returning, so the caller's next command sees a live
dongle in the same logical state.
"""
assert self._reopener is not None
log.warning("dongle: session died, reconnecting transparently")
old_session = self._session
import contextlib as _contextlib
with _contextlib.suppress(Exception):
old_session.close()
backoff = 0.5
max_backoff = 2.0
while not self._closed and not self._keepalive_stop.is_set():
try:
new_session, new_info, applied = self._reopener()
except (DongloraError, OSError) as exc:
log.info("dongle: reconnect attempt failed (%s); retrying...", exc)
if self._keepalive_stop.wait(backoff):
return # close() fired during backoff
backoff = min(backoff * 1.5, max_backoff)
continue
self._session = new_session
self._info = new_info
if applied is not None:
self._applied_config = applied
# Restart RX if the caller had it running.
if self._rx_started:
try:
new_session.rx_start()
except DongloraError as exc:
log.info("dongle: restart rx after reconnect failed: %s", exc)
self._rx_started = False
log.info("dongle: reconnect successful")
return
def _with_recovery(self, op):
"""Run *op*, transparently recovering from two kinds of drop:
* Session reader death (mux restart, transport blip) —
rebuild the session via :meth:`_ensure_session_alive` and
retry once.
* ``ERR(ENOTCONFIGURED)`` from an inactivity timeout —
re-apply the cached config and retry once.
Other errors pass through untouched.
"""
self._ensure_session_alive()
try:
return op()
except NotConfiguredError:
if self._applied_config is None:
raise
log.info("dongle: auto-recovering after inactivity timeout")
self._session.set_config(self._applied_config)
return op()
except DongloraError:
# If the session died mid-call, reconnect and retry once.
if not self._session.is_alive and self._reopener is not None:
self._ensure_session_alive()
return op()
raise
def _keepalive_loop(self) -> None:
while not self._keepalive_stop.wait(_KEEPALIVE_INTERVAL_S):
# If the session died, let the keepalive thread drive the
# reconnect proactively — without this, we'd only notice
# when the caller next invoked a public method.
if not self._session.is_alive and self._reopener is not None:
try:
self._ensure_session_alive()
except DongloraError as exc:
log.debug("keepalive reconnect failed: %s", exc)
continue
with self._last_write_lock:
since = _now() - self._last_write_time
if since < _KEEPALIVE_INTERVAL_S:
continue
try:
self._session.ping(timeout=2.0)
with self._last_write_lock:
self._last_write_time = _now()
except NotConfiguredError:
# Session timed out during quiet period. Try to re-apply
# the cached config so the next user call sees a live
# session. If the caller issues a command first, the
# with_recovery path will handle it instead — so treat
# failures here as best-effort.
if self._applied_config is not None:
try:
self._session.set_config(self._applied_config)
except Exception as exc:
log.debug("keepalive recovery failed: %s", exc)
except DongloraError as exc:
log.debug("keepalive ping failed: %s", exc)
except Exception as exc:
log.debug("keepalive thread quiet error: %s", exc)
class _RxIterator:
"""Iterator view over a Dongle's incoming RX events."""
def __init__(self, dongle: Dongle, timeout: float | None):
self._dongle = dongle
self._timeout = timeout
def __iter__(self) -> Iterator[RxEvent]:
return self
def __next__(self) -> RxEvent:
pkt = self._dongle._session.next_rx(self._timeout)
if pkt is None:
raise StopIteration
return pkt
def _now() -> float:
import time
return time.monotonic()
# Re-export the default LoRa config builder so users don't have to
# import from modulation.py.
__all__ = ["Dongle", "LoRaConfig"]
+142
View File
@@ -0,0 +1,142 @@
"""Wire error codes + Python exception hierarchy.
``ErrorCode`` mirrors PROTOCOL.md §7. ``DongloraError`` subclasses are
raised by the high-level :class:`donglora.dongle.Dongle` so callers can
catch specific failures (e.g. ``ChannelBusy``) without inspecting codes.
"""
from __future__ import annotations
import enum
class ErrorCode(enum.IntEnum):
"""Wire-level error code (u16 LE). Values assigned by PROTOCOL.md §7."""
# Synchronous (0x0001..=0x00FF)
EPARAM = 0x0001
ELENGTH = 0x0002
ENOT_CONFIGURED = 0x0003
EMODULATION = 0x0004
EUNKNOWN_CMD = 0x0005
EBUSY = 0x0006
# Asynchronous (0x0100..=0x01FF)
ERADIO = 0x0101
EFRAME = 0x0102
EINTERNAL = 0x0103
@classmethod
def from_u16(cls, raw: int) -> ErrorCode | int:
"""Return the named variant if recognised, otherwise the raw int."""
try:
return cls(raw)
except ValueError:
return raw
# ── Python exception hierarchy ─────────────────────────────────────
class DongloraError(Exception):
"""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):
"""COBS decode, CRC check, or length check failed on an inbound frame."""
class TimeoutError_(DongloraError):
"""Command response not received within the configured deadline."""
class DeviceError(DongloraError):
"""The device returned an ``ERR`` frame."""
def __init__(self, code: ErrorCode | int, *, tag: int | None = None):
self.code = code
self.tag = tag
name = code.name if isinstance(code, ErrorCode) else f"0x{int(code):04X}"
super().__init__(f"device error {name} (tag={tag})")
class NotConfiguredError(DeviceError):
"""Mapped from ``ERR(ENOTCONFIGURED)``."""
class BusyError(DeviceError):
"""Mapped from ``ERR(EBUSY)``."""
class ParamError(DeviceError):
"""Mapped from ``ERR(EPARAM)``."""
class LengthError(DeviceError):
"""Mapped from ``ERR(ELENGTH)``."""
class ModulationError(DeviceError):
"""Mapped from ``ERR(EMODULATION)``."""
class UnknownCommandError(DeviceError):
"""Mapped from ``ERR(EUNKNOWN_CMD)``."""
class RadioError(DeviceError):
"""Mapped from ``ERR(ERADIO)`` — SPI error or unexpected hardware state."""
class InternalError(DeviceError):
"""Mapped from ``ERR(EINTERNAL)`` — firmware bug or invariant violation."""
_SPECIALISATIONS: dict[int, type[DeviceError]] = {
ErrorCode.EPARAM: ParamError,
ErrorCode.ELENGTH: LengthError,
ErrorCode.ENOT_CONFIGURED: NotConfiguredError,
ErrorCode.EMODULATION: ModulationError,
ErrorCode.EUNKNOWN_CMD: UnknownCommandError,
ErrorCode.EBUSY: BusyError,
ErrorCode.ERADIO: RadioError,
ErrorCode.EINTERNAL: InternalError,
}
def device_error(code: ErrorCode | int, *, tag: int | None = None) -> DeviceError:
"""Construct the most specific :class:`DeviceError` subclass for *code*."""
cls = _SPECIALISATIONS.get(int(code), DeviceError)
return cls(code, tag=tag)
# ── TX-specific non-error results ──────────────────────────────────
class ChannelBusy(DongloraError):
"""CAD detected activity and TX was not attempted. Retry with new tag."""
class Cancelled(DongloraError):
"""A queued TX was cancelled before it reached the air (SET_CONFIG
or disconnect interrupted the queue).
"""
+186
View File
@@ -0,0 +1,186 @@
"""Device-to-host message payloads: ``OK`` / ``ERR`` / ``RX`` / ``TX_DONE``."""
from __future__ import annotations
import enum
import struct
from dataclasses import dataclass
from donglora.errors import ErrorCode
from donglora.info import Info
from donglora.modulation import Modulation, decode_modulation, encode_modulation
# Message type identifiers
TYPE_OK: int = 0x80
TYPE_ERR: int = 0x81
TYPE_RX: int = 0xC0
TYPE_TX_DONE: int = 0xC1
# ── SET_CONFIG response payload ────────────────────────────────────
class SetConfigResultCode(enum.IntEnum):
APPLIED = 0
ALREADY_MATCHED = 1
LOCKED_MISMATCH = 2
class Owner(enum.IntEnum):
NONE = 0
MINE = 1
OTHER = 2
@dataclass(frozen=True)
class SetConfigResult:
result: SetConfigResultCode
owner: Owner
current: Modulation
def encode(self) -> bytes:
return bytes([int(self.result), int(self.owner)]) + encode_modulation(self.current)
@classmethod
def decode(cls, data: bytes) -> SetConfigResult:
if len(data) < 2:
raise ValueError("SetConfigResult too short")
return cls(
result=SetConfigResultCode(data[0]),
owner=Owner(data[1]),
current=decode_modulation(data[2:]),
)
# ── TX_DONE payload ────────────────────────────────────────────────
class TxResult(enum.IntEnum):
TRANSMITTED = 0
CHANNEL_BUSY = 1
CANCELLED = 2
@dataclass(frozen=True)
class TxDone:
result: TxResult
airtime_us: int
def encode(self) -> bytes:
return bytes([int(self.result)]) + struct.pack("<I", self.airtime_us)
@classmethod
def decode(cls, data: bytes) -> TxDone:
if len(data) != 5:
raise ValueError(f"TxDone wire size must be 5, got {len(data)}")
result = TxResult(data[0])
(airtime_us,) = struct.unpack_from("<I", data, 1)
return cls(result=result, airtime_us=airtime_us)
# ── RX event payload ───────────────────────────────────────────────
class RxOrigin(enum.IntEnum):
OTA = 0
LOCAL_LOOPBACK = 1
@dataclass(frozen=True)
class RxEvent:
"""Rich over-the-air packet metadata + the received bytes.
``rssi_dbm`` and ``snr_db`` are exposed as floats (converted from
the wire's tenths-of-dB representation) so user code reads like
``pkt.rssi_dbm`` instead of having to divide by 10.
"""
rssi_dbm: float
snr_db: float
freq_err_hz: int
timestamp_us: int
crc_valid: bool
packets_dropped: int
origin: RxOrigin
data: bytes
METADATA_SIZE: int = 20
def encode(self) -> bytes:
return (
struct.pack("<h", round(self.rssi_dbm * 10))
+ struct.pack("<h", round(self.snr_db * 10))
+ struct.pack("<i", self.freq_err_hz)
+ struct.pack("<Q", self.timestamp_us)
+ bytes([1 if self.crc_valid else 0])
+ struct.pack("<H", self.packets_dropped)
+ bytes([int(self.origin)])
+ self.data
)
@classmethod
def decode(cls, data: bytes) -> RxEvent:
if len(data) < cls.METADATA_SIZE:
raise ValueError(f"RxEvent payload too short: {len(data)}")
(rssi_raw,) = struct.unpack_from("<h", data, 0)
(snr_raw,) = struct.unpack_from("<h", data, 2)
(freq_err_hz,) = struct.unpack_from("<i", data, 4)
(timestamp_us,) = struct.unpack_from("<Q", data, 8)
if data[16] not in (0, 1):
raise ValueError("crc_valid must be 0 or 1")
(packets_dropped,) = struct.unpack_from("<H", data, 17)
origin = RxOrigin(data[19])
return cls(
rssi_dbm=rssi_raw / 10.0,
snr_db=snr_raw / 10.0,
freq_err_hz=freq_err_hz,
timestamp_us=timestamp_us,
crc_valid=bool(data[16]),
packets_dropped=packets_dropped,
origin=origin,
data=bytes(data[cls.METADATA_SIZE :]),
)
# ── OK payload helpers (context-dependent shape) ──────────────────
# Import command type IDs lazily inside functions to avoid cycles.
def parse_ok_payload(originating_cmd_type: int, payload: bytes):
"""Parse the payload of an ``OK`` frame given the type of the
originating H→D command. Returns:
- ``None`` for ``PING`` / ``TX`` / ``RX_START`` / ``RX_STOP`` (empty payload)
- an :class:`Info` for ``GET_INFO``
- a :class:`SetConfigResult` for ``SET_CONFIG``
"""
from donglora.commands import (
TYPE_GET_INFO,
TYPE_PING,
TYPE_RX_START,
TYPE_RX_STOP,
TYPE_SET_CONFIG,
TYPE_TX,
)
if originating_cmd_type in (TYPE_PING, TYPE_TX, TYPE_RX_START, TYPE_RX_STOP):
if payload:
raise ValueError(f"expected empty OK payload for cmd 0x{originating_cmd_type:02X}")
return None
if originating_cmd_type == TYPE_GET_INFO:
return Info.decode(payload)
if originating_cmd_type == TYPE_SET_CONFIG:
return SetConfigResult.decode(payload)
raise ValueError(f"no OK shape for originating cmd 0x{originating_cmd_type:02X}")
def encode_err_payload(code: ErrorCode | int) -> bytes:
return struct.pack("<H", int(code))
def decode_err_payload(data: bytes) -> ErrorCode | int:
if len(data) != 2:
raise ValueError(f"ERR payload must be 2 bytes, got {len(data)}")
(raw,) = struct.unpack_from("<H", data, 0)
return ErrorCode.from_u16(raw)
+150
View File
@@ -0,0 +1,150 @@
"""DongLoRa Protocol frame encode/decode: ``COBS(type || tag_le || payload || crc_le) || 0x00``.
This module owns the wire-level codec. Nothing protocol-semantic lives
here; higher-level modules parse the ``(type_id, tag, payload)`` tuple
into typed commands / events.
"""
from __future__ import annotations
import struct
from collections.abc import Iterator
from dataclasses import dataclass
from typing import Protocol, runtime_checkable
from cobs import cobs
from donglora.crc import crc16
# ── Size constants (kept in lockstep with the Rust crate) ───────────
MAX_OTA_PAYLOAD: int = 255
"""Maximum over-the-air LoRa packet payload. Chip-dependent per
PROTOCOL.md §6.2; 255 is the ceiling for all currently-supported
Semtech silicon."""
MAX_PAYLOAD_FIELD: int = MAX_OTA_PAYLOAD + 20
"""Maximum DongLoRa Protocol frame payload-field size. The ``+20`` accounts for the
RX event's fixed metadata prefix."""
FRAME_HEADER_SIZE: int = 3 # type(1) + tag(2)
FRAME_TRAILER_SIZE: int = 2 # crc16(2)
MAX_PRE_COBS_FRAME: int = FRAME_HEADER_SIZE + MAX_PAYLOAD_FIELD + FRAME_TRAILER_SIZE
MAX_COBS_OVERHEAD: int = 3 # ceil(280 / 254) + 1
MAX_WIRE_FRAME: int = MAX_PRE_COBS_FRAME + MAX_COBS_OVERHEAD + 1
# ── Frame encoding ─────────────────────────────────────────────────
class FrameTooLargeError(ValueError):
"""Payload exceeds ``MAX_PAYLOAD_FIELD``."""
def encode_frame(type_id: int, tag: int, payload: bytes) -> bytes:
"""Encode a complete wire-ready DongLoRa Protocol frame.
Returns ``COBS(type || tag_le || payload || crc_le) || 0x00``.
"""
if not (0 <= type_id <= 0xFF):
raise ValueError(f"type_id out of range: {type_id}")
if not (0 <= tag <= 0xFFFF):
raise ValueError(f"tag out of range: {tag}")
if len(payload) > MAX_PAYLOAD_FIELD:
raise FrameTooLargeError(f"payload too large ({len(payload)} > {MAX_PAYLOAD_FIELD})")
body = struct.pack("<BH", type_id, tag) + payload
crc = crc16(body)
pre_cobs = body + struct.pack("<H", crc)
return cobs.encode(pre_cobs) + b"\x00"
# ── Decoded-frame result ───────────────────────────────────────────
@dataclass(frozen=True)
class Frame:
"""A successfully-decoded DongLoRa Protocol frame."""
type_id: int
tag: int
payload: bytes
class FrameError(Exception):
"""A frame failed COBS, CRC, or minimum-length validation."""
class FrameCrcError(FrameError):
"""CRC trailer didn't match the computed CRC over ``type || tag || payload``."""
class FrameCobsError(FrameError):
"""COBS decode failed on the bytes before the ``0x00`` delimiter."""
class FrameTooShortError(FrameError):
"""Decoded frame was smaller than the 5-byte minimum."""
def decode_frame(frame_bytes: bytes) -> Frame:
"""Decode a single COBS+sentinel-terminated DongLoRa Protocol frame.
*frame_bytes* must NOT include the trailing ``0x00``.
"""
try:
decoded = cobs.decode(frame_bytes)
except cobs.DecodeError as exc:
raise FrameCobsError(str(exc)) from exc
if len(decoded) < FRAME_HEADER_SIZE + FRAME_TRAILER_SIZE:
raise FrameTooShortError(f"decoded frame too short: {len(decoded)} bytes")
body, crc_bytes = decoded[:-FRAME_TRAILER_SIZE], decoded[-FRAME_TRAILER_SIZE:]
expected = crc16(body)
(got,) = struct.unpack("<H", crc_bytes)
if expected != got:
raise FrameCrcError(f"CRC mismatch: got 0x{got:04X}, expected 0x{expected:04X}")
type_id = body[0]
(tag,) = struct.unpack_from("<H", body, 1)
payload = body[FRAME_HEADER_SIZE:]
return Frame(type_id=type_id, tag=tag, payload=payload)
# ── Streaming reader ───────────────────────────────────────────────
@runtime_checkable
class Readable(Protocol):
"""Anything with a blocking ``read(n)`` method — serial port, socket, etc."""
def read(self, n: int = 1) -> bytes: ...
def read_frame(reader: Readable) -> Frame | None:
"""Block on *reader* until a full COBS-delimited frame arrives.
Returns the decoded :class:`Frame`, or ``None`` on transport timeout.
Raises :class:`FrameError` on CRC/COBS/length failure so the caller
can distinguish corruption from timeout.
"""
buf = bytearray()
while True:
b = reader.read(1)
if not b:
return None
if b == b"\x00":
if not buf:
return None
return decode_frame(bytes(buf))
buf.extend(b)
def iter_frames(data: bytes) -> Iterator[Frame]:
"""Yield each complete frame embedded in *data* (for tests / replay).
Raises :class:`FrameError` on the first corrupt frame encountered.
"""
start = 0
for i, byte in enumerate(data):
if byte == 0x00:
if i > start:
yield decode_frame(bytes(data[start:i]))
start = i + 1
+196
View File
@@ -0,0 +1,196 @@
"""``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,
)
+406
View File
@@ -0,0 +1,406 @@
"""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}")
-234
View File
@@ -1,234 +0,0 @@
"""Wire protocol types and fixed-size little-endian serialization.
Mirrors the firmware's protocol and the Rust client's ``protocol.rs``
using Python types. Every integer is fixed-width LE.
"""
from __future__ import annotations
import enum
import struct
from typing import Any, TypedDict
# ── Size constants ────────────────────────────────────────────────
MAX_PAYLOAD: int = 256
"""Maximum LoRa payload size in bytes."""
RADIO_CONFIG_SIZE: int = 13
"""RadioConfig wire size (fixed)."""
TX_POWER_MAX: int = -128
"""Sentinel: set TX power to the board's maximum (i8::MIN on the wire)."""
PREAMBLE_DEFAULT: int = 0
"""Sentinel: use firmware default preamble length (16 symbols)."""
# ── Tag constants ─────────────────────────────────────────────────
CMD_TAG_PING: int = 0
CMD_TAG_GET_CONFIG: int = 1
CMD_TAG_SET_CONFIG: int = 2
CMD_TAG_START_RX: int = 3
CMD_TAG_STOP_RX: int = 4
CMD_TAG_TRANSMIT: int = 5
CMD_TAG_DISPLAY_ON: int = 6
CMD_TAG_DISPLAY_OFF: int = 7
CMD_TAG_GET_MAC: int = 8
RESP_TAG_PONG: int = 0
RESP_TAG_CONFIG: int = 1
RESP_TAG_RX_PACKET: int = 2
RESP_TAG_TX_DONE: int = 3
RESP_TAG_OK: int = 4
RESP_TAG_ERROR: int = 5
RESP_TAG_MAC_ADDRESS: int = 6
# ── Enums ─────────────────────────────────────────────────────────
class Bandwidth(enum.IntEnum):
"""LoRa signal bandwidth."""
Khz7 = 0
Khz10 = 1
Khz15 = 2
Khz20 = 3
Khz31 = 4
Khz41 = 5
Khz62 = 6
Khz125 = 7
Khz250 = 8
Khz500 = 9
class ErrorCode(enum.IntEnum):
"""Error codes reported by the firmware."""
InvalidConfig = 0
RadioBusy = 1
TxTimeout = 2
CrcError = 3
NotConfigured = 4
NoDisplay = 5
ERROR_INVALID_CONFIG: int = ErrorCode.InvalidConfig
ERROR_RADIO_BUSY: int = ErrorCode.RadioBusy
ERROR_TX_TIMEOUT: int = ErrorCode.TxTimeout
ERROR_NOT_CONFIGURED: int = ErrorCode.NotConfigured
ERROR_NO_DISPLAY: int = ErrorCode.NoDisplay
# ── RadioConfig ───────────────────────────────────────────────────
class RadioConfig(TypedDict):
"""Complete LoRa radio configuration.
Wire layout (13 bytes, all little-endian)::
[freq_hz:4] [bw:1] [sf:1] [cr:1] [sync_word:2]
[tx_power_dbm:1] [preamble_len:2] [cad:1]
"""
freq_hz: int
bw: int
sf: int
cr: int
sync_word: int
tx_power_dbm: int
preamble_len: int
cad: int
DEFAULT_CONFIG: RadioConfig = {
"freq_hz": 915_000_000,
"bw": Bandwidth.Khz125,
"sf": 7,
"cr": 5,
"sync_word": 0x1424,
"tx_power_dbm": TX_POWER_MAX,
"preamble_len": PREAMBLE_DEFAULT,
"cad": 1,
}
"""Default radio config: 915 MHz, 125 kHz BW, SF7, CR 4/5, max power, CAD on."""
# ── Config wire encoding ──────────────────────────────────────────
_CONFIG_STRUCT = struct.Struct("<IBBBHBHB")
def encode_config(cfg: RadioConfig) -> bytes:
"""Encode a RadioConfig to 13 fixed-size LE bytes."""
return _CONFIG_STRUCT.pack(
cfg["freq_hz"],
cfg["bw"],
cfg["sf"],
cfg["cr"],
cfg["sync_word"],
cfg["tx_power_dbm"] & 0xFF,
cfg.get("preamble_len", PREAMBLE_DEFAULT),
cfg.get("cad", 1),
)
def decode_config(data: bytes) -> RadioConfig | None:
"""Decode a RadioConfig from 13 LE bytes. Returns ``None`` if too short or invalid."""
if len(data) < RADIO_CONFIG_SIZE:
return None
freq_hz, bw_raw, sf, cr, sync_word, _pwr_unsigned, preamble_len, cad = (
_CONFIG_STRUCT.unpack_from(data, 0)
)
try:
Bandwidth(bw_raw)
except ValueError:
return None
tx_power_dbm = struct.unpack_from("<b", data, 9)[0]
return {
"freq_hz": freq_hz,
"bw": bw_raw,
"sf": sf,
"cr": cr,
"sync_word": sync_word,
"tx_power_dbm": tx_power_dbm,
"preamble_len": preamble_len,
"cad": cad,
}
# ── Command encoding ──────────────────────────────────────────────
_COMMAND_TAGS: dict[str, int] = {
"Ping": CMD_TAG_PING,
"GetConfig": CMD_TAG_GET_CONFIG,
"SetConfig": CMD_TAG_SET_CONFIG,
"StartRx": CMD_TAG_START_RX,
"StopRx": CMD_TAG_STOP_RX,
"Transmit": CMD_TAG_TRANSMIT,
"DisplayOn": CMD_TAG_DISPLAY_ON,
"DisplayOff": CMD_TAG_DISPLAY_OFF,
"GetMac": CMD_TAG_GET_MAC,
}
def encode_command(cmd: str, **kwargs: Any) -> bytes:
"""Encode a command to fixed-size LE bytes.
Raises ``ValueError`` for unknown commands or oversized payloads.
"""
tag = _COMMAND_TAGS.get(cmd)
if tag is None:
raise ValueError(f"unknown command: {cmd!r}")
out = bytes([tag])
if cmd == "SetConfig":
out += encode_config(kwargs["config"])
elif cmd == "Transmit":
config: RadioConfig | None = kwargs.get("config")
if config is None:
out += b"\x00"
else:
out += b"\x01" + encode_config(config)
payload: bytes = kwargs["payload"]
if len(payload) > MAX_PAYLOAD:
raise ValueError(f"payload too large ({len(payload)} bytes, max {MAX_PAYLOAD})")
out += struct.pack("<H", len(payload)) + payload
return out
# ── Response decoding ─────────────────────────────────────────────
def decode_response(data: bytes) -> dict[str, Any]:
"""Decode a fixed-size LE response into a dict."""
if not data:
return {"type": "Empty"}
tag = data[0]
rest = data[1:]
if tag == RESP_TAG_PONG:
return {"type": "Pong"}
if tag == RESP_TAG_CONFIG:
cfg = decode_config(rest)
if cfg is not None:
return {"type": "Config", **cfg}
return {"type": "Config", "raw": rest.hex()}
if tag == RESP_TAG_RX_PACKET:
if len(rest) < 6:
return {"type": "RxPacket", "rssi": 0, "snr": 0, "payload": b""}
rssi = struct.unpack_from("<h", rest, 0)[0]
snr = struct.unpack_from("<h", rest, 2)[0]
plen = struct.unpack_from("<H", rest, 4)[0]
payload = rest[6 : 6 + plen]
return {"type": "RxPacket", "rssi": rssi, "snr": snr, "payload": payload}
if tag == RESP_TAG_TX_DONE:
return {"type": "TxDone"}
if tag == RESP_TAG_OK:
return {"type": "Ok"}
if tag == RESP_TAG_ERROR:
code = rest[0] if rest else -1
return {"type": "Error", "code": code}
if tag == RESP_TAG_MAC_ADDRESS:
if len(rest) >= 6:
mac = ":".join(f"{b:02X}" for b in rest[:6])
return {"type": "MacAddress", "mac": mac}
return {"type": "MacAddress", "raw": rest.hex()}
return {"type": f"Unknown({tag})"}
+371
View File
@@ -0,0 +1,371 @@
"""Internal session plumbing: tag counter, outstanding-tag correlation,
background reader thread, async event queue.
Not exposed to end users — :class:`donglora.dongle.Dongle` owns a
Session and handles all request/response traffic through it.
"""
from __future__ import annotations
import logging
import queue
import threading
from dataclasses import dataclass
from typing import Any
from donglora.commands import (
TYPE_PING,
encode_get_info_payload,
encode_ping_payload,
encode_rx_start_payload,
encode_rx_stop_payload,
encode_set_config_payload,
encode_tx_payload,
)
from donglora.errors import (
DongloraError,
ErrorCode,
FrameError,
TimeoutError_,
device_error,
)
from donglora.events import (
TYPE_ERR,
TYPE_OK,
TYPE_RX,
TYPE_TX_DONE,
RxEvent,
TxDone,
decode_err_payload,
parse_ok_payload,
)
from donglora.frame import (
Frame,
encode_frame,
read_frame,
)
from donglora.frame import (
FrameError as FrameCodecError,
)
from donglora.modulation import Modulation
log = logging.getLogger("donglora.session")
# Sentinel placed on ``_rx_queue`` when the reader thread exits, so any
# blocked ``next_rx`` caller unblocks promptly instead of waiting out
# its full timeout. The sentinel is filtered out before reaching user
# code.
_DEAD_SENTINEL: object = object()
# ── Pending-command tracking ───────────────────────────────────────
@dataclass
class _Pending:
"""One outstanding command awaiting a response."""
tag: int
cmd_type: int
event: threading.Event
# Result slots (one of these is filled by the reader thread):
ok_payload: Any = None # parsed OK payload (None for empty OKs)
err: DongloraError | None = None
tx_done: TxDone | None = None # only for TX
# TX commands get two responses: OK (enqueued) then TX_DONE. The
# event is signalled after TX_DONE for TX; after OK/ERR for others.
class Session:
"""Owns the transport, demultiplexes inbound frames, correlates tags
to pending commands, queues async events.
Public API is thread-safe.
"""
def __init__(self, transport: Any):
self._transport = transport
self._transport_lock = threading.Lock()
# Tag generator: monotonic u16, skips 0.
self._next_tag = 1
self._tag_lock = threading.Lock()
# Outstanding commands by tag.
self._pending: dict[int, _Pending] = {}
self._pending_lock = threading.Lock()
# Async RX events + async ERR frames. The queue may also hold
# `_DEAD_SENTINEL` to signal reader death without a user value.
self._rx_queue: queue.Queue[Any] = queue.Queue()
self._async_errors: queue.Queue[DongloraError] = queue.Queue()
# Reader thread. `_reader_dead` fires when the reader loop
# exits for any reason (transport closed, session closed,
# unexpected exception). The Dongle watches this to drive
# transparent reconnect.
self._closed = threading.Event()
self._reader_dead = threading.Event()
self._reader = threading.Thread(
target=self._reader_loop,
name="donglora-reader",
daemon=True,
)
self._reader.start()
# ── Public: send / send_tx / rx / close ──────────────────────────
def close(self) -> None:
self._closed.set()
# Wake any pending commands so they don't hang forever.
with self._pending_lock:
for p in self._pending.values():
p.err = DongloraError("session closed")
p.event.set()
self._pending.clear()
# Close the underlying transport so the reader thread's blocking
# read unblocks and it can exit cleanly.
import contextlib as _contextlib
with _contextlib.suppress(Exception):
self._transport.close()
@property
def is_alive(self) -> bool:
"""True while the reader thread is running and no close was requested."""
return not self._reader_dead.is_set() and not self._closed.is_set()
@property
def reader_dead_event(self) -> threading.Event:
"""Event that fires when the reader thread exits (for any reason)."""
return self._reader_dead
def ping(self, *, timeout: float = 2.0) -> None:
self._send_and_wait(TYPE_PING, encode_ping_payload(), timeout=timeout)
def get_info(self, *, timeout: float = 2.0):
return self._send_and_wait(0x02, encode_get_info_payload(), timeout=timeout)
def set_config(self, modulation: Modulation, *, timeout: float = 2.0):
return self._send_and_wait(
0x03,
encode_set_config_payload(modulation),
timeout=timeout,
)
def transmit(self, data: bytes, *, skip_cad: bool = False, timeout: float = 10.0) -> TxDone:
return self._send_tx(data, skip_cad=skip_cad, timeout=timeout)
def rx_start(self, *, timeout: float = 2.0) -> None:
self._send_and_wait(0x05, encode_rx_start_payload(), timeout=timeout)
def rx_stop(self, *, timeout: float = 2.0) -> None:
self._send_and_wait(0x06, encode_rx_stop_payload(), timeout=timeout)
def next_rx(self, timeout: float | None) -> RxEvent | None:
"""Pop the next RX event from the queue, or ``None`` on timeout.
``timeout=None`` blocks forever. Returns ``None`` if the reader
thread has died — the caller (normally :class:`Dongle`) should
then trigger a reconnect.
"""
try:
item = self._rx_queue.get(block=True, timeout=timeout)
except queue.Empty:
return None
if item is _DEAD_SENTINEL:
# Reader is gone — re-post the sentinel for any other
# waiters and return None.
self._rx_queue.put(_DEAD_SENTINEL)
return None
return item
def drain_async_errors(self) -> list[DongloraError]:
"""Return and clear any async errors observed since the last call."""
drained = []
while True:
try:
drained.append(self._async_errors.get_nowait())
except queue.Empty:
break
return drained
# ── Internal: send machinery ─────────────────────────────────────
def _alloc_tag(self) -> int:
with self._tag_lock:
tag = self._next_tag
self._next_tag = (tag + 1) & 0xFFFF
if self._next_tag == 0:
self._next_tag = 1
return tag
def _write_frame(self, type_id: int, tag: int, payload: bytes) -> None:
wire = encode_frame(type_id, tag, payload)
with self._transport_lock:
self._transport.write(wire)
flush = getattr(self._transport, "flush", None)
if callable(flush):
flush()
def _register_pending(self, tag: int, cmd_type: int) -> _Pending:
p = _Pending(tag=tag, cmd_type=cmd_type, event=threading.Event())
with self._pending_lock:
self._pending[tag] = p
return p
def _forget_pending(self, tag: int) -> None:
with self._pending_lock:
self._pending.pop(tag, None)
def _send_and_wait(self, type_id: int, payload: bytes, *, timeout: float) -> Any:
tag = self._alloc_tag()
pending = self._register_pending(tag, type_id)
try:
self._write_frame(type_id, tag, payload)
if not pending.event.wait(timeout=timeout):
raise TimeoutError_(f"command 0x{type_id:02X} tag={tag} timed out")
if pending.err is not None:
raise pending.err
return pending.ok_payload
finally:
self._forget_pending(tag)
def _send_tx(self, data: bytes, *, skip_cad: bool, timeout: float) -> TxDone:
"""TX has a two-step completion: OK (enqueued) then TX_DONE.
We re-use the same pending slot for both: the reader flags ``err``
(early ERR during enqueue) or ``tx_done`` (after TX_DONE arrives)
and signals the event. We skip the intermediate OK entirely — the
reader treats the OK as "keep waiting for TX_DONE" on this tag.
"""
tag = self._alloc_tag()
pending = self._register_pending(tag, 0x04)
try:
self._write_frame(0x04, tag, encode_tx_payload(data, skip_cad=skip_cad))
if not pending.event.wait(timeout=timeout):
raise TimeoutError_(f"TX tag={tag} timed out waiting for TX_DONE")
if pending.err is not None:
raise pending.err
if pending.tx_done is None:
raise DongloraError("TX completed without TX_DONE (internal error)")
return pending.tx_done
finally:
self._forget_pending(tag)
# ── Reader thread ────────────────────────────────────────────────
def _reader_loop(self) -> None:
try:
while not self._closed.is_set():
try:
frame = read_frame(self._transport)
except FrameCodecError as exc:
# CRC/COBS/length failure on an inbound frame. Surface as
# an async error so the caller can see it.
log.debug("inbound frame error: %s", exc)
self._async_errors.put(FrameError(str(exc)))
continue
except OSError:
# Transport dropped (USB unplug, socket closed, etc.).
if not self._closed.is_set():
log.debug("session reader: transport closed")
return
if frame is None:
# Transport timeout — loop and keep reading.
continue
try:
self._dispatch(frame)
except Exception as exc:
log.exception("reader dispatch failed: %s", exc)
finally:
# Reader is going away — wake anyone still waiting so they
# don't hang on .event.wait() forever, and signal the
# Dongle so it can trigger transparent reconnect.
with self._pending_lock:
for p in self._pending.values():
if p.err is None and p.tx_done is None:
p.err = DongloraError("transport closed")
p.event.set()
self._pending.clear()
self._reader_dead.set()
# Poison the RX queue with a sentinel so any blocked
# next_rx() unblocks promptly instead of waiting for its
# full timeout. The sentinel is filtered out in next_rx.
self._rx_queue.put(_DEAD_SENTINEL)
def _dispatch(self, frame: Frame) -> None:
type_id = frame.type_id
tag = frame.tag
payload = frame.payload
# Async events: tag==0 or RX (which always has tag 0).
if tag == 0:
if type_id == TYPE_RX:
try:
rx = RxEvent.decode(payload)
except ValueError as exc:
log.debug("bad RX event: %s", exc)
self._async_errors.put(FrameError(f"bad RX payload: {exc}"))
return
self._rx_queue.put(rx)
return
if type_id == TYPE_ERR:
code = decode_err_payload(payload)
self._async_errors.put(device_error(code, tag=0))
return
log.debug("unknown async frame type 0x%02X", type_id)
return
# Tag-correlated: OK / ERR / TX_DONE / RX-with-tag (never expected).
with self._pending_lock:
pending = self._pending.get(tag)
if pending is None:
log.debug("no pending command for tag 0x%04X (type 0x%02X)", tag, type_id)
return
if type_id == TYPE_OK:
try:
ok = parse_ok_payload(pending.cmd_type, payload)
except (ValueError, KeyError) as exc:
pending.err = DongloraError(f"bad OK payload: {exc}")
pending.event.set()
return
if pending.cmd_type == 0x04:
# TX: OK means "enqueued" — do NOT signal yet, wait for TX_DONE.
pending.ok_payload = ok
return
pending.ok_payload = ok
pending.event.set()
return
if type_id == TYPE_ERR:
code = decode_err_payload(payload)
pending.err = device_error(code, tag=tag)
pending.event.set()
return
if type_id == TYPE_TX_DONE:
try:
td = TxDone.decode(payload)
except ValueError as exc:
pending.err = DongloraError(f"bad TX_DONE payload: {exc}")
pending.event.set()
return
from donglora.errors import Cancelled, ChannelBusy
from donglora.events import TxResult
if td.result == TxResult.CHANNEL_BUSY:
pending.err = ChannelBusy()
elif td.result == TxResult.CANCELLED:
pending.err = Cancelled()
else:
pending.tx_done = td
pending.event.set()
return
log.debug("unexpected tag-correlated type 0x%02X for tag 0x%04X", type_id, tag)
_ = ErrorCode # re-export so users can still catch specific codes
+2 -2
View File
@@ -1,6 +1,6 @@
[project]
name = "donglora"
version = "0.2.0"
version = "1.1.0"
description = "DongLoRa host library — talk LoRa from Python over USB"
license = "MIT"
requires-python = ">=3.10"
@@ -8,7 +8,7 @@ readme = "README.md"
authors = [{ name = "Stephen Waits", email = "steve@waits.net" }]
keywords = ["lora", "radio", "usb", "embedded", "serial"]
classifiers = [
"Development Status :: 3 - Alpha",
"Development Status :: 5 - Production/Stable",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
-135
View File
@@ -1,135 +0,0 @@
"""Tests for donglora.client — send/recv/drain_rx/validate."""
from __future__ import annotations
import struct
from io import BytesIO
from typing import Any
from donglora.client import _rx_queue, drain_rx, recv, send, validate
from donglora.codec import cobs_encode
def _make_mock(responses: list[bytes]) -> Any:
"""Create a mock transport that returns pre-encoded COBS frames."""
wire = b""
for resp in responses:
wire += cobs_encode(resp)
class Mock:
def __init__(self) -> None:
self._stream = BytesIO(wire)
self.timeout = 2.0
self._written: list[bytes] = []
def read(self, n: int = 1) -> bytes:
return self._stream.read(n)
def write(self, data: bytes) -> int:
self._written.append(data)
return len(data)
def flush(self) -> None:
pass
return Mock()
def _pong() -> bytes:
return bytes([0])
def _ok() -> bytes:
return bytes([4])
def _rx_packet(payload: bytes, rssi: int = -80, snr: int = 10) -> bytes:
return (
bytes([2])
+ struct.pack("<h", rssi)
+ struct.pack("<h", snr)
+ struct.pack("<H", len(payload))
+ payload
)
class TestSend:
def setup_method(self) -> None:
_rx_queue.clear()
def test_basic_command(self) -> None:
mock = _make_mock([_pong()])
resp = send(mock, "Ping")
assert resp["type"] == "Pong"
def test_buffers_rx_packets(self) -> None:
mock = _make_mock([_rx_packet(b"pkt1"), _rx_packet(b"pkt2"), _ok()])
resp = send(mock, "StartRx")
assert resp["type"] == "Ok"
assert len(_rx_queue) == 2
def test_timeout(self) -> None:
mock = _make_mock([])
resp = send(mock, "Ping")
assert resp["type"] == "Timeout"
class TestRecv:
def setup_method(self) -> None:
_rx_queue.clear()
def test_from_buffer(self) -> None:
_rx_queue.append({"type": "RxPacket", "rssi": -80, "snr": 10, "payload": b"buf"})
mock = _make_mock([])
pkt = recv(mock)
assert pkt is not None
assert pkt["payload"] == b"buf"
def test_from_wire(self) -> None:
mock = _make_mock([_rx_packet(b"wire")])
pkt = recv(mock)
assert pkt is not None
assert pkt["payload"] == b"wire"
def test_timeout(self) -> None:
mock = _make_mock([])
assert recv(mock) is None
class TestDrainRx:
def setup_method(self) -> None:
_rx_queue.clear()
def test_drains_buffer_and_wire(self) -> None:
_rx_queue.append({"type": "RxPacket", "rssi": 0, "snr": 0, "payload": b"buf"})
mock = _make_mock([_rx_packet(b"wire")])
packets = drain_rx(mock)
assert len(packets) == 2
assert packets[0]["payload"] == b"buf"
assert packets[1]["payload"] == b"wire"
assert len(_rx_queue) == 0
class TestValidate:
def setup_method(self) -> None:
_rx_queue.clear()
def test_success(self) -> None:
mock = _make_mock([_pong()])
validate(mock)
def test_failure(self) -> None:
mock = _make_mock([_ok()])
try:
validate(mock)
assert False, "should have raised" # noqa: B011
except ConnectionError:
pass
def test_timeout(self) -> None:
mock = _make_mock([])
try:
validate(mock)
assert False, "should have raised" # noqa: B011
except ConnectionError:
pass
+5 -43
View File
@@ -1,12 +1,13 @@
"""Tests for donglora.codec — COBS framing."""
"""COBS helper tests — the thin re-export layer.
Frame-level codec tests live in ``test_frame.py``.
"""
from __future__ import annotations
from io import BytesIO
from cobs import cobs
from donglora.codec import cobs_encode, read_frame
from donglora.codec import cobs_encode
class TestCobsEncode:
@@ -34,42 +35,3 @@ class TestCobsEncode:
encoded = cobs_encode(data)
decoded = cobs.decode(encoded[:-1])
assert decoded == data
class _MockSerial:
"""Minimal mock that behaves like serial.Serial for read_frame."""
def __init__(self, data: bytes) -> None:
self._stream = BytesIO(data)
def read(self, n: int = 1) -> bytes:
return self._stream.read(n)
class TestReadFrame:
def test_basic(self) -> None:
data = b"hello"
frame = cobs_encode(data)
result = read_frame(_MockSerial(frame))
assert result == data
def test_timeout(self) -> None:
result = read_frame(_MockSerial(b""))
assert result is None
def test_empty_frame(self) -> None:
result = read_frame(_MockSerial(b"\x00"))
assert result is None
def test_corrupt_frame(self) -> None:
result = read_frame(_MockSerial(b"\xff\x00"))
# cobs.decode of [0xff] may or may not raise — just verify we get bytes or None
assert result is None or isinstance(result, bytes)
def test_multiple_frames(self) -> None:
frame1 = cobs_encode(b"first")
frame2 = cobs_encode(b"second")
mock = _MockSerial(frame1 + frame2)
assert read_frame(mock) == b"first"
assert read_frame(mock) == b"second"
assert read_frame(mock) is None
+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
+23
View File
@@ -0,0 +1,23 @@
"""CRC-16/CCITT-FALSE conformance tests."""
from donglora.crc import crc16
def test_check_value_matches_spec():
# PROTOCOL.md §2.2 / Appendix B.
assert crc16(b"123456789") == 0x29B1
def test_empty_input_is_initial_value():
# No reflection, no XOR-out: empty leaves init untouched.
assert crc16(b"") == 0xFFFF
def test_single_null_byte():
# A naive init=0 impl would yield 0; correct init=0xFFFF yields 0xE1F0.
assert crc16(b"\x00") == 0xE1F0
def test_differs_from_kermit_variant():
# CRC-16/KERMIT of "123456789" is 0x2189.
assert crc16(b"123456789") != 0x2189
+219
View File
@@ -0,0 +1,219 @@
"""Dongle-level tests: keepalive thread, auto-recovery, context manager."""
from __future__ import annotations
import time
import pytest
from donglora.commands import TYPE_PING, TYPE_TX
from donglora.dongle import Dongle
from donglora.errors import DongloraError
from donglora.events import (
TYPE_ERR,
TxResult,
)
from donglora.frame import encode_frame
from donglora.modulation import LoRaConfig
from donglora.session import Session
from tests.test_session import FakeTransport, _default_script
def _make_dongle(transport: FakeTransport) -> Dongle:
session = Session(transport)
info = session.get_info()
cfg = LoRaConfig.default()
session.set_config(cfg)
return Dongle(session, info, applied_config=cfg, keepalive=False)
def test_dongle_tx_returns_tx_done():
t = FakeTransport()
try:
d = _make_dongle(t)
with d:
td = d.tx(b"hello")
assert td.airtime_us == 30_976
finally:
t.close()
def test_dongle_info_property_exposes_cached_info():
t = FakeTransport()
try:
with _make_dongle(t) as d:
assert d.info.proto_major == 1
finally:
t.close()
def test_dongle_auto_recovers_from_not_configured():
"""First TX returns ENOTCONFIGURED; Dongle re-applies cached config,
then retries and succeeds.
"""
class FlipScript:
def __init__(self):
self._first_tx = True
def __call__(self, type_id, tag, payload):
if type_id == TYPE_TX and self._first_tx:
self._first_tx = False
return [encode_frame(TYPE_ERR, tag, b"\x03\x00")] # ENOTCONFIGURED
return _default_script(type_id, tag, payload)
t = FakeTransport(script=FlipScript())
try:
with _make_dongle(t) as d:
td = d.tx(b"retry-me")
assert td.result == TxResult.TRANSMITTED
finally:
t.close()
def test_dongle_context_manager_closes_session():
t = FakeTransport()
try:
d = _make_dongle(t)
with d:
pass
# After exit, further operations raise DongloraError.
with pytest.raises(DongloraError):
d.tx(b"too-late")
finally:
t.close()
def test_dongle_close_is_idempotent():
t = FakeTransport()
try:
d = _make_dongle(t)
d.close()
d.close() # must not raise
finally:
t.close()
def test_dongle_keepalive_daemon_sends_pings():
"""With keepalive enabled, a quiet dongle emits PINGs at ~500ms cadence."""
t = FakeTransport()
try:
session = Session(t)
info = session.get_info()
cfg = LoRaConfig.default()
session.set_config(cfg)
# Record frames sent after setup. keepalive=True starts the
# background thread.
t.log.clear()
d = Dongle(session, info, applied_config=cfg, keepalive=True)
time.sleep(0.8) # allow at least one keepalive interval
d.close()
# Expect at least one PING in the log.
from donglora.frame import decode_frame
ping_count = sum(1 for raw in t.log if decode_frame(raw).type_id == TYPE_PING)
assert ping_count >= 1
finally:
t.close()
def test_dongle_config_property_tracks_set_config():
t = FakeTransport()
try:
with _make_dongle(t) as d:
assert d.config == LoRaConfig.default()
custom = LoRaConfig(
freq_hz=915_000_000,
sf=10,
bw=d.config.bw,
cr=d.config.cr,
)
d.set_config(custom)
assert d.config == custom
finally:
t.close()
class _EOFTransport(FakeTransport):
"""Like FakeTransport, but `read()` raises ConnectionError after close.
Matches the real-world ``MuxConnection.read`` / ``serial.Serial.read``
behaviour when the remote hangs up, which is what the Dongle's
transparent-reconnect path needs to see.
"""
def read(self, n: int = 1) -> bytes:
data = super().read(n)
if not data and self._closed:
raise ConnectionError("transport closed")
return data
def test_dongle_transparent_reconnect_on_session_death():
"""When the session's reader dies (mux restart / transport blip),
the Dongle transparently reopens and a subsequent ``tx()`` call
just works. The caller never sees an exception.
Drives it with a reopener that returns a *fresh* transport for the
second connect — simulating the mux coming back up under a new
process with the original radio state intact.
"""
# Transport #1 — will be "killed" mid-test to simulate mux death.
t1 = _EOFTransport()
# Transport #2 — stands in for the replacement mux coming back.
t2 = FakeTransport()
session1 = Session(t1)
info1 = session1.get_info()
cfg = LoRaConfig.default()
session1.set_config(cfg)
def reopener():
s = Session(t2)
i = s.get_info()
s.set_config(cfg)
return (s, i, cfg)
d = Dongle(
session1,
info1,
applied_config=cfg,
keepalive=False,
_reopener=reopener,
)
try:
# Sanity: first TX works through session #1.
assert d.tx(b"hi").result == TxResult.TRANSMITTED
# Simulate mux going away. Reader thread sees ConnectionError
# on its next read and exits.
t1.close()
# Give the reader a moment to notice.
time.sleep(0.2)
# Second TX MUST succeed — Dongle reconnects through the
# reopener transparently.
assert d.tx(b"after-reconnect").result == TxResult.TRANSMITTED
# And the cached config is preserved across reconnect.
assert d.config == cfg
finally:
d.close()
t2.close()
def test_dongle_without_reopener_surfaces_dead_session():
"""Manually-constructed Dongles (no reopener) must not swallow
session death — the caller gets a real error."""
t = _EOFTransport()
try:
session = Session(t)
info = session.get_info()
cfg = LoRaConfig.default()
session.set_config(cfg)
d = Dongle(session, info, applied_config=cfg, keepalive=False) # no reopener
t.close()
time.sleep(0.2) # let the reader die
with pytest.raises(DongloraError):
d.tx(b"no-reopener")
finally:
t.close()
+75
View File
@@ -0,0 +1,75 @@
"""Sanity check that the three-line happy path works.
If these tests need anything beyond ``import donglora as dl`` + three
method calls, the client lost its reason for existing.
"""
from __future__ import annotations
import donglora as dl
from donglora.events import TYPE_RX, RxOrigin
def test_happy_path_tx_is_three_lines():
"""Three lines: get a Dongle (from the test harness), tx, done."""
from donglora.dongle import Dongle
from donglora.modulation import LoRaConfig
from donglora.session import Session
from tests.test_session import FakeTransport
t = FakeTransport()
try:
session = Session(t)
info = session.get_info()
cfg = LoRaConfig.default()
session.set_config(cfg)
with Dongle(session, info, applied_config=cfg, keepalive=False) as d:
td = d.tx(b"Hello")
assert td.result.name == "TRANSMITTED"
finally:
t.close()
def test_rx_iterator_yields_incoming_packets():
"""The `.rx()` iterator terminates cleanly on timeout."""
from donglora.dongle import Dongle
from donglora.events import RxEvent
from donglora.modulation import LoRaConfig
from donglora.session import Session
from tests.test_session import FakeTransport
t = FakeTransport()
try:
session = Session(t)
info = session.get_info()
cfg = LoRaConfig.default()
session.set_config(cfg)
rx = RxEvent(
rssi_dbm=-72.0,
snr_db=8.0,
freq_err_hz=0,
timestamp_us=100,
crc_valid=True,
packets_dropped=0,
origin=RxOrigin.OTA,
data=b"ping",
)
with Dongle(session, info, applied_config=cfg, keepalive=False) as d:
# Inject two RX events, then time out.
t.push_async(TYPE_RX, 0, rx.encode())
t.push_async(TYPE_RX, 0, rx.encode())
received: list[bytes] = []
for pkt in d.rx(timeout=0.5):
received.append(pkt.data)
if len(received) == 2:
break
assert received == [b"ping", b"ping"]
finally:
t.close()
def test_public_api_names_are_stable():
# The top-level module exposes the core happy-path surface. If any
# of these disappear it's a breaking change we should notice.
for name in ("connect", "Dongle", "LoRaConfig", "tx", "rx", "recv"):
assert hasattr(dl, name), f"donglora.{name} must stay exported"
+265
View File
@@ -0,0 +1,265 @@
"""Event payload tests: Info, SetConfigResult, TxDone, RxEvent, ERR."""
import pytest
from donglora.commands import TYPE_PING, TYPE_SET_CONFIG
from donglora.errors import ErrorCode
from donglora.events import (
Owner,
RxEvent,
RxOrigin,
SetConfigResult,
SetConfigResultCode,
TxDone,
TxResult,
decode_err_payload,
encode_err_payload,
parse_ok_payload,
)
from donglora.info import Capability, Info, RadioChipId
from donglora.modulation import LoRaConfig
# ── SetConfigResult ───────────────────────────────────────────
def test_set_config_result_c23_bytes():
# PROTOCOL.md §C.2.3: OK in reply to SET_CONFIG, LoRa applied.
r = SetConfigResult(
result=SetConfigResultCode.APPLIED,
owner=Owner.MINE,
current=LoRaConfig.default(),
)
expected = bytes(
[
0x00,
0x01, # result=APPLIED, owner=MINE
0x01, # modulation_id = LoRa
0xA0,
0x27,
0xBE,
0x33, # freq
0x07,
0x07,
0x00, # sf / bw / cr
0x08,
0x00, # preamble_len
0x24,
0x14, # sync_word
0x0E, # tx_power
0x00,
0x01,
0x00, # header / crc / iq
],
)
assert r.encode() == expected
assert SetConfigResult.decode(expected) == r
# ── TxDone ────────────────────────────────────────────────────
def test_tx_done_transmitted_airtime():
td = TxDone(result=TxResult.TRANSMITTED, airtime_us=30_976)
assert td.encode() == bytes([0x00, 0x00, 0x79, 0x00, 0x00])
assert TxDone.decode(td.encode()) == td
def test_tx_done_channel_busy():
td = TxDone(result=TxResult.CHANNEL_BUSY, airtime_us=0)
assert TxDone.decode(td.encode()) == td
# ── RxEvent ───────────────────────────────────────────────────
def test_rx_event_c26_bytes():
rx = RxEvent(
rssi_dbm=-73.5,
snr_db=9.5,
freq_err_hz=-125,
timestamp_us=42_000_000,
crc_valid=True,
packets_dropped=0,
origin=RxOrigin.OTA,
data=bytes([0x01, 0x02, 0x03, 0x04]),
)
# PROTOCOL.md §C.2.6 payload (strip frame header + CRC).
expected = bytes(
[
0x21,
0xFD, # rssi = -735 LE
0x5F,
0x00, # snr = 95 LE
0x83,
0xFF,
0xFF,
0xFF, # freq_err = -125 LE
0x80,
0xDE,
0x80,
0x02,
0x00,
0x00,
0x00,
0x00, # ts = 42_000_000 LE
0x01, # crc_valid
0x00,
0x00, # packets_dropped
0x00, # origin OTA
0x01,
0x02,
0x03,
0x04,
],
)
assert rx.encode() == expected
decoded = RxEvent.decode(expected)
assert decoded.rssi_dbm == pytest.approx(-73.5)
assert decoded.snr_db == pytest.approx(9.5)
assert decoded.data == bytes([0x01, 0x02, 0x03, 0x04])
def test_rx_event_rejects_invalid_crc_byte():
buf = bytearray(
RxEvent(
rssi_dbm=0.0,
snr_db=0.0,
freq_err_hz=0,
timestamp_us=0,
crc_valid=True,
packets_dropped=0,
origin=RxOrigin.OTA,
data=b"",
).encode(),
)
buf[16] = 2
with pytest.raises(ValueError):
RxEvent.decode(bytes(buf))
# ── ERR payload ───────────────────────────────────────────────
def test_err_payload_roundtrip():
wire = encode_err_payload(ErrorCode.ENOT_CONFIGURED)
assert wire == bytes([0x03, 0x00])
assert decode_err_payload(wire) == ErrorCode.ENOT_CONFIGURED
def test_err_payload_preserves_unknown_code():
wire = encode_err_payload(0xABCD)
assert decode_err_payload(wire) == 0xABCD
# ── OK payload context dispatch ───────────────────────────────
def test_parse_ok_for_ping_requires_empty():
assert parse_ok_payload(TYPE_PING, b"") is None
with pytest.raises(ValueError):
parse_ok_payload(TYPE_PING, b"\x00")
def test_parse_ok_for_set_config_returns_result():
r = SetConfigResult(
result=SetConfigResultCode.APPLIED,
owner=Owner.MINE,
current=LoRaConfig.default(),
)
parsed = parse_ok_payload(TYPE_SET_CONFIG, r.encode())
assert parsed == r
# ── Info ──────────────────────────────────────────────────────
def _sample_info() -> Info:
return Info(
proto_major=1,
proto_minor=0,
fw_major=0,
fw_minor=1,
fw_patch=0,
radio_chip_id=RadioChipId.SX1262,
capability_bitmap=Capability.LORA | Capability.FSK | Capability.CAD_BEFORE_TX,
supported_sf_bitmap=0x1FE0,
supported_bw_bitmap=0x03FF,
max_payload_bytes=255,
rx_queue_capacity=64,
tx_queue_capacity=16,
freq_min_hz=150_000_000,
freq_max_hz=960_000_000,
tx_power_min_dbm=-9,
tx_power_max_dbm=22,
mcu_uid=bytes([0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x23, 0x45, 0x67]),
radio_uid=b"",
)
def test_info_appendix_c22_bytes():
info = _sample_info()
expected = bytes(
[
0x01,
0x00,
0x00,
0x01,
0x00,
0x02,
0x00, # chip id
0x03,
0x00,
0x01,
0x00,
0x00,
0x00,
0x00,
0x00, # cap bitmap
0xE0,
0x1F, # supported SF
0xFF,
0x03, # supported BW
0xFF,
0x00, # max payload 255
0x40,
0x00, # rx q 64
0x10,
0x00, # tx q 16
0x80,
0xD1,
0xF0,
0x08, # freq min
0x00,
0x70,
0x38,
0x39, # freq max
0xF7,
0x16, # tx power min/max
0x08, # mcu_uid_len
0xDE,
0xAD,
0xBE,
0xEF,
0x01,
0x23,
0x45,
0x67,
0x00, # radio_uid_len
],
)
assert info.encode() == expected
assert Info.decode(expected) == info
def test_info_tolerates_trailing_bytes():
info = _sample_info()
wire = info.encode() + b"\xaa" * 10
assert Info.decode(wire) == info
def test_info_supports():
info = _sample_info()
assert info.supports(Capability.LORA)
assert info.supports(Capability.LORA | Capability.FSK)
assert not info.supports(Capability.MULTI_CLIENT)
assert info.chip == RadioChipId.SX1262
+175
View File
@@ -0,0 +1,175 @@
"""Frame codec tests — every Appendix C hex vector pinned.
These match the Rust crate's ``tests/vectors.rs`` byte-for-byte. If the
client-py and firmware ever disagree, one of these tests fails first.
"""
import pytest
from donglora.commands import (
TYPE_PING,
TYPE_TX,
encode_ping_payload,
encode_tx_payload,
)
from donglora.events import TYPE_ERR, TYPE_OK, TYPE_TX_DONE
from donglora.frame import (
Frame,
FrameCobsError,
FrameCrcError,
FrameTooShortError,
decode_frame,
encode_frame,
read_frame,
)
# ── §C.2.1 PING / OK ─────────────────────────────────────────────
def test_c21_ping_encodes_to_spec_bytes():
wire = encode_frame(TYPE_PING, 0x0001, encode_ping_payload())
assert wire == bytes([0x03, 0x01, 0x01, 0x03, 0x9D, 0xC8, 0x00])
def test_c21_ok_encodes_to_spec_bytes():
wire = encode_frame(TYPE_OK, 0x0001, b"")
assert wire == bytes([0x03, 0x80, 0x01, 0x03, 0xF7, 0xC4, 0x00])
# ── §C.2.4 TX / OK / TX_DONE ─────────────────────────────────────
def test_c24_tx_with_cad():
wire = encode_frame(TYPE_TX, 0x0004, encode_tx_payload(b"Hello", skip_cad=False))
assert wire == bytes(
[0x03, 0x04, 0x04, 0x01, 0x08, 0x48, 0x65, 0x6C, 0x6C, 0x6F, 0x26, 0x40, 0x00],
)
def test_c24_ok():
wire = encode_frame(TYPE_OK, 0x0004, b"")
assert wire == bytes([0x03, 0x80, 0x04, 0x03, 0x02, 0x3B, 0x00])
def test_c24_tx_done():
# Payload = result(0=TRANSMITTED) + airtime_us(30976 LE).
payload = bytes([0x00, 0x00, 0x79, 0x00, 0x00])
wire = encode_frame(TYPE_TX_DONE, 0x0004, payload)
assert wire == bytes(
[0x03, 0xC1, 0x04, 0x01, 0x01, 0x02, 0x79, 0x01, 0x03, 0xE3, 0xFA, 0x00],
)
# ── §C.2.5 TX skip_cad ──────────────────────────────────────────
def test_c25_tx_skip_cad():
wire = encode_frame(TYPE_TX, 0x0005, encode_tx_payload(b"URGENT", skip_cad=True))
assert wire == bytes(
[0x03, 0x04, 0x05, 0x0A, 0x01, 0x55, 0x52, 0x47, 0x45, 0x4E, 0x54, 0xDB, 0x1C, 0x00],
)
# ── §C.5.1 ENOTCONFIGURED ───────────────────────────────────────
def test_c51_err_notconfigured():
# Payload = 0x0003 LE.
wire = encode_frame(TYPE_ERR, 0x0028, bytes([0x03, 0x00]))
assert wire == bytes([0x03, 0x81, 0x28, 0x02, 0x03, 0x03, 0x53, 0x7E, 0x00])
# ── §C.6.4 Async EFRAME (tag=0) ─────────────────────────────────
def test_c64_async_err_eframe():
wire = encode_frame(TYPE_ERR, 0x0000, bytes([0x02, 0x01]))
assert wire == bytes([0x02, 0x81, 0x01, 0x05, 0x02, 0x01, 0xCE, 0xEF, 0x00])
# ── §C.8.1 COBS edge: no zeros in body ─────────────────────────
def test_c81_no_zeros_in_body():
# PING tag=0x0101 → tag bytes have no 0x00, body has no zeros.
wire = encode_frame(TYPE_PING, 0x0101, b"")
assert wire == bytes([0x06, 0x01, 0x01, 0x01, 0xBC, 0xD8, 0x00])
# ── Decode-side round-trip ──────────────────────────────────────
def test_decode_roundtrip_empty_payload():
wire = encode_frame(TYPE_PING, 0x0042, b"")
frame = decode_frame(wire[:-1]) # strip sentinel
assert frame == Frame(type_id=TYPE_PING, tag=0x0042, payload=b"")
def test_decode_roundtrip_with_payload():
payload = b"hello-world"
wire = encode_frame(TYPE_TX, 0x0123, payload)
frame = decode_frame(wire[:-1])
assert frame.type_id == TYPE_TX
assert frame.tag == 0x0123
assert frame.payload == payload
def test_decode_rejects_crc_flip():
wire = bytearray(encode_frame(TYPE_PING, 0x0001, b""))
# Flip one byte of the CRC region.
wire[-3] ^= 0x01
with pytest.raises((FrameCrcError, FrameCobsError)):
decode_frame(bytes(wire[:-1]))
def test_decode_rejects_too_short_frame():
# COBS of a 4-byte body: [0x05, 0xAA, 0xBB, 0xCC, 0xDD]. Decodes to
# 4 bytes, under the 5-byte minimum (type + tag + crc).
wire = bytes([0x05, 0xAA, 0xBB, 0xCC, 0xDD])
with pytest.raises(FrameTooShortError):
decode_frame(wire)
# ── read_frame with a BytesIO-like reader ──────────────────────
class _FakeReader:
def __init__(self, data: bytes):
self._data = bytearray(data)
def read(self, n: int = 1) -> bytes:
if not self._data:
return b""
chunk = bytes(self._data[:n])
del self._data[:n]
return chunk
def test_read_frame_yields_decoded_frame():
wire = encode_frame(TYPE_PING, 0x0001, b"")
r = _FakeReader(wire)
frame = read_frame(r)
assert frame == Frame(type_id=TYPE_PING, tag=0x0001, payload=b"")
def test_read_frame_returns_none_on_timeout():
r = _FakeReader(b"")
assert read_frame(r) is None
def test_read_frame_handles_back_to_back_frames():
a = encode_frame(TYPE_PING, 1, b"")
b = encode_frame(TYPE_PING, 2, b"")
r = _FakeReader(a + b)
assert read_frame(r).tag == 1
assert read_frame(r).tag == 2
# ── Oversized payloads rejected ────────────────────────────────
def test_encode_rejects_oversized_payload():
from donglora.frame import MAX_PAYLOAD_FIELD, FrameTooLargeError
with pytest.raises(FrameTooLargeError):
encode_frame(0xC0, 0x0000, b"\x42" * (MAX_PAYLOAD_FIELD + 1))
+202
View File
@@ -0,0 +1,202 @@
"""Per-modulation config encode/decode tests.
The LoRa test pins the Appendix C.2.3 canonical EU868 byte sequence.
The others round-trip through encode → decode.
"""
import pytest
from donglora.modulation import (
FlrcBitrate,
FlrcBt,
FlrcCodingRate,
FlrcConfig,
FlrcPreambleLen,
FskConfig,
LoRaBandwidth,
LoRaCodingRate,
LoRaConfig,
LoRaHeaderMode,
LrFhssBandwidth,
LrFhssCodingRate,
LrFhssConfig,
LrFhssGrid,
decode_modulation,
encode_modulation,
)
# ── LoRa — spec bytes ───────────────────────────────────────────
def test_lora_default_matches_appendix_c23():
cfg = LoRaConfig.default()
expected = bytes(
[
0xA0,
0x27,
0xBE,
0x33, # freq_hz = 868_100_000
0x07, # sf
0x07, # bw = Khz125
0x00, # cr = 4/5
0x08,
0x00, # preamble_len = 8
0x24,
0x14, # sync_word = 0x1424
0x0E, # tx_power_dbm = 14
0x00, # header explicit
0x01, # payload_crc on
0x00, # iq normal
],
)
assert cfg.encode() == expected
def test_lora_roundtrip():
cfg = LoRaConfig(
freq_hz=915_000_000,
sf=10,
bw=LoRaBandwidth.KHZ_250,
cr=LoRaCodingRate.CR_4_8,
preamble_len=32,
sync_word=0xABCD,
tx_power_dbm=22,
header_mode=LoRaHeaderMode.IMPLICIT,
payload_crc=False,
iq_invert=True,
)
assert LoRaConfig.decode(cfg.encode()) == cfg
def test_lora_decode_rejects_wrong_length():
with pytest.raises(ValueError):
LoRaConfig.decode(b"\x00" * 14)
def test_lora_decode_rejects_invalid_enum():
buf = bytearray(LoRaConfig.default().encode())
buf[5] = 99 # invalid bandwidth
with pytest.raises(ValueError):
LoRaConfig.decode(bytes(buf))
# ── FSK ────────────────────────────────────────────────────────
def test_fsk_roundtrip_with_sync_word():
cfg = FskConfig(
freq_hz=868_000_000,
bitrate_bps=50_000,
freq_dev_hz=25_000,
rx_bw=0x1A,
preamble_len=32,
sync_word=b"\x12\x34\x56\x78",
)
assert FskConfig.decode(cfg.encode()) == cfg
def test_fsk_rejects_oversized_sync_word():
cfg = FskConfig(
freq_hz=0,
bitrate_bps=0,
freq_dev_hz=0,
rx_bw=0,
preamble_len=0,
sync_word=b"\x00" * 9,
)
with pytest.raises(ValueError):
cfg.encode()
# ── LR-FHSS ────────────────────────────────────────────────────
def test_lr_fhss_roundtrip():
cfg = LrFhssConfig(
freq_hz=915_000_000,
bw=LrFhssBandwidth.KHZ_137,
cr=LrFhssCodingRate.CR_2_3,
grid=LrFhssGrid.KHZ_25,
hopping=True,
tx_power_dbm=14,
)
assert LrFhssConfig.decode(cfg.encode()) == cfg
def test_lr_fhss_rejects_nonzero_reserved():
cfg = LrFhssConfig(
freq_hz=0,
bw=LrFhssBandwidth.KHZ_39,
cr=LrFhssCodingRate.CR_1_3,
grid=LrFhssGrid.KHZ_25,
hopping=False,
tx_power_dbm=0,
)
buf = bytearray(cfg.encode())
buf[9] = 1
with pytest.raises(ValueError):
LrFhssConfig.decode(bytes(buf))
# ── FLRC ───────────────────────────────────────────────────────
def test_flrc_roundtrip():
cfg = FlrcConfig(
freq_hz=2_400_000_000,
bitrate=FlrcBitrate.KBPS_1300,
cr=FlrcCodingRate.CR_3_4,
bt=FlrcBt.BT_0_5,
preamble_len=FlrcPreambleLen.BITS_24,
sync_word=0x12345678,
tx_power_dbm=10,
)
assert FlrcConfig.decode(cfg.encode()) == cfg
# ── Sum type (modulation_id prefix) ───────────────────────────
def test_encode_modulation_prepends_lora_id():
cfg = LoRaConfig.default()
wire = encode_modulation(cfg)
assert wire[0] == 0x01
assert wire[1:] == cfg.encode()
def test_decode_modulation_rejects_unknown_id():
with pytest.raises(ValueError):
decode_modulation(bytes([0x99]) + b"\x00" * 15)
def test_decode_modulation_dispatches_correctly():
for cfg in (
LoRaConfig.default(),
FskConfig(
freq_hz=868_000_000,
bitrate_bps=9_600,
freq_dev_hz=5_000,
rx_bw=0,
preamble_len=16,
sync_word=b"\x12\x34",
),
LrFhssConfig(
freq_hz=915_000_000,
bw=LrFhssBandwidth.KHZ_137,
cr=LrFhssCodingRate.CR_2_3,
grid=LrFhssGrid.KHZ_25,
hopping=True,
tx_power_dbm=14,
),
FlrcConfig(
freq_hz=2_400_000_000,
bitrate=FlrcBitrate.KBPS_1300,
cr=FlrcCodingRate.CR_3_4,
bt=FlrcBt.BT_0_5,
preamble_len=FlrcPreambleLen.BITS_24,
sync_word=0x12345678,
tx_power_dbm=10,
),
):
wire = encode_modulation(cfg)
assert decode_modulation(wire) == cfg
-254
View File
@@ -1,254 +0,0 @@
"""Tests for donglora.protocol — ported from client-rs/src/protocol.rs tests."""
from __future__ import annotations
import struct
import pytest
from donglora.protocol import (
DEFAULT_CONFIG,
MAX_PAYLOAD,
PREAMBLE_DEFAULT,
RADIO_CONFIG_SIZE,
TX_POWER_MAX,
Bandwidth,
ErrorCode,
RadioConfig,
decode_config,
decode_response,
encode_command,
encode_config,
)
def _make_config() -> RadioConfig:
return {
"freq_hz": 915_000_000,
"bw": Bandwidth.Khz125,
"sf": 7,
"cr": 5,
"sync_word": 0x3444,
"tx_power_dbm": 22,
"preamble_len": 16,
"cad": 1,
}
# ── RadioConfig ──────────────────────────────────────────────────
class TestRadioConfig:
def test_roundtrip(self) -> None:
cfg = _make_config()
encoded = encode_config(cfg)
assert len(encoded) == RADIO_CONFIG_SIZE
decoded = decode_config(encoded)
assert decoded == cfg
def test_default_roundtrip(self) -> None:
encoded = encode_config(DEFAULT_CONFIG)
decoded = decode_config(encoded)
assert decoded is not None
assert decoded["freq_hz"] == DEFAULT_CONFIG["freq_hz"]
assert decoded["bw"] == DEFAULT_CONFIG["bw"]
assert decoded["sf"] == DEFAULT_CONFIG["sf"]
assert decoded["cr"] == DEFAULT_CONFIG["cr"]
assert decoded["sync_word"] == DEFAULT_CONFIG["sync_word"]
assert decoded["tx_power_dbm"] == DEFAULT_CONFIG["tx_power_dbm"]
assert decoded["cad"] == DEFAULT_CONFIG["cad"]
def test_all_bandwidths(self) -> None:
for bw in Bandwidth:
cfg = {**_make_config(), "bw": bw}
encoded = encode_config(cfg)
decoded = decode_config(encoded)
assert decoded is not None
assert decoded["bw"] == bw
def test_invalid_bandwidth(self) -> None:
buf = bytearray(encode_config(_make_config()))
buf[4] = 255
assert decode_config(bytes(buf)) is None
def test_negative_power(self) -> None:
cfg = {**_make_config(), "tx_power_dbm": TX_POWER_MAX}
encoded = encode_config(cfg)
decoded = decode_config(encoded)
assert decoded is not None
assert decoded["tx_power_dbm"] == TX_POWER_MAX
def test_short_buffer(self) -> None:
assert decode_config(bytes(12)) is None
assert decode_config(b"") is None
# ── Command encoding ─────────────────────────────────────────────
class TestCommandEncoding:
def test_simple_tags(self) -> None:
assert encode_command("Ping") == bytes([0])
assert encode_command("GetConfig") == bytes([1])
assert encode_command("StartRx") == bytes([3])
assert encode_command("StopRx") == bytes([4])
assert encode_command("DisplayOn") == bytes([6])
assert encode_command("DisplayOff") == bytes([7])
assert encode_command("GetMac") == bytes([8])
def test_set_config(self) -> None:
cfg = _make_config()
cmd = encode_command("SetConfig", config=cfg)
assert cmd[0] == 2
assert len(cmd) == 1 + RADIO_CONFIG_SIZE
decoded = decode_config(cmd[1:])
assert decoded == cfg
def test_transmit_no_config(self) -> None:
cmd = encode_command("Transmit", payload=b"hello")
assert cmd[0] == 5
assert cmd[1] == 0 # has_config = false
length = struct.unpack_from("<H", cmd, 2)[0]
assert length == 5
assert cmd[4:] == b"hello"
def test_transmit_with_config(self) -> None:
cfg = _make_config()
cmd = encode_command("Transmit", config=cfg, payload=b"test")
assert cmd[0] == 5
assert cmd[1] == 1 # has_config = true
decoded = decode_config(cmd[2:])
assert decoded == cfg
offset = 2 + RADIO_CONFIG_SIZE
length = struct.unpack_from("<H", cmd, offset)[0]
assert length == 4
assert cmd[offset + 2 :] == b"test"
def test_transmit_empty_payload(self) -> None:
cmd = encode_command("Transmit", payload=b"")
assert cmd[0] == 5
length = struct.unpack_from("<H", cmd, 2)[0]
assert length == 0
def test_transmit_oversized_payload(self) -> None:
with pytest.raises(ValueError, match="payload too large"):
encode_command("Transmit", payload=bytes(MAX_PAYLOAD + 1))
def test_transmit_max_payload(self) -> None:
cmd = encode_command("Transmit", payload=bytes(MAX_PAYLOAD))
length = struct.unpack_from("<H", cmd, 2)[0]
assert length == MAX_PAYLOAD
def test_unknown_command(self) -> None:
with pytest.raises(ValueError, match="unknown command"):
encode_command("FakeCommand")
# ── Response decoding ────────────────────────────────────────────
class TestResponseDecoding:
def test_pong(self) -> None:
assert decode_response(bytes([0])) == {"type": "Pong"}
def test_config(self) -> None:
cfg = _make_config()
resp = decode_response(bytes([1]) + encode_config(cfg))
assert resp["type"] == "Config"
assert resp["freq_hz"] == cfg["freq_hz"]
assert resp["bw"] == cfg["bw"]
assert resp["sf"] == cfg["sf"]
assert resp["cr"] == cfg["cr"]
assert resp["sync_word"] == cfg["sync_word"]
assert resp["tx_power_dbm"] == cfg["tx_power_dbm"]
assert resp["preamble_len"] == cfg["preamble_len"]
assert resp["cad"] == cfg["cad"]
def test_rx_packet(self) -> None:
payload = b"data"
data = bytes([2])
data += struct.pack("<h", -80)
data += struct.pack("<h", 10)
data += struct.pack("<H", len(payload))
data += payload
resp = decode_response(data)
assert resp["type"] == "RxPacket"
assert resp["rssi"] == -80
assert resp["snr"] == 10
assert resp["payload"] == b"data"
def test_rx_packet_empty(self) -> None:
data = bytes([2])
data += struct.pack("<h", -120)
data += struct.pack("<h", -5)
data += struct.pack("<H", 0)
resp = decode_response(data)
assert resp["type"] == "RxPacket"
assert resp["payload"] == b""
def test_tx_done(self) -> None:
assert decode_response(bytes([3])) == {"type": "TxDone"}
def test_ok(self) -> None:
assert decode_response(bytes([4])) == {"type": "Ok"}
def test_error_codes(self) -> None:
for code in ErrorCode:
resp = decode_response(bytes([5, code]))
assert resp["type"] == "Error"
assert resp["code"] == code
def test_mac_address(self) -> None:
mac_bytes = bytes([0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF])
resp = decode_response(bytes([6]) + mac_bytes)
assert resp["type"] == "MacAddress"
assert resp["mac"] == "AA:BB:CC:DD:EE:FF"
def test_empty(self) -> None:
assert decode_response(b"") == {"type": "Empty"}
def test_unknown_tag(self) -> None:
resp = decode_response(bytes([255]))
assert resp["type"] == "Unknown(255)"
# ── Cross-compatibility with firmware encoding ────────────────────
class TestFirmwareCompatibility:
def test_worked_example(self) -> None:
"""From PROTOCOL.md: 915 MHz, 125 kHz BW, SF7, CR 4/5, sync 0x1424, max power."""
cfg: RadioConfig = {
"freq_hz": 915_000_000,
"bw": Bandwidth.Khz125,
"sf": 7,
"cr": 5,
"sync_word": 0x1424,
"tx_power_dbm": TX_POWER_MAX,
"preamble_len": PREAMBLE_DEFAULT,
"cad": 1,
}
cmd = encode_command("SetConfig", config=cfg)
expected = bytes(
[0x02, 0xC0, 0xCA, 0x89, 0x36, 0x07, 0x07, 0x05, 0x24, 0x14, 0x80, 0x00, 0x00, 0x01]
)
assert cmd == expected
# ── Enum coverage ────────────────────────────────────────────────
class TestEnums:
def test_bandwidth_values(self) -> None:
assert Bandwidth.Khz7 == 0
assert Bandwidth.Khz500 == 9
assert len(Bandwidth) == 10
def test_error_code_values(self) -> None:
assert ErrorCode.InvalidConfig == 0
assert ErrorCode.RadioBusy == 1
assert ErrorCode.TxTimeout == 2
assert ErrorCode.CrcError == 3
assert ErrorCode.NotConfigured == 4
assert ErrorCode.NoDisplay == 5
assert len(ErrorCode) == 6
+337
View File
@@ -0,0 +1,337 @@
"""Session-level tests: tag correlation, async events, auto-recovery.
Uses a `FakeTransport` — a thread-safe in-memory duplex pipe with a
simple script of pre-canned firmware responses. Every test exercises
the real frame codec; only the transport is faked.
"""
from __future__ import annotations
import threading
from collections.abc import Callable
import pytest
from donglora.commands import (
TYPE_GET_INFO,
TYPE_PING,
TYPE_RX_START,
TYPE_SET_CONFIG,
TYPE_TX,
)
from donglora.errors import (
BusyError,
Cancelled,
ChannelBusy,
NotConfiguredError,
)
from donglora.events import (
TYPE_ERR,
TYPE_OK,
TYPE_RX,
TYPE_TX_DONE,
Owner,
RxEvent,
RxOrigin,
SetConfigResult,
SetConfigResultCode,
TxDone,
TxResult,
)
from donglora.frame import decode_frame, encode_frame
from donglora.info import Info, RadioChipId
from donglora.modulation import LoRaConfig
from donglora.session import Session
# ── A tiny in-memory transport that simulates a device ─────────
class FakeTransport:
"""Thread-safe in-memory duplex pipe with a scripted device."""
def __init__(self, *, script: Callable[[int, int, bytes], list[bytes]] | None = None):
self._read_buf = bytearray()
self._read_cond = threading.Condition()
self._write_buf = bytearray()
self._write_cond = threading.Condition()
self._closed = False
self._script = script or _default_script
self._writer_thread = threading.Thread(
target=self._writer_loop,
name="fake-transport-writer",
daemon=True,
)
self._writer_thread.start()
self.log: list[bytes] = []
def write(self, data: bytes) -> int:
with self._write_cond:
self._write_buf.extend(data)
self._write_cond.notify_all()
return len(data)
def flush(self) -> None:
pass
def read(self, n: int = 1) -> bytes:
with self._read_cond:
while not self._read_buf and not self._closed:
self._read_cond.wait(timeout=0.1)
if self._closed:
return b""
chunk = bytes(self._read_buf[:n])
del self._read_buf[:n]
return chunk
def close(self) -> None:
with self._read_cond:
self._closed = True
self._read_cond.notify_all()
with self._write_cond:
self._write_cond.notify_all()
def _writer_loop(self) -> None:
"""Read framed commands off _write_buf, feed to the script,
push responses onto _read_buf.
"""
while not self._closed:
with self._write_cond:
while not self._write_buf and not self._closed:
self._write_cond.wait(timeout=0.1)
if self._closed:
return
# Collect complete frames (ending in 0x00) from write_buf.
frames_raw: list[bytes] = []
while True:
try:
idx = self._write_buf.index(0)
except ValueError:
break
frames_raw.append(bytes(self._write_buf[:idx]))
del self._write_buf[: idx + 1]
for raw in frames_raw:
try:
frame = decode_frame(raw)
except Exception:
continue
self.log.append(raw)
responses = self._script(frame.type_id, frame.tag, frame.payload)
if responses:
with self._read_cond:
for r in responses:
self._read_buf.extend(r)
self._read_cond.notify_all()
# Kick an async event (e.g. RX) into the read stream.
def push_async(self, type_id: int, tag: int, payload: bytes) -> None:
wire = encode_frame(type_id, tag, payload)
with self._read_cond:
self._read_buf.extend(wire)
self._read_cond.notify_all()
def _default_script(type_id: int, tag: int, payload: bytes) -> list[bytes]: # noqa: ARG001
"""Reasonable default: ACK every command with a shaped OK."""
if type_id == TYPE_PING:
return [encode_frame(TYPE_OK, tag, b"")]
if type_id == TYPE_GET_INFO:
info = _make_info()
return [encode_frame(TYPE_OK, tag, info.encode())]
if type_id == TYPE_SET_CONFIG:
result = SetConfigResult(
result=SetConfigResultCode.APPLIED,
owner=Owner.MINE,
current=LoRaConfig.default(),
)
return [encode_frame(TYPE_OK, tag, result.encode())]
if type_id == TYPE_RX_START:
return [encode_frame(TYPE_OK, tag, b"")]
if type_id == TYPE_TX:
td = TxDone(result=TxResult.TRANSMITTED, airtime_us=30_976)
return [
encode_frame(TYPE_OK, tag, b""),
encode_frame(TYPE_TX_DONE, tag, td.encode()),
]
return [encode_frame(TYPE_OK, tag, b"")]
def _make_info() -> Info:
return Info(
proto_major=1,
proto_minor=0,
fw_major=0,
fw_minor=1,
fw_patch=0,
radio_chip_id=RadioChipId.SX1262,
capability_bitmap=1 | (1 << 16), # LoRa + CAD_BEFORE_TX
supported_sf_bitmap=0x1FE0,
supported_bw_bitmap=0x03FF,
max_payload_bytes=255,
rx_queue_capacity=32,
tx_queue_capacity=1,
freq_min_hz=150_000_000,
freq_max_hz=960_000_000,
tx_power_min_dbm=-9,
tx_power_max_dbm=22,
mcu_uid=b"\xde\xad\xbe\xef\x01\x23",
radio_uid=b"",
)
# ── Session tests ──────────────────────────────────────────────
@pytest.fixture
def transport():
t = FakeTransport()
yield t
t.close()
def test_session_ping(transport):
s = Session(transport)
try:
s.ping(timeout=2.0)
finally:
s.close()
def test_session_get_info_returns_info_struct(transport):
s = Session(transport)
try:
info = s.get_info(timeout=2.0)
assert isinstance(info, Info)
assert info.proto_major == 1
finally:
s.close()
def test_session_set_config_returns_set_config_result(transport):
s = Session(transport)
try:
result = s.set_config(LoRaConfig.default())
assert isinstance(result, SetConfigResult)
assert result.result == SetConfigResultCode.APPLIED
finally:
s.close()
def test_session_transmit_returns_tx_done(transport):
s = Session(transport)
try:
td = s.transmit(b"Hello")
assert td.result == TxResult.TRANSMITTED
assert td.airtime_us == 30_976
finally:
s.close()
def test_session_transmit_channel_busy(transport):
def script(type_id: int, tag: int, payload: bytes) -> list[bytes]:
if type_id == TYPE_TX:
td = TxDone(result=TxResult.CHANNEL_BUSY, airtime_us=0)
return [
encode_frame(TYPE_OK, tag, b""),
encode_frame(TYPE_TX_DONE, tag, td.encode()),
]
return _default_script(type_id, tag, payload)
transport._script = script
s = Session(transport)
try:
with pytest.raises(ChannelBusy):
s.transmit(b"x")
finally:
s.close()
def test_session_transmit_cancelled(transport):
def script(type_id: int, tag: int, payload: bytes) -> list[bytes]:
if type_id == TYPE_TX:
td = TxDone(result=TxResult.CANCELLED, airtime_us=0)
return [
encode_frame(TYPE_OK, tag, b""),
encode_frame(TYPE_TX_DONE, tag, td.encode()),
]
return _default_script(type_id, tag, payload)
transport._script = script
s = Session(transport)
try:
with pytest.raises(Cancelled):
s.transmit(b"x")
finally:
s.close()
def test_session_err_not_configured_raises(transport):
def script(type_id: int, tag: int, payload: bytes) -> list[bytes]:
if type_id == TYPE_TX:
return [encode_frame(TYPE_ERR, tag, b"\x03\x00")]
return _default_script(type_id, tag, payload)
transport._script = script
s = Session(transport)
try:
with pytest.raises(NotConfiguredError):
s.transmit(b"x")
finally:
s.close()
def test_session_err_ebusy_raises(transport):
def script(type_id: int, tag: int, payload: bytes) -> list[bytes]:
if type_id == TYPE_TX:
return [encode_frame(TYPE_ERR, tag, b"\x06\x00")]
return _default_script(type_id, tag, payload)
transport._script = script
s = Session(transport)
try:
with pytest.raises(BusyError):
s.transmit(b"x")
finally:
s.close()
def test_session_async_rx_event_arrives_on_queue(transport):
s = Session(transport)
try:
rx = RxEvent(
rssi_dbm=-73.5,
snr_db=9.5,
freq_err_hz=0,
timestamp_us=100,
crc_valid=True,
packets_dropped=0,
origin=RxOrigin.OTA,
data=b"\x01\x02\x03",
)
transport.push_async(TYPE_RX, 0, rx.encode())
delivered = s.next_rx(timeout=2.0)
assert delivered is not None
assert delivered.data == b"\x01\x02\x03"
finally:
s.close()
def test_session_tag_correlation_across_concurrent_sends(transport):
# Two threads each send a PING. Both must resolve against their own tag.
s = Session(transport)
try:
errors: list[Exception] = []
def do_ping() -> None:
try:
s.ping(timeout=2.0)
except Exception as exc:
errors.append(exc)
threads = [threading.Thread(target=do_ping) for _ in range(10)]
for t in threads:
t.start()
for t in threads:
t.join(timeout=3.0)
assert errors == []
finally:
s.close()
Generated
+1 -1
View File
@@ -296,7 +296,7 @@ wheels = [
[[package]]
name = "donglora"
version = "0.2.0"
version = "1.1.0"
source = { editable = "." }
dependencies = [
{ name = "cobs" },