refactor: split bot.py into runtime/dispatch/worker modules; add retry scheduler, route TTL, ack tracking, follow-up screener, structured event log, and test suite

This commit is contained in:
swaits committed 2026-04-16 13:47:17 -06:00
1 parent 9e6f171c5d
commit 228432f9e3
24 files changed
+3531 -434

No files matched your search

+37 -4
View File
@@ -6,12 +6,12 @@ default: run
_ensure_tools: _ensure_tools:
@mise trust --yes . 2>/dev/null; mise install --quiet @mise trust --yes . 2>/dev/null; mise install --quiet
# Run the AI bot # Run the AI bot (optionally pass a serial port: `just run /dev/ttyACM0`)
run *args: _ensure_tools run *args: _ensure_tools
@uv run ai_bot.py {{args}} @uv run ai_bot.py {{args}}
# Run all checks (fmt, lint) # Run the full gauntlet: format-check, lint, type-check, tests
check: fmt-check lint check: fmt-check lint typecheck test
# Format code # Format code
fmt: _ensure_tools fmt: _ensure_tools
@@ -21,6 +21,39 @@ fmt: _ensure_tools
fmt-check: _ensure_tools fmt-check: _ensure_tools
@uv run ruff format --check . @uv run ruff format --check .
# Lint # Lint (ruff)
lint: _ensure_tools lint: _ensure_tools
@uv run ruff check . @uv run ruff check .
# Auto-fix lint where possible
lint-fix: _ensure_tools
@uv run ruff check --fix .
# Static type check (pyright)
typecheck: _ensure_tools
@uv run pyright orac tests ai_bot.py
# Run the test suite
test *args: _ensure_tools
@uv run pytest {{args}}
# Run tests with verbose output
test-v: _ensure_tools
@uv run pytest -v
# Dump live metrics from a running bot (sends SIGUSR1 by PID; pass the pid)
metrics pid: _ensure_tools
@kill -USR1 {{pid}}
@echo "Sent SIGUSR1 to PID {{pid}}; check the bot's stderr for METRICS line"
# Tail the structured event log (JSONL)
events:
@tail -F ~/.donglora/orac-events.jsonl
# Pretty-print the events log with jq
events-pretty:
@tail -F ~/.donglora/orac-events.jsonl | jq .
# Clean cached artifacts
clean:
@rm -rf .pytest_cache .ruff_cache __pycache__ orac/__pycache__ tests/__pycache__
+94 -10
View File
@@ -5,12 +5,23 @@ from __future__ import annotations
import logging import logging
import random import random
import time import time
from typing import Any, cast
import anthropic import anthropic
from anthropic.types import (
MessageParam,
ServerToolUseBlock,
TextBlock,
ToolUnionParam,
)
from orac.constants import ( from orac.constants import (
AT_MENTIONS, AT_MENTIONS,
BOT_NAME,
CLAUDE_TIMEOUT_S,
MAX_RESPONSE_CHARS, MAX_RESPONSE_CHARS,
SCREENER_MODEL,
SCREENER_TIMEOUT_S,
SYSTEM_PROMPT, SYSTEM_PROMPT,
TRIGGERS, TRIGGERS,
) )
@@ -38,6 +49,7 @@ def _shorten_with_claude(text: str, max_chars: int) -> str:
resp = _get_client().messages.create( resp = _get_client().messages.create(
model="claude-sonnet-4-6", model="claude-sonnet-4-6",
max_tokens=256, max_tokens=256,
timeout=CLAUDE_TIMEOUT_S,
messages=[ messages=[
{ {
"role": "user", "role": "user",
@@ -45,7 +57,8 @@ def _shorten_with_claude(text: str, max_chars: int) -> str:
} }
], ],
) )
shortened: str = resp.content[0].text.strip() first = resp.content[0] if resp.content else None
shortened = first.text.strip() if isinstance(first, TextBlock) else text
except Exception as e: except Exception as e:
log.error("Shorten API error: %s", e) log.error("Shorten API error: %s", e)
shortened = text shortened = text
@@ -61,7 +74,7 @@ def _extract_text(resp: anthropic.types.Message) -> str:
"""Extract the last non-empty text block from a Claude response.""" """Extract the last non-empty text block from a Claude response."""
text = "" text = ""
for block in resp.content: for block in resp.content:
if block.type == "text" and block.text.strip(): if isinstance(block, TextBlock) and block.text.strip():
text = block.text.strip() text = block.text.strip()
return text return text
@@ -94,8 +107,10 @@ def call_claude(
else: else:
user_content = f"[seed:{nonce}] {sender} says: {query}" user_content = f"[seed:{nonce}] {sender} says: {query}"
messages: list[dict[str, object]] = [{"role": "user", "content": user_content}] messages: list[MessageParam] = [{"role": "user", "content": user_content}]
tools: list[dict[str, str]] = [{"type": "web_search_20260209", "name": "web_search"}] tools: list[ToolUnionParam] = [
cast("ToolUnionParam", {"type": "web_search_20260209", "name": "web_search"})
]
text = "" text = ""
for round_num in range(3): for round_num in range(3):
@@ -103,6 +118,7 @@ def call_claude(
model="claude-sonnet-4-6", model="claude-sonnet-4-6",
max_tokens=4096, max_tokens=4096,
temperature=1.0, temperature=1.0,
timeout=CLAUDE_TIMEOUT_S,
system=prompt, system=prompt,
tools=tools, tools=tools,
messages=messages, messages=messages,
@@ -119,14 +135,14 @@ def call_claude(
log.info("(web search round %d)...", round_num + 1) log.info("(web search round %d)...", round_num + 1)
messages.append({"role": "assistant", "content": resp.content}) messages.append({"role": "assistant", "content": resp.content})
tool_results: list[dict[str, str]] = [] tool_results: list[dict[str, Any]] = []
for block in resp.content: for block in resp.content:
if hasattr(block, "id") and block.type == "server_tool_use": if isinstance(block, ServerToolUseBlock):
tool_results.append( tool_results.append(
{"type": "tool_result", "tool_use_id": block.id, "content": ""} {"type": "tool_result", "tool_use_id": block.id, "content": ""}
) )
if tool_results: if tool_results:
messages.append({"role": "user", "content": tool_results}) messages.append({"role": "user", "content": cast("Any", tool_results)})
else: else:
break break
@@ -155,6 +171,7 @@ def rate_limit_message() -> str:
model="claude-sonnet-4-6", model="claude-sonnet-4-6",
max_tokens=100, max_tokens=100,
temperature=1.0, temperature=1.0,
timeout=CLAUDE_TIMEOUT_S,
system=( system=(
f"You are Orac, a sardonic AI on a radio mesh network. " f"You are Orac, a sardonic AI on a radio mesh network. "
f"Generate a single short message ({MAX_RESPONSE_CHARS} chars max, ASCII only) " f"Generate a single short message ({MAX_RESPONSE_CHARS} chars max, ASCII only) "
@@ -163,9 +180,11 @@ def rate_limit_message() -> str:
), ),
messages=[{"role": "user", "content": f"[seed:{nonce}] Rate limit hit."}], messages=[{"role": "user", "content": f"[seed:{nonce}] Rate limit hit."}],
) )
text: str = resp.content[0].text.strip().strip("\"'") first = resp.content[0] if resp.content else None
if len(text.encode("utf-8")) <= MAX_RESPONSE_CHARS and text: if isinstance(first, TextBlock):
return text text = first.text.strip().strip("\"'")
if len(text.encode("utf-8")) <= MAX_RESPONSE_CHARS and text:
return text
except Exception: except Exception:
pass pass
return "Patience. Try again in a moment." return "Patience. Try again in a moment."
@@ -186,3 +205,68 @@ def extract_trigger_query(text: str) -> str | None:
if query: if query:
return query return query
return None return None
# ── Channel follow-up screener ─────────────────────────────────
_SCREENER_SYSTEM_PROMPT = f"""\
You are a one-shot classifier. Your job is to decide whether the LATEST message
in a group chat is addressed (implicitly) to an AI assistant named {BOT_NAME}.
Reply with exactly one token: YES or NO. No punctuation, no explanation.
Say YES only when ALL of the following hold:
- {BOT_NAME} has been part of the recent conversation (see history).
- The latest message is plausibly a direct follow-up to {BOT_NAME}'s most
recent message: answering a question {BOT_NAME} asked, providing info
{BOT_NAME} requested, pushing back on {BOT_NAME}'s claim, or continuing
a thread {BOT_NAME} was participating in.
- No other human in the chat is the more obvious addressee.
Say NO if:
- The message is between two other humans, about an unrelated topic.
- The message could go either way. When uncertain, NO. People hate bot noise.
- The message just mentions or references {BOT_NAME} third-person without
asking anything ("someone could ask {BOT_NAME}").
- {BOT_NAME} wasn't in the recent conversation at all.
Err strongly toward NO. It's much better to miss a follow-up than to barge into
a conversation where nobody invited you."""
def screen_channel_message(
channel_name: str,
sender: str,
text: str,
history: list[str],
) -> bool:
"""Classify whether *text* (in *channel_name*) is a follow-up for Orac.
Uses a cheap Haiku call. Returns True for YES, False for NO/error.
"""
try:
history_block = "\n".join(history[-10:]) if history else "(no prior messages)"
user_content = (
f"Channel: {channel_name}\n\n"
f"Recent history (oldest first):\n{history_block}\n\n"
f"Latest message:\n{sender}: {text}\n\n"
f"Verdict (YES or NO):"
)
resp = _get_client().messages.create(
model=SCREENER_MODEL,
max_tokens=4,
temperature=0.0,
timeout=SCREENER_TIMEOUT_S,
system=_SCREENER_SYSTEM_PROMPT,
messages=[{"role": "user", "content": user_content}],
)
except Exception as e:
log.warning("Screener API error: %s", e)
return False
first = resp.content[0] if resp.content else None
if not isinstance(first, TextBlock):
return False
verdict = first.text.strip().upper()
# Accept exact "YES" or a response starting with YES; anything else is NO.
return verdict.startswith("YES")
+165 -392
View File
@@ -1,19 +1,19 @@
"""Main bot loop, packet dispatch, ADVERT scheduling, connection management, CLI.""" """Orchestration: wire up runtime + dispatch + worker, manage connect loop + signals."""
from __future__ import annotations from __future__ import annotations
import contextlib
import logging import logging
import os import os
import signal
import sys import sys
import time import time
from typing import Any, ClassVar
import serial import serial
import donglora as dl import donglora as dl
from orac.ai import call_claude, extract_trigger_query, rate_limit_message from orac import events, logfmt
from orac.constants import ( from orac.constants import (
ADVERT_INTERVAL,
BOT_NAME, BOT_NAME,
MAX_DM_TEXT, MAX_DM_TEXT,
MAX_RESPONSE_CHARS, MAX_RESPONSE_CHARS,
@@ -27,419 +27,192 @@ from orac.crypto import (
my_hash, my_hash,
pubkey_bytes, pubkey_bytes,
) )
from orac.dispatch import RateLimiter, RxRouter, make_advert_pusher
from orac.logfmt import boot
from orac.meshcore import ( from orac.meshcore import (
build_ack_packet,
build_advert_packet, build_advert_packet,
build_dm_packet, build_dm_packet_with_plaintext,
build_grp_channel_packet,
build_login_response_packet,
build_path_return_packet,
compute_ack_hash, compute_ack_hash,
forget_route,
get_route, get_route,
has_pending_dms, route_table_size,
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.state import (
get_channel_history,
get_dm_history,
known_node_count,
load_state,
record_channel_msg,
record_dm_msg,
save_state,
) )
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") log = logging.getLogger("orac")
# ── Rate limiting (bot-level timers) ─────────────────────────────
_last_response: dict[str, float] = {} # ── Retry rebuild callback ───────────────────────────────────────
_last_global_response: float = 0.0
_last_rate_limit_reply: dict[str, float] = {}
def _rate_limit_check(key: str) -> bool: def _make_rebuild_reply(metrics: Metrics):
"""Check if we're allowed to respond. Returns True if OK.""" """Return a rebuild-reply fn for the RetryScheduler."""
global _last_global_response
from orac.constants import RATE_LIMIT_DM, RATE_LIMIT_GLOBAL, RATE_LIMIT_PER_SENDER
now = time.monotonic() def _rebuild(entry: PendingReply, attempt: int, force_flood: bool) -> tuple[bytes, bytes]:
if now - _last_global_response < RATE_LIMIT_GLOBAL: # `attempt` is 1-indexed (1 = first send). The wire byte uses attempt-1
return False # so attempt=2 (first retry) sets txt_type_attempt byte = 1.
limit = RATE_LIMIT_DM if key.startswith("dm:") else RATE_LIMIT_PER_SENDER attempt_byte = max(0, min(63, attempt - 1))
return not (key in _last_response and now - _last_response[key] < limit) 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
def _rate_limit_reply_ok(key: str) -> bool: # ── Orchestration ────────────────────────────────────────────────
"""Check if we should send a rate-limit reply (not too often)."""
from orac.constants import RATE_LIMIT_REPLY_COOLDOWN
now = time.monotonic()
if (
key in _last_rate_limit_reply
and now - _last_rate_limit_reply[key] < RATE_LIMIT_REPLY_COOLDOWN
):
return False
_last_rate_limit_reply[key] = now
return True
def _rate_limit_record(key: str) -> None: class BotRuntime:
"""Record that we just responded.""" """Holds the per-connection runtime: threads + queues + state tables."""
global _last_global_response
now = time.monotonic() def __init__(self, conn) -> None:
_last_response[key] = now self.conn = conn
_last_global_response = now 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(
conn=self.conn,
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())
# ── Transmit helpers ───────────────────────────────────────────── # ── Single-instance signal wiring ───────────────────────────────
_active_runtime: BotRuntime | None = None
def _transmit_packet(conn: Any, packet: bytes, label: str = "Transmit") -> None: # noqa: ARG001 def _handle_sigusr1(signum: int, _frame: object) -> None: # noqa: ARG001
"""Transmit a raw MeshCore packet via the donglora library.""" rt = _active_runtime
try: if rt is not None:
resp = dl.send(conn, "Transmit", payload=packet) rt.dump_metrics()
if resp["type"] == "Timeout":
log.error("TX failed: no response from radio")
elif resp["type"] == "Error":
log.error("TX failed: %s", resp)
except Exception as e:
log.error("TX exception: %s", e)
def _send_advert(conn: Any) -> None: # ── Connect loop ─────────────────────────────────────────────────
"""Transmit our ADVERT."""
packet = build_advert_packet()
log.info("Sending ADVERT as %s (hash=0x%02x)", BOT_NAME, my_hash())
_transmit_packet(conn, packet, label="ADVERT")
def _grp_transmit(conn: Any, channel_name: str, sender: str, text: str) -> None: def connect_and_run(port: str | None) -> None:
"""Encrypt and transmit a GRP_TXT on a channel.""" """Connect to a donglora dongle, spin up the runtime, and run until disconnect."""
packet = build_grp_channel_packet(channel_name, sender, text) global _active_runtime
if packet is None:
log.error("TX failed: unknown channel %s", channel_name)
return
_transmit_packet(conn, packet)
boot("Connecting...")
conn = dl.connect(port=port, timeout=2)
boot("Connected")
def _dm_transmit(conn: Any, peer_pubkey: bytes, text: str) -> None:
"""Encrypt and transmit a DM with simulated typing delay."""
delay = min(max(len(text) * 0.04, 1.0), 5.0)
time.sleep(delay)
packet = build_dm_packet(peer_pubkey, text)
peer = node_name(peer_pubkey.hex())
route_str = "direct" if get_route(peer_pubkey[0]) else "flood"
_transmit_packet(conn, packet, label=f"DM({route_str})->{peer}")
def _send_ack(conn: Any, ack_crc: bytes, dest_hash: int) -> None:
"""Send an ACK with a brief delay for the DM flood to clear."""
time.sleep(0.5)
packet = build_ack_packet(ack_crc, dest_hash)
_transmit_packet(conn, packet, label="ACK")
def _send_path_return(
conn: Any, peer_pubkey: bytes, path_hops: list[bytes], hash_size: int
) -> None:
"""Send a PATH return so the sender can learn a direct route to us."""
packet = build_path_return_packet(peer_pubkey, path_hops, hash_size)
_transmit_packet(conn, packet, label="PATH")
def _send_login_response(conn: Any, peer_pubkey: bytes) -> None:
"""Send a login OK response."""
packet = build_login_response_packet(peer_pubkey)
_transmit_packet(conn, packet, label=f"LOGIN_RESP->{node_name(peer_pubkey.hex())}")
# ── Pending DM processing ───────────────────────────────────────
def _process_pending_dms(conn: Any, node_hash: int, peer_label: str) -> None:
"""Process queued DMs that were waiting for an ADVERT or ANON_REQ."""
pending = pop_pending_dms(node_hash)
if not pending:
return
log.info("Processing %d pending DM(s) from %s", len(pending), peer_label)
for pending_payload, ts in pending:
if is_pending_expired(ts):
continue
dm_result = try_decrypt_dm(pending_payload)
if dm_result is None:
continue
peer_pubkey, peer_name, dm_text, dm_plaintext = dm_result
pk_hex = peer_pubkey.hex()
_send_ack(conn, compute_ack_hash(dm_plaintext, peer_pubkey), peer_pubkey[0])
if is_dm_duplicate(pk_hex, dm_text):
continue
log.info("DM(flood) from %s: %s", peer_name, dm_text)
record_dm_msg(pk_hex, f"{peer_name}: {dm_text}")
history = get_dm_history(pk_hex)
response = call_claude(dm_text, peer_name, history, MAX_DM_TEXT, f"DM with {peer_name}")
if response:
rt = "direct" if get_route(peer_pubkey[0]) else "flood"
log.info("<<< DM(%s) %s -> %s: %s", rt, BOT_NAME, peer_name, response)
_dm_transmit(conn, peer_pubkey, response)
record_dm_msg(pk_hex, f"{BOT_NAME}: {response}")
_rate_limit_record(f"dm:{pk_hex}")
# ── Packet dispatch ──────────────────────────────────────────────
def _handle_advert(conn: Any, raw_payload: bytes) -> None:
"""Handle an incoming ADVERT packet."""
result = try_decode_advert(raw_payload)
if result is None:
return
adv_pubkey, name = result
if adv_pubkey == pubkey_bytes():
return # ignore our own ADVERT echo
register_node(adv_pubkey, name)
log.info("ADVERT %s (hash=0x%02x pk=%s...)", name, adv_pubkey[0], adv_pubkey.hex()[:16])
if has_pending_dms(adv_pubkey[0]):
_process_pending_dms(conn, adv_pubkey[0], name)
def _handle_grp_txt(conn: Any, raw_payload: bytes) -> None:
"""Handle an incoming GRP_TXT packet."""
result = try_decrypt_grp(raw_payload)
if result is None:
return
channel_name, text = result
log.info("GRP_TXT %s %s", channel_name, text)
record_channel_msg(channel_name, text)
if ": " not in text:
return
sender, _, body = text.partition(": ")
if sender == BOT_NAME:
return
query = extract_trigger_query(body)
if query is None:
return
log.info(">>> Query from %s on %s: %s", sender, channel_name, query)
rl_key = f"ch:{channel_name}:{sender}"
if not _rate_limit_check(rl_key):
log.warning("Rate limited: %s on %s", sender, channel_name)
if _rate_limit_reply_ok(f"ch:{channel_name}"):
msg = rate_limit_message()
log.info("<<< %s: %s", BOT_NAME, msg)
_grp_transmit(conn, channel_name, BOT_NAME, msg)
_rate_limit_record(rl_key)
return
history = get_channel_history(channel_name)
response = call_claude(query, sender, history, MAX_RESPONSE_CHARS, f"channel {channel_name}")
if response is None:
return
log.info("<<< %s: %s", BOT_NAME, response)
_grp_transmit(conn, channel_name, BOT_NAME, response)
record_channel_msg(channel_name, f"{BOT_NAME}: {response}")
_rate_limit_record(rl_key)
def _handle_anon_req(
conn: Any,
raw_payload: bytes,
path_hops: list[bytes],
hash_size: int,
) -> None:
"""Handle an incoming ANON_REQ (login) packet."""
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)
log.info("LOGIN from %s -- sending login OK", peer_name)
_send_login_response(conn, peer_pubkey)
if has_pending_dms(peer_pubkey[0]):
_process_pending_dms(conn, peer_pubkey[0], peer_name)
def _handle_txt_msg(
conn: Any,
raw_payload: bytes,
route_type: int,
path_hops: list[bytes],
hash_size: int,
) -> None:
"""Handle an incoming TXT_MSG (DM) packet."""
result = try_decrypt_dm(raw_payload)
if result is None:
# If 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:
log.info("Queued DM from unknown 0x%02x, waiting for their ADVERT", src_hash)
return
peer_pubkey, peer_name, dm_text, raw_plaintext = result
# Send ACK immediately so the sender stops retrying
ack_crc = compute_ack_hash(raw_plaintext, peer_pubkey)
_send_ack(conn, ack_crc, peer_pubkey[0])
# Send PATH return so sender can learn a direct route to us
_send_path_return(conn, peer_pubkey, path_hops, hash_size)
pk_hex = peer_pubkey.hex()
if is_dm_duplicate(pk_hex, dm_text):
return
log.info("DM(%s) from %s: %s", route_name(route_type), peer_name, dm_text)
record_dm_msg(pk_hex, f"{peer_name}: {dm_text}")
rl_key = f"dm:{pk_hex}"
if not _rate_limit_check(rl_key):
log.warning("Rate limited DM: %s", peer_name)
return
history = get_dm_history(pk_hex)
response = call_claude(dm_text, peer_name, history, MAX_DM_TEXT, f"DM with {peer_name}")
if response is None:
return
rt = "direct" if get_route(peer_pubkey[0]) else "flood"
log.info("<<< DM(%s) %s -> %s: %s", rt, BOT_NAME, peer_name, response)
_dm_transmit(conn, peer_pubkey, response)
record_dm_msg(pk_hex, f"{BOT_NAME}: {response}")
_rate_limit_record(rl_key)
# ── Bot loop ─────────────────────────────────────────────────────
def bot_loop(conn: Any) -> None:
"""Main receive loop: read packets and dispatch."""
dl.send(conn, "Ping") dl.send(conn, "Ping")
dl.send(conn, "SetConfig", config=RADIO_CONFIG) dl.send(conn, "SetConfig", config=RADIO_CONFIG)
dl.send(conn, "StartRx") dl.send(conn, "StartRx")
_send_advert(conn)
last_advert = time.monotonic()
triggers_str = ", ".join(TRIGGERS) triggers_str = ", ".join(TRIGGERS)
log.info("%s listening (Ctrl+C to stop)", BOT_NAME) boot("%s listening (Ctrl+C to stop)", BOT_NAME)
log.info("Channels: %d | Triggers: %s | DMs: enabled", channel_count(), triggers_str) boot(
conn.timeout = 1 "Channels: %d | Triggers: %s | DMs: enabled",
channel_count(),
triggers_str,
)
while True: runtime = BotRuntime(conn)
# Periodic ADVERT _active_runtime = runtime
now = time.monotonic()
if now - last_advert >= ADVERT_INTERVAL:
_send_advert(conn)
last_advert = now
pkt = dl.recv(conn) try:
if pkt is None: runtime.start()
continue # Block main thread until IOThread exits (via stop() or crash).
# Short timed joins keep Ctrl+C responsive.
packet: bytes = pkt["payload"] while runtime.io_thread.is_alive():
parsed = parse_header_and_path(packet) runtime.io_thread.join(timeout=0.5)
if parsed is None: if runtime.io_thread.last_error is not None:
continue raise runtime.io_thread.last_error
except KeyboardInterrupt:
payload_type, route_type, _payload_ver, _pos, raw_payload, path_hops, hash_size = parsed boot("Shutting down...")
raise
# Learn return route from incoming packet's path finally:
if raw_payload and len(raw_payload) >= 2: runtime.stop()
src_hash: int | None = None runtime.join(timeout=3.0)
if payload_type in (0x01, 0x02, 0x08): # RESPONSE, TXT_MSG, PATH runtime.dump_metrics()
src_hash = raw_payload[1] _active_runtime = None
elif payload_type == 0x04: # ADVERT with contextlib.suppress(Exception):
src_hash = raw_payload[0] conn.timeout = 2
if src_hash is not None: dl.send(conn, "StopRx")
learn_route(src_hash, path_hops, hash_size)
# Dedup via packet hash (spec Section 16)
if is_duplicate(payload_type, raw_payload):
continue
# Dispatch by payload type
if payload_type == 0x03: # ACK -- ignore (fire-and-forget)
continue
elif payload_type == 0x04: # ADVERT
_handle_advert(conn, raw_payload)
elif payload_type == 0x05: # GRP_TXT
_handle_grp_txt(conn, raw_payload)
elif payload_type == 0x07: # ANON_REQ (login)
_handle_anon_req(conn, raw_payload, path_hops, hash_size)
elif payload_type == 0x02: # TXT_MSG (DM)
_handle_txt_msg(conn, raw_payload, route_type, path_hops, hash_size)
# ── Logging setup ────────────────────────────────────────────────
class _ColorFormatter(logging.Formatter):
"""Log formatter that adds ANSI colors when stderr is a TTY."""
_COLORS: ClassVar[dict[int, str]] = {
logging.DEBUG: "\033[2m", # dim
logging.INFO: "\033[32m", # green
logging.WARNING: "\033[33m", # yellow
logging.ERROR: "\033[31m", # red
logging.CRITICAL: "\033[1;31m", # bold red
}
_RESET = "\033[0m"
def __init__(self, use_color: bool = True) -> None:
super().__init__(fmt=" %(message)s")
self._use_color = use_color
def format(self, record: logging.LogRecord) -> str:
msg = super().format(record)
if self._use_color:
color = self._COLORS.get(record.levelno, "")
return f"{color}{msg}{self._RESET}"
return msg
def _setup_logging() -> None:
"""Configure the orac logger with colored console output."""
logger = logging.getLogger("orac")
logger.setLevel(logging.DEBUG)
handler = logging.StreamHandler(sys.stderr)
handler.setFormatter(_ColorFormatter(use_color=sys.stderr.isatty()))
logger.addHandler(handler)
# ── Entry point ────────────────────────────────────────────────── # ── Entry point ──────────────────────────────────────────────────
def main() -> None: def main() -> None:
"""CLI entry point.""" logfmt.setup_logging()
_setup_logging()
if not os.environ.get("ANTHROPIC_API_KEY"): if not os.environ.get("ANTHROPIC_API_KEY"):
log.error("ANTHROPIC_API_KEY environment variable not set") log.error("ANTHROPIC_API_KEY environment variable not set")
@@ -447,32 +220,32 @@ def main() -> None:
init_identity() init_identity()
init_channels() init_channels()
events.init()
load_state() load_state()
log.info("%s -- MeshCore AI Bot", BOT_NAME) boot("%s -- MeshCore AI Bot", BOT_NAME)
log.info( boot(
"Max GRP response: %d chars | Max DM response: %d chars", MAX_RESPONSE_CHARS, MAX_DM_TEXT "Max GRP response: %d chars | Max DM response: %d chars",
MAX_RESPONSE_CHARS,
MAX_DM_TEXT,
) )
log.info("Channels: %d | Known nodes: %d", channel_count(), known_node_count()) 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)
port = sys.argv[1] if len(sys.argv) > 1 else None port = sys.argv[1] if len(sys.argv) > 1 else None
while True: while True:
try: try:
log.info("Connecting...") connect_and_run(port)
conn = dl.connect(port=port, timeout=2)
log.info("Connected")
bot_loop(conn)
except (serial.SerialException, ConnectionError, OSError) as e: except (serial.SerialException, ConnectionError, OSError) as e:
log.error("Disconnected: %s", e) log.error("Disconnected: %s", e)
log.info("Reconnecting when device reappears...") boot("Reconnecting when device reappears...")
time.sleep(1) time.sleep(1)
except KeyboardInterrupt: except KeyboardInterrupt:
print() print()
save_state() save_state()
try:
conn.timeout = 2 # type: ignore[possibly-undefined]
dl.send(conn, "StopRx") # type: ignore[possibly-undefined]
except Exception:
pass
break break
+134 -4
View File
@@ -65,10 +65,31 @@ is permanently lost and never transmitted. There is NO exception.
Stay well under {{max_chars}}. Aim for 120 or fewer. NEVER show a character count or HTML tags in your reply. Stay well under {{max_chars}}. Aim for 120 or fewer. NEVER show a character count or HTML tags in your reply.
You can see recent messages for context. When someone asks you a question, they may You can see recent messages for context. Use the chat history to understand what
be referring to the ongoing conversation — use the chat history to understand what they mean. people mean. "what do you think?" refers to whatever was just discussed. "tell me
For example, "what do you think?" refers to whatever was just discussed. "tell me more" means more" means elaborate on the recent topic.
elaborate on the recent topic. Read the room.
Social awareness is YOUR TOP PRIORITY — above cleverness, above information density.
Before every response, work out:
- Who is actually talking, and to whom? Are they asking you, or about you?
- What's the vibe: casual banter, technical help, emergency, venting, inside joke?
- What's the group's energy? Are you being invited in, or just name-dropped?
- If someone said "you could always ask @Orac" (or similar), that's a social nudge,
NOT a request for a factual answer. Acknowledge lightly, or offer to help if the
invitation was sincere — don't barge in with an essay.
- If the conversation is jokey, match the tone. If serious, be substantive.
If someone's frustrated, don't be flippant. Read the room like a person would.
- When people are talking among themselves and you're tagged in passing, a short
witty nod is often the right move — or silence would be, if you could stay silent.
- When you ARE being asked something directly, answer crisply.
NEVER assume location. Weather, traffic, time zones, sunset, local services —
all of these are useless without knowing where the person is. If someone asks
"what's the weather?" or anything else location-sensitive without telling you
where, ask them. A short quip is fine: "Where?" or "Drop a city/airport code
and I'll tell you." Do NOT guess a city. Do NOT default to Denver or anywhere
else. Only answer a location-dependent question after the user has provided
the location (either in the current turn or visible in recent history).
Rules: Rules:
- One sentence or short phrase. Never more. - One sentence or short phrase. Never more.
@@ -76,6 +97,7 @@ Rules:
- Use common abbreviations freely (e.g., w/, b/c, approx, etc). - Use common abbreviations freely (e.g., w/, b/c, approx, etc).
- Skip all pleasantries, greetings, and filler. - Skip all pleasantries, greetings, and filler.
- You have a dry, sardonic wit. Be helpful but never wordy. - You have a dry, sardonic wit. Be helpful but never wordy.
- Cynicism is fine in small doses; punching-down or mean-spirited is not.
- If the question requires a long answer, give the most important point only. - If the question requires a long answer, give the most important point only.
- NEVER repeat a previous answer. Always find a fresh angle, new phrasing, or different take. - NEVER repeat a previous answer. Always find a fresh angle, new phrasing, or different take.
- Be creative and unpredictable. Surprise the reader.\ - Be creative and unpredictable. Surprise the reader.\
@@ -91,6 +113,114 @@ DM_DEDUP_TTL: float = 60 # seconds
RESP_SERVER_LOGIN_OK: int = 0x01 RESP_SERVER_LOGIN_OK: int = 0x01
# ── DM reliability: IO loop ──────────────────────────────────────
IO_POLL_TIMEOUT_S: float = 0.2
RX_DRAIN_PER_TICK: int = 8
# Donglora's send() reads the TxDone response using the serial timeout; LoRa
# airtime at SF7/62.5 kHz for a max-size 180 B DM is ~425 ms, plus up to
# 100 ms of CAD retries in the firmware. 2 s gives a comfortable margin.
TX_RESPONSE_TIMEOUT_S: float = 2.0
# ── DM reliability: ACK scheduling ───────────────────────────────
# Small jitter below single-ACK airtime (~89 ms at SF7/62.5 kHz/CR4/5)
# decouples us from other responders, without delaying the sender's
# firmware retry clock.
ACK_JITTER_MIN_S: float = 0.03
ACK_JITTER_MAX_S: float = 0.09
PATH_RETURN_DELAY_S: float = 0.15
# Multi-ACK protocol (upstream commits b1ca3d1 + 5881b04, `multi_acks` pref).
# When > 0, N extra ACKs are emitted as PAYLOAD_TYPE_MULTIPART (0x0A) packets
# with remaining-count nibbles counting down to 1, followed by a final plain
# PAYLOAD_TYPE_ACK (0x03). Each successive packet is 300 ms + small jitter
# after the previous.
#
# Firmware restricts extra ACKs to direct-routed packets only (see
# Mesh::routeDirectRecvAcks). We do the same: if no learned direct route to
# the peer exists, we fall back to a single plain ACK and skip MULTIPART,
# matching upstream semantics.
#
# Upstream default is 0 (multi_acks pref = 0). MeshCore One's iOS app surfaces
# this as the "2 ACKs" toggle (multi_acks=1 -> 1 MULTIPART + 1 ACK = 2 total).
MULTI_ACK_COUNT: int = 0
MULTI_ACK_SPACING_S: float = 0.3
# ── DM reliability: reply scheduling ─────────────────────────────
# Initial reply jitter keeps the reply from racing our own ACK/PATH
# on the air and below any retry window.
REPLY_INITIAL_JITTER_MIN_S: float = 0.05
REPLY_INITIAL_JITTER_MAX_S: float = 0.20
# Retry delays between attempts. Index i = delay AFTER firing attempt (i+1)
# before attempt (i+2). With REPLY_MAX_ATTEMPTS=5 we need 4 entries (for
# attempts 2-5). Schedule mirrors pymc's 3-direct + 2-flood pattern.
#
# Timeline at current radio config (SF7/62.5 kHz/CR4/5, ~3.3 s 2-hop RTT):
# Attempt 1 (initial) at T=0
# Attempt 2 (direct) at T=6 (~1.8x 2-hop RTT headroom)
# Attempt 3 (direct) at T=20 (bigger gap for congested relays)
# Attempt 4 (flood, path-reset) at T=50 (mesh quiesce window)
# Attempt 5 (flood) at T=95 (last hail-Mary after a long wait)
# Give up (sweep) at T=125 (~30s past the last attempt for ACK)
RETRY_SCHEDULE_S: tuple[float, ...] = (6.0, 14.0, 30.0, 45.0)
REPLY_MAX_ATTEMPTS: int = 5 # 1 initial + 2 direct retries + 2 flood retries
REPLY_FLOOD_FIRST_ATTEMPT: int = 4 # 1-indexed: first attempt that forces flood
# ── DM reliability: state bounds ─────────────────────────────────
# TTL must be beyond the final retry's transmit time plus enough for the
# reply's ACK to arrive: 6+14+30+45 = 95s to final retry, +30s for ACK.
PENDING_ACK_TTL_S: float = 125.0
PENDING_ACK_CAP: int = 64
REPLY_CACHE_CAP: int = 32
REPLY_CACHE_TTL_S: float = 300.0 # matches PENDING_DM_TTL
# Duplicate DMs arriving within this window *with a cached reply* trigger a
# cached-reply resend (interpreted as loss recovery). Outside this window, a
# duplicate text is treated as the peer legitimately re-asking after giving up,
# and falls through to normal text-level dedup (silent drop inside DM_DEDUP_TTL
# and a fresh Claude call beyond that).
REPLY_CACHE_RESEND_WINDOW_S: float = 20.0
TX_QUEUE_CAP: int = 64
# ── DM reliability: route table ──────────────────────────────────
ROUTE_TTL_S: float = 1800.0 # 30 min
# Minimum hop-hash size (bytes) we will accept when learning a return path,
# and the minimum we will echo back in a PATH_RETURN. 1-byte hashes have a
# 256-way namespace that collides under any real node population; this bot
# refuses to participate in 1-byte routing.
MIN_HASH_SIZE: int = 2
# ── DM reliability: worker / Claude ──────────────────────────────
WORKER_QUEUE_CAP: int = 8
CLAUDE_TIMEOUT_S: float = 20.0
# ── Channel follow-up screener ───────────────────────────────────
# Inexpensive yes/no classifier that decides whether a NON-TRIGGERED channel
# message is a follow-up to Orac (we should reply) or just chatter between
# other participants (we should not). Keeps Orac capable of natural multi-turn
# conversation without flooding the channel with bot noise.
# Haiku is the cheapest Claude model — suitable for a yes/no gate.
SCREENER_MODEL: str = "claude-haiku-4-5"
# How long after Orac was last active in a channel to keep screening new
# non-triggered messages. Outside this window, non-triggered messages are
# ignored without spending any API call.
CHANNEL_FOLLOWUP_WINDOW_S: float = 180.0 # 3 min
# Per-channel minimum gap between screener API calls, to prevent spam from
# running up cost during a burst of traffic.
SCREENER_PER_CHANNEL_RATE_S: float = 3.0
# Short timeout for the screener — it returns a single token.
SCREENER_TIMEOUT_S: float = 6.0
# ── Events JSONL path ────────────────────────────────────────────
EVENTS_FILE: Path = DATA_DIR / "orac-events.jsonl"
# ── Channels CSV path ──────────────────────────────────────────── # ── Channels CSV path ────────────────────────────────────────────
# Resolved at import time relative to the project root (ai_bot.py's directory). # Resolved at import time relative to the project root (ai_bot.py's directory).
CHANNELS_CSV: Path = Path(__file__).resolve().parent.parent / "channels.csv" CHANNELS_CSV: Path = Path(__file__).resolve().parent.parent / "channels.csv"
+25 -4
View File
@@ -138,12 +138,31 @@ def ecdh_shared_secret(peer_ed25519_pub: bytes) -> bytes:
return crypto_scalarmult(my_x25519, peer_x25519) return crypto_scalarmult(my_x25519, peer_x25519)
def peer_encrypt(shared_secret: bytes, text: str) -> bytes: def build_peer_plaintext(text: str, ts: int | None = None, attempt: int = 0) -> bytes:
"""Encrypt a DM plaintext. Returns mac(2) + ciphertext.""" """Build a DM plaintext blob: timestamp(4 LE) + txt_type_attempt(1) + text + \\x00 + zero-pad to 16.
plaintext = struct.pack("<I", int(time.time())) + b"\x00"
Using the timestamp + attempt fields from the MeshCore TXT_MSG plaintext layout.
Callers that need the expected ACK hash must keep this plaintext.
"""
if ts is None:
ts = int(time.time())
plaintext = struct.pack("<I", ts) + bytes([attempt & 0xFF])
plaintext += text.encode("utf-8") + b"\x00" plaintext += text.encode("utf-8") + b"\x00"
pad_len = (16 - len(plaintext) % 16) % 16 pad_len = (16 - len(plaintext) % 16) % 16
plaintext += b"\x00" * pad_len return plaintext + b"\x00" * pad_len
def peer_encrypt(shared_secret: bytes, text: str) -> bytes:
"""Encrypt a DM plaintext. Returns mac(2) + ciphertext.
Convenience wrapper; callers that also need the plaintext should use
:func:`build_peer_plaintext` + :func:`peer_encrypt_plaintext`.
"""
return peer_encrypt_plaintext(shared_secret, build_peer_plaintext(text))
def peer_encrypt_plaintext(shared_secret: bytes, plaintext: bytes) -> bytes:
"""Encrypt a pre-built DM plaintext. Returns mac(2) + ciphertext."""
cipher = AES.new(shared_secret[:16], AES.MODE_ECB) cipher = AES.new(shared_secret[:16], AES.MODE_ECB)
ciphertext = b"" ciphertext = b""
for i in range(0, len(plaintext), 16): for i in range(0, len(plaintext), 16):
@@ -302,6 +321,7 @@ def raw_peer_encrypt(shared_secret: bytes, plaintext: bytes) -> bytes:
__all__: list[str] = [ __all__: list[str] = [
"aes_ecb_encrypt", "aes_ecb_encrypt",
"build_advert_payload", "build_advert_payload",
"build_peer_plaintext",
"channel_count", "channel_count",
"channel_hash", "channel_hash",
"channel_secret_from_hashtag", "channel_secret_from_hashtag",
@@ -317,6 +337,7 @@ __all__: list[str] = [
"parse_grp_plaintext", "parse_grp_plaintext",
"parse_peer_plaintext", "parse_peer_plaintext",
"peer_encrypt", "peer_encrypt",
"peer_encrypt_plaintext",
"peer_verify_and_decrypt", "peer_verify_and_decrypt",
"pubkey_bytes", "pubkey_bytes",
"raw_peer_encrypt", "raw_peer_encrypt",
+651
View File
@@ -0,0 +1,651 @@
"""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
from typing import Any
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
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: dict[str, Any]) -> None:
payload_bytes: bytes = pkt.get("payload", b"")
snr: float = float(pkt.get("snr", 0))
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 = result
if adv_pubkey == pubkey_bytes():
return # our own advert echo
register_node(adv_pubkey, name)
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",
]
+64
View File
@@ -0,0 +1,64 @@
"""Structured JSONL event logger for DM reliability observability.
Each event is written as one JSON object per line to :data:`EVENTS_FILE`
(default ``~/.donglora/orac-events.jsonl``). Non-blocking best-effort writes
from any thread.
Use :func:`emit` to log an event with ``event`` name and arbitrary kwargs.
"""
from __future__ import annotations
import json
import logging
import threading
import time
from pathlib import Path
from typing import Any
from orac.constants import DATA_DIR, EVENTS_FILE
log = logging.getLogger("orac")
_events_lock = threading.Lock()
_events_file: Path = EVENTS_FILE
_initialized = False
def init() -> None:
"""Open the events file (creating parent directory if needed)."""
global _initialized
if _initialized:
return
try:
DATA_DIR.mkdir(parents=True, exist_ok=True)
_initialized = True
except Exception as e:
log.warning("Events file init failed: %s", e)
def set_path(path: Path) -> None:
"""Override the events file path (used by tests)."""
global _events_file, _initialized
_events_file = path
_initialized = False
def emit(event: str, **fields: Any) -> None:
"""Write one JSON event line. Never raises."""
if not _initialized:
init()
rec: dict[str, Any] = {"ts": time.time(), "event": event}
for k, v in fields.items():
# Coerce bytes to hex for JSON
if isinstance(v, (bytes, bytearray)):
rec[k] = v.hex()
else:
rec[k] = v
line = json.dumps(rec, default=str, separators=(",", ":"))
try:
with _events_lock, open(_events_file, "a") as f:
f.write(line + "\n")
except Exception as e:
# Don't let observability failures break the bot.
log.debug("events.emit failed: %s", e)
+63
View File
@@ -0,0 +1,63 @@
"""Track recent bot activity per channel, for follow-up detection.
Used by :class:`orac.dispatch.RxRouter` to decide whether a non-triggered
channel message might be a follow-up to Orac (and therefore worth screening
with a cheap classifier) or routine chatter between other participants
(ignore without any API call).
All state is in-memory and thread-safe — state is intentionally NOT persisted
across restarts so a bot restart gives everyone a fresh conversational slate.
"""
from __future__ import annotations
import threading
import time
_lock = threading.Lock()
_last_interaction: dict[str, float] = {}
_last_screener: dict[str, float] = {}
def touch(channel: str) -> None:
"""Mark a channel as having just had Orac activity (trigger reply, screener
hit, or an ADVERT from a peer we were just talking to)."""
now = time.monotonic()
with _lock:
_last_interaction[channel] = now
def was_recent(channel: str, window_s: float) -> bool:
"""True if the bot was last active in *channel* within *window_s* seconds."""
with _lock:
ts = _last_interaction.get(channel)
return ts is not None and (time.monotonic() - ts) < window_s
def last_interaction_age(channel: str) -> float | None:
"""Seconds since last bot activity in *channel*, or None if never seen."""
with _lock:
ts = _last_interaction.get(channel)
return (time.monotonic() - ts) if ts is not None else None
def screener_ok(channel: str, min_interval_s: float) -> bool:
"""Per-channel rate limit for screener API calls.
Returns True if a screener call may fire now, and records the intent. Returns
False if the last screener call for this channel was within ``min_interval_s``.
"""
now = time.monotonic()
with _lock:
last = _last_screener.get(channel)
if last is not None and (now - last) < min_interval_s:
return False
_last_screener[channel] = now
return True
def reset() -> None:
"""Test helper: clear all state."""
with _lock:
_last_interaction.clear()
_last_screener.clear()
+205
View File
@@ -0,0 +1,205 @@
"""Structured log formatting: fixed-width colored categories for readable TTY.
Emit via helper functions (:func:`dm_in`, :func:`dm_out`, :func:`net`, etc.)
so each line carries a category tag that the formatter colors and labels.
Environment overrides:
- ``ORAC_LOG_LEVEL=DEBUG`` or ``ORAC_DEBUG=1`` raises verbosity to DEBUG.
- ``NO_COLOR=1`` disables ANSI color regardless of TTY.
"""
from __future__ import annotations
import logging
import os
import sys
import time
from typing import Any
log = logging.getLogger("orac")
class Cat:
"""Semantic log categories. Each maps to a tag + color in :data:`_CATS`."""
BOOT = "boot"
NET = "net"
DM_IN = "dm_in"
DM_OUT = "dm_out"
CH_IN = "ch_in"
CH_OUT = "ch_out"
ACK_OK = "ack_ok"
RETRY = "retry"
GONE = "gone"
RAW = "raw"
# Each entry: (5-char tag, ANSI color code). Tag is rendered inside [brackets].
_CATS: dict[str, tuple[str, str]] = {
Cat.BOOT: ("BOOT ", "\033[36m"), # cyan
Cat.NET: (" NET", "\033[90m"), # bright black (dim gray)
Cat.DM_IN: ("DM <-", "\033[1;96m"), # bold bright cyan
Cat.DM_OUT: ("DM ->", "\033[1;95m"), # bold bright magenta
Cat.CH_IN: ("CH <-", "\033[32m"), # green
Cat.CH_OUT: ("CH ->", "\033[1;32m"), # bold green
Cat.ACK_OK: ("ACK v", "\033[2;32m"), # dim green
Cat.RETRY: ("RETRY", "\033[33m"), # yellow
Cat.GONE: ("GONE!", "\033[31m"), # red
Cat.RAW: (" raw ", "\033[2m"), # dim
}
_LEVEL_FALLBACK: dict[int, tuple[str, str]] = {
logging.DEBUG: (" DBG ", "\033[2m"),
logging.INFO: ("INFO ", ""),
logging.WARNING: ("WARN ", "\033[33m"),
logging.ERROR: ("ERROR", "\033[31m"),
logging.CRITICAL: (" CRIT", "\033[1;31m"),
}
_RESET = "\033[0m"
# Column widths (characters).
_PEER_WIDTH = 14
_CHAN_WIDTH = 12
# ── Formatter ──────────────────────────────────────────────────
class LogFormatter(logging.Formatter):
"""``HH:MM:SS.ms [TAG ] body`` with per-category ANSI color on TTY."""
def __init__(self, use_color: bool = True) -> None:
super().__init__()
self._use_color = use_color
def format(self, record: logging.LogRecord) -> str:
ms = int((record.created - int(record.created)) * 1000)
timestamp = f"{time.strftime('%H:%M:%S', time.localtime(record.created))}.{ms:03d}"
cat_attr: Any = getattr(record, "cat", None)
if isinstance(cat_attr, str) and cat_attr in _CATS:
tag, color = _CATS[cat_attr]
else:
tag, color = _LEVEL_FALLBACK.get(record.levelno, (" ", ""))
msg = record.getMessage()
line = f"{timestamp} [{tag}] {msg}"
if self._use_color and color:
return f"{color}{line}{_RESET}"
return line
# ── Setup helper ────────────────────────────────────────────────
def setup_logging() -> None:
"""Install the orac logger with the structured formatter."""
level_name = os.environ.get("ORAC_LOG_LEVEL", "INFO").upper()
level = getattr(logging, level_name, logging.INFO)
if os.environ.get("ORAC_DEBUG"):
level = logging.DEBUG
logger = logging.getLogger("orac")
logger.setLevel(level)
# Avoid stacking handlers if setup_logging is called twice.
for h in list(logger.handlers):
logger.removeHandler(h)
handler = logging.StreamHandler(sys.stderr)
use_color = sys.stderr.isatty() and not os.environ.get("NO_COLOR")
handler.setFormatter(LogFormatter(use_color=use_color))
logger.addHandler(handler)
# ── Category helpers (call sites use these) ─────────────────────
def boot(msg: str, *args: Any) -> None:
"""Startup / shutdown / lifecycle."""
log.info(msg, *args, extra={"cat": Cat.BOOT})
def net(msg: str, *args: Any) -> None:
"""Control-plane traffic: adverts, path returns, TX wire confirmation."""
log.info(msg, *args, extra={"cat": Cat.NET})
def dm_in(peer: str, text: str, route: str | None = None) -> None:
"""DM arrived addressed to us."""
label = _peer_label(peer, route)
log.info("%s %s", label, text, extra={"cat": Cat.DM_IN})
def dm_out(peer: str, text: str, route: str | None = None) -> None:
"""DM we are sending."""
label = _peer_label(peer, route)
log.info("%s %s", label, text, extra={"cat": Cat.DM_OUT})
def ch_in(channel: str, sender: str, text: str) -> None:
"""Channel message arrived (not necessarily to us)."""
log.info(
"%s %s: %s",
_left(channel, _CHAN_WIDTH),
sender,
text,
extra={"cat": Cat.CH_IN},
)
def ch_out(channel: str, text: str) -> None:
"""Channel message we are sending."""
log.info("%s %s", _left(channel, _CHAN_WIDTH), text, extra={"cat": Cat.CH_OUT})
def ack_ok(peer: str, attempt: int, elapsed_ms: int) -> None:
"""Inbound ACK confirms one of our outbound DMs."""
log.info(
"%s attempt %d, %d ms",
_left(peer, _PEER_WIDTH),
attempt,
elapsed_ms,
extra={"cat": Cat.ACK_OK},
)
def retry(peer: str, attempt: int, max_attempts: int, route: str) -> None:
"""Reply retry fired."""
log.info(
"%s attempt %d/%d via %s",
_left(peer, _PEER_WIDTH),
attempt,
max_attempts,
route,
extra={"cat": Cat.RETRY},
)
def gone(peer: str, attempts: int, reason: str = "max_attempts") -> None:
"""Reply exhausted; peer did not ACK within our retry budget."""
log.warning(
"%s gave up after %d attempts (%s)",
_left(peer, _PEER_WIDTH),
attempts,
reason,
extra={"cat": Cat.GONE},
)
def raw(msg: str, *args: Any) -> None:
"""Low-level parse / debug traffic; only visible at DEBUG level."""
log.debug(msg, *args, extra={"cat": Cat.RAW})
# ── Internal helpers ────────────────────────────────────────────
def _peer_label(peer: str, route: str | None) -> str:
core = peer if not route else f"{peer}/{route}"
return _left(core, _PEER_WIDTH)
def _left(s: str, width: int) -> str:
"""Pad/truncate *s* to exactly *width* columns (no trailing whitespace trim)."""
if len(s) > width:
return s[: width - 1] + "\u2026"
return s.ljust(width)
+148 -18
View File
@@ -6,20 +6,25 @@ import hashlib
import logging import logging
import os import os
import struct import struct
import threading
import time import time
from dataclasses import dataclass, field
from orac.constants import ( from orac.constants import (
DEDUP_TTL, DEDUP_TTL,
DM_DEDUP_TTL, DM_DEDUP_TTL,
MIN_HASH_SIZE,
PENDING_DM_TTL, PENDING_DM_TTL,
RESP_SERVER_LOGIN_OK, RESP_SERVER_LOGIN_OK,
ROUTE_TTL_S,
) )
from orac.crypto import ( from orac.crypto import (
build_advert_payload, build_advert_payload,
build_peer_plaintext,
ecdh_shared_secret, ecdh_shared_secret,
grp_encrypt, grp_encrypt,
my_hash, my_hash,
peer_encrypt, peer_encrypt_plaintext,
raw_peer_encrypt, raw_peer_encrypt,
verify_advert_signature, verify_advert_signature,
) )
@@ -27,31 +32,102 @@ from orac.crypto import (
log = logging.getLogger("orac") log = logging.getLogger("orac")
# ── Route table ────────────────────────────────────────────────── # ── Route table ──────────────────────────────────────────────────
# src_hash (int) -> (reversed_path_hops: list[bytes], hash_size: int)
_route_table: dict[int, tuple[list[bytes], int]] = {}
@dataclass
class RouteEntry:
"""Learned return route with TTL and SNR metadata."""
hops: list[bytes] # closest-relay-first after reversal of inbound path
hash_size: int
learned_at: float = field(default_factory=time.monotonic)
last_snr: float | None = None
_route_table: dict[int, RouteEntry] = {}
_route_lock = threading.Lock()
_RT_NAMES: dict[int, str] = {0: "tflood", 1: "flood", 2: "direct", 3: "tdirect"} _RT_NAMES: dict[int, str] = {0: "tflood", 1: "flood", 2: "direct", 3: "tdirect"}
def learn_route(src_hash: int, path_hops: list[bytes], hash_size: int) -> None: def learn_route(
src_hash: int,
path_hops: list[bytes],
hash_size: int,
snr: float | None = None,
) -> None:
"""Learn a return route from an incoming packet's path. """Learn a return route from an incoming packet's path.
path_hops is ordered closest-relay-first as received. path_hops is ordered closest-relay-first as received. We reverse it
We reverse it so our outgoing path goes through the same relays so our outgoing path traverses the same relays back toward the sender.
back toward the sender. Prefers larger hash sizes (3 > 2 > 1).
Upgrade rule: newer entry wins if its hash_size is not smaller AND
its SNR is not >=3 dB worse than the existing entry's last SNR.
Policy: 1-byte hashes are rejected (see :data:`MIN_HASH_SIZE`). A peer
using 1-byte path encoding simply won't get a cached route — our replies
to them will flood instead (flood packets always use 2-byte hash mode).
""" """
existing = _route_table.get(src_hash) if hash_size < MIN_HASH_SIZE:
if existing is not None: return
_, existing_hs = existing reversed_hops = list(reversed(path_hops))
if hash_size < existing_hs: with _route_lock:
return # Don't downgrade hash size existing = _route_table.get(src_hash)
_route_table[src_hash] = (list(reversed(path_hops)), hash_size) if existing is not None:
if hash_size < existing.hash_size:
return # never downgrade hash size
if snr is not None and existing.last_snr is not None and snr < existing.last_snr - 3.0:
# New path is ≥3 dB worse; keep current path and just refresh
# last-seen snr for TTL purposes.
existing.learned_at = time.monotonic()
return
_route_table[src_hash] = RouteEntry(
hops=reversed_hops,
hash_size=hash_size,
learned_at=time.monotonic(),
last_snr=snr,
)
def get_route(dest_hash: int) -> tuple[list[bytes], int] | None: def get_route(dest_hash: int) -> tuple[list[bytes], int] | None:
"""Get a learned return route for a destination, or None if unknown.""" """Return a learned route if it exists and hasn't expired, else None.
return _route_table.get(dest_hash)
Legacy tuple return preserved so callers that only care about (hops,
hash_size) keep working. Use :func:`get_route_entry` to access SNR.
"""
with _route_lock:
entry = _route_table.get(dest_hash)
if entry is None:
return None
if time.monotonic() - entry.learned_at > ROUTE_TTL_S:
# Expired: lazy-evict so the next learn can re-populate.
del _route_table[dest_hash]
return None
return (list(entry.hops), entry.hash_size)
def get_route_entry(dest_hash: int) -> RouteEntry | None:
"""Return the full RouteEntry if live, else None."""
with _route_lock:
entry = _route_table.get(dest_hash)
if entry is None:
return None
if time.monotonic() - entry.learned_at > ROUTE_TTL_S:
del _route_table[dest_hash]
return None
return entry
def forget_route(dest_hash: int) -> bool:
"""Hard-delete a learned route. Returns True if an entry was removed."""
with _route_lock:
return _route_table.pop(dest_hash, None) is not None
def route_table_size() -> int:
"""Current live route table size (after lazy eviction — call sparingly)."""
with _route_lock:
return len(_route_table)
def route_name(route_type: int) -> str: def route_name(route_type: int) -> str:
@@ -230,10 +306,40 @@ def build_advert_packet() -> bytes:
def build_dm_packet(peer_pubkey: bytes, text: str) -> bytes: def build_dm_packet(peer_pubkey: bytes, text: str) -> bytes:
"""Build a TXT_MSG DM packet using learned route or flood.""" """Build a TXT_MSG DM packet using learned route or flood."""
packet, _ = build_dm_packet_with_plaintext(peer_pubkey, text)
return packet
def build_dm_packet_with_plaintext(
peer_pubkey: bytes,
text: str,
force_flood: bool = False,
ts: int | None = None,
attempt: int = 0,
) -> tuple[bytes, bytes]:
"""Build a TXT_MSG DM packet and return (packet, plaintext).
Plaintext is needed to precompute the expected ACK CRC that the
recipient's firmware will emit back to us.
:param ts: override the plaintext timestamp; defaults to ``int(time.time())``.
Callers that need retry-stable ACK CRCs should pass a fixed ts and
increment ``attempt`` on each retry.
:param attempt: value for the ``txt_type_attempt`` plaintext byte (0-63).
:param force_flood: if True, emit a flood route-type packet regardless of
any learned route in the table.
"""
plaintext = build_peer_plaintext(text, ts=ts, attempt=attempt)
shared_secret = ecdh_shared_secret(peer_pubkey) shared_secret = ecdh_shared_secret(peer_pubkey)
mac_ct = peer_encrypt(shared_secret, text) mac_ct = peer_encrypt_plaintext(shared_secret, plaintext)
dm_payload = bytes([peer_pubkey[0], my_hash()]) + mac_ct dm_payload = bytes([peer_pubkey[0], my_hash()]) + mac_ct
return _build_routed_packet(0x02, dm_payload, peer_pubkey[0]) if force_flood:
header = bytes([(0x02 << 2) | 1]) # flood route_type = 1
path_len = bytes([0x40]) # 0 hops, 2-byte hash mode
packet = header + path_len + dm_payload
else:
packet = _build_routed_packet(0x02, dm_payload, peer_pubkey[0])
return packet, plaintext
def build_ack_packet(ack_crc: bytes, dest_hash: int) -> bytes: def build_ack_packet(ack_crc: bytes, dest_hash: int) -> bytes:
@@ -241,8 +347,32 @@ def build_ack_packet(ack_crc: bytes, dest_hash: int) -> bytes:
return _build_routed_packet(0x03, ack_crc, dest_hash) return _build_routed_packet(0x03, ack_crc, dest_hash)
def build_multiack_packet(ack_crc: bytes, remaining: int, dest_hash: int) -> bytes:
"""Build a MULTIPART ACK (PAYLOAD_TYPE_MULTIPART = 0x0A).
Upstream `createMultiAck()` (src/Mesh.cpp:570-583 in commit b1ca3d1):
the payload is 5 bytes — ``(remaining << 4) | PAYLOAD_TYPE_ACK(0x03)``
followed by the 4-byte ACK CRC.
MULTIPART ACKs are emitted ONLY on direct-routed returns (see
:func:`Mesh::routeDirectRecvAcks` upstream). Caller should verify a
direct route exists for ``dest_hash`` and skip MULTIPART otherwise.
"""
if len(ack_crc) != 4:
raise ValueError(f"ack_crc must be 4 bytes, got {len(ack_crc)}")
if not 0 <= remaining <= 15:
raise ValueError(f"remaining must fit a nibble (0..15), got {remaining}")
payload = bytes([(remaining << 4) | 0x03]) + ack_crc
return _build_routed_packet(0x0A, payload, dest_hash)
def build_path_return_packet(peer_pubkey: bytes, path_hops: list[bytes], hash_size: int) -> bytes: def build_path_return_packet(peer_pubkey: bytes, path_hops: list[bytes], hash_size: int) -> bytes:
"""Build a PATH return (0x08) so the sender can learn a direct route to us.""" """Build a PATH return (0x08) so the sender can learn a direct route to us.
Rejects 1-byte hash encodings (see :data:`MIN_HASH_SIZE`).
"""
if hash_size < MIN_HASH_SIZE:
raise ValueError(f"refusing to build PATH_RETURN with hash_size={hash_size}")
reversed_hops = list(reversed(path_hops)) reversed_hops = list(reversed(path_hops))
hash_size_code = hash_size - 1 hash_size_code = hash_size - 1
path_len_byte = (hash_size_code << 6) | (len(reversed_hops) & 0x3F) path_len_byte = (hash_size_code << 6) | (len(reversed_hops) & 0x3F)
+213
View File
@@ -0,0 +1,213 @@
"""Pending-ACK tracking and reply caching for DM reliability.
- :class:`PendingAckTable` — outbound replies awaiting an ACK. Workers register
on send; the RxRouter consumes on inbound ACK; the RetryScheduler walks
expired entries to fire retries.
- :class:`ReplyCache` — per-peer cache of the last reply we sent. If a peer
retransmits the same DM (their ACK to us got through but our reply did not),
we re-send the cached packet bytes instead of re-calling Claude.
Both are bounded in size and time and are safe for concurrent access.
"""
from __future__ import annotations
import threading
import time
from collections.abc import Callable
from dataclasses import dataclass, field
from orac.constants import (
PENDING_ACK_CAP,
PENDING_ACK_TTL_S,
REPLY_CACHE_CAP,
REPLY_CACHE_TTL_S,
)
# ── Pending-ACK table ───────────────────────────────────────────
@dataclass
class PendingReply:
"""An outbound DM reply awaiting an ACK from its recipient.
Each retry *rebuilds* the packet with an incremented ``attempt`` counter
and (for the flood retry) freshly-decided routing. Keeping ``text`` and
``first_ts`` here lets :class:`RetryScheduler` recreate the exact plaintext
on each retry and recompute the expected ACK CRC.
"""
expected_ack: bytes # 4-byte CRC the peer will emit; may be re-keyed on retry
peer_pk: bytes
peer_name: str
text: str # reply text
first_ts: int # wall-clock timestamp used in the plaintext; stable across retries
first_sent_at: float = field(default_factory=time.monotonic)
attempt: int = 1 # 1 for first TX; 2 for first retry, etc.
retry_at: float = 0.0
max_attempts: int = 5
class PendingAckTable:
"""Thread-safe bounded table keyed on the expected ACK CRC.
Capacity-bounded (LRU-ish: insertion-order eviction when full). TTL-bounded
at registration time — callers can call :meth:`expired` to find deadlines.
"""
def __init__(self, cap: int = PENDING_ACK_CAP) -> None:
self._cap = cap
self._by_ack: dict[bytes, PendingReply] = {}
self._lock = threading.Lock()
def register(self, entry: PendingReply) -> None:
"""Insert a new pending-reply entry."""
with self._lock:
if len(self._by_ack) >= self._cap and entry.expected_ack not in self._by_ack:
# Evict the oldest registration (dict insertion order in CPython ≥3.7)
oldest = next(iter(self._by_ack))
self._by_ack.pop(oldest, None)
self._by_ack[entry.expected_ack] = entry
def consume(self, ack_crc: bytes) -> PendingReply | None:
"""Remove and return a pending entry matching *ack_crc*, or None."""
with self._lock:
return self._by_ack.pop(ack_crc, None)
def drop(self, ack_crc: bytes) -> PendingReply | None:
"""Remove an entry without the 'matched' semantic. Same shape as consume()."""
with self._lock:
return self._by_ack.pop(ack_crc, None)
def expired(self, now: float) -> list[PendingReply]:
"""Return entries whose retry_at ≤ now. Caller handles them.
Does NOT remove them — the scheduler decides whether to retry or drop.
"""
with self._lock:
return [e for e in self._by_ack.values() if e.retry_at <= now]
def sweep_stale(self, now: float) -> list[PendingReply]:
"""Remove and return entries older than PENDING_ACK_TTL_S."""
cutoff = now - PENDING_ACK_TTL_S
with self._lock:
stale = [e for e in self._by_ack.values() if e.first_sent_at < cutoff]
for e in stale:
self._by_ack.pop(e.expected_ack, None)
return stale
def update(
self,
ack_crc: bytes,
mutator: Callable[[PendingReply], None],
) -> PendingReply | None:
"""Apply *mutator* to an existing entry atomically.
Returns the (now-mutated) entry, or None if no match.
"""
with self._lock:
entry = self._by_ack.get(ack_crc)
if entry is None:
return None
mutator(entry)
return entry
def replace_key(self, old_ack: bytes, new_ack: bytes) -> None:
"""Re-key an entry after path-reset rebuilds the packet (and ACK CRC)."""
with self._lock:
entry = self._by_ack.pop(old_ack, None)
if entry is not None:
entry.expected_ack = new_ack
self._by_ack[new_ack] = entry
def depth(self) -> int:
"""Current entry count."""
with self._lock:
return len(self._by_ack)
def snapshot(self) -> list[PendingReply]:
"""Return a shallow copy of current entries for debug inspection."""
with self._lock:
return list(self._by_ack.values())
def has_pending_for_peer(self, peer_pk: bytes) -> bool:
"""True if any entry is still waiting for an ACK from *peer_pk*."""
with self._lock:
return any(e.peer_pk == peer_pk for e in self._by_ack.values())
# ── Reply cache ─────────────────────────────────────────────────
@dataclass
class _CachedReply:
peer_pk: bytes
peer_name: str
dm_text: str # the DM plaintext we're replying TO (dedup key)
reply_packet: bytes
reply_text: str
expected_ack: bytes
ts: float = field(default_factory=time.monotonic)
class ReplyCache:
"""Last-reply cache per peer; retransmitted DMs get the cached reply resend.
Keyed by ``peer_pubkey_hex``. If the peer's next DM plaintext matches the
stored ``dm_text``, we know the same DM is being retried and we re-send
the original reply instead of calling Claude again.
"""
def __init__(self, cap: int = REPLY_CACHE_CAP, ttl: float = REPLY_CACHE_TTL_S) -> None:
self._cap = cap
self._ttl = ttl
self._entries: dict[str, _CachedReply] = {}
self._lock = threading.Lock()
def put(
self,
peer_pk: bytes,
peer_name: str,
dm_text: str,
reply_packet: bytes,
reply_text: str,
expected_ack: bytes,
) -> None:
key = peer_pk.hex()
now = time.monotonic()
with self._lock:
if len(self._entries) >= self._cap and key not in self._entries:
# Drop oldest by ts
oldest = min(self._entries, key=lambda k: self._entries[k].ts)
self._entries.pop(oldest, None)
self._entries[key] = _CachedReply(
peer_pk=peer_pk,
peer_name=peer_name,
dm_text=dm_text,
reply_packet=reply_packet,
reply_text=reply_text,
expected_ack=expected_ack,
ts=now,
)
def get(self, peer_pk_hex: str, dm_text: str) -> _CachedReply | None:
"""Return the cached reply if the DM text matches and TTL fresh."""
with self._lock:
entry = self._entries.get(peer_pk_hex)
if entry is None:
return None
if time.monotonic() - entry.ts > self._ttl:
self._entries.pop(peer_pk_hex, None)
return None
if entry.dm_text != dm_text:
return None
return entry
def drop(self, peer_pk_hex: str) -> None:
with self._lock:
self._entries.pop(peer_pk_hex, None)
def depth(self) -> int:
with self._lock:
return len(self._entries)
+513
View File
@@ -0,0 +1,513 @@
"""Concurrency runtime: TxQueue, RetryScheduler, IOThread, Metrics.
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.
- **Worker** (defined in :mod:`orac.worker`) — processes DM reply work items,
calls Claude off the IO loop, and enqueues reply packets onto the TxQueue.
- **main** — setup + CLI + signal handlers only; no hot-path responsibility.
The TxQueue serializes all outbound packets through a single priority heap.
The RetryScheduler drives reply retransmission against :class:`PendingAckTable`.
"""
from __future__ import annotations
import heapq
import itertools
import logging
import threading
import time
from collections.abc import Callable
from dataclasses import dataclass, field
from enum import IntEnum
from typing import Any
import donglora as dl
from orac import logfmt
from orac.constants import (
ADVERT_INTERVAL,
IO_POLL_TIMEOUT_S,
PENDING_ACK_TTL_S,
REPLY_FLOOD_FIRST_ATTEMPT,
RETRY_SCHEDULE_S,
RX_DRAIN_PER_TICK,
TX_QUEUE_CAP,
TX_RESPONSE_TIMEOUT_S,
)
from orac.reply_state import PendingAckTable, PendingReply
log = logging.getLogger("orac")
# ── TX priorities ────────────────────────────────────────────────
class TxPriority(IntEnum):
"""Ordering priorities for outbound packets. Lower = higher priority."""
ACK = 0
PATH = 1
REPLY_DIRECT = 2
REPLY_FLOOD = 3
LOGIN_RESP = 4
GRP_TXT = 5
ADVERT = 6
# ── TxItem ───────────────────────────────────────────────────────
@dataclass
class TxItem:
"""A packet queued for transmission.
:attr:`not_before` lets us schedule jittered / delayed sends without
blocking the IO loop. :attr:`expected_ack` ties a REPLY TX to its entry
in :class:`PendingAckTable` so we can cancel it if the ACK arrives while
the reply is still queued.
"""
priority: int
not_before: float
packet: bytes
label: str
expected_ack: bytes | None = None
peer_pk: bytes | None = None
attempt: int = 1
cached_reply: bool = False
# ── TxQueue ──────────────────────────────────────────────────────
@dataclass(order=True)
class _HeapEntry:
"""Min-heap ordering key. Secondary counter breaks priority/deadline ties."""
not_before: float
priority: int
seq: int
item: TxItem = field(compare=False)
class TxQueue:
"""Thread-safe priority queue of TxItems.
Ordering: earliest ``not_before`` wins; ties broken by priority, then seq.
Capacity-bounded at :data:`TX_QUEUE_CAP` — when full, the lowest-priority
(largest priority int) oldest entry is evicted.
"""
def __init__(self, cap: int = TX_QUEUE_CAP) -> None:
self._heap: list[_HeapEntry] = []
self._cap = cap
self._seq = itertools.count()
self._lock = threading.Lock()
# Counter-based snapshot access for metrics without holding the lock
# longer than necessary.
def push(self, item: TxItem) -> None:
with self._lock:
if len(self._heap) >= self._cap:
victim = max(self._heap, key=lambda e: (e.priority, e.not_before))
try:
self._heap.remove(victim)
heapq.heapify(self._heap)
log.warning(
"TxQueue full (%d); evicted %s (prio=%d)",
self._cap,
victim.item.label,
victim.priority,
)
except ValueError:
pass
entry = _HeapEntry(
not_before=item.not_before,
priority=item.priority,
seq=next(self._seq),
item=item,
)
heapq.heappush(self._heap, entry)
def pop_ready(self, now: float) -> TxItem | None:
"""Return the highest-priority ready item, or None if nothing due."""
with self._lock:
if not self._heap:
return None
# Find the earliest-ready entry; if its not_before > now, nothing ready.
top = self._heap[0]
if top.not_before > now:
return None
# Pop it
heapq.heappop(self._heap)
# There may be another ready item with higher priority (lower int)
# whose not_before is also ≤ now but was ordered later in the heap.
# Scan the remaining heap for a better candidate to keep fairness tight.
better_idx = -1
best_prio = top.priority
for i, entry in enumerate(self._heap):
if entry.not_before <= now and entry.priority < best_prio:
best_prio = entry.priority
better_idx = i
if better_idx >= 0:
swapped = self._heap[better_idx]
# Put `top` back; take `swapped` instead.
self._heap[better_idx] = top
heapq.heapify(self._heap)
return swapped.item
return top.item
def cancel_by_ack(self, ack_crc: bytes) -> int:
"""Remove any queued TxItems whose ``expected_ack`` matches. Returns count."""
with self._lock:
before = len(self._heap)
self._heap = [e for e in self._heap if e.item.expected_ack != ack_crc]
heapq.heapify(self._heap)
return before - len(self._heap)
def depth(self) -> int:
with self._lock:
return len(self._heap)
# ── Metrics ──────────────────────────────────────────────────────
class Metrics:
"""Simple threadsafe counters + gauges for SIGUSR1 dump / periodic log."""
def __init__(self) -> None:
self._lock = threading.Lock()
self._counters: dict[str, int] = {}
self._gauges: dict[str, int] = {}
self.started_at = time.monotonic()
def inc(self, key: str, amount: int = 1) -> None:
with self._lock:
self._counters[key] = self._counters.get(key, 0) + amount
def gauge(self, key: str, value: int) -> None:
with self._lock:
self._gauges[key] = value
def snapshot(self) -> tuple[dict[str, int], dict[str, int], float]:
with self._lock:
return dict(self._counters), dict(self._gauges), time.monotonic() - self.started_at
def summary(self) -> str:
counters, gauges, uptime_s = self.snapshot()
parts = [f"uptime={int(uptime_s)}s"]
for k in sorted(counters):
parts.append(f"{k}={counters[k]}")
for k in sorted(gauges):
parts.append(f"{k}={gauges[k]}")
return " ".join(parts)
# ── RetryScheduler ───────────────────────────────────────────────
# Callback signature: (entry, attempt, force_flood) -> (packet, new_ack_crc)
RebuildReplyFn = Callable[[PendingReply, int, bool], tuple[bytes, bytes]]
class RetryScheduler:
"""Drives reply retries against :class:`PendingAckTable`.
On :meth:`tick`, expired pending replies are either (a) rebuilt for the
next attempt and re-queued, or (b) dropped with a WARN if exhausted.
Attempt :data:`REPLY_FLOOD_FIRST_ATTEMPT` forgets the peer's learned route
and rebuilds the packet with ``force_flood=True``.
"""
def __init__(
self,
pending: PendingAckTable,
tx_queue: TxQueue,
metrics: Metrics,
rebuild_reply: RebuildReplyFn,
forget_route_fn: Callable[[int], bool],
has_route_fn: Callable[[int], bool] | None = None,
event_emit: Callable[..., None] | None = None,
) -> None:
self._pending = pending
self._tx = tx_queue
self._metrics = metrics
self._rebuild = rebuild_reply
self._forget = forget_route_fn
# Optional predicate to check whether a learned route exists for a peer.
# Used only for honest log labeling; if not provided we assume no route.
self._has_route = has_route_fn or (lambda _h: False)
self._emit = event_emit or _noop_emit
def tick(self, now: float) -> None:
"""Fire retries/exhaustion for any expired entries."""
# 1) Sweep very-old entries defensively
stale = self._pending.sweep_stale(now)
for entry in stale:
self._metrics.inc("reply_exhausted")
self._emit(
"reply.exhausted",
peer=entry.peer_name,
peer_pk=entry.peer_pk.hex(),
total_elapsed_ms=int((now - entry.first_sent_at) * 1000),
reason="ttl_sweep",
)
logfmt.gone(entry.peer_name, entry.attempt, reason=f"stale>{int(PENDING_ACK_TTL_S)}s")
# 2) Fire retries for entries whose retry_at has elapsed
for entry in self._pending.expired(now):
if entry.attempt >= entry.max_attempts:
self._pending.drop(entry.expected_ack)
self._metrics.inc("reply_exhausted")
self._emit(
"reply.exhausted",
peer=entry.peer_name,
peer_pk=entry.peer_pk.hex(),
total_elapsed_ms=int((now - entry.first_sent_at) * 1000),
attempt=entry.attempt,
reason="max_attempts",
)
logfmt.gone(entry.peer_name, entry.attempt, reason="max_attempts")
continue
next_attempt = entry.attempt + 1
force_flood = next_attempt >= REPLY_FLOOD_FIRST_ATTEMPT
reason = "no_ack"
if next_attempt == REPLY_FLOOD_FIRST_ATTEMPT:
# First flood retry: forget the learned route so the rebuild
# actually produces a flood packet.
removed = self._forget(entry.peer_pk[0])
if removed:
self._metrics.inc("route_forgot")
self._emit(
"route.forgot",
peer=entry.peer_name,
peer_pk=entry.peer_pk.hex(),
reason="reply_retry",
)
reason = "path_reset"
try:
packet, new_ack = self._rebuild(entry, next_attempt, force_flood)
except Exception as e:
log.error(
"Failed to rebuild reply for %s on attempt %d: %s",
entry.peer_name,
next_attempt,
e,
)
# Drop the entry rather than retrying forever on a rebuild bug.
self._pending.drop(entry.expected_ack)
self._metrics.inc("reply_exhausted")
continue
old_ack = entry.expected_ack
# Index i of RETRY_SCHEDULE_S is the delay BEFORE attempt (i+2),
# i.e., the delay from firing attempt (i+2) back to this entry.
# After firing attempt `next_attempt`, we need the delay until
# attempt (next_attempt+1): that's RETRY_SCHEDULE_S[next_attempt-1].
# Clamp to the final entry if we've run out of explicit deltas.
schedule_idx = min(next_attempt - 1, len(RETRY_SCHEDULE_S) - 1)
retry_at = now + RETRY_SCHEDULE_S[schedule_idx]
# Default-arg binding to freeze the loop variables into the closure.
self._pending.update(
old_ack,
lambda e, _a=next_attempt, _n=new_ack, _r=retry_at: _apply_retry_update(
e, _a, _n, _r
),
)
# Re-key if the ACK CRC changed (different attempt byte → different hash)
if new_ack != old_ack:
self._pending.replace_key(old_ack, new_ack)
# Honest route label: force_flood means we definitely flooded.
# Otherwise, the actual transmission falls back to flood if we
# don't have a cached route — reflect that in the log.
if force_flood:
priority = TxPriority.REPLY_FLOOD
route_label = "flood"
elif self._has_route(entry.peer_pk[0]):
priority = TxPriority.REPLY_DIRECT
route_label = "direct"
else:
priority = TxPriority.REPLY_FLOOD
route_label = "flood(no-route)"
self._tx.push(
TxItem(
priority=priority,
not_before=now,
packet=packet,
label=f"DM({route_label}, retry {next_attempt})->{entry.peer_name}",
expected_ack=new_ack,
peer_pk=entry.peer_pk,
attempt=next_attempt,
)
)
self._metrics.inc("reply_retried")
self._metrics.inc(f"reply_retried_attempt_{next_attempt}")
self._emit(
"reply.retry",
peer=entry.peer_name,
peer_pk=entry.peer_pk.hex(),
attempt=next_attempt,
route=route_label,
reason=reason,
)
logfmt.retry(entry.peer_name, next_attempt, entry.max_attempts, route_label)
# ── IOThread ─────────────────────────────────────────────────────
class IOThread(threading.Thread):
"""Sole owner of the donglora ``conn``; 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`.
"""
def __init__(
self,
conn: Any,
tx_queue: TxQueue,
retry_sched: RetryScheduler,
rx_handler: Callable[[dict[str, Any]], None],
advert_fn: Callable[[TxQueue], None],
metrics: Metrics,
advert_interval: float = ADVERT_INTERVAL,
gauge_collector: Callable[[], None] | None = None,
name: str = "orac-io",
) -> None:
super().__init__(daemon=True, name=name)
self._conn = conn
self._tx = tx_queue
self._retry = retry_sched
self._rx_handler = rx_handler
self._advert_fn = advert_fn
self._metrics = metrics
self._advert_interval = advert_interval
self._gauge_collector = gauge_collector
self._stop_event = threading.Event()
self._last_gauge_dump = 0.0
self.last_error: BaseException | None = None
def stop(self) -> None:
self._stop_event.set()
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()
while not self._stop_event.is_set():
# 1) Drain RX — up to N per cycle
for _ in range(RX_DRAIN_PER_TICK):
if self._stop_event.is_set():
return
pkt = dl.recv(self._conn)
if pkt is None:
break
self._metrics.inc("rx_total")
try:
self._rx_handler(pkt)
except Exception as e:
log.exception("rx_handler error: %s", e)
if self._stop_event.is_set():
return
now = time.monotonic()
# 2) Tick retry scheduler
try:
self._retry.tick(now)
except Exception as e:
log.exception("retry scheduler error: %s", e)
# 3) Advert timer
if now - last_advert >= self._advert_interval:
self._advert_fn(self._tx)
last_advert = now
# 4) Fire one TX item if ready
item = self._tx.pop_ready(now)
if item is not None:
self._transmit(item)
# 5) Periodic gauge dump (every 60 s)
if now - self._last_gauge_dump > 60.0:
self._metrics.gauge("tx_queue_depth", self._tx.depth())
if self._gauge_collector is not None:
try:
self._gauge_collector()
except Exception:
log.exception("gauge_collector failed")
self._last_gauge_dump = now
except BaseException as e: # surface fatal errors to main thread
self.last_error = e
log.exception("IOThread died: %s", e)
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.
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.
"""
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)
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:
try:
return TxPriority(prio).name.lower()
except ValueError:
return f"prio{prio}"
def _noop_emit(*_args: Any, **_kwargs: Any) -> None:
"""Default event_emit when caller doesn't provide one."""
return None
def _apply_retry_update(entry: PendingReply, attempt: int, new_ack: bytes, retry_at: float) -> None:
entry.attempt = attempt
entry.expected_ack = new_ack
entry.retry_at = retry_at
+399
View File
@@ -0,0 +1,399 @@
"""Worker thread: drains DM reply work, calls Claude, builds replies, queues TX.
Decouples the 3-15 s Claude round trip from the RX/ACK hot path. Reply
composition + enqueueing happens here; the IOThread handles actual transmission
and retry firing.
"""
from __future__ import annotations
import contextlib
import logging
import queue
import random
import threading
import time
from collections.abc import Callable
from dataclasses import dataclass
from orac import events, followup, logfmt
from orac.ai import call_claude, screen_channel_message
from orac.constants import (
BOT_NAME,
MAX_DM_TEXT,
MAX_RESPONSE_CHARS,
REPLY_INITIAL_JITTER_MAX_S,
REPLY_INITIAL_JITTER_MIN_S,
REPLY_MAX_ATTEMPTS,
RETRY_SCHEDULE_S,
WORKER_QUEUE_CAP,
)
from orac.crypto import pubkey_bytes
from orac.meshcore import (
build_dm_packet_with_plaintext,
build_grp_channel_packet,
compute_ack_hash,
get_route,
)
from orac.reply_state import PendingAckTable, PendingReply, ReplyCache
from orac.runtime import Metrics, TxItem, TxPriority, TxQueue
from orac.state import get_channel_history, get_dm_history, record_channel_msg, record_dm_msg
log = logging.getLogger("orac")
# ── Work items ───────────────────────────────────────────────────
@dataclass
class DmReplyWork:
"""Request to compose a reply to an inbound DM."""
peer_pk: bytes
peer_name: str
dm_text: str
received_at: float
@dataclass
class ChannelReplyWork:
"""Request to compose a reply to a channel query (trigger or @mention)."""
channel_name: str
sender: str
query: str
received_at: float
rl_key: str # for post-send rate-limit record
@dataclass
class ChannelScreenWork:
"""Classify whether a non-triggered channel message is meant for Orac.
Fires a cheap Haiku screener; on YES, dispatches a :class:`ChannelReplyWork`
inline.
"""
channel_name: str
sender: str
text: str
history_snapshot: list[str]
received_at: float
rl_key: str
# Sentinel pushed by :meth:`Worker.stop`.
class _Stop:
pass
_STOP = _Stop()
# ── Worker ───────────────────────────────────────────────────────
class Worker(threading.Thread):
"""Single-threaded Claude-call worker.
Serializes Claude calls by design — the Anthropic API is thread-safe but
the radio is the bottleneck; running multiple Claude calls in parallel
only helps if we'd otherwise starve TX, which we don't.
"""
def __init__(
self,
tx_queue: TxQueue,
pending_acks: PendingAckTable,
reply_cache: ReplyCache,
metrics: Metrics,
on_reply_recorded: Callable[[str], None] | None = None,
queue_cap: int = WORKER_QUEUE_CAP,
name: str = "orac-worker",
) -> None:
super().__init__(daemon=True, name=name)
self._tx = tx_queue
self._pending = pending_acks
self._cache = reply_cache
self._metrics = metrics
self._on_recorded = on_reply_recorded
self._queue: queue.Queue[DmReplyWork | ChannelReplyWork | ChannelScreenWork | _Stop] = (
queue.Queue(maxsize=queue_cap)
)
self._stop_flag = threading.Event()
# -- public API -----------------------------------------------
def submit(self, work: DmReplyWork | ChannelReplyWork | ChannelScreenWork) -> bool:
"""Enqueue a work item. Drops oldest if full. Returns True on accept."""
try:
self._queue.put_nowait(work)
self._metrics.gauge("worker_queue_depth", self._queue.qsize())
return True
except queue.Full:
# Drop oldest to make room
try:
dropped = self._queue.get_nowait()
if isinstance(dropped, DmReplyWork):
log.warning(
"Worker queue full; dropped DM from %s: %s",
dropped.peer_name,
dropped.dm_text[:40],
)
self._metrics.inc("worker_queue_dropped_dm")
events.emit(
"worker.dropped",
peer=dropped.peer_name,
peer_pk=dropped.peer_pk.hex(),
reason="queue_full",
)
elif isinstance(dropped, ChannelReplyWork):
log.warning(
"Worker queue full; dropped channel query on %s from %s",
dropped.channel_name,
dropped.sender,
)
self._metrics.inc("worker_queue_dropped_channel")
except queue.Empty:
pass
try:
self._queue.put_nowait(work)
self._metrics.gauge("worker_queue_depth", self._queue.qsize())
return True
except queue.Full:
log.error("Worker queue still full after eviction; rejecting")
return False
def stop(self) -> None:
self._stop_flag.set()
with contextlib.suppress(queue.Full):
self._queue.put_nowait(_STOP)
# -- main loop ------------------------------------------------
def run(self) -> None:
while not self._stop_flag.is_set():
try:
work = self._queue.get(timeout=1.0)
except queue.Empty:
continue
if isinstance(work, _Stop):
return
self._metrics.gauge("worker_queue_depth", self._queue.qsize())
try:
if isinstance(work, DmReplyWork):
self._handle_dm_work(work)
elif isinstance(work, ChannelReplyWork):
self._handle_channel_work(work)
elif isinstance(work, ChannelScreenWork):
self._handle_channel_screen_work(work)
except Exception as e:
log.exception("Worker error: %s", e)
# -- reply composition ----------------------------------------
def _handle_dm_work(self, work: DmReplyWork) -> None:
pk_hex = work.peer_pk.hex()
# Record the inbound side of the conversation before calling Claude,
# matching legacy behavior.
record_dm_msg(pk_hex, f"{work.peer_name}: {work.dm_text}")
history = get_dm_history(pk_hex)
claude_start = time.monotonic()
response = call_claude(
work.dm_text,
work.peer_name,
history,
MAX_DM_TEXT,
f"DM with {work.peer_name}",
)
claude_ms = int((time.monotonic() - claude_start) * 1000)
self._metrics.inc("claude_calls")
if response is None:
log.warning("Claude returned no text for DM from %s", work.peer_name)
self._metrics.inc("claude_empty")
events.emit(
"claude.empty",
peer=work.peer_name,
peer_pk=pk_hex,
elapsed_ms=claude_ms,
)
return
# Build packet + plaintext with a stable ts we can reuse on retries.
route_known = get_route(work.peer_pk[0]) is not None
force_flood = not route_known
first_ts = int(time.time())
packet, plaintext = build_dm_packet_with_plaintext(
work.peer_pk,
response,
force_flood=force_flood,
ts=first_ts,
attempt=0,
)
expected_ack = compute_ack_hash(plaintext, pubkey_bytes())
now = time.monotonic()
entry = PendingReply(
expected_ack=expected_ack,
peer_pk=work.peer_pk,
peer_name=work.peer_name,
text=response,
first_ts=first_ts,
first_sent_at=now,
attempt=1,
max_attempts=REPLY_MAX_ATTEMPTS,
retry_at=now + RETRY_SCHEDULE_S[0],
)
self._pending.register(entry)
# Cache for duplicate-DM resend
self._cache.put(
peer_pk=work.peer_pk,
peer_name=work.peer_name,
dm_text=work.dm_text,
reply_packet=packet,
reply_text=response,
expected_ack=expected_ack,
)
jitter = random.uniform(REPLY_INITIAL_JITTER_MIN_S, REPLY_INITIAL_JITTER_MAX_S)
route_label = "flood" if force_flood else "direct"
priority = TxPriority.REPLY_FLOOD if force_flood else TxPriority.REPLY_DIRECT
self._tx.push(
TxItem(
priority=priority,
not_before=now + jitter,
packet=packet,
label=f"DM({route_label})->{work.peer_name}",
expected_ack=expected_ack,
peer_pk=work.peer_pk,
attempt=1,
)
)
record_dm_msg(pk_hex, f"{BOT_NAME}: {response}")
self._metrics.inc("reply_sent")
self._metrics.gauge("pending_ack_depth", self._pending.depth())
events.emit(
"reply.sent",
peer=work.peer_name,
peer_pk=pk_hex,
attempt=1,
route=route_label,
ack_crc=expected_ack.hex(),
text_len=len(response),
claude_ms=claude_ms,
jitter_ms=int(jitter * 1000),
)
logfmt.dm_out(work.peer_name, response, route=route_label)
if self._on_recorded is not None:
try:
self._on_recorded(f"dm:{pk_hex}")
except Exception:
log.exception("on_reply_recorded callback raised")
# -- channel reply composition -------------------------------
def _handle_channel_work(self, work: ChannelReplyWork) -> None:
history = get_channel_history(work.channel_name)
claude_start = time.monotonic()
response = call_claude(
work.query,
work.sender,
history,
MAX_RESPONSE_CHARS,
f"channel {work.channel_name}",
)
claude_ms = int((time.monotonic() - claude_start) * 1000)
self._metrics.inc("claude_calls")
if response is None:
self._metrics.inc("claude_empty")
events.emit(
"claude.empty",
channel=work.channel_name,
sender=work.sender,
elapsed_ms=claude_ms,
)
return
packet = build_grp_channel_packet(work.channel_name, BOT_NAME, response)
if packet is None:
log.error("Channel unknown: %s", work.channel_name)
return
self._tx.push(
TxItem(
priority=TxPriority.GRP_TXT,
not_before=time.monotonic(),
packet=packet,
label=f"GRP({work.channel_name})",
expected_ack=None,
peer_pk=None,
attempt=1,
)
)
record_channel_msg(work.channel_name, f"{BOT_NAME}: {response}")
logfmt.ch_out(work.channel_name, response)
# Mark this channel as having had bot activity so follow-ups get screened.
followup.touch(work.channel_name)
events.emit(
"channel.reply_sent",
channel=work.channel_name,
sender=work.sender,
claude_ms=claude_ms,
text_len=len(response),
)
if self._on_recorded is not None:
try:
self._on_recorded(work.rl_key)
except Exception:
log.exception("on_reply_recorded callback raised")
# -- channel screener (cheap classifier) ---------------------
def _handle_channel_screen_work(self, work: ChannelScreenWork) -> None:
verdict_start = time.monotonic()
try:
verdict = screen_channel_message(
work.channel_name, work.sender, work.text, work.history_snapshot
)
except Exception as e:
log.exception("Screener call raised: %s", e)
return
elapsed_ms = int((time.monotonic() - verdict_start) * 1000)
self._metrics.inc("screener_calls")
self._metrics.inc("screener_yes" if verdict else "screener_no")
events.emit(
"screener.verdict",
channel=work.channel_name,
sender=work.sender,
verdict=bool(verdict),
elapsed_ms=elapsed_ms,
)
if not verdict:
return
# YES: treat as if triggered. Refresh the followup window now so
# immediate successive messages also get screened.
followup.touch(work.channel_name)
logfmt.net(
"screener: follow-up from %s on %s (%d ms) -> replying",
work.sender,
work.channel_name,
elapsed_ms,
)
self._handle_channel_work(
ChannelReplyWork(
channel_name=work.channel_name,
sender=work.sender,
query=work.text,
received_at=work.received_at,
rl_key=work.rl_key,
)
)
+10 -1
View File
@@ -15,7 +15,16 @@ dependencies = [
] ]
[dependency-groups] [dependency-groups]
dev = ["ruff"] dev = ["ruff", "pytest", "pyright"]
[tool.pytest.ini_options]
testpaths = ["tests"]
[tool.pyright]
include = ["orac", "tests", "ai_bot.py"]
pythonVersion = "3.10"
typeCheckingMode = "standard"
reportMissingTypeStubs = false
[tool.uv] [tool.uv]
package = false package = false
View File
Whitespace-only changes.
+11
View File
@@ -0,0 +1,11 @@
"""Shared pytest fixtures and path setup."""
from __future__ import annotations
import sys
from pathlib import Path
# Ensure the project root is on sys.path so `import orac...` works from anywhere.
_ROOT = Path(__file__).resolve().parent.parent
if str(_ROOT) not in sys.path:
sys.path.insert(0, str(_ROOT))
+42
View File
@@ -0,0 +1,42 @@
"""ACK-hash stability across retry rebuilds.
The retry scheduler must be able to recompute the expected ACK CRC using the
same plaintext (ts + attempt byte + text) that the receiver will compute against.
"""
from __future__ import annotations
from orac.crypto import build_peer_plaintext
from orac.meshcore import compute_ack_hash
def test_ack_hash_stable_for_same_inputs() -> None:
ts = 1700000000
pt1 = build_peer_plaintext("hello", ts=ts, attempt=0)
pt2 = build_peer_plaintext("hello", ts=ts, attempt=0)
sender = b"\x11" * 32
assert compute_ack_hash(pt1, sender) == compute_ack_hash(pt2, sender)
def test_ack_hash_changes_with_attempt_byte() -> None:
ts = 1700000000
pt0 = build_peer_plaintext("hello", ts=ts, attempt=0)
pt1 = build_peer_plaintext("hello", ts=ts, attempt=1)
sender = b"\x22" * 32
assert compute_ack_hash(pt0, sender) != compute_ack_hash(pt1, sender)
def test_ack_hash_changes_with_text() -> None:
ts = 1700000000
sender = b"\x33" * 32
a = compute_ack_hash(build_peer_plaintext("hello", ts=ts, attempt=0), sender)
b = compute_ack_hash(build_peer_plaintext("world", ts=ts, attempt=0), sender)
assert a != b
def test_ack_hash_changes_with_sender() -> None:
ts = 1700000000
pt = build_peer_plaintext("hello", ts=ts, attempt=0)
a = compute_ack_hash(pt, b"\x44" * 32)
b = compute_ack_hash(pt, b"\x55" * 32)
assert a != b
+46
View File
@@ -0,0 +1,46 @@
"""Tests for orac.followup: recent-activity tracker + screener rate limit."""
from __future__ import annotations
import time
from orac import followup
def test_untouched_channel_is_not_recent() -> None:
followup.reset()
assert followup.was_recent("unused", window_s=60.0) is False
assert followup.last_interaction_age("unused") is None
def test_touch_then_was_recent_within_window() -> None:
followup.reset()
followup.touch("Public")
assert followup.was_recent("Public", window_s=60.0) is True
age = followup.last_interaction_age("Public")
assert age is not None and age < 1.0
def test_was_recent_expires_after_window() -> None:
followup.reset()
followup.touch("Public")
# Immediately check with a zero-width window — should not count as recent.
time.sleep(0.01)
assert followup.was_recent("Public", window_s=0.001) is False
def test_screener_ok_rate_limits_per_channel() -> None:
followup.reset()
# First call allowed
assert followup.screener_ok("Public", min_interval_s=0.5) is True
# Immediate second call blocked
assert followup.screener_ok("Public", min_interval_s=0.5) is False
# Different channel not blocked
assert followup.screener_ok("#general", min_interval_s=0.5) is True
def test_screener_ok_allows_after_interval() -> None:
followup.reset()
assert followup.screener_ok("Public", min_interval_s=0.05) is True
time.sleep(0.1)
assert followup.screener_ok("Public", min_interval_s=0.05) is True
+102
View File
@@ -0,0 +1,102 @@
"""Protocol fidelity: MULTIPART ACK packet layout + RX unwrap.
Mirrors upstream MeshCore:
- `createMultiAck(crc, remaining)` (src/Mesh.cpp:570-583)
- `createAck(crc)` (src/Mesh.cpp:556-568)
- Receiver unwrap (src/Mesh.cpp:270-282)
"""
from __future__ import annotations
from orac import meshcore
from orac.meshcore import build_ack_packet, build_multiack_packet, parse_header_and_path
def _clear_routes() -> None:
with meshcore._route_lock:
meshcore._route_table.clear()
def test_multiack_flood_layout() -> None:
"""With no learned route, MULTIPART uses flood route_type = 1."""
_clear_routes()
crc = bytes([0xDE, 0xAD, 0xBE, 0xEF])
packet = build_multiack_packet(crc, remaining=1, dest_hash=0x42)
# Expect: header(1) + path_len(1) + payload(5)
# header: PAYLOAD_TYPE_MULTIPART(0x0A) << 2 | ROUTE_TYPE_FLOOD(0x01)
# = (0x0A << 2) | 0x01 = 0x29
assert packet[0] == 0x29, f"header byte wrong: {packet[0]:#04x}"
# path_len byte: 0x40 = hash_size_code=1 (2-byte), 0 hops
assert packet[1] == 0x40
# Payload: (remaining << 4) | 0x03 + crc
assert packet[2] == (1 << 4) | 0x03
assert packet[3:7] == crc
def test_multiack_direct_layout_with_route() -> None:
"""With a learned direct route, header uses ROUTE_TYPE_DIRECT = 2."""
_clear_routes()
# Seed a 2-byte single-hop route
meshcore.learn_route(0x77, [b"\xaa\xbb"], hash_size=2, snr=10.0)
crc = bytes([0x01, 0x02, 0x03, 0x04])
packet = build_multiack_packet(crc, remaining=2, dest_hash=0x77)
# header = (0x0A << 2) | 0x02 (ROUTE_TYPE_DIRECT) = 0x2A
assert packet[0] == 0x2A, f"direct-route header wrong: {packet[0]:#04x}"
# path_len byte: hash_size_code=1 (2-byte), 1 hop -> 0x41
assert packet[1] == 0x41
# Path data
assert packet[2:4] == b"\xaa\xbb"
# Payload: (remaining << 4) | 0x03 + crc
assert packet[4] == (2 << 4) | 0x03
assert packet[5:9] == crc
def test_multiack_remaining_nibble_bounds() -> None:
import pytest
with pytest.raises(ValueError):
build_multiack_packet(b"\x00\x00\x00\x00", remaining=-1, dest_hash=0x11)
with pytest.raises(ValueError):
build_multiack_packet(b"\x00\x00\x00\x00", remaining=16, dest_hash=0x11)
# 0 and 15 are valid
build_multiack_packet(b"\x00\x00\x00\x00", remaining=0, dest_hash=0x11)
build_multiack_packet(b"\x00\x00\x00\x00", remaining=15, dest_hash=0x11)
def test_multiack_crc_length_enforced() -> None:
import pytest
with pytest.raises(ValueError):
build_multiack_packet(b"\x00\x00\x00", remaining=1, dest_hash=0x11)
with pytest.raises(ValueError):
build_multiack_packet(b"\x00\x00\x00\x00\x00", remaining=1, dest_hash=0x11)
def test_plain_ack_layout_unchanged() -> None:
"""Regression: plain PAYLOAD_TYPE_ACK (0x03) still works as before."""
_clear_routes()
crc = bytes([0xAA, 0xBB, 0xCC, 0xDD])
packet = build_ack_packet(crc, dest_hash=0x99)
# header = (0x03 << 2) | 0x01 = 0x0D (flood)
assert packet[0] == 0x0D
assert packet[1] == 0x40
# Payload is just the 4-byte CRC, no remaining-nibble byte.
assert packet[2:6] == crc
assert len(packet) == 6
def test_round_trip_parse_extracts_multiack_payload() -> None:
"""Firmware-style RX: parse_header_and_path strips header+path and hands us
the 5-byte MULTIPART payload; our handler expects [nibble|type][crc:4]."""
_clear_routes()
crc = bytes([0xC0, 0xDE, 0xCA, 0xFE])
packet = build_multiack_packet(crc, remaining=1, dest_hash=0x33)
parsed = parse_header_and_path(packet)
assert parsed is not None
payload_type, route_type, _ver, _pos, payload, _path_hops, _hash_size = parsed
assert payload_type == 0x0A # PAYLOAD_TYPE_MULTIPART
assert route_type == 0x01 # flood (no route learned)
assert len(payload) == 5
assert payload[0] & 0x0F == 0x03 # inner type == PAYLOAD_TYPE_ACK
assert payload[0] >> 4 == 1 # remaining = 1
assert payload[1:5] == crc
+129
View File
@@ -0,0 +1,129 @@
"""Tests for orac.reply_state.PendingAckTable and ReplyCache."""
from __future__ import annotations
import time
from orac.reply_state import PendingAckTable, PendingReply, ReplyCache
def _entry(ack: bytes, peer_pk: bytes, attempt: int = 1) -> PendingReply:
now = time.monotonic()
return PendingReply(
expected_ack=ack,
peer_pk=peer_pk,
peer_name=f"peer{peer_pk[0]:02x}",
text="hello",
first_ts=1234567890,
first_sent_at=now,
attempt=attempt,
max_attempts=5,
retry_at=now + 6.0,
)
def test_register_and_consume_roundtrip() -> None:
table = PendingAckTable(cap=4)
ack = b"\x01\x02\x03\x04"
table.register(_entry(ack, b"\xaa" * 32))
assert table.depth() == 1
entry = table.consume(ack)
assert entry is not None
assert entry.expected_ack == ack
assert table.depth() == 0
# Consuming again returns None.
assert table.consume(ack) is None
def test_expired_returns_entries_past_retry_at() -> None:
table = PendingAckTable()
ack = b"\x00\x00\x00\x01"
e = _entry(ack, b"\x11" * 32)
table.register(e)
# retry_at was set to now + 6.0 — force expiry by passing a far-future "now".
expired = table.expired(time.monotonic() + 10.0)
assert len(expired) == 1
assert expired[0].expected_ack == ack
# expired() does not remove.
assert table.depth() == 1
def test_replace_key_rekeys_entry() -> None:
table = PendingAckTable()
old = b"\x00\x00\x00\x01"
new = b"\x00\x00\x00\x02"
table.register(_entry(old, b"\x22" * 32))
table.replace_key(old, new)
assert table.consume(old) is None
got = table.consume(new)
assert got is not None and got.expected_ack == new
def test_has_pending_for_peer() -> None:
table = PendingAckTable()
pk_a = b"\x10" + b"\x00" * 31
pk_b = b"\x20" + b"\x00" * 31
table.register(_entry(b"\x00\x00\x00\x01", pk_a))
assert table.has_pending_for_peer(pk_a) is True
assert table.has_pending_for_peer(pk_b) is False
def test_cap_evicts_oldest_on_overflow() -> None:
table = PendingAckTable(cap=2)
table.register(_entry(b"\x01\x00\x00\x00", b"\xaa" * 32))
table.register(_entry(b"\x02\x00\x00\x00", b"\xbb" * 32))
table.register(_entry(b"\x03\x00\x00\x00", b"\xcc" * 32))
# Oldest (first registered) should be gone.
assert table.consume(b"\x01\x00\x00\x00") is None
assert table.consume(b"\x02\x00\x00\x00") is not None
assert table.consume(b"\x03\x00\x00\x00") is not None
def test_reply_cache_roundtrip() -> None:
cache = ReplyCache(cap=4, ttl=100.0)
pk = b"\x55" * 32
cache.put(
peer_pk=pk,
peer_name="alice",
dm_text="hi",
reply_packet=b"\xff\xee",
reply_text="hello back",
expected_ack=b"\x01\x02\x03\x04",
)
got = cache.get(pk.hex(), "hi")
assert got is not None and got.reply_text == "hello back"
# Different DM text misses.
assert cache.get(pk.hex(), "different") is None
def test_reply_cache_ttl_expires() -> None:
cache = ReplyCache(cap=4, ttl=0.001)
pk = b"\x66" * 32
cache.put(
peer_pk=pk,
peer_name="bob",
dm_text="q",
reply_packet=b"\x00",
reply_text="a",
expected_ack=b"\x00\x00\x00\x00",
)
time.sleep(0.01)
assert cache.get(pk.hex(), "q") is None
def test_reply_cache_ts_lets_caller_gate_on_age() -> None:
"""Caller can decide whether a hit is within the loss-recovery window."""
cache = ReplyCache(cap=4, ttl=100.0)
pk = b"\x77" * 32
cache.put(
peer_pk=pk,
peer_name="carol",
dm_text="status?",
reply_packet=b"\x00",
reply_text="ok",
expected_ack=b"\x00\x00\x00\x01",
)
got = cache.get(pk.hex(), "status?")
assert got is not None
# Caller interprets .ts; entry is fresh right now.
assert (time.monotonic() - got.ts) < 1.0
+149
View File
@@ -0,0 +1,149 @@
"""Tests for orac.runtime.RetryScheduler: attempt progression, path reset, exhaustion."""
from __future__ import annotations
import itertools
import time
from orac.constants import (
PENDING_ACK_TTL_S,
REPLY_FLOOD_FIRST_ATTEMPT,
REPLY_MAX_ATTEMPTS,
RETRY_SCHEDULE_S,
)
from orac.reply_state import PendingAckTable, PendingReply, ReplyCache # noqa: F401
from orac.runtime import Metrics, RetryScheduler, TxQueue
def _entry(
ack: bytes,
attempt: int = 1,
retry_at_offset: float = 0.0,
max_attempts: int = 5,
) -> PendingReply:
now = time.monotonic()
return PendingReply(
expected_ack=ack,
peer_pk=b"\xaa" * 32,
peer_name="peer_aa",
text="hi",
first_ts=1234567890,
first_sent_at=now,
attempt=attempt,
max_attempts=max_attempts,
retry_at=now + retry_at_offset,
)
def _noop_rebuild(_entry: PendingReply, attempt: int, force_flood: bool) -> tuple[bytes, bytes]:
"""Rebuild stub: new ACK differs from old so replace_key is exercised."""
new_ack = bytes([attempt & 0xFF, 0xAA, 0xBB, 0xCC])
return (b"REBUILT-%d-%s" % (attempt, b"F" if force_flood else b"D"), new_ack)
def test_direct_retry_arms_next_attempt() -> None:
pending = PendingAckTable()
tx = TxQueue()
m = Metrics()
forgot: list[int] = []
sched = RetryScheduler(
pending=pending,
tx_queue=tx,
metrics=m,
rebuild_reply=_noop_rebuild,
forget_route_fn=lambda h: (forgot.append(h), True)[1],
)
# First attempt (entry.attempt=1) with retry_at already passed.
e = _entry(b"\x00\x00\x00\x01", attempt=1, retry_at_offset=-0.1)
pending.register(e)
sched.tick(time.monotonic())
# Next attempt (2) is a direct retry — route should NOT have been forgotten.
assert forgot == []
# The entry's expected_ack has been re-keyed.
assert pending.consume(b"\x00\x00\x00\x01") is None
# New key from _noop_rebuild is (attempt=2, ...).
item = tx.pop_ready(time.monotonic())
assert item is not None
assert item.attempt == 2
assert item.expected_ack == bytes([0x02, 0xAA, 0xBB, 0xCC])
def test_flood_retry_forgets_route() -> None:
pending = PendingAckTable()
tx = TxQueue()
m = Metrics()
forgot: list[int] = []
sched = RetryScheduler(
pending=pending,
tx_queue=tx,
metrics=m,
rebuild_reply=_noop_rebuild,
forget_route_fn=lambda h: (forgot.append(h), True)[1],
)
# Entry attempt = REPLY_FLOOD_FIRST_ATTEMPT - 1; next tick hits flood attempt.
e = _entry(
b"\x00\x00\x00\x02",
attempt=REPLY_FLOOD_FIRST_ATTEMPT - 1,
retry_at_offset=-0.1,
)
pending.register(e)
sched.tick(time.monotonic())
# route should have been forgotten for the peer's 1-byte hash.
assert forgot == [e.peer_pk[0]]
item = tx.pop_ready(time.monotonic())
assert item is not None
assert item.attempt == REPLY_FLOOD_FIRST_ATTEMPT
# The rebuilt packet's label tag for flood is 'F' in our stub.
assert item.packet.endswith(b"F")
def test_exhausted_drops_and_counts() -> None:
pending = PendingAckTable()
tx = TxQueue()
m = Metrics()
sched = RetryScheduler(
pending=pending,
tx_queue=tx,
metrics=m,
rebuild_reply=_noop_rebuild,
forget_route_fn=lambda _h: False,
)
e = _entry(b"\x00\x00\x00\x03", attempt=5, retry_at_offset=-0.1, max_attempts=5)
pending.register(e)
sched.tick(time.monotonic())
assert pending.depth() == 0
counters, _, _ = m.snapshot()
assert counters.get("reply_exhausted", 0) == 1
def test_retry_schedule_is_airtime_headroom() -> None:
# Guardrail: encourage authors to keep realistic spacing.
assert RETRY_SCHEDULE_S[0] >= 3.0, "attempt-2 must leave headroom for a 2-hop RTT"
for prev, cur in itertools.pairwise(RETRY_SCHEDULE_S):
assert cur >= prev, "retry schedule should be monotonically non-decreasing"
def test_schedule_covers_all_retries_for_max_attempts() -> None:
"""Each retry after the initial send needs an explicit delay entry."""
# REPLY_MAX_ATTEMPTS counts the initial send, so retries = MAX - 1.
assert len(RETRY_SCHEDULE_S) >= REPLY_MAX_ATTEMPTS - 1, (
"RETRY_SCHEDULE_S too short for REPLY_MAX_ATTEMPTS — the final retry clamps "
"back to the last entry rather than using a distinct delay"
)
def test_pending_ack_ttl_exceeds_final_retry_plus_headroom() -> None:
"""TTL sweep must not kill pending entries before the last retry's ACK window."""
total_retry_delay = sum(RETRY_SCHEDULE_S[: REPLY_MAX_ATTEMPTS - 1])
# At least 20 s past the final retry's send time for its ACK to arrive.
min_required = total_retry_delay + 20
assert min_required <= PENDING_ACK_TTL_S, (
f"PENDING_ACK_TTL_S={PENDING_ACK_TTL_S} cuts the last retry short; "
f"needs >= {min_required}s (sum of retry delays + 20s ACK window)"
)
+95
View File
@@ -0,0 +1,95 @@
"""Tests for orac.meshcore route table TTL and SNR upgrade rule."""
from __future__ import annotations
import time
from unittest.mock import patch
import orac.meshcore as mc
def _clear_route_table() -> None:
with mc._route_lock:
mc._route_table.clear()
def test_learn_and_get_roundtrip() -> None:
_clear_route_table()
mc.learn_route(0xAB, [b"\x11\x22"], hash_size=2, snr=10.0)
got = mc.get_route(0xAB)
assert got is not None
hops, hs = got
# path stored reversed
assert hops == [b"\x11\x22"]
assert hs == 2
def test_ttl_expiry_and_lazy_eviction() -> None:
_clear_route_table()
mc.learn_route(0xCD, [b"\x01\x02"], hash_size=2, snr=5.0)
# Fast-forward monotonic time by patching time.monotonic to return now+TTL+1
fake_future = time.monotonic() + mc.ROUTE_TTL_S + 1.0
with patch("orac.meshcore.time.monotonic", return_value=fake_future):
assert mc.get_route(0xCD) is None
# Entry should have been lazy-evicted.
assert mc.get_route(0xCD) is None
def test_forget_route_removes_entry() -> None:
_clear_route_table()
mc.learn_route(0x55, [b"\xaa\xbb"], hash_size=2)
assert mc.forget_route(0x55) is True
assert mc.get_route(0x55) is None
# Double-forget is a no-op.
assert mc.forget_route(0x55) is False
def test_snr_upgrade_rule_rejects_bad_path() -> None:
_clear_route_table()
mc.learn_route(0x77, [b"\x01\x02", b"\x03\x04"], hash_size=2, snr=10.0)
# New path arrives with much worse SNR → existing path kept.
mc.learn_route(0x77, [b"\xff\xff"], hash_size=2, snr=-5.0)
got = mc.get_route(0x77)
assert got is not None
hops, _ = got
assert hops == [b"\x03\x04", b"\x01\x02"] # original (reversed) hops
def test_snr_upgrade_rule_accepts_comparable_path() -> None:
_clear_route_table()
mc.learn_route(0x88, [b"\x01\x02", b"\x03\x04"], hash_size=2, snr=5.0)
# New path with slightly worse SNR (within 3 dB tolerance) → upgrade (refreshes learned_at).
mc.learn_route(0x88, [b"\xaa\xbb"], hash_size=2, snr=3.0)
got = mc.get_route(0x88)
assert got is not None
hops, _ = got
assert hops == [b"\xaa\xbb"]
def test_hash_size_downgrade_rejected() -> None:
_clear_route_table()
mc.learn_route(0x99, [b"\x01\x02"], hash_size=2, snr=10.0)
# Smaller hash size should NOT replace a larger one.
mc.learn_route(0x99, [b"\xff"], hash_size=1, snr=10.0)
got = mc.get_route(0x99)
assert got is not None
_, hs = got
assert hs == 2
def test_one_byte_hash_paths_are_never_learned() -> None:
"""Policy: we refuse to learn routes with 1-byte hashes (MIN_HASH_SIZE=2)."""
_clear_route_table()
mc.learn_route(0xAA, [b"\xff"], hash_size=1, snr=10.0)
assert mc.get_route(0xAA) is None
def test_build_path_return_rejects_one_byte_hashes() -> None:
"""Policy: refuse to echo 1-byte PATH returns."""
import pytest
peer_pk = b"\x55" * 32
with pytest.raises(ValueError):
mc.build_path_return_packet(peer_pk, [b"\xff"], hash_size=1)
+90
View File
@@ -0,0 +1,90 @@
"""Tests for orac.runtime.TxQueue: priority ordering, not_before gating, eviction."""
from __future__ import annotations
import time
from orac.runtime import TxItem, TxPriority, TxQueue
def _item(
priority: int,
not_before: float,
*,
label: str = "x",
expected_ack: bytes | None = None,
) -> TxItem:
return TxItem(
priority=priority,
not_before=not_before,
packet=b"\x00\x01\x02",
label=label,
expected_ack=expected_ack,
)
def test_pop_ready_none_when_nothing_due() -> None:
q = TxQueue(cap=8)
now = time.monotonic()
q.push(_item(TxPriority.ACK, now + 10.0))
assert q.pop_ready(now) is None
def test_pop_ready_highest_priority_wins_when_all_ready() -> None:
q = TxQueue(cap=8)
now = time.monotonic()
q.push(_item(TxPriority.ADVERT, now - 1.0, label="advert"))
q.push(_item(TxPriority.REPLY_FLOOD, now - 1.0, label="reply_flood"))
q.push(_item(TxPriority.ACK, now - 1.0, label="ack"))
q.push(_item(TxPriority.PATH, now - 1.0, label="path"))
order: list[str] = []
for _ in range(4):
item = q.pop_ready(now)
assert item is not None
order.append(item.label)
assert order == ["ack", "path", "reply_flood", "advert"]
def test_not_before_gates_lower_priority_first() -> None:
q = TxQueue(cap=8)
now = time.monotonic()
q.push(_item(TxPriority.ACK, now + 5.0, label="future_ack"))
q.push(_item(TxPriority.ADVERT, now - 1.0, label="advert"))
item = q.pop_ready(now)
assert item is not None
assert item.label == "advert"
# Future ACK still gated
assert q.pop_ready(now) is None
def test_eviction_drops_lowest_priority_oldest_when_cap() -> None:
q = TxQueue(cap=3)
now = time.monotonic()
q.push(_item(TxPriority.ACK, now, label="a"))
q.push(_item(TxPriority.PATH, now, label="b"))
q.push(_item(TxPriority.ADVERT, now, label="c"))
# Cap hit; new ACK should evict ADVERT.
q.push(_item(TxPriority.ACK, now, label="d"))
labels: set[str] = set()
for _ in range(3):
item = q.pop_ready(now)
assert item is not None
labels.add(item.label)
assert labels == {"a", "b", "d"}
def test_cancel_by_ack_removes_matching_items() -> None:
q = TxQueue(cap=8)
now = time.monotonic()
q.push(_item(TxPriority.REPLY_DIRECT, now, label="x", expected_ack=b"\xde\xad\xbe\xef"))
q.push(_item(TxPriority.REPLY_DIRECT, now, label="y", expected_ack=b"\x01\x02\x03\x04"))
q.push(_item(TxPriority.ACK, now, label="ack_bare"))
removed = q.cancel_by_ack(b"\xde\xad\xbe\xef")
assert removed == 1
# The other two items survive.
remaining: set[str] = set()
for _ in range(2):
item = q.pop_ready(now)
assert item is not None
remaining.add(item.label)
assert remaining == {"y", "ack_bare"}
Generated
+146 -1
View File
@@ -149,6 +149,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b6/64/390e226843cc83066acc60257e0e71ebdbce6dd915ec4f3d34101b9ac31b/cobs-1.2.2-cp314-cp314-win_amd64.whl", hash = "sha256:1f27b7a553d809487287e4ed88540b2c30ed9c9bb596173bfcf549e705af7bf0", size = 28205, upload-time = "2026-03-09T02:18:20.193Z" }, { url = "https://files.pythonhosted.org/packages/b6/64/390e226843cc83066acc60257e0e71ebdbce6dd915ec4f3d34101b9ac31b/cobs-1.2.2-cp314-cp314-win_amd64.whl", hash = "sha256:1f27b7a553d809487287e4ed88540b2c30ed9c9bb596173bfcf549e705af7bf0", size = 28205, upload-time = "2026-03-09T02:18:20.193Z" },
] ]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]] [[package]]
name = "distro" name = "distro"
version = "1.9.0" version = "1.9.0"
@@ -210,6 +219,8 @@ dependencies = [
[package.dev-dependencies] [package.dev-dependencies]
dev = [ dev = [
{ name = "pyright" },
{ name = "pytest" },
{ name = "ruff" }, { name = "ruff" },
] ]
@@ -224,7 +235,11 @@ requires-dist = [
] ]
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [{ name = "ruff" }] dev = [
{ name = "pyright" },
{ name = "pytest" },
{ name = "ruff" },
]
[[package]] [[package]]
name = "exceptiongroup" name = "exceptiongroup"
@@ -284,6 +299,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
] ]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]] [[package]]
name = "jiter" name = "jiter"
version = "0.13.0" version = "0.13.0"
@@ -381,6 +405,33 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/67/8a/a342b2f0251f3dac4ca17618265d93bf244a2a4d089126e81e4c1056ac50/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb00b6d26db67a05fe3e12c76edc75f32077fb51deed13822dc648fa373bc19", size = 343768, upload-time = "2026-02-02T12:37:55.055Z" }, { url = "https://files.pythonhosted.org/packages/67/8a/a342b2f0251f3dac4ca17618265d93bf244a2a4d089126e81e4c1056ac50/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb00b6d26db67a05fe3e12c76edc75f32077fb51deed13822dc648fa373bc19", size = 343768, upload-time = "2026-02-02T12:37:55.055Z" },
] ]
[[package]]
name = "nodeenv"
version = "1.10.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" },
]
[[package]]
name = "packaging"
version = "26.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]] [[package]]
name = "pycparser" name = "pycparser"
version = "3.0" version = "3.0"
@@ -558,6 +609,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" },
] ]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]] [[package]]
name = "pynacl" name = "pynacl"
version = "1.6.2" version = "1.6.2"
@@ -593,6 +653,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/29/7d/5945b5af29534641820d3bd7b00962abbbdfee84ec7e19f0d5b3175f9a31/pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c", size = 184801, upload-time = "2026-01-01T17:32:36.309Z" }, { url = "https://files.pythonhosted.org/packages/29/7d/5945b5af29534641820d3bd7b00962abbbdfee84ec7e19f0d5b3175f9a31/pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c", size = 184801, upload-time = "2026-01-01T17:32:36.309Z" },
] ]
[[package]]
name = "pyright"
version = "1.1.408"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nodeenv" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/74/b2/5db700e52554b8f025faa9c3c624c59f1f6c8841ba81ab97641b54322f16/pyright-1.1.408.tar.gz", hash = "sha256:f28f2321f96852fa50b5829ea492f6adb0e6954568d1caa3f3af3a5f555eb684", size = 4400578, upload-time = "2026-01-08T08:07:38.795Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/82/a2c93e32800940d9573fb28c346772a14778b84ba7524e691b324620ab89/pyright-1.1.408-py3-none-any.whl", hash = "sha256:090b32865f4fdb1e0e6cd82bf5618480d48eecd2eb2e70f960982a3d9a4c17c1", size = 6399144, upload-time = "2026-01-08T08:07:37.082Z" },
]
[[package]] [[package]]
name = "pyserial" name = "pyserial"
version = "3.5" version = "3.5"
@@ -602,6 +675,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/07/bc/587a445451b253b285629263eb51c2d8e9bcea4fc97826266d186f96f558/pyserial-3.5-py2.py3-none-any.whl", hash = "sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0", size = 90585, upload-time = "2020-11-23T03:59:13.41Z" }, { url = "https://files.pythonhosted.org/packages/07/bc/587a445451b253b285629263eb51c2d8e9bcea4fc97826266d186f96f558/pyserial-3.5-py2.py3-none-any.whl", hash = "sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0", size = 90585, upload-time = "2020-11-23T03:59:13.41Z" },
] ]
[[package]]
name = "pytest"
version = "9.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
]
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.15.9" version = "0.15.9"
@@ -636,6 +727,60 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
] ]
[[package]]
name = "tomli"
version = "2.4.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" },
{ url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" },
{ url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" },
{ url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" },
{ url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" },
{ url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" },
{ url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" },
{ url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" },
{ url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" },
{ url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" },
{ url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" },
{ url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" },
{ url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" },
{ url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" },
{ url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" },
{ url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" },
{ url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" },
{ url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" },
{ url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" },
{ url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" },
{ url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" },
{ url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" },
{ url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" },
{ url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" },
{ url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" },
{ url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" },
{ url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" },
{ url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" },
{ url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" },
{ url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" },
{ url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" },
{ url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" },
{ url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" },
{ url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" },
{ url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" },
{ url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" },
{ url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" },
{ url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" },
{ url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" },
{ url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" },
{ url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" },
{ url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" },
{ url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" },
{ url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" },
{ url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" },
{ url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
]
[[package]] [[package]]
name = "typing-extensions" name = "typing-extensions"
version = "4.15.0" version = "4.15.0"