"""SIGINT Grid — unified radio intelligence bridge. Three protocol bridges feeding a shared signal buffer: - APRS-IS: TCP to rotate.aprs2.net:14580 (amateur radio positions/weather) - Meshtastic: MQTT to mqtt.meshtastic.org:1883 (mesh network messages) - JS8Call: TCP to 127.0.0.1:2442 (HF digital mode, local radio only) Each bridge runs in a daemon thread and pushes parsed signals into a shared collections.deque (thread-safe, bounded). The SIGINTGrid orchestrator merges and deduplicates all signals on demand. """ import json import os import socket import struct import threading import time import logging import uuid from collections import deque from datetime import datetime, timezone from services.config import get_settings from services.meshtastic_mqtt_settings import ( mqtt_bridge_enabled, mqtt_connection_config, mqtt_psk_hex, mqtt_subscription_settings, ) from services.mesh.meshtastic_topics import all_available_roots, build_subscription_topics, known_roots, parse_topic_metadata logger = logging.getLogger("services.sigint") # Maximum signals retained per bridge (prevents unbounded memory) _MAX_SIGNALS = 500 # Maximum age of signals before discard (seconds) _MAX_AGE_S = 600 # 10 minutes def _is_plausible_land(lat: float, lng: float) -> bool: """Reject coordinates that are obviously in the middle of the ocean. Uses coarse bounding boxes for major landmasses. Not perfect, but filters out the bulk of garbage coordinates from bad GPS / protobuf parsing. Radio operators are on land (or near coasts), not mid-ocean. """ # Major landmass bounding boxes (generous margins for coastal/island coverage) _LAND_BOXES = [ # North America (incl. Caribbean, Central America) (15, 72, -170, -50), # South America (-60, 15, -82, -34), # Europe (35, 72, -12, 45), # Africa (-36, 38, -18, 52), # Asia (incl. Middle East, India, SE Asia) (0, 75, 25, 180), # Australia / Oceania (-50, -8, 110, 180), # New Zealand / Pacific islands (-48, -10, 165, 180), # Japan / Korea / Taiwan (20, 46, 124, 146), # Indonesia / Philippines (-12, 20, 95, 130), # UK / Ireland / Iceland (50, 67, -25, 2), # Alaska (51, 72, -180, -130), # Hawaii (18, 23, -161, -154), # Caribbean islands (10, 28, -86, -59), # Madagascar (-26, -12, 43, 51), ] for min_lat, max_lat, min_lng, max_lng in _LAND_BOXES: if min_lat <= lat <= max_lat and min_lng <= lng <= max_lng: return True return False # ─── Emergency Lexicon (multilingual SOS/crisis keyword scanner) ────────────── # Extracted from Pete's universal_translator.py — real Unicode keywords _EMERGENCY_LEXICON: dict[str, list[str]] = { # English "en": ["SOS", "MAYDAY", "EMERGENCY", "HELP", "MEDIC", "EVACUAT"], # Mandarin (Chinese) "zh": ["救命", "求助", "停电", "医生", "地震", "火灾", "爆炸"], # Russian "ru": ["помощь", "удар", "врач", "эвакуация", "пожар"], # Ukrainian "uk": ["допомога", "вогонь", "обстріл", "евакуація", "лікар"], # Farsi (Persian) "fa": ["کمک", "انفجار", "پزشک", "برق", "زلزله"], # Arabic "ar": ["مساعدة", "طبيب", "قنبلة", "ماء", "إغاثة"], # Burmese (Myanmar) "my": ["ကူညီပါ", "ဆေးဆရာ", "မီးပျက်"], # Hebrew "he": ["עזרה", "חובש", "פיצוץ", "אש"], # Korean "ko": ["도와주세요", "응급", "화재", "지진"], # Japanese "ja": ["助けて", "緊急", "地震", "火事", "避難"], } # Flatten all keywords into a single set for fast scanning _ALL_EMERGENCY_KEYWORDS: set[str] = set() for _kws in _EMERGENCY_LEXICON.values(): for _kw in _kws: _ALL_EMERGENCY_KEYWORDS.add(_kw.upper()) _ALL_EMERGENCY_KEYWORDS.add(_kw) # keep original case for CJK def _scan_emergency(text: str) -> str | None: """Check if text contains any emergency keyword. Returns matched keyword or None.""" if not text: return None text_upper = text.upper() for kw in _ALL_EMERGENCY_KEYWORDS: if kw in text_upper or kw in text: return kw return None # ─── APRS Symbol Decoding ──────────────────────────────────────────────────── # Primary table (/) symbol codes → human-readable labels _APRS_SYMBOLS: dict[str, str] = { "/-": "House/QTH", "/!": "Police", "/#": "Digipeater", "/$": "Phone", "/%": "DX Cluster", "/&": "HF Gateway", "/'": "Aircraft (small)", "/(": "Mobile Sat", "/)": "Wheelchair", "/*": "Snowmobile", "/+": "Red Cross", "/,": "Boy Scout", "/.": "Unknown/X", "//": "Red Dot", "/:": "Fire", "/;": "Campground", "/<": "Motorcycle", "/=": "Railroad", "/>": "Car", "/?": "Server/Info", "/@": "Hurricane/Tropical", "/A": "Aid Station", "/E": "Eyeball", "/F": "Farm/Tractor", "/H": "Hotel", "/I": "TCP/IP", "/K": "School", "/N": "NTS Station", "/O": "Balloon", "/P": "Police", "/R": "RV", "/S": "Shuttle", "/T": "SSTV", "/U": "Bus", "/W": "NWS Site", "/Y": "Yacht/Sailboat", "/[": "Jogger/Human", "/\\": "Triangle", "/^": "Aircraft (large)", "/_": "Weather Station", "/a": "Ambulance", "/b": "Bicycle", "/c": "Incident", "/d": "Fire Dept", "/e": "Horse", "/f": "Fire Truck", "/g": "Glider", "/h": "Hospital", "/i": "IOTA", "/j": "Jeep", "/k": "Truck", "/l": "Laptop", "/n": "Node/Relay", "/o": "EOC", "/p": "Rover/Dog", "/r": "Antenna", "/s": "Powerboat", "/u": "Truck (18-wheel)", "/v": "Van", "/w": "Water Station", "/y": "House+Yagi", } # Alternate table (\) — common overrides _APRS_SYMBOLS_ALT: dict[str, str] = { "\\-": "House (HF)", "\\>": "Car", "\\#": "Digipeater (alt)", "\\/": "Red Dot", "\\&": "Gateway/Digi", "\\^": "Aircraft", "\\_": "WX Station", "\\k": "SUV", "\\n": "Node", } # D-Star / DMR gateways use 'D' table prefix _APRS_DSTAR: dict[str, str] = { "D&": "D-Star/DMR Gateway", "D#": "D-Star Digipeater", } def _decode_aprs_symbol(symbol: str) -> str: """Decode APRS symbol table+code into a human-readable station type.""" if not symbol or len(symbol) < 2: return "Station" return ( _APRS_SYMBOLS.get(symbol) or _APRS_SYMBOLS_ALT.get(symbol) or _APRS_DSTAR.get(symbol) or "Station" ) def _parse_aprs_comment(comment: str) -> dict: """Extract structured metadata from APRS comment field. Returns dict with optional keys: frequency, altitude_ft, course, speed_knots, power """ import re meta: dict = {} # Frequency: e.g., "146.520MHz" or "439.01250MHz" freq_match = re.search(r"(\d{2,3}\.\d{2,6})\s*MHz", comment, re.IGNORECASE) if freq_match: meta["frequency"] = f"{freq_match.group(1)} MHz" # Altitude: /A=NNNNNN (in feet) alt_match = re.search(r"/A=(\d{6})", comment) if alt_match: alt = int(alt_match.group(1)) if alt > 0: meta["altitude_ft"] = alt # Course/Speed: CCC/SSS at start of comment (course deg / speed knots) cs_match = re.match(r"^(\d{3})/(\d{3})", comment) if cs_match: course = int(cs_match.group(1)) speed = int(cs_match.group(2)) if speed > 0: meta["course"] = course meta["speed_knots"] = speed # Battery voltage: "Bat:X.XV" or "XX.XV" at end batt_match = re.search(r"Bat[:\s]*(\d+\.\d+)\s*V", comment, re.IGNORECASE) if batt_match: meta["battery_v"] = float(batt_match.group(1)) # PHG (Power-Height-Gain-Directivity) phg_match = re.search(r"PHG(\d)(\d)(\d)(\d)", comment) if phg_match: power_code = int(phg_match.group(1)) power_watts = power_code**2 # APRS PHG power encoding meta["power_watts"] = power_watts # Clean comment: strip leading course/speed, PHG, /A= cruft clean = comment clean = re.sub(r"^\d{3}/\d{3}/", "", clean) clean = re.sub(r"/A=\d{6}", "", clean) clean = re.sub(r"PHG\d{4,}", "", clean) clean = clean.strip(" /") if clean: meta["status"] = clean[:80] return meta # ─── APRS-IS Bridge ───────────────────────────────────────────────────────── class APRSBridge: """Connects to APRS-IS and parses position reports.""" HOST = "rotate.aprs2.net" PORT = 14580 # Read-only login (no callsign needed for receive-only) LOGIN = "user N0CALL pass -1 vers ShadowBroker 1.0 filter r/0/0/25000\r\n" CONFIDENCE = 0.7 def __init__(self): self.signals: deque[dict] = deque(maxlen=_MAX_SIGNALS) self._thread: threading.Thread | None = None self._stop = threading.Event() def start(self): if self._thread and self._thread.is_alive(): return self._stop.clear() self._thread = threading.Thread(target=self._run, daemon=True, name="aprs-bridge") self._thread.start() logger.info("APRS-IS bridge started") def stop(self): self._stop.set() def _run(self): while not self._stop.is_set(): try: self._connect_and_read() except Exception as e: logger.warning(f"APRS-IS connection error: {e}") if not self._stop.is_set(): time.sleep(15) # reconnect delay @staticmethod def _decode_line(raw_bytes: bytes) -> str: """Decode APRS packet bytes trying UTF-8 first, then GBK (Chinese), then latin-1.""" try: return raw_bytes.decode("utf-8") except UnicodeDecodeError: pass try: return raw_bytes.decode("gbk") except UnicodeDecodeError: pass return raw_bytes.decode("latin-1") # latin-1 never fails (1:1 byte mapping) def _connect_and_read(self): with socket.create_connection((self.HOST, self.PORT), timeout=30) as sock: sock.settimeout(90) # Read server banner banner = sock.recv(512).decode("utf-8", errors="replace") logger.info(f"APRS-IS: {banner.strip()}") # Send login sock.sendall(self.LOGIN.encode("ascii")) buf = b"" while not self._stop.is_set(): try: chunk = sock.recv(4096) except socket.timeout: # Send keepalive sock.sendall(b"#keepalive\r\n") continue if not chunk: break buf += chunk while b"\n" in buf: line_bytes, buf = buf.split(b"\n", 1) line_bytes = line_bytes.strip() if not line_bytes or line_bytes.startswith(b"#"): continue line = self._decode_line(line_bytes) self._parse_packet(line) def _parse_packet(self, raw: str): """Parse an APRS packet and extract position if present.""" try: # Format: CALLSIGN>PATH:PAYLOAD if ":" not in raw: return header, payload = raw.split(":", 1) callsign = header.split(">")[0].strip() if not callsign or callsign == "N0CALL": return # Position reports start with ! @ / or = if not payload or payload[0] not in "!@/=": return # Try to extract lat/lng from uncompressed position # Format: !DDMM.MMN/DDDMM.MMW... or similar pos = payload[1:] lat = self._parse_lat(pos[:8]) lng = self._parse_lng(pos[9:18]) if lat is None or lng is None: return symbol = pos[8] + pos[18] if len(pos) > 18 else "" comment = pos[19:].strip() if len(pos) > 19 else "" station_type = _decode_aprs_symbol(symbol) meta = _parse_aprs_comment(comment) sig = { "callsign": callsign, "lat": lat, "lng": lng, "source": "aprs", "confidence": self.CONFIDENCE, "timestamp": datetime.now(timezone.utc).isoformat(), "raw_message": raw[:200], "symbol": symbol, "station_type": station_type, "comment": comment[:100], } # Merge parsed metadata into signal if meta.get("frequency"): sig["frequency"] = meta["frequency"] if meta.get("altitude_ft"): sig["altitude_ft"] = meta["altitude_ft"] if meta.get("speed_knots"): sig["speed_knots"] = meta["speed_knots"] sig["course"] = meta.get("course", 0) if meta.get("battery_v"): sig["battery_v"] = meta["battery_v"] if meta.get("power_watts"): sig["power_watts"] = meta["power_watts"] if meta.get("status"): sig["status"] = meta["status"] # Emergency keyword scan across all text fields emergency_kw = _scan_emergency(comment) or _scan_emergency(sig.get("status", "")) if emergency_kw: sig["emergency"] = True sig["emergency_keyword"] = emergency_kw self.signals.append(sig) except (ValueError, IndexError): pass @staticmethod def _parse_lat(s: str) -> float | None: """Parse APRS latitude: DDMM.MMN""" try: if len(s) < 8: return None deg = int(s[:2]) minutes = float(s[2:7]) direction = s[7].upper() lat = deg + minutes / 60.0 if direction == "S": lat = -lat if -90 <= lat <= 90: return round(lat, 5) except (ValueError, IndexError): pass return None @staticmethod def _parse_lng(s: str) -> float | None: """Parse APRS longitude: DDDMM.MMW""" try: if len(s) < 9: return None deg = int(s[:3]) minutes = float(s[3:8]) direction = s[8].upper() lng = deg + minutes / 60.0 if direction == "W": lng = -lng if -180 <= lng <= 180: return round(lng, 5) except (ValueError, IndexError): pass return None # ─── Meshtastic MQTT Bridge ───────────────────────────────────────────────── class MeshtasticBridge: """Connects to Meshtastic public MQTT broker for mesh network messages.""" # Default LongFast PSK (firmware-hardcoded for PSK=0x01) DEFAULT_KEY = bytes( [ 0xD4, 0xF1, 0xBB, 0x3A, 0x20, 0x29, 0x07, 0x59, 0xF0, 0xBC, 0xFF, 0xAB, 0xCF, 0x4E, 0x69, 0x01, ] ) # Client-side rate limit — drop inbound messages beyond this threshold # to stay within Meshtastic community guidelines. _RATE_LIMIT_PER_MIN = 100 _RATE_WINDOW_S = 60.0 CONFIDENCE = 0.5 @staticmethod def _mqtt_config() -> tuple[str, int, str, str]: """Return (broker, port, user, password) from settings.""" return mqtt_connection_config() @classmethod def _resolve_psk(cls) -> bytes: """Return the PSK from config, or the default LongFast key if empty.""" try: raw = mqtt_psk_hex() except Exception: raw = "" if not raw: return cls.DEFAULT_KEY return bytes.fromhex(raw) def __init__(self): self.signals: deque[dict] = deque(maxlen=_MAX_SIGNALS) self.messages: deque[dict] = deque(maxlen=500) self._message_dedupe: dict[str, float] = {} self._thread: threading.Thread | None = None self._stop = threading.Event() self._client_id = self._build_client_id() self._connected = False self._last_error = "" self._last_connected_at = 0.0 self._last_disconnected_at = 0.0 self._last_broker = "" # Rate-limiter: sliding window of receive timestamps self._rx_timestamps: deque[float] = deque() self._rx_dropped = 0 @staticmethod def _build_client_id() -> str: """Return a runtime-unique MQTT client id. The public Meshtastic broker will drop an existing MQTT session when a second client connects with the same id. Using a fixed id made separate ShadowBroker instances kick each other off the broker. This is deliberately not tied to the user's public mesh address or ShadowBroker node identity; it is only an MQTT session handle. """ suffix = uuid.uuid4().hex[:8] return f"meshchat-{suffix}" def _dedupe_message( self, sender: str, channel: str, text: str, recipient: str = "broadcast", root: str = "", ) -> bool: now = time.time() cutoff = now - 120 for key, ts in list(self._message_dedupe.items()): if ts < cutoff: del self._message_dedupe[key] key = f"{sender}:{recipient}:{root}:{channel}:{text}" if key in self._message_dedupe: return True self._message_dedupe[key] = now return False @staticmethod def _message_dedupe_key(message: dict) -> str: sender = str(message.get("from") or "???").strip().lower() recipient = str(message.get("to") or "broadcast").strip().lower() text = str(message.get("text") or "").strip() channel = str(message.get("channel") or "LongFast").strip().lower() root = str(message.get("root") or message.get("region") or "").strip().lower() if root == "us": root = "us" return f"{sender}:{recipient}:{root}:{channel}:{text}" def append_text_message(self, message: dict, *, dedupe_window_s: float = 5.0) -> bool: """Append a Meshtastic text message unless it is a near-immediate echo.""" if not str(message.get("text") or "").strip(): return False now = time.time() cutoff = now - max(1.0, dedupe_window_s) next_message = dict(message) next_message.setdefault("to", "broadcast") next_message.setdefault("channel", "LongFast") next_message.setdefault("timestamp", datetime.utcnow().isoformat() + "Z") key = self._message_dedupe_key(next_message) for existing in list(self.messages)[:40]: if self._message_dedupe_key(existing) != key: continue try: existing_ts_raw = existing.get("timestamp") existing_ts = ( datetime.fromisoformat(str(existing_ts_raw).replace("Z", "+00:00")).timestamp() if existing_ts_raw else now ) except Exception: existing_ts = now if existing_ts >= cutoff: if not existing.get("root") and next_message.get("root"): existing["root"] = next_message.get("root") if not existing.get("region") and next_message.get("region"): existing["region"] = next_message.get("region") return False self.messages.appendleft(next_message) return True @staticmethod def _coerce_node_ref(value) -> str: """Normalize Meshtastic node identifiers into the public !xxxxxxxx form.""" if value is None: return "" if isinstance(value, int): return f"!{value & 0xFFFFFFFF:08x}" raw = str(value).strip() if not raw: return "" if raw.startswith("!"): return raw lowered = raw.lower() if lowered.startswith("0x"): try: return f"!{int(lowered, 16) & 0xFFFFFFFF:08x}" except ValueError: return raw if raw.isdigit(): try: return f"!{int(raw) & 0xFFFFFFFF:08x}" except ValueError: return raw if len(raw) == 8 and all(ch in "0123456789abcdefABCDEF" for ch in raw): return f"!{raw.lower()}" return raw @staticmethod def _first_text_value(*values) -> str: for value in values: if isinstance(value, bytes): value = value.decode("utf-8", errors="replace") if isinstance(value, str): text = value.strip() if text: return MeshtasticBridge._repair_text_mojibake(text) return "" @staticmethod def _repair_text_mojibake(text: str) -> str: """Repair common UTF-8-as-Latin-1 mojibake from MQTT JSON bridges.""" if not text or not any(marker in text for marker in ("Ã", "Ð", "Ñ")): return text try: repaired = text.encode("latin-1").decode("utf-8").strip() except UnicodeError: return text if repaired and repaired != text: return repaired return text @staticmethod def _first_present(*values): for value in values: if value is not None and value != "": return value return None def _extract_json_text_message(self, data: dict, topic: str) -> dict | None: """Extract a public Meshtastic text event from decoded MQTT JSON. Meshtastic JSON brokers are not perfectly uniform. Some packets expose text at the top level, some under ``decoded`` or ``payload``. Keep this permissive for receive, but only return messages with non-empty text. """ if not isinstance(data, dict): return None topic_meta = parse_topic_metadata(topic) packet = data.get("packet") if isinstance(data.get("packet"), dict) else {} decoded = data.get("decoded") if isinstance(data.get("decoded"), dict) else {} payload_obj = data.get("payload") payload = payload_obj if isinstance(payload_obj, dict) else {} decoded_payload_obj = decoded.get("payload") if decoded else None decoded_payload = decoded_payload_obj if isinstance(decoded_payload_obj, dict) else {} text = self._first_text_value( data.get("text"), data.get("message"), data.get("msg"), payload_obj if isinstance(payload_obj, str) else "", payload.get("text"), payload.get("message"), payload.get("msg"), payload.get("payload") if isinstance(payload.get("payload"), str) else "", decoded.get("text"), decoded.get("message"), decoded.get("payload") if isinstance(decoded.get("payload"), str) else "", decoded_payload.get("text"), decoded_payload.get("message"), decoded_payload.get("msg"), ) if not text: return None sender = self._coerce_node_ref( self._first_present( data.get("from"), data.get("fromId"), data.get("from_id"), data.get("sender"), data.get("senderId"), data.get("sender_id"), packet.get("from"), packet.get("fromId"), packet.get("from_id"), decoded.get("from"), ) ) recipient = self._coerce_node_ref( self._first_present( data.get("to"), data.get("toId"), data.get("to_id"), data.get("recipient"), data.get("recipientId"), data.get("recipient_id"), packet.get("to"), packet.get("toId"), packet.get("to_id"), decoded.get("to"), ) ) if not recipient or recipient in {"!ffffffff", "broadcast"}: recipient = "broadcast" timestamp = datetime.utcnow().isoformat() + "Z" rx_time = self._first_present( data.get("rxTime"), data.get("rx_time"), data.get("timestamp"), packet.get("rxTime"), packet.get("timestamp"), ) if isinstance(rx_time, (int, float)) and rx_time > 0: try: timestamp = datetime.fromtimestamp(float(rx_time), tz=timezone.utc).isoformat() except (OSError, ValueError): pass return { "from": sender or topic.split("/")[-1], "to": recipient, "text": text[:500], "region": topic_meta["region"], "root": topic_meta["root"], "channel": topic_meta["channel"], "timestamp": timestamp, } def start(self): if self._thread and self._thread.is_alive(): if not self._stop.is_set(): return self._thread.join(timeout=2.0) if self._thread.is_alive(): logger.warning("Meshtastic MQTT bridge is still stopping; start deferred") return self._stop.clear() self._thread = threading.Thread(target=self._run, daemon=True, name="mesh-bridge") self._thread.start() logger.info("Meshtastic MQTT bridge started") def stop(self): self._stop.set() self._connected = False def is_running(self) -> bool: return bool(self._thread and self._thread.is_alive() and not self._stop.is_set()) def status(self) -> dict: broker, port, user, _pw = self._mqtt_config() display_user = "" if user == "meshdev" else user return { "enabled": mqtt_bridge_enabled(), "running": self.is_running(), "connected": bool(self._connected), "broker": broker, "port": port, "username": display_user, "client_id": self._client_id, "message_log_size": len(self.messages), "signal_log_size": len(self.signals), "last_error": self._last_error, "last_broker": self._last_broker, "last_connected_at": self._last_connected_at, "last_disconnected_at": self._last_disconnected_at, "rx_dropped": self._rx_dropped, } def _subscription_topics(self) -> list[str]: extra_roots, extra_topics, include_defaults = mqtt_subscription_settings() return build_subscription_topics( extra_roots=extra_roots, extra_topics=extra_topics, include_defaults=include_defaults, ) def _run(self): while not self._stop.is_set(): try: self._connect() except Exception as e: logger.warning(f"Meshtastic MQTT error: {e}") if not self._stop.is_set(): time.sleep(15) def _connect(self): try: import paho.mqtt.client as mqtt except ImportError: logger.error("paho-mqtt not installed — Meshtastic bridge disabled") self._stop.set() return topics = self._subscription_topics() def _on_connect(client, userdata, flags, rc): if rc == 0: self._connected = True self._last_error = "" self._last_connected_at = time.time() logger.info( "Meshtastic MQTT connected (%s), subscribing to %s", self._client_id, topics, ) for topic in topics: client.subscribe(topic, qos=0) else: self._connected = False self._last_error = f"connect_refused:{rc}" logger.error( "Meshtastic MQTT connection refused (%s): rc=%s", self._client_id, rc, ) def _on_disconnect(client, userdata, rc): self._connected = False self._last_disconnected_at = time.time() if rc != 0: self._last_error = f"disconnect:{rc}" logger.warning( "Meshtastic MQTT disconnected unexpectedly (%s, rc=%s), will auto-reconnect", self._client_id, rc, ) else: logger.info("Meshtastic MQTT disconnected cleanly (%s)", self._client_id) broker, port, user, pw = self._mqtt_config() self._last_broker = f"{broker}:{port}" client = mqtt.Client(client_id=self._client_id, protocol=mqtt.MQTTv311) client.username_pw_set(user, pw) client.on_connect = _on_connect client.on_message = self._on_message client.on_disconnect = _on_disconnect client.reconnect_delay_set(min_delay=15, max_delay=300) client.connect(broker, port, keepalive=120) client.loop_start() while not self._stop.is_set(): self._stop.wait(1.0) client.loop_stop() client.disconnect() def _rate_limited(self) -> bool: """Return True (and discard) if we've exceeded _RATE_LIMIT_PER_MIN.""" now = time.time() cutoff = now - self._RATE_WINDOW_S # Trim timestamps older than the window while self._rx_timestamps and self._rx_timestamps[0] < cutoff: self._rx_timestamps.popleft() if len(self._rx_timestamps) >= self._RATE_LIMIT_PER_MIN: self._rx_dropped += 1 if self._rx_dropped % 200 == 1: logger.warning( "Meshtastic rate limiter: dropped %d messages (>%d/min)", self._rx_dropped, self._RATE_LIMIT_PER_MIN, ) return True self._rx_timestamps.append(now) return False def _on_message(self, client, userdata, msg): """Parse Meshtastic MQTT messages — protobuf + AES decryption.""" try: payload = msg.payload topic = msg.topic # Try JSON first (some nodes publish JSON on /json/ topics) if "/json/" in topic: try: data = json.loads(payload) text_message = self._extract_json_text_message(data, topic) if text_message: self.append_text_message(text_message, dedupe_window_s=30.0) if self._rate_limited(): return self._ingest_data(data, topic) return except (json.JSONDecodeError, UnicodeDecodeError): pass # Protobuf ServiceEnvelope (the standard format) data = self._decode_protobuf(payload, topic) if data: # Text messages don't have positions — store in message log if data.get("portnum") == "TEXT_MESSAGE_APP" and data.get("text"): topic_meta = parse_topic_metadata(topic) recipient = data.get("to", "broadcast") if self._dedupe_message( data.get("from", "???"), topic_meta["channel"], data["text"], recipient, topic_meta["root"], ): return self.append_text_message( { "from": data.get("from", "???"), "to": recipient, "text": data["text"], "region": topic_meta["region"], "root": topic_meta["root"], "channel": topic_meta["channel"], "timestamp": datetime.utcnow().isoformat() + "Z", } ) else: if self._rate_limited(): return self._ingest_data(data, topic) except Exception as e: logger.debug(f"Meshtastic parse error: {e}") def _decode_protobuf(self, payload: bytes, topic: str) -> dict | None: """Decode a Meshtastic ServiceEnvelope protobuf with AES decryption.""" try: from meshtastic import mesh_pb2, mqtt_pb2, portnums_pb2 except ImportError: return None try: envelope = mqtt_pb2.ServiceEnvelope() envelope.ParseFromString(payload) except Exception: return None packet = envelope.packet if not packet or not packet.HasField("encrypted"): # Already decoded or empty if packet and packet.HasField("decoded"): return self._extract_from_decoded(packet, topic) return None # Decrypt with configured PSK (default: LongFast key) try: from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes psk = self._resolve_psk() # Nonce: packetId (little-endian u64) + fromNode (little-endian u64) = 16 bytes nonce = struct.pack(" dict | None: """Extract data from an already-decoded MeshPacket.""" decoded = packet.decoded return self._extract_from_data(decoded, packet, topic) def _extract_from_data(self, data_msg, packet, topic: str) -> dict | None: """Extract position/text from a decoded Data message.""" try: from meshtastic import mesh_pb2, portnums_pb2 except ImportError: return None portnum = data_msg.portnum from_id = getattr(packet, "from", 0) to_id = getattr(packet, "to", 0) callsign = f"!{from_id:08x}" if from_id else topic.split("/")[-1] result = {"from": callsign} if to_id == 0xFFFFFFFF: result["to"] = "broadcast" elif to_id: result["to"] = f"!{to_id:08x}" if portnum == portnums_pb2.PortNum.POSITION_APP: try: pos = mesh_pb2.Position() pos.ParseFromString(data_msg.payload) if pos.latitude_i and pos.longitude_i: result["latitude_i"] = pos.latitude_i result["longitude_i"] = pos.longitude_i if pos.altitude: result["altitude"] = pos.altitude return result except Exception: pass elif portnum == portnums_pb2.PortNum.TEXT_MESSAGE_APP: try: text = data_msg.payload.decode("utf-8", errors="replace") result["text"] = text result["portnum"] = "TEXT_MESSAGE_APP" return result except Exception: pass elif portnum == portnums_pb2.PortNum.NODEINFO_APP: try: user = mesh_pb2.User() user.ParseFromString(data_msg.payload) if user.long_name: result["long_name"] = user.long_name if user.short_name: result["short_name"] = user.short_name # No position in nodeinfo return None except Exception: pass return None def _ingest_data(self, data: dict, topic: str): """Process a decoded data dict into a signal entry.""" lat = data.get("latitude_i") or data.get("lat") lng = data.get("longitude_i") or data.get("lng") or data.get("lon") if lat is None or lng is None: return # Meshtastic stores lat/lng as int32 × 1e-7 if isinstance(lat, int) and abs(lat) > 1000: lat = lat / 1e7 if isinstance(lng, int) and abs(lng) > 1000: lng = lng / 1e7 lat = float(lat) lng = float(lng) if not (-90 <= lat <= 90 and -180 <= lng <= 180): return if lat == 0.0 and lng == 0.0: return if abs(lat) < 0.1 and abs(lng) < 0.1: return if not _is_plausible_land(lat, lng): return callsign = data.get("from", data.get("sender", topic.split("/")[-1])) if isinstance(callsign, int): callsign = f"!{callsign:08x}" topic_meta = parse_topic_metadata(topic) text_content = data.get("text", data.get("message", "")) sig = { "callsign": str(callsign)[:20], "lat": round(lat, 5), "lng": round(lng, 5), "source": "meshtastic", "region": topic_meta["region"], "root": topic_meta["root"], "channel": topic_meta["channel"], "confidence": self.CONFIDENCE, "timestamp": datetime.now(timezone.utc).isoformat(), "raw_message": str(data)[:200], "snr": data.get("snr"), "altitude": data.get("altitude"), } if text_content: sig["status"] = str(text_content)[:200] emergency_kw = _scan_emergency(str(text_content)) if emergency_kw: sig["emergency"] = True sig["emergency_keyword"] = emergency_kw self.signals.append(sig) # ─── JS8Call Bridge ────────────────────────────────────────────────────────── class JS8CallBridge: """Connects to local JS8Call API for HF digital mode intelligence. Requires JS8Call running locally with API enabled on port 2442. Gracefully disables itself if not available. """ HOST = "127.0.0.1" PORT = 2442 CONFIDENCE = 0.9 def __init__(self): self.signals: deque[dict] = deque(maxlen=_MAX_SIGNALS) self._thread: threading.Thread | None = None self._stop = threading.Event() self._available = True def start(self): if self._thread and self._thread.is_alive(): return self._stop.clear() self._available = True self._thread = threading.Thread(target=self._run, daemon=True, name="js8-bridge") self._thread.start() logger.info("JS8Call bridge started (will check for local instance)") def stop(self): self._stop.set() def _run(self): failures = 0 while not self._stop.is_set(): try: self._connect_and_read() failures = 0 except ConnectionRefusedError: if self._available: logger.info("JS8Call not running locally — bridge inactive (will retry)") self._available = False failures += 1 except Exception as e: logger.warning(f"JS8Call error: {e}") failures += 1 # Exponential backoff: 30s, 60s, 120s, max 300s delay = min(30 * (2 ** min(failures, 4)), 300) self._stop.wait(delay) def _connect_and_read(self): with socket.create_connection((self.HOST, self.PORT), timeout=10) as sock: sock.settimeout(30) if not self._available: logger.info("JS8Call detected — bridge active") self._available = True buf = "" while not self._stop.is_set(): try: data = sock.recv(4096).decode("utf-8", errors="replace") except socket.timeout: continue if not data: break buf += data while "\n" in buf: line, buf = buf.split("\n", 1) self._parse_message(line.strip()) def _parse_message(self, line: str): """Parse a JS8Call API JSON message.""" if not line: return try: msg = json.loads(line) msg_type = msg.get("type", "") # We care about RX.DIRECTED and RX.ACTIVITY messages if msg_type not in ("RX.DIRECTED", "RX.ACTIVITY", "RX.SPOT"): return params = msg.get("params", {}) callsign = params.get("FROM", params.get("CALL", "")) if not callsign: return # Grid locator → lat/lng grid = params.get("GRID", "") lat, lng = self._grid_to_latlon(grid) if lat is None: return freq = params.get("FREQ", params.get("DIAL", 0)) snr = params.get("SNR") text = params.get("TEXT", "") self.signals.append( { "callsign": callsign[:20], "lat": lat, "lng": lng, "source": "js8call", "confidence": self.CONFIDENCE, "timestamp": datetime.now(timezone.utc).isoformat(), "raw_message": text[:200] if text else line[:200], "frequency": freq, "snr": snr, "grid": grid, } ) except (json.JSONDecodeError, KeyError): pass @staticmethod def _grid_to_latlon(grid: str) -> tuple[float | None, float | None]: """Convert Maidenhead grid locator to lat/lng (center of grid square).""" if not grid or len(grid) < 4: return None, None try: grid = grid.upper() lng = (ord(grid[0]) - ord("A")) * 20 - 180 lat = (ord(grid[1]) - ord("A")) * 10 - 90 lng += int(grid[2]) * 2 lat += int(grid[3]) # Add center offset for 4-char grid if len(grid) >= 6: lng += (ord(grid[4]) - ord("A")) * (2 / 24) lat += (ord(grid[5]) - ord("A")) * (1 / 24) lng += 1 / 24 lat += 1 / 48 else: lng += 1 lat += 0.5 if -90 <= lat <= 90 and -180 <= lng <= 180: return round(lat, 4), round(lng, 4) except (IndexError, ValueError): pass return None, None # ─── SIGINT Grid Orchestrator ──────────────────────────────────────────────── class SIGINTGrid: """Orchestrates all three SIGINT bridges and provides unified signal access.""" def __init__(self): self.aprs = APRSBridge() self.mesh = MeshtasticBridge() self.js8 = JS8CallBridge() self._started = False def start(self): """Start all bridges (idempotent). Meshtastic MQTT is gated behind MESH_MQTT_ENABLED (default off) to avoid flooding the public broker unless the operator opts in. """ if self._started: return self._started = True self.aprs.start() try: mqtt_enabled = mqtt_bridge_enabled() except Exception: mqtt_enabled = False if mqtt_enabled: self.mesh.start() else: logger.info("Meshtastic MQTT bridge disabled (set MESH_MQTT_ENABLED=true to enable)") self.js8.start() logger.info("SIGINT Grid started (APRS%s + JS8Call)", " + Meshtastic" if mqtt_enabled else "") def stop(self): self.aprs.stop() self.mesh.stop() self.js8.stop() self._started = False def get_all_signals(self) -> list[dict]: """Merge signals from all bridges, deduplicate, and return newest first.""" now = datetime.now(timezone.utc) all_signals = [] for bridge in (self.aprs, self.mesh, self.js8): for sig in list(bridge.signals): # Filter stale signals try: ts = datetime.fromisoformat(sig["timestamp"]) age = (now - ts).total_seconds() if age > _MAX_AGE_S: continue except (ValueError, KeyError): continue all_signals.append(sig) # Deduplicate: keep latest per callsign+source seen: dict[str, dict] = {} for sig in all_signals: key = f"{sig['callsign']}:{sig['source']}" if key not in seen or sig["timestamp"] > seen[key]["timestamp"]: seen[key] = sig result = list(seen.values()) result.sort(key=lambda x: x["timestamp"], reverse=True) return result def get_mesh_channel_stats(self, api_nodes: list[dict] | None = None) -> dict: """Aggregate Meshtastic channel populations from live MQTT + API nodes. Returns { "regions": { "US": {"nodes": 1234, "channels": {"LongFast": 45, ...}}, ... }, "roots": { "US/rob/snd": {"nodes": 12, ...}, ... }, "total_nodes": N, "total_live": N, # from MQTT (last 10 min) "total_api": N, # from map API } """ now = datetime.now(timezone.utc) regions: dict[str, dict] = {} roots: dict[str, dict] = {} seen_callsigns: set[str] = set() live_count = 0 # Live MQTT signals (recent, have region + channel) for sig in list(self.mesh.signals): try: ts = datetime.fromisoformat(sig["timestamp"]) if (now - ts).total_seconds() > _MAX_AGE_S: continue except (ValueError, KeyError): continue cs = sig.get("callsign", "") region = sig.get("region", "?") root = sig.get("root", region or "?") channel = sig.get("channel", "LongFast") if cs in seen_callsigns: continue seen_callsigns.add(cs) live_count += 1 if region not in regions: regions[region] = {"nodes": 0, "live": 0, "channels": {}} regions[region]["nodes"] += 1 regions[region]["live"] += 1 regions[region]["channels"][channel] = regions[region]["channels"].get(channel, 0) + 1 if root not in roots: roots[root] = {"nodes": 0, "live": 0, "region": region, "channels": {}} roots[root]["nodes"] += 1 roots[root]["live"] += 1 roots[root]["channels"][channel] = roots[root]["channels"].get(channel, 0) + 1 # API nodes (global, no channel info but have region from topic/hardware) api_count = 0 if api_nodes: for node in api_nodes: cs = node.get("callsign", "") if cs in seen_callsigns: continue seen_callsigns.add(cs) api_count += 1 # API nodes don't have region/channel — count as "MAP" region region = "MAP" if region not in regions: regions[region] = {"nodes": 0, "live": 0, "channels": {}} regions[region]["nodes"] += 1 # Also count messages per channel from the message log channel_msgs: dict[str, int] = {} for msg in list(self.mesh.messages): ch = msg.get("channel", "LongFast") channel_msgs[ch] = channel_msgs.get(ch, 0) + 1 extra_roots, _extra_topics, include_defaults = mqtt_subscription_settings() return { "regions": regions, "roots": roots, "known_roots": known_roots(extra_roots, include_defaults=include_defaults), "all_roots": all_available_roots(), "channel_messages": channel_msgs, "total_nodes": len(seen_callsigns), "total_live": live_count, "total_api": api_count, } @property def status(self) -> dict: """Return bridge status summary.""" return { "aprs": len(self.aprs.signals), "meshtastic": len(self.mesh.signals), "js8call": len(self.js8.signals), "total": len(self.aprs.signals) + len(self.mesh.signals) + len(self.js8.signals), } # ─── APRS-IS Transmit (two-way messaging) ───────────────────────────────── def send_aprs_message(callsign: str, passcode: str, target: str, message: str) -> dict: """Send a text message to a specific callsign via APRS-IS. Requires a valid amateur radio callsign and passcode. Returns {"ok": True/False, "detail": "..."}. """ if not callsign or not passcode or not target or not message: return {"ok": False, "detail": "Missing required fields"} if len(message) > 67: message = message[:67] server = "rotate.aprs2.net" port = 14580 login = f"user {callsign} pass {passcode} vers ShadowBroker 1.0\r\n" # APRS message format: SENDER>APRS,TCPIP*::TARGET :MESSAGE # Target must be exactly 9 chars (padded with spaces) packet = f"{callsign}>APRS,TCPIP*::{target.ljust(9)}:{message}\r\n" try: with socket.create_connection((server, port), timeout=10) as sock: sock.settimeout(10) banner = sock.recv(512).decode("utf-8", errors="replace") sock.sendall(login.encode("ascii")) response = sock.recv(512).decode("utf-8", errors="replace") if "verified" not in response.lower(): return {"ok": False, "detail": "Login rejected — check callsign/passcode"} sock.sendall(packet.encode("utf-8", errors="replace")) logger.info(f"APRS TX: {callsign} → {target}: {message}") return {"ok": True, "detail": f"Message sent to {target}"} except (socket.timeout, ConnectionRefusedError, OSError) as e: return {"ok": False, "detail": f"Connection error: {e}"} # ─── Nearest KiwiSDR finder ─────────────────────────────────────────────── def find_nearest_kiwisdr( lat: float, lng: float, kiwisdr_list: list[dict], max_results: int = 3 ) -> list[dict]: """Find the closest KiwiSDR receivers to a given coordinate. Uses simple Euclidean distance (fine for ranking nearby points). Returns list of {name, url, distance_deg, bands, location}. """ import math results = [] for sdr in kiwisdr_list: slat = sdr.get("lat") slng = sdr.get("lon") or sdr.get("lng") if slat is None or slng is None: continue dist = math.sqrt((lat - slat) ** 2 + (lng - slng) ** 2) results.append( { "name": sdr.get("name", "Unknown SDR"), "url": sdr.get("url", ""), "distance_deg": round(dist, 2), "bands": sdr.get("bands", ""), "location": sdr.get("location", ""), "lat": slat, "lon": slng, } ) results.sort(key=lambda x: x["distance_deg"]) return results[:max_results] # Module-level singleton — bridges start on first fetch sigint_grid = SIGINTGrid()