diff --git a/justfile b/justfile index 02e42d9..4fd0246 100644 --- a/justfile +++ b/justfile @@ -54,6 +54,10 @@ events: events-pretty: @tail -F ~/.donglora/orac-events.jsonl | jq . +# Dump heard repeater positions + IQR-trimmed average we'd advertise +positions: _ensure_tools + @uv run python -c 'from orac.state import load_state, heard_positions, average_heard_position; load_state(); ps = heard_positions(); print(f"heard {len(ps)} unique positions:"); [print(f" {lat:+.3f}, {lon:+.3f}") for lat, lon in sorted(ps)]; avg = average_heard_position(); print(f"IQR-trimmed mean: {avg}" if avg else "no positions yet")' + # Clean cached artifacts clean: @rm -rf .pytest_cache .ruff_cache __pycache__ orac/__pycache__ tests/__pycache__ diff --git a/orac/bot.py b/orac/bot.py index 689edaf..169066e 100644 --- a/orac/bot.py +++ b/orac/bot.py @@ -161,6 +161,16 @@ def _handle_sigusr1(signum: int, _frame: object) -> None: # noqa: ARG001 rt.dump_metrics() +def _handle_sigint(_signum: int, _frame: object) -> None: + """SIGINT: flag the IOThread for shutdown *before* KeyboardInterrupt + unwinds the main thread, so the interrupted serial read exits cleanly + instead of looking like a device disconnect.""" + rt = _active_runtime + if rt is not None: + rt.io_thread.stop() + raise KeyboardInterrupt + + # ── Connect loop ───────────────────────────────────────────────── @@ -235,6 +245,9 @@ def main() -> None: # SIGUSR1 dumps metrics; not available on all platforms (e.g. Windows). with contextlib.suppress(ValueError, AttributeError): signal.signal(signal.SIGUSR1, _handle_sigusr1) + # SIGINT: pre-set stop_event so IOThread's interrupted read exits cleanly. + with contextlib.suppress(ValueError, AttributeError): + signal.signal(signal.SIGINT, _handle_sigint) port = sys.argv[1] if len(sys.argv) > 1 else None diff --git a/orac/crypto.py b/orac/crypto.py index 3ec734c..2885a70 100644 --- a/orac/crypto.py +++ b/orac/crypto.py @@ -300,9 +300,22 @@ def compute_mac(secret: bytes, ciphertext: bytes) -> bytes: def build_advert_payload() -> bytes: - """Build the inner ADVERT payload: pubkey + timestamp + signature + app_data.""" + """Build the inner ADVERT payload: pubkey + timestamp + signature + app_data. + + If we've heard at least 5 unique repeater positions, include our averaged + position in the advert's app_data. + """ + from orac.state import average_heard_position, heard_position_count + timestamp = struct.pack("= 5 and (avg := average_heard_position()) is not None: + lat_i = int(round(avg[0] * 1_000_000)) + lon_i = int(round(avg[1] * 1_000_000)) + # flags: has_name (0x80) | has_location (0x10) | chat node (0x01) + app_data = bytes([0x91]) + struct.pack(" tuple[bytes, str] | None: - """Decode and verify an ADVERT payload. Returns (pubkey, name) or None.""" +def try_decode_advert( + raw_payload: bytes, +) -> tuple[bytes, str, int, tuple[float, float] | None] | None: + """Decode and verify an ADVERT payload. + + Returns (pubkey, name, node_type, position_or_None) or None on failure. + node_type: low nibble of app_data flags (1=chat, 2=repeater, 3=room). + position: (lat, lon) in degrees, or None if advert lacks location. + """ if len(raw_payload) < 100: return None @@ -568,10 +575,15 @@ def try_decode_advert(raw_payload: bytes) -> tuple[bytes, str] | None: return None name = "" + node_type = 0 + position: tuple[float, float] | None = None if app_data: flags = app_data[0] + node_type = flags & 0x0F pos = 1 - if flags & 0x10: # has location + if flags & 0x10 and pos + 8 <= len(app_data): # has location + lat_i, lon_i = struct.unpack(" tuple[bytes, str] | None: if flags & 0x80 and pos < len(app_data): # has name name = app_data[pos:].decode("utf-8", errors="replace") - return pubkey, name if name else pubkey.hex()[:8] + return pubkey, name if name else pubkey.hex()[:8], node_type, position diff --git a/orac/runtime.py b/orac/runtime.py index 61965b8..f10226b 100644 --- a/orac/runtime.py +++ b/orac/runtime.py @@ -28,6 +28,8 @@ from enum import IntEnum from typing import Any import donglora as dl +from serial import SerialException + from orac import logfmt from orac.constants import ( ADVERT_INTERVAL, @@ -413,7 +415,13 @@ class IOThread(threading.Thread): for _ in range(RX_DRAIN_PER_TICK): if self._stop_event.is_set(): return - pkt = dl.recv(self._conn) + try: + pkt = dl.recv(self._conn) + except (SerialException, OSError): + # Shutdown: signal interrupted a blocking serial read. + if self._stop_event.is_set(): + return + raise if pkt is None: break self._metrics.inc("rx_total") @@ -453,6 +461,8 @@ class IOThread(threading.Thread): log.exception("gauge_collector failed") self._last_gauge_dump = now except BaseException as e: # surface fatal errors to main thread + if self._stop_event.is_set(): + return # expected: shutdown race self.last_error = e log.exception("IOThread died: %s", e) raise diff --git a/orac/state.py b/orac/state.py index a4e9c66..b3ce7a3 100644 --- a/orac/state.py +++ b/orac/state.py @@ -21,6 +21,7 @@ _state: dict[str, dict[str, object]] = { "channel_history": {}, # channel_name -> list of messages "dm_history": {}, # peer_pubkey_hex -> list of messages "known_nodes": {}, # pubkey_hex -> {"name": str, "seen": float} + "heard_positions": [], # list of [lat, lon] rounded to 3 decimals (dedup'd) } @@ -94,6 +95,68 @@ def known_node_count() -> int: return len(_state["known_nodes"]) +# ── Heard repeater positions ───────────────────────────────────── + + +def record_heard_position(lat: float, lon: float) -> None: + """Record a repeater position (rounded to 3 decimals, dedup'd as a set).""" + entry = [round(lat, 3), round(lon, 3)] + positions: list[list[float]] = _state["heard_positions"] # type: ignore[assignment] + if entry in positions: + return + positions.append(entry) + save_state() + + +def heard_position_count() -> int: + return len(_state["heard_positions"]) # type: ignore[arg-type] + + +def _iqr_bounds(values: list[float]) -> tuple[float, float]: + """Return (lo, hi) IQR fence: [Q1 - 1.5*IQR, Q3 + 1.5*IQR].""" + s = sorted(values) + n = len(s) + + # Linear interpolation quantiles (same as numpy default). + def q(p: float) -> float: + idx = p * (n - 1) + lo_i = int(idx) + hi_i = min(lo_i + 1, n - 1) + frac = idx - lo_i + return s[lo_i] * (1 - frac) + s[hi_i] * frac + + q1, q3 = q(0.25), q(0.75) + iqr = q3 - q1 + return q1 - 1.5 * iqr, q3 + 1.5 * iqr + + +def heard_positions() -> list[tuple[float, float]]: + """All recorded heard positions.""" + return [(p[0], p[1]) for p in _state["heard_positions"]] # type: ignore[union-attr] + + +def average_heard_position() -> tuple[float, float] | None: + """IQR-trimmed mean of heard positions. Rejects lat/lon outliers + (e.g., null-island 0/0 or bogus points) before averaging. Falls back + to a plain mean when there are too few points for a robust IQR.""" + positions = heard_positions() + if not positions: + return None + if len(positions) < 4: + lat = sum(p[0] for p in positions) / len(positions) + lon = sum(p[1] for p in positions) / len(positions) + return lat, lon + + lat_lo, lat_hi = _iqr_bounds([p[0] for p in positions]) + lon_lo, lon_hi = _iqr_bounds([p[1] for p in positions]) + kept = [p for p in positions if lat_lo <= p[0] <= lat_hi and lon_lo <= p[1] <= lon_hi] + if not kept: + kept = positions + lat = sum(p[0] for p in kept) / len(kept) + lon = sum(p[1] for p in kept) / len(kept) + return lat, lon + + # ── Channel history ──────────────────────────────────────────────