fix(sigint): decouple APRS lifecycle from Meshtastic

This commit is contained in:
Shadowbroker committed 2026-08-23 06:52:14 -06:00
1 parent 685dc0a947
commit a263230130
1 file changed
+55 -16
+55 -16
View File
@@ -1,12 +1,15 @@
"""SIGINT fetcher — pulls latest signals from the SIGINT Grid into latest_data. """SIGINT fetcher — pulls latest signals from the SIGINT Grid into latest_data.
Merges live MQTT signals with cached Meshtastic map API nodes. Merges live APRS/MQTT/JS8Call signals with cached Meshtastic map API nodes.
Live MQTT signals always take priority (fresher) — API nodes fill in the gaps Each external bridge is reconciled independently so enabling one transport does
for the thousands of nodes our MQTT listener hasn't heard yet. not implicitly start another.
""" """
import logging import logging
from services.aprs_is_bridge import aprs_is_bridge
from services.fetchers._store import latest_data, _data_lock, _mark_fresh from services.fetchers._store import latest_data, _data_lock, _mark_fresh
from services.meshtastic_mqtt_settings import mqtt_bridge_enabled
logger = logging.getLogger("services.data_fetcher") logger = logging.getLogger("services.data_fetcher")
@@ -23,7 +26,7 @@ def _merge_sigint_snapshot(
# Shallow-copy every entry so the published list owns its own dicts. The # Shallow-copy every entry so the published list owns its own dicts. The
# inputs alias objects that other threads keep mutating in place: live # inputs alias objects that other threads keep mutating in place: live
# signals are the SIGINT bridge's own dicts (updated as packets arrive), # signals are the SIGINT bridges' own dicts (updated as packets arrive),
# and api_nodes are the same objects published under latest_data # and api_nodes are the same objects published under latest_data
# ["meshtastic_map_nodes"]. Publishing those references into # ["meshtastic_map_nodes"]. Publishing those references into
# latest_data["sigint"] lets a concurrent mutation race the lock-free # latest_data["sigint"] lets a concurrent mutation race the lock-free
@@ -69,7 +72,11 @@ def build_sigint_snapshot() -> tuple[list[dict], dict[str, object], dict[str, in
from services.sigint_bridge import sigint_grid from services.sigint_bridge import sigint_grid
# The legacy APRS member in SIGINTGrid is intentionally never started by
# the production fetch path after #533. Safe APRS-IS receive traffic comes
# from aprs_is_bridge, which requires explicit bounded configuration.
live_signals = sigint_grid.get_all_signals() live_signals = sigint_grid.get_all_signals()
live_signals.extend(aprs_is_bridge.get_signals())
with _data_lock: with _data_lock:
api_nodes = list(latest_data.get("meshtastic_map_nodes", [])) api_nodes = list(latest_data.get("meshtastic_map_nodes", []))
merged = _merge_sigint_snapshot(live_signals, api_nodes) merged = _merge_sigint_snapshot(live_signals, api_nodes)
@@ -90,22 +97,54 @@ def refresh_sigint_snapshot() -> tuple[list[dict], dict[str, object], dict[str,
return signals, channel_stats, totals return signals, channel_stats, totals
def fetch_sigint(): def _reconcile_sigint_bridges(aprs_requested: bool, mesh_requested: bool) -> None:
"""Fetch all signals from the SIGINT Grid, merge with Meshtastic map nodes.""" """Start/stop each bridge independently from current operator state."""
from services.fetchers._store import is_any_active
if not is_any_active("sigint_meshtastic", "sigint_aprs"):
return
from services.sigint_bridge import sigint_grid from services.sigint_bridge import sigint_grid
# Start bridges on first call (idempotent) # Defense-in-depth: the old SIGINTGrid APRS client used an effectively
sigint_grid.start() # global range subscription. It is no longer part of the production fetch
# path; force it stopped even if another caller started it accidentally.
sigint_grid.aprs.stop()
aprs_is_bridge.reconcile(aprs_requested)
try:
mesh_network_enabled = mqtt_bridge_enabled()
except Exception:
mesh_network_enabled = False
if mesh_requested and mesh_network_enabled:
sigint_grid.mesh.start()
else:
sigint_grid.mesh.stop()
# JS8Call is localhost-only and historically accompanies the SIGINT view.
# Keep that behavior without coupling it to either public network bridge.
if aprs_requested or mesh_requested:
sigint_grid.js8.start()
else:
sigint_grid.js8.stop()
def fetch_sigint():
"""Refresh SIGINT while matching bridge lifecycles to operator settings."""
from services.fetchers._store import effective_layers
layers = effective_layers()
aprs_requested = bool(layers.get("sigint_aprs", False))
mesh_requested = bool(layers.get("sigint_meshtastic", False))
_reconcile_sigint_bridges(aprs_requested, mesh_requested)
if not aprs_requested and not mesh_requested:
return
signals, channel_stats, totals = refresh_sigint_snapshot() signals, channel_stats, totals = refresh_sigint_snapshot()
from services.sigint_bridge import sigint_grid
status = sigint_grid.status
logger.info( logger.info(
f"SIGINT: {len(signals)} signals " "SIGINT: %d signals (APRS:%d MESH:%d JS8:%d MAP:%d)",
f"(APRS:{status['aprs']} MESH:{status['meshtastic']} " len(signals),
f"JS8:{status['js8call']} MAP:{totals['meshtastic_map']})" totals["aprs"],
totals["meshtastic_live"],
len(sigint_grid.js8.signals),
totals["meshtastic_map"],
) )