264 lines
8.6 KiB
Python
264 lines
8.6 KiB
Python
"""Orchestration: wire up runtime + dispatch + worker, manage connect loop + signals."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import logging
|
|
import os
|
|
import signal
|
|
import sys
|
|
import time
|
|
|
|
import donglora as dl
|
|
from orac import events, logfmt
|
|
from orac.constants import (
|
|
BOT_NAME,
|
|
MAX_DM_TEXT,
|
|
MAX_RESPONSE_CHARS,
|
|
RADIO_CONFIG,
|
|
TRIGGERS,
|
|
)
|
|
from orac.crypto import (
|
|
channel_count,
|
|
init_channels,
|
|
init_identity,
|
|
my_hash,
|
|
pubkey_bytes,
|
|
)
|
|
from orac.dispatch import RateLimiter, RxRouter, make_advert_pusher
|
|
from orac.logfmt import boot
|
|
from orac.meshcore import (
|
|
build_advert_packet,
|
|
build_dm_packet_with_plaintext,
|
|
compute_ack_hash,
|
|
forget_route,
|
|
get_route,
|
|
route_table_size,
|
|
)
|
|
from orac.reply_state import PendingAckTable, PendingReply, ReplyCache
|
|
from orac.runtime import IOThread, Metrics, RetryScheduler, TxQueue
|
|
from orac.state import known_node_count, load_state, save_state
|
|
from orac.worker import Worker
|
|
|
|
log = logging.getLogger("orac")
|
|
|
|
|
|
# ── Retry rebuild callback ───────────────────────────────────────
|
|
|
|
|
|
def _make_rebuild_reply(metrics: Metrics):
|
|
"""Return a rebuild-reply fn for the RetryScheduler."""
|
|
|
|
def _rebuild(entry: PendingReply, attempt: int, force_flood: bool) -> tuple[bytes, bytes]:
|
|
# `attempt` is 1-indexed (1 = first send). The wire byte uses attempt-1
|
|
# so attempt=2 (first retry) sets txt_type_attempt byte = 1.
|
|
attempt_byte = max(0, min(63, attempt - 1))
|
|
packet, plaintext = build_dm_packet_with_plaintext(
|
|
entry.peer_pk,
|
|
entry.text,
|
|
force_flood=force_flood,
|
|
ts=entry.first_ts,
|
|
attempt=attempt_byte,
|
|
)
|
|
new_ack = compute_ack_hash(plaintext, pubkey_bytes())
|
|
metrics.inc("reply_rebuilt")
|
|
return packet, new_ack
|
|
|
|
return _rebuild
|
|
|
|
|
|
# ── Orchestration ────────────────────────────────────────────────
|
|
|
|
|
|
class BotRuntime:
|
|
"""Holds the per-connection runtime: threads + queues + state tables."""
|
|
|
|
def __init__(self, dongle: dl.Dongle) -> None:
|
|
self.dongle = dongle
|
|
self.metrics = Metrics()
|
|
self.tx_queue = TxQueue()
|
|
self.pending_acks = PendingAckTable()
|
|
self.reply_cache = ReplyCache()
|
|
self.rate_limiter = RateLimiter()
|
|
|
|
self.worker = Worker(
|
|
tx_queue=self.tx_queue,
|
|
pending_acks=self.pending_acks,
|
|
reply_cache=self.reply_cache,
|
|
metrics=self.metrics,
|
|
on_reply_recorded=self.rate_limiter.record,
|
|
)
|
|
|
|
self.rx_router = RxRouter(
|
|
tx_queue=self.tx_queue,
|
|
worker=self.worker,
|
|
pending_acks=self.pending_acks,
|
|
reply_cache=self.reply_cache,
|
|
rate_limiter=self.rate_limiter,
|
|
metrics=self.metrics,
|
|
)
|
|
|
|
self.retry_scheduler = RetryScheduler(
|
|
pending=self.pending_acks,
|
|
tx_queue=self.tx_queue,
|
|
metrics=self.metrics,
|
|
rebuild_reply=_make_rebuild_reply(self.metrics),
|
|
forget_route_fn=forget_route,
|
|
has_route_fn=lambda h: get_route(h) is not None,
|
|
event_emit=events.emit,
|
|
)
|
|
|
|
self.advert_pusher = make_advert_pusher(build_advert_packet, self.metrics)
|
|
|
|
self.io_thread = IOThread(
|
|
dongle=self.dongle,
|
|
tx_queue=self.tx_queue,
|
|
retry_sched=self.retry_scheduler,
|
|
rx_handler=self.rx_router.handle,
|
|
advert_fn=self.advert_pusher,
|
|
metrics=self.metrics,
|
|
gauge_collector=self._collect_gauges,
|
|
)
|
|
|
|
# -- gauge dumper called by IOThread every 60s ----------------
|
|
|
|
def _collect_gauges(self) -> None:
|
|
self.metrics.gauge("tx_queue_depth", self.tx_queue.depth())
|
|
self.metrics.gauge("pending_ack_depth", self.pending_acks.depth())
|
|
self.metrics.gauge("reply_cache_depth", self.reply_cache.depth())
|
|
self.metrics.gauge("route_table_size", route_table_size())
|
|
|
|
# -- lifecycle ------------------------------------------------
|
|
|
|
def start(self) -> None:
|
|
self.worker.start()
|
|
self.io_thread.start()
|
|
|
|
def stop(self) -> None:
|
|
self.io_thread.stop()
|
|
self.worker.stop()
|
|
|
|
def join(self, timeout: float = 5.0) -> None:
|
|
"""Wait for both threads to exit. Bounded so Ctrl+C can't hang us forever."""
|
|
self.io_thread.join(timeout=timeout)
|
|
self.worker.join(timeout=timeout)
|
|
|
|
def dump_metrics(self) -> None:
|
|
self._collect_gauges()
|
|
boot("METRICS %s", self.metrics.summary())
|
|
|
|
|
|
# ── Single-instance signal wiring ───────────────────────────────
|
|
|
|
_active_runtime: BotRuntime | None = None
|
|
|
|
|
|
def _handle_sigusr1(signum: int, _frame: object) -> None: # noqa: ARG001
|
|
rt = _active_runtime
|
|
if rt is not None:
|
|
rt.dump_metrics()
|
|
|
|
|
|
def _handle_sigint(_signum: int, _frame: object) -> None:
|
|
"""SIGINT: flag the IOThread for shutdown *before* KeyboardInterrupt
|
|
unwinds the main thread, so the interrupted serial read exits cleanly
|
|
instead of looking like a device disconnect."""
|
|
rt = _active_runtime
|
|
if rt is not None:
|
|
rt.io_thread.stop()
|
|
raise KeyboardInterrupt
|
|
|
|
|
|
# ── Connect loop ─────────────────────────────────────────────────
|
|
|
|
|
|
def connect_and_run(port: str | None) -> None:
|
|
"""Connect to a donglora dongle, spin up the runtime, and run until disconnect."""
|
|
global _active_runtime
|
|
|
|
boot("Connecting...")
|
|
# `dl.connect()` auto-discovers the port, sends GET_INFO, applies
|
|
# RADIO_CONFIG via SET_CONFIG (with `tx_power_dbm` auto-clamped to
|
|
# the device's PA ceiling — SX1262 caps at 22 dBm, SX1276 PA_BOOST
|
|
# at 20), and starts the keepalive daemon.
|
|
dongle = dl.connect(port=port, timeout=2, config=RADIO_CONFIG)
|
|
boot("Connected")
|
|
|
|
triggers_str = ", ".join(TRIGGERS)
|
|
boot("%s listening (Ctrl+C to stop)", BOT_NAME)
|
|
boot(
|
|
"Channels: %d | Triggers: %s | DMs: enabled",
|
|
channel_count(),
|
|
triggers_str,
|
|
)
|
|
|
|
runtime = BotRuntime(dongle)
|
|
_active_runtime = runtime
|
|
|
|
try:
|
|
runtime.start()
|
|
# Block main thread until IOThread exits (via stop() or crash).
|
|
# Short timed joins keep Ctrl+C responsive.
|
|
while runtime.io_thread.is_alive():
|
|
runtime.io_thread.join(timeout=0.5)
|
|
if runtime.io_thread.last_error is not None:
|
|
raise runtime.io_thread.last_error
|
|
except KeyboardInterrupt:
|
|
boot("Shutting down...")
|
|
raise
|
|
finally:
|
|
runtime.stop()
|
|
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):
|
|
dongle.close()
|
|
|
|
|
|
# ── Entry point ──────────────────────────────────────────────────
|
|
|
|
|
|
def main() -> None:
|
|
logfmt.setup_logging()
|
|
|
|
if not os.environ.get("ANTHROPIC_API_KEY"):
|
|
log.error("ANTHROPIC_API_KEY environment variable not set")
|
|
sys.exit(1)
|
|
|
|
init_identity()
|
|
init_channels()
|
|
events.init()
|
|
load_state()
|
|
|
|
boot("%s -- MeshCore AI Bot", BOT_NAME)
|
|
boot(
|
|
"Max GRP response: %d chars | Max DM response: %d chars",
|
|
MAX_RESPONSE_CHARS,
|
|
MAX_DM_TEXT,
|
|
)
|
|
boot("Channels: %d | Known nodes: %d", channel_count(), known_node_count())
|
|
boot("Pubkey hash: 0x%02x", my_hash())
|
|
|
|
# SIGUSR1 dumps metrics; not available on all platforms (e.g. Windows).
|
|
with contextlib.suppress(ValueError, AttributeError):
|
|
signal.signal(signal.SIGUSR1, _handle_sigusr1)
|
|
# SIGINT: pre-set stop_event so IOThread's interrupted read exits cleanly.
|
|
with contextlib.suppress(ValueError, AttributeError):
|
|
signal.signal(signal.SIGINT, _handle_sigint)
|
|
|
|
port = sys.argv[1] if len(sys.argv) > 1 else None
|
|
|
|
while True:
|
|
try:
|
|
connect_and_run(port)
|
|
except (dl.DongloraError, ConnectionError, OSError) as e:
|
|
log.error("Disconnected: %s", e)
|
|
boot("Reconnecting when device reappears...")
|
|
time.sleep(1)
|
|
except KeyboardInterrupt:
|
|
print()
|
|
save_state()
|
|
break
|