fix(satpass): broadcast safety controls — opt-in filter, rate cap, dry-run, wire format

Incident response for 343 broadcasts in 126s (2026-06-12 22:10 UTC).

Five safety controls:

1. OPT-IN BIRD FILTER: norad_ids=[] now means "broadcast nothing"
   (was "all birds"). Empty list logs once at INFO and suppresses all
   broadcasts. The !satpass DM command remains ungated — it queries
   any bird in the TLE cache using command_norad_ids as bare-command
   default. Two paths, two rules.

2. RATE CAP: new satpass.max_broadcasts_per_hour (int, default 4).
   Excess qualifying passes logged and suppressed. Broadcast path only.

3. DRY-RUN MODE: new satpass.dry_run (bool, default TRUE). Logs exact
   wire text at INFO prefixed "DRY-RUN would air:" without dispatching.
   Go-live: enabled=true + dry_run=true → observe → dry_run=false.

4. ELEVATION DEFAULT: min_elevation REGISTRY default already at 30
   (confirmed, no change needed).

5. BROADCAST WIRE FORMAT: two-line LoRa-tight format with buckets:
     🛰️ {name} {bucket}, {aos_compass}→{los_compass}
     {duration} minute window, {rise}–{set} {AM/PM} MDT
   Buckets: overhead (≥60°), high pass (30-59°), low pass (<30°).
   DM format keeps exact degrees. One format_pass() function with
   broadcast= mode switch — two callers, one function.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Claude 2026-06-12 23:00:03 +00:00
commit e903444356
8 changed files with 704 additions and 80 deletions

View file

@ -66,7 +66,7 @@ NOAA18_ENVELOPE = {
}
def _enable_satpass():
def _enable_satpass(norad_ids=None):
"""Set satpass.enabled=true in the test DB."""
from meshai.persistence import get_db
from meshai.adapter_config import invalidate_cache
@ -80,6 +80,19 @@ def _enable_satpass():
"UPDATE adapter_config SET value_json='5' "
"WHERE adapter='satpass' AND key='min_elevation'"
)
# Disable dry_run for tests that expect wire output
conn.execute(
"UPDATE adapter_config SET value_json='false' "
"WHERE adapter='satpass' AND key='dry_run'"
)
# Set norad_ids (must be non-empty for opt-in)
if norad_ids is None:
norad_ids = [28654]
conn.execute(
"UPDATE adapter_config SET value_json=? "
"WHERE adapter='satpass' AND key='norad_ids'",
(json.dumps(norad_ids),)
)
invalidate_cache()
@ -93,6 +106,8 @@ def test_noaa18_envelope_produces_satpass_event():
_enable_satpass()
if hasattr(handle_satpass, "_disabled_logged"):
del handle_satpass._disabled_logged
if hasattr(handle_satpass, "_no_norad_ids_logged"):
del handle_satpass._no_norad_ids_logged
now = int(time.time())
wire = handle_satpass(
@ -123,12 +138,14 @@ def test_noaa18_envelope_produces_satpass_event():
def test_noaa18_wire_message_format():
"""Wire message includes satellite name, elevation, observer, direction."""
"""Wire message includes satellite name, direction in new 2-line format."""
from meshai.central.satpass_handler import handle_satpass
_enable_satpass()
if hasattr(handle_satpass, "_disabled_logged"):
del handle_satpass._disabled_logged
if hasattr(handle_satpass, "_no_norad_ids_logged"):
del handle_satpass._no_norad_ids_logged
wire = handle_satpass(
NOAA18_ENVELOPE,
@ -138,17 +155,17 @@ def test_noaa18_wire_message_format():
)
assert wire is not None
# Line 1: satellite name + elevation
assert "NOAA 18" in wire
assert "22" in wire # int(22.69) = 22
lines = wire.split("\n")
assert len(lines) == 2, f"Expected 2 lines, got {len(lines)}: {wire!r}"
# Line 2: AOS/LOS times
assert "AOS" in wire
assert "LOS" in wire
# Line 1: satellite name + bucket + compass directions
assert "NOAA 18" in lines[0]
assert "low pass" in lines[0] # 22.69 < 30 = low pass
assert "SE" in lines[0] # aos_compass
assert "N" in lines[0] # los_compass
# Line 3: observer + azimuth direction
assert "Filer" in wire
assert "ENE" in wire # azimuth_at_peak_compass
# Line 2: duration + time window
assert "minute window" in lines[1]
def test_missing_norad_id_rejected():
@ -158,6 +175,8 @@ def test_missing_norad_id_rejected():
_enable_satpass()
if hasattr(handle_satpass, "_disabled_logged"):
del handle_satpass._disabled_logged
if hasattr(handle_satpass, "_no_norad_ids_logged"):
del handle_satpass._no_norad_ids_logged
# Deep copy and remove norad_id
import copy
@ -180,6 +199,8 @@ def test_missing_max_elevation_deg_rejected():
_enable_satpass()
if hasattr(handle_satpass, "_disabled_logged"):
del handle_satpass._disabled_logged
if hasattr(handle_satpass, "_no_norad_ids_logged"):
del handle_satpass._no_norad_ids_logged
import copy
env = copy.deepcopy(NOAA18_ENVELOPE)
@ -201,6 +222,8 @@ def test_observer_fallback_to_slug():
_enable_satpass()
if hasattr(handle_satpass, "_disabled_logged"):
del handle_satpass._disabled_logged
if hasattr(handle_satpass, "_no_norad_ids_logged"):
del handle_satpass._no_norad_ids_logged
import copy
env = copy.deepcopy(NOAA18_ENVELOPE)
@ -214,7 +237,15 @@ def test_observer_fallback_to_slug():
now=int(time.time()),
)
assert wire is not None
assert "filer" in wire
# Observer name stored in DB, not in broadcast wire format
from meshai.persistence import get_db
conn = get_db()
row = conn.execute(
"SELECT observer FROM satpass_events WHERE norad_id=28654"
).fetchone()
assert row is not None
assert row["observer"] == "filer"
# ── Consumer category mapping ──────────────────────────────────────