feat!: orac on donglora 1.0 async Dongle API

Upgrade the MeshCore bot to talk the DongLoRa Protocol v2 via donglora 1.0.0.

* BotRuntime now holds a donglora.Dongle instead of a raw serial conn. All transmit / receive / ADVERT plumbing goes through the async Dongle surface.

* Drop the direct pyserial imports; SerialException handling is now dongle-level (reconnect is handled by donglora.connect()).

* Update constants and dispatch to match the Dongle accessors (info, set_config, transmit, start_rx) exposed by the 1.0 client.
This commit is contained in:
swaits committed 2026-04-22 09:57:01 -06:00
1 parent 54228a6f92
commit a0a26a32f1
4 files changed
+75 -68

No files matched your search

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