diff --git a/orac/bot.py b/orac/bot.py index 169066e..87dafde 100644 --- a/orac/bot.py +++ b/orac/bot.py @@ -9,8 +9,6 @@ import signal import sys import time -import serial - import donglora as dl from orac import events, logfmt from orac.constants import ( @@ -75,8 +73,8 @@ def _make_rebuild_reply(metrics: Metrics): class BotRuntime: """Holds the per-connection runtime: threads + queues + state tables.""" - def __init__(self, conn) -> None: - self.conn = conn + def __init__(self, dongle: dl.Dongle) -> None: + self.dongle = dongle self.metrics = Metrics() self.tx_queue = TxQueue() self.pending_acks = PendingAckTable() @@ -113,7 +111,7 @@ class BotRuntime: self.advert_pusher = make_advert_pusher(build_advert_packet, self.metrics) self.io_thread = IOThread( - conn=self.conn, + dongle=self.dongle, tx_queue=self.tx_queue, retry_sched=self.retry_scheduler, rx_handler=self.rx_router.handle, @@ -179,13 +177,12 @@ def connect_and_run(port: str | None) -> None: global _active_runtime boot("Connecting...") - conn = dl.connect(port=port, timeout=2) + # `dl.connect()` auto-discovers the port, sends GET_INFO, applies + # RADIO_CONFIG via SET_CONFIG, and starts the keepalive daemon — + # no manual Ping/SetConfig/StartRx dance required. + dongle = dl.connect(port=port, timeout=2, config=RADIO_CONFIG) boot("Connected") - dl.send(conn, "Ping") - dl.send(conn, "SetConfig", config=RADIO_CONFIG) - dl.send(conn, "StartRx") - triggers_str = ", ".join(TRIGGERS) boot("%s listening (Ctrl+C to stop)", BOT_NAME) boot( @@ -194,7 +191,7 @@ def connect_and_run(port: str | None) -> None: triggers_str, ) - runtime = BotRuntime(conn) + runtime = BotRuntime(dongle) _active_runtime = runtime try: @@ -213,9 +210,10 @@ def connect_and_run(port: str | None) -> None: runtime.join(timeout=3.0) runtime.dump_metrics() _active_runtime = None + # `Dongle.close()` stops the keepalive thread, drains pending + # commands, and releases the transport fd. Idempotent. with contextlib.suppress(Exception): - conn.timeout = 2 - dl.send(conn, "StopRx") + dongle.close() # ── Entry point ────────────────────────────────────────────────── @@ -254,7 +252,7 @@ def main() -> None: while True: try: connect_and_run(port) - except (serial.SerialException, ConnectionError, OSError) as e: + except (dl.DongloraError, ConnectionError, OSError) as e: log.error("Disconnected: %s", e) boot("Reconnecting when device reappears...") time.sleep(1) diff --git a/orac/constants.py b/orac/constants.py index 5412f6c..caf0c9f 100644 --- a/orac/constants.py +++ b/orac/constants.py @@ -4,6 +4,8 @@ from __future__ import annotations from pathlib import Path +import donglora as dl + # ── Bot identity ───────────────────────────────────────────────── BOT_NAME: str = "Orac" @@ -45,14 +47,21 @@ STATE_FILE: Path = DATA_DIR / "orac_state.json" # ── Radio ──────────────────────────────────────────────────────── -RADIO_CONFIG: dict[str, int] = { - "freq_hz": 910_525_000, - "bw": 6, # 62.5 kHz - "sf": 7, - "cr": 5, # CR 4/5 - "sync_word": 0x3444, - "tx_power_dbm": -128, # TX_POWER_MAX -} +RADIO_CONFIG: dl.LoRaConfig = dl.LoRaConfig( + freq_hz=910_525_000, + bw=dl.LoRaBandwidth.KHZ_62_5, + sf=7, + cr=dl.LoRaCodingRate.CR_4_5, + sync_word=0x3444, + tx_power_dbm=22, # DongLoRa Protocol v2 removed the "max power" sentinel; 22 dBm + # is the SX1262 chip ceiling and the practical upper bound on every + # current DongLoRa board. If you operate a board with a lower cap, + # lower this (or read `d.info.tx_power_max_dbm` and rebuild the config). + preamble_len=16, + header_mode=dl.LoRaHeaderMode.EXPLICIT, + payload_crc=True, + iq_invert=False, +) # ── AI system prompt ───────────────────────────────────────────── diff --git a/orac/dispatch.py b/orac/dispatch.py index d0bc198..55d1dce 100644 --- a/orac/dispatch.py +++ b/orac/dispatch.py @@ -7,8 +7,8 @@ import random import threading import time from collections.abc import Callable -from typing import Any +import donglora as dl from orac import events, followup, logfmt from orac.ai import extract_trigger_query, rate_limit_message from orac.constants import ( @@ -122,9 +122,9 @@ class RxRouter: # -- top-level dispatch --------------------------------------- - def handle(self, pkt: dict[str, Any]) -> None: - payload_bytes: bytes = pkt.get("payload", b"") - snr: float = float(pkt.get("snr", 0)) + def handle(self, pkt: dl.RxEvent) -> None: + payload_bytes = pkt.data + snr = pkt.snr_db parsed = parse_header_and_path(payload_bytes) if parsed is None: diff --git a/orac/runtime.py b/orac/runtime.py index f10226b..c437157 100644 --- a/orac/runtime.py +++ b/orac/runtime.py @@ -2,9 +2,9 @@ Three threads cooperate: -- **IOThread** — sole owner of the donglora ``conn``. Polls RX with a short - timeout, ticks the retry scheduler, fires the ADVERT timer, and transmits - the next ready ``TxItem`` from the priority queue. +- **IOThread** — sole owner of the :class:`donglora.Dongle`. Polls RX with + a short timeout, ticks the retry scheduler, fires the ADVERT timer, and + transmits the next ready ``TxItem`` from the priority queue. - **Worker** (defined in :mod:`orac.worker`) — processes DM reply work items, calls Claude off the IO loop, and enqueues reply packets onto the TxQueue. @@ -28,8 +28,6 @@ from enum import IntEnum from typing import Any import donglora as dl -from serial import SerialException - from orac import logfmt from orac.constants import ( ADVERT_INTERVAL, @@ -369,7 +367,7 @@ class RetryScheduler: class IOThread(threading.Thread): - """Sole owner of the donglora ``conn``; drives RX, retries, TX. + """Sole owner of the :class:`donglora.Dongle`; drives RX, retries, TX. Runs a single-threaded event loop. Does NOT call Claude — Worker does that on a different thread and enqueues the resulting reply onto :class:`TxQueue`. @@ -377,10 +375,10 @@ class IOThread(threading.Thread): def __init__( self, - conn: Any, + dongle: dl.Dongle, tx_queue: TxQueue, retry_sched: RetryScheduler, - rx_handler: Callable[[dict[str, Any]], None], + rx_handler: Callable[[dl.RxEvent], None], advert_fn: Callable[[TxQueue], None], metrics: Metrics, advert_interval: float = ADVERT_INTERVAL, @@ -388,7 +386,7 @@ class IOThread(threading.Thread): name: str = "orac-io", ) -> None: super().__init__(daemon=True, name=name) - self._conn = conn + self._dongle = dongle self._tx = tx_queue self._retry = retry_sched self._rx_handler = rx_handler @@ -405,7 +403,6 @@ class IOThread(threading.Thread): def run(self) -> None: try: - self._conn.timeout = IO_POLL_TIMEOUT_S # Advert at startup to announce ourselves; timer starts from monotonic. self._advert_fn(self._tx) last_advert = time.monotonic() @@ -416,9 +413,10 @@ class IOThread(threading.Thread): if self._stop_event.is_set(): return try: - pkt = dl.recv(self._conn) - except (SerialException, OSError): - # Shutdown: signal interrupted a blocking serial read. + pkt = self._dongle.recv(timeout=IO_POLL_TIMEOUT_S) + except (dl.DongloraError, OSError): + # Shutdown: signal interrupted the session reader, + # or the transport went away. if self._stop_event.is_set(): return raise @@ -468,41 +466,43 @@ class IOThread(threading.Thread): raise def _transmit(self, item: TxItem) -> None: - """Drive one TX via donglora.send. Does not retry on error — the retry - scheduler already covers reply failures; ACK/PATH/ADVERT are single-shot - by design. + """Drive one TX via :meth:`Dongle.tx`. Does not retry on error — the + retry scheduler already covers reply failures; ACK/PATH/ADVERT are + single-shot by design. - donglora.send() reads the TxDone response using the serial port's - timeout. Raise it around the call (LoRa airtime for a max DM is - ~425 ms + CAD) and restore after so the rest of the loop stays - responsive at IO_POLL_TIMEOUT_S. + ``Dongle.tx()`` blocks until TX_DONE arrives (including LoRa airtime + + CAD), so ``timeout`` must accommodate the maximum-size-payload air time. """ - old_timeout = self._conn.timeout - self._conn.timeout = TX_RESPONSE_TIMEOUT_S try: - resp = dl.send(self._conn, "Transmit", payload=item.packet) - rtype = resp.get("type") - if rtype == "TxDone": - self._metrics.inc("tx_sent") - self._metrics.inc(f"tx_{_priority_tag(item.priority)}_sent") - logfmt.net("TX %s [%dB]", item.label, len(item.packet)) - elif rtype == "Timeout": - self._metrics.inc("tx_error_timeout") - log.error("TX %s: firmware timeout", item.label) - elif rtype == "Error": - code = resp.get("code") - self._metrics.inc(f"tx_error_code_{code}") - if code == 1: # RadioBusy - self._metrics.inc("tx_radio_busy") - log.error("TX %s: firmware error code=%s", item.label, code) - else: - self._metrics.inc("tx_error_other") - log.error("TX %s: unexpected response %s", item.label, resp) + self._dongle.tx(item.packet, timeout=TX_RESPONSE_TIMEOUT_S) + self._metrics.inc("tx_sent") + self._metrics.inc(f"tx_{_priority_tag(item.priority)}_sent") + logfmt.net("TX %s [%dB]", item.label, len(item.packet)) + except dl.TimeoutError_ as e: + self._metrics.inc("tx_error_timeout") + log.error("TX %s: firmware timeout: %s", item.label, e) + except dl.ChannelBusy as e: + self._metrics.inc("tx_channel_busy") + log.error("TX %s: CAD detected channel busy: %s", item.label, e) + except dl.Cancelled as e: + self._metrics.inc("tx_cancelled") + log.error("TX %s: cancelled by firmware: %s", item.label, e) + except dl.BusyError as e: + # Old protocol's RadioBusy maps to EBUSY in DongLoRa Protocol v2: the firmware + # TX queue was full when we tried to enqueue. + self._metrics.inc("tx_radio_busy") + log.error("TX %s: firmware TX queue busy: %s", item.label, e) + except dl.NotConfiguredError as e: + # The Dongle auto-recovery already re-applied the config and + # retried once; if we still see this, give up on this TX. + self._metrics.inc("tx_not_configured") + log.error("TX %s: device lost config: %s", item.label, e) + except dl.DongloraError as e: + self._metrics.inc("tx_error_other") + log.error("TX %s: device error: %s", item.label, e) except Exception as e: self._metrics.inc("tx_error_exception") log.exception("TX %s exception: %s", item.label, e) - finally: - self._conn.timeout = old_timeout def _priority_tag(prio: int) -> str: