Files
swaits a0a26a32f1 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.
2026-04-22 09:57:01 -06:00

654 lines
22 KiB
Python

"""RX dispatch: parse incoming frames, drive ACK/PATH/worker/retry feedback."""
from __future__ import annotations
import logging
import random
import threading
import time
from collections.abc import Callable
import donglora as dl
from orac import events, followup, logfmt
from orac.ai import extract_trigger_query, rate_limit_message
from orac.constants import (
ACK_JITTER_MAX_S,
ACK_JITTER_MIN_S,
BOT_NAME,
CHANNEL_FOLLOWUP_WINDOW_S,
MIN_HASH_SIZE,
MULTI_ACK_COUNT,
MULTI_ACK_SPACING_S,
PATH_RETURN_DELAY_S,
RATE_LIMIT_DM,
RATE_LIMIT_GLOBAL,
RATE_LIMIT_PER_SENDER,
RATE_LIMIT_REPLY_COOLDOWN,
REPLY_CACHE_RESEND_WINDOW_S,
SCREENER_PER_CHANNEL_RATE_S,
)
from orac.crypto import my_hash, pubkey_bytes
from orac.meshcore import (
build_ack_packet,
build_grp_channel_packet,
build_login_response_packet,
build_multiack_packet,
build_path_return_packet,
compute_ack_hash,
get_route,
has_pending_dms,
is_dm_duplicate,
is_duplicate,
is_pending_expired,
learn_route,
lookup_node_by_hash,
node_name,
parse_header_and_path,
pop_pending_dms,
queue_pending_dm,
register_node,
route_name,
try_decode_advert,
try_decrypt_anon_req,
try_decrypt_dm,
try_decrypt_grp,
)
from orac.reply_state import PendingAckTable, ReplyCache
from orac.runtime import Metrics, TxItem, TxPriority, TxQueue
from orac.state import get_channel_history, record_channel_msg, record_heard_position
from orac.worker import ChannelReplyWork, ChannelScreenWork, DmReplyWork, Worker
log = logging.getLogger("orac")
class RateLimiter:
"""Bot-level rate limiter — per-sender + global timers, thread-safe."""
def __init__(self) -> None:
self._lock = threading.Lock()
self._per_key: dict[str, float] = {}
self._global_last = 0.0
self._reply_last: dict[str, float] = {}
def check(self, key: str) -> bool:
now = time.monotonic()
with self._lock:
if now - self._global_last < RATE_LIMIT_GLOBAL:
return False
limit = RATE_LIMIT_DM if key.startswith("dm:") else RATE_LIMIT_PER_SENDER
last = self._per_key.get(key)
return not (last is not None and now - last < limit)
def reply_ok(self, key: str) -> bool:
now = time.monotonic()
with self._lock:
last = self._reply_last.get(key)
if last is not None and now - last < RATE_LIMIT_REPLY_COOLDOWN:
return False
self._reply_last[key] = now
return True
def record(self, key: str) -> None:
now = time.monotonic()
with self._lock:
self._per_key[key] = now
self._global_last = now
class RxRouter:
"""Parses and dispatches every RxPacket the IOThread drains.
Designed to be cheap and non-blocking — never calls Claude, never sleeps.
Reply work goes to :class:`Worker`; ACK/PATH transmissions go to
:class:`TxQueue`; retry cancellation goes to :class:`PendingAckTable` and
:meth:`TxQueue.cancel_by_ack`.
"""
def __init__(
self,
tx_queue: TxQueue,
worker: Worker,
pending_acks: PendingAckTable,
reply_cache: ReplyCache,
rate_limiter: RateLimiter,
metrics: Metrics,
) -> None:
self._tx = tx_queue
self._worker = worker
self._pending = pending_acks
self._cache = reply_cache
self._rl = rate_limiter
self._metrics = metrics
# -- top-level dispatch ---------------------------------------
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:
return
(
payload_type,
route_type,
_payload_ver,
_pos,
raw_payload,
path_hops,
hash_size,
) = parsed
# Route-learn from the incoming path
if raw_payload and len(raw_payload) >= 2:
src_hash: int | None = None
if payload_type in (0x01, 0x02, 0x08):
src_hash = raw_payload[1]
elif payload_type == 0x04:
src_hash = raw_payload[0]
if src_hash is not None:
learn_route(src_hash, path_hops, hash_size, snr=snr)
# Packet-hash dedup (spec Section 16)
if is_duplicate(payload_type, raw_payload):
self._metrics.inc("rx_dup")
return
if payload_type == 0x03:
self._handle_ack(raw_payload)
elif payload_type == 0x0A:
self._handle_multipart(raw_payload)
elif payload_type == 0x04:
self._handle_advert(raw_payload)
elif payload_type == 0x05:
self._handle_grp_txt(raw_payload)
elif payload_type == 0x07:
self._handle_anon_req(raw_payload, path_hops, hash_size)
elif payload_type == 0x02:
self._handle_dm(raw_payload, route_type, path_hops, hash_size, snr)
# -- ACK ------------------------------------------------------
def _handle_ack(self, raw_payload: bytes) -> None:
"""Handle a plain PAYLOAD_TYPE_ACK (0x03): payload is 4-byte CRC."""
if len(raw_payload) < 4:
return
self._consume_ack(raw_payload[:4], source="ack")
def _handle_multipart(self, raw_payload: bytes) -> None:
"""Handle PAYLOAD_TYPE_MULTIPART (0x0A).
Upstream uses MULTIPART as a transport-level wrapper. For inner
type == PAYLOAD_TYPE_ACK (0x03), the layout is
``[remaining<<4 | 0x03][crc:4]`` (5 bytes). We unwrap it and treat
the inner CRC identically to a plain ACK — which matches how the
firmware's receiver handles it (src/Mesh.cpp:270-282).
"""
if len(raw_payload) < 5:
return
inner_type = raw_payload[0] & 0x0F
if inner_type != 0x03:
# MULTIPART wrappers for non-ACK inner types aren't something we
# currently generate or consume — ignore.
return
self._consume_ack(raw_payload[1:5], source="multipart")
def _consume_ack(self, ack_crc: bytes, source: str) -> None:
entry = self._pending.consume(ack_crc)
if entry is None:
return
now = time.monotonic()
elapsed_ms = int((now - entry.first_sent_at) * 1000)
self._tx.cancel_by_ack(ack_crc)
self._metrics.inc("ack_inbound_consumed")
self._metrics.inc(f"ack_inbound_source_{source}")
self._metrics.inc(f"reply_confirmed_attempt_{entry.attempt}")
events.emit(
"reply.confirmed",
peer=entry.peer_name,
peer_pk=entry.peer_pk.hex(),
attempt=entry.attempt,
elapsed_ms=elapsed_ms,
ack_crc=ack_crc.hex(),
source=source,
)
logfmt.ack_ok(entry.peer_name, entry.attempt, elapsed_ms)
# -- ADVERT ---------------------------------------------------
def _handle_advert(self, raw_payload: bytes) -> None:
result = try_decode_advert(raw_payload)
if result is None:
return
adv_pubkey, name, node_type, position = result
if adv_pubkey == pubkey_bytes():
return # our own advert echo
register_node(adv_pubkey, name)
if node_type == 2 and position is not None:
record_heard_position(position[0], position[1])
logfmt.net(
"ADVERT %s (hash=0x%02x pk=%s...)",
name,
adv_pubkey[0],
adv_pubkey.hex()[:16],
)
if has_pending_dms(adv_pubkey[0]):
self._process_pending_dms(adv_pubkey[0], name)
# -- GRP_TXT --------------------------------------------------
def _handle_grp_txt(self, raw_payload: bytes) -> None:
result = try_decrypt_grp(raw_payload)
if result is None:
return
channel_name, text = result
record_channel_msg(channel_name, text)
if ": " in text:
sender, _, body = text.partition(": ")
logfmt.ch_in(channel_name, sender, body)
else:
sender, body = "", text
logfmt.ch_in(channel_name, "?", body)
if sender == BOT_NAME:
return
rl_key = f"ch:{channel_name}:{sender}"
query = extract_trigger_query(body)
if query is not None:
# Explicit trigger or @mention — always process.
logfmt.net("trigger from %s on %s: %s", sender, channel_name, query)
self._dispatch_channel_reply(
channel_name=channel_name,
sender=sender,
query=query,
rl_key=rl_key,
origin="trigger",
)
return
# No trigger. Only consider this message as a potential follow-up
# if Orac has been active in this channel recently. The vast majority
# of channel chatter hits this early return with zero API cost.
if not followup.was_recent(channel_name, CHANNEL_FOLLOWUP_WINDOW_S):
return
# Rate-limit screener calls per channel to cap cost on busy channels.
if not followup.screener_ok(channel_name, SCREENER_PER_CHANNEL_RATE_S):
self._metrics.inc("screener_skipped_rate")
return
self._metrics.inc("screener_submitted")
self._worker.submit(
ChannelScreenWork(
channel_name=channel_name,
sender=sender,
text=body,
history_snapshot=get_channel_history(channel_name),
received_at=time.monotonic(),
rl_key=rl_key,
)
)
def _dispatch_channel_reply(
self,
*,
channel_name: str,
sender: str,
query: str,
rl_key: str,
origin: str,
) -> None:
"""Run rate-limit gate then enqueue a ChannelReplyWork (or a rate-limit TX)."""
if not self._rl.check(rl_key):
log.warning("rate limited: %s on %s (%s)", sender, channel_name, origin)
if self._rl.reply_ok(f"ch:{channel_name}"):
msg = rate_limit_message()
packet = build_grp_channel_packet(channel_name, BOT_NAME, msg)
if packet is not None:
self._tx.push(
TxItem(
priority=TxPriority.GRP_TXT,
not_before=time.monotonic(),
packet=packet,
label=f"GRP-rate({channel_name})",
)
)
self._rl.record(rl_key)
return
# Mark the channel warm so screener activates for subsequent messages.
followup.touch(channel_name)
self._worker.submit(
ChannelReplyWork(
channel_name=channel_name,
sender=sender,
query=query,
received_at=time.monotonic(),
rl_key=rl_key,
)
)
# -- ANON_REQ -------------------------------------------------
def _handle_anon_req(
self,
raw_payload: bytes,
path_hops: list[bytes],
hash_size: int,
) -> None:
result = try_decrypt_anon_req(raw_payload)
if result is None:
return
peer_pubkey, peer_name, _plaintext = result
learn_route(peer_pubkey[0], path_hops, hash_size)
logfmt.net("LOGIN from %s -- sending login OK", peer_name)
packet = build_login_response_packet(peer_pubkey)
self._tx.push(
TxItem(
priority=TxPriority.LOGIN_RESP,
not_before=time.monotonic(),
packet=packet,
label=f"LOGIN_RESP->{peer_name}",
)
)
if has_pending_dms(peer_pubkey[0]):
self._process_pending_dms(peer_pubkey[0], peer_name)
# -- DM -------------------------------------------------------
def _handle_dm(
self,
raw_payload: bytes,
route_type: int,
path_hops: list[bytes],
hash_size: int,
snr: float,
) -> None:
result = try_decrypt_dm(raw_payload)
if result is None:
# Addressed to us but sender unknown: queue for later
if len(raw_payload) >= 4 and raw_payload[0] == my_hash():
src_hash = raw_payload[1]
if not lookup_node_by_hash(src_hash):
is_first = queue_pending_dm(src_hash, raw_payload)
if is_first:
logfmt.net(
"queued DM from unknown 0x%02x, waiting for their ADVERT",
src_hash,
)
return
peer_pubkey, peer_name, dm_text, raw_plaintext = result
pk_hex = peer_pubkey.hex()
now = time.monotonic()
# 1) Schedule ACK with jitter — always, regardless of hash_size or dedup.
# Receiving and ACKing is the highest-priority guarantee.
self._schedule_ack(raw_plaintext, peer_pubkey, now, route_type=route_type)
# 2) Schedule PATH return — only if inbound used >=2-byte hashes.
# We refuse to echo 1-byte paths; if the inbound was 1-byte we skip
# the optional path hint. The peer still gets ACKed above and will
# still get our reply below (via flood, since learn_route also
# rejects 1-byte — see MIN_HASH_SIZE policy).
if hash_size >= MIN_HASH_SIZE:
self._schedule_path_return(peer_pubkey, path_hops, hash_size, now)
events.emit(
"dm.received",
peer=peer_name,
peer_pk=pk_hex,
route=route_name(route_type),
path_hops=len(path_hops),
snr=snr,
text_len=len(dm_text),
)
# 3) Text-level dedup
if is_dm_duplicate(pk_hex, dm_text):
self._metrics.inc("dm_text_duplicate")
cached = self._cache.get(pk_hex, dm_text)
# Only treat the duplicate as loss-recovery (resend cached reply)
# if it's close in time to the original reply; older retries are
# assumed to be the peer legitimately re-asking.
if cached is None or (now - cached.ts) > REPLY_CACHE_RESEND_WINDOW_S:
if cached is not None:
self._metrics.inc("cached_resend_skipped_stale")
return
# Don't pile on if the retry scheduler is still working the original.
if self._pending.has_pending_for_peer(peer_pubkey):
self._metrics.inc("cached_resend_skipped_pending")
return
jitter = random.uniform(
ACK_JITTER_MIN_S + PATH_RETURN_DELAY_S, PATH_RETURN_DELAY_S + 0.3
)
self._tx.push(
TxItem(
priority=TxPriority.REPLY_DIRECT,
not_before=now + jitter,
packet=cached.reply_packet,
label=f"DM(cached)->{peer_name}",
expected_ack=cached.expected_ack,
peer_pk=peer_pubkey,
attempt=1,
cached_reply=True,
)
)
self._metrics.inc("reply_cached_resent")
events.emit(
"reply.cached_resend",
peer=peer_name,
peer_pk=pk_hex,
ack_crc=cached.expected_ack.hex(),
age_ms=int((now - cached.ts) * 1000),
)
logfmt.net(
"duplicate DM from %s within %.0fs; re-sent cached reply",
peer_name,
now - cached.ts,
)
return
logfmt.dm_in(peer_name, dm_text, route=route_name(route_type))
# 4) Rate limit check — drops silently (ACK already queued)
rl_key = f"dm:{pk_hex}"
if not self._rl.check(rl_key):
log.warning("rate limited DM: %s", peer_name)
self._metrics.inc("dm_rate_limited")
events.emit(
"dm.rate_limited",
peer=peer_name,
peer_pk=pk_hex,
)
return
# 5) Post reply work to worker (non-blocking)
self._worker.submit(
DmReplyWork(
peer_pk=peer_pubkey,
peer_name=peer_name,
dm_text=dm_text,
received_at=now,
)
)
# -- pending-DM drain (run on IO thread, cheap decrypts only) ---
def _process_pending_dms(self, node_hash: int, peer_label: str) -> None:
pending = pop_pending_dms(node_hash)
if not pending:
return
logfmt.net("processing %d pending DM(s) from %s", len(pending), peer_label)
for pending_payload, ts in pending:
if is_pending_expired(ts):
continue
result = try_decrypt_dm(pending_payload)
if result is None:
continue
peer_pubkey, peer_name, dm_text, raw_plaintext = result
pk_hex = peer_pubkey.hex()
now = time.monotonic()
# ACK always
self._schedule_ack(raw_plaintext, peer_pubkey, now, route_type=1)
if is_dm_duplicate(pk_hex, dm_text):
continue
logfmt.dm_in(peer_name, dm_text, route="pending")
rl_key = f"dm:{pk_hex}"
if not self._rl.check(rl_key):
log.warning("rate limited pending DM: %s", peer_name)
continue
self._worker.submit(
DmReplyWork(
peer_pk=peer_pubkey,
peer_name=peer_name,
dm_text=dm_text,
received_at=now,
)
)
# -- helpers --------------------------------------------------
def _schedule_ack(
self,
plaintext: bytes,
peer_pubkey: bytes,
now: float,
route_type: int,
) -> None:
"""Schedule the ACK(s) for a received DM.
Protocol (upstream Mesh::routeDirectRecvAcks + NodePrefs.multi_acks):
* When :data:`MULTI_ACK_COUNT` > 0 AND we have a learned direct route
to the peer: emit N MULTIPART ACKs with ``remaining`` counting down
to 1, spaced ~300 ms apart, then the final plain ACK 300 ms later.
* Otherwise: a single plain ACK with small jitter.
MULTIPART ACKs on flood paths are NOT protocol-correct and would just
burn airtime (receivers key MULTIPART handling to direct-route recv).
"""
ack_crc = compute_ack_hash(plaintext, peer_pubkey)
target_name = node_name(peer_pubkey.hex())
jitter = random.uniform(ACK_JITTER_MIN_S, ACK_JITTER_MAX_S)
use_multipart = MULTI_ACK_COUNT > 0 and get_route(peer_pubkey[0]) is not None
if not use_multipart:
packet = build_ack_packet(ack_crc, peer_pubkey[0])
self._tx.push(
TxItem(
priority=TxPriority.ACK,
not_before=now + jitter,
packet=packet,
label=f"ACK->0x{peer_pubkey[0]:02x}",
)
)
self._metrics.inc("ack_sent")
events.emit(
"ack.sent",
target_hash=peer_pubkey[0],
target=target_name,
ack_crc=ack_crc.hex(),
route=route_name(route_type),
jitter_ms=int(jitter * 1000),
multipart=False,
)
return
# MULTIPART chain: N MULTIPART packets (remaining N, N-1, ..., 1),
# then final plain ACK, each 300 ms + jitter after the previous.
cumulative = jitter
for i in range(MULTI_ACK_COUNT):
remaining = MULTI_ACK_COUNT - i
multi_packet = build_multiack_packet(ack_crc, remaining, peer_pubkey[0])
self._tx.push(
TxItem(
priority=TxPriority.ACK,
not_before=now + cumulative,
packet=multi_packet,
label=f"MULTIACK->0x{peer_pubkey[0]:02x}(r={remaining})",
)
)
self._metrics.inc("multiack_sent")
cumulative += MULTI_ACK_SPACING_S
# Final plain ACK
final_packet = build_ack_packet(ack_crc, peer_pubkey[0])
self._tx.push(
TxItem(
priority=TxPriority.ACK,
not_before=now + cumulative,
packet=final_packet,
label=f"ACK->0x{peer_pubkey[0]:02x}",
)
)
self._metrics.inc("ack_sent")
events.emit(
"ack.sent",
target_hash=peer_pubkey[0],
target=target_name,
ack_crc=ack_crc.hex(),
route=route_name(route_type),
jitter_ms=int(jitter * 1000),
multipart=True,
multipart_count=MULTI_ACK_COUNT,
)
def _schedule_path_return(
self,
peer_pubkey: bytes,
path_hops: list[bytes],
hash_size: int,
now: float,
) -> None:
packet = build_path_return_packet(peer_pubkey, path_hops, hash_size)
self._tx.push(
TxItem(
priority=TxPriority.PATH,
not_before=now + PATH_RETURN_DELAY_S,
packet=packet,
label=f"PATH->{node_name(peer_pubkey.hex())}",
)
)
self._metrics.inc("path_sent")
# ── Advert scheduling callback ───────────────────────────────────
def make_advert_pusher(
build_advert_fn: Callable[[], bytes],
metrics: Metrics,
) -> Callable[[TxQueue], None]:
"""Return a fn that enqueues an ADVERT onto the TxQueue."""
def _advert_push(tx: TxQueue) -> None:
packet = build_advert_fn()
tx.push(
TxItem(
priority=TxPriority.ADVERT,
not_before=time.monotonic(),
packet=packet,
label=f"ADVERT({BOT_NAME})",
)
)
metrics.inc("advert_sent")
events.emit("advert.sent", bot=BOT_NAME)
logfmt.net("queued ADVERT as %s (hash=0x%02x)", BOT_NAME, my_hash())
return _advert_push
__all__ = [
"RateLimiter",
"RxRouter",
"make_advert_pusher",
]