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

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

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

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

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

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

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

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

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

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)