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:
4 files changed
+75
-68
No files matched your search
+12
-14
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||||
|
|||||||
Reference in new issue
Block a user