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.
325 lines
10 KiB
Python
325 lines
10 KiB
Python
"""Auto-detect and connect to a DongLoRa device.
|
|
|
|
``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``.
|
|
5. Spawns a keepalive thread to keep the session alive.
|
|
6. Returns a :class:`Dongle` ready for ``.tx()`` / ``.rx()``.
|
|
|
|
Every step is overrideable via keyword arguments. See ``connect``'s docstring.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import socket
|
|
import time
|
|
from typing import Any
|
|
|
|
import serial
|
|
|
|
from donglora.discovery import find_port, wait_for_device
|
|
from donglora.dongle import Dongle
|
|
from donglora.errors import 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.connect")
|
|
|
|
DEFAULT_TIMEOUT: float = 2.0
|
|
"""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."""
|
|
env = os.environ.get("DONGLORA_MUX")
|
|
if env:
|
|
return env
|
|
xdg = os.environ.get("XDG_RUNTIME_DIR")
|
|
if xdg:
|
|
return os.path.join(xdg, "donglora", "mux.sock")
|
|
return "/tmp/donglora-mux.sock"
|
|
|
|
|
|
def _find_mux_socket() -> str | None:
|
|
env = os.environ.get("DONGLORA_MUX")
|
|
if env:
|
|
return env if os.path.exists(env) else None
|
|
xdg = os.environ.get("XDG_RUNTIME_DIR")
|
|
if xdg:
|
|
p = os.path.join(xdg, "donglora", "mux.sock")
|
|
if os.path.exists(p):
|
|
return p
|
|
p = "/tmp/donglora-mux.sock"
|
|
return p if os.path.exists(p) else None
|
|
|
|
|
|
_used_mux = False
|
|
|
|
|
|
def _try_tcp_mux(addr: str, timeout: float) -> MuxConnection | None:
|
|
if ":" in addr:
|
|
host, _, port_str = addr.rpartition(":")
|
|
host = host or "localhost"
|
|
else:
|
|
host = "localhost"
|
|
port_str = addr
|
|
try:
|
|
port = int(port_str)
|
|
except ValueError:
|
|
return None
|
|
try:
|
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
sock.connect((host, port))
|
|
return MuxConnection(sock, timeout)
|
|
except (ConnectionRefusedError, OSError):
|
|
return None
|
|
|
|
|
|
def _open_transport(port: str | None, timeout: float) -> Any:
|
|
"""Bottom-level transport opener. Returns a serial/socket-like object.
|
|
|
|
Priority: explicit port → sticky mux → TCP mux env → Unix mux socket → USB auto-detect.
|
|
"""
|
|
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()
|
|
return ser
|
|
|
|
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
|
|
|
|
sock_path = _find_mux_socket()
|
|
if sock_path is not None:
|
|
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)
|
|
|
|
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()
|
|
return ser
|
|
|
|
|
|
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.
|
|
auto_configure
|
|
If False, skip the ``SET_CONFIG`` step. 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:
|
|
applied = config if config is not None else LoRaConfig.default()
|
|
try:
|
|
session.set_config(applied, timeout=max(timeout, 2.0))
|
|
except DongloraError:
|
|
session.close()
|
|
raise
|
|
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:
|
|
_used_mux = True
|
|
return _session_on(conn)
|
|
|
|
sock_path = _find_mux_socket()
|
|
if sock_path is None:
|
|
raise FileNotFoundError("no mux socket found")
|
|
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) -> Dongle:
|
|
"""Like :func:`connect` but single-scan USB (no blocking wait)."""
|
|
global _used_mux
|
|
if _used_mux:
|
|
return connect_mux_auto(timeout)
|
|
|
|
tcp = os.environ.get("DONGLORA_MUX_TCP")
|
|
if tcp:
|
|
conn = _try_tcp_mux(tcp, timeout)
|
|
if conn is not None:
|
|
_used_mux = True
|
|
return _session_on(conn)
|
|
|
|
sock_path = _find_mux_socket()
|
|
if sock_path is not None:
|
|
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
sock.connect(sock_path)
|
|
_used_mux = True
|
|
return _session_on(MuxConnection(sock, timeout))
|
|
|
|
port_path = find_port()
|
|
if port_path is None:
|
|
raise FileNotFoundError("no DongLoRa device found (no mux, no USB device)")
|
|
ser = serial.Serial(port_path, baudrate=115200, timeout=timeout)
|
|
ser.reset_input_buffer()
|
|
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",
|
|
)
|
|
applied = LoRaConfig.default()
|
|
try:
|
|
session.set_config(applied)
|
|
except DongloraError:
|
|
session.close()
|
|
raise
|
|
return Dongle(session, info, applied_config=applied, keepalive=True)
|