import json import logging import os import signal import ssl import sys import threading import time from dataclasses import dataclass from typing import Any import orjson import paho.mqtt.client as mqtt import psycopg from psycopg.rows import dict_row STOP_EVENT = threading.Event() def env_bool(name: str, default: bool = False) -> bool: value = os.getenv(name) if value is None: return default return value.strip().lower() in {"1", "true", "yes", "y", "on"} def env_int(name: str, default: int) -> int: value = os.getenv(name) if not value: return default return int(value) def split_topics(value: str) -> list[str]: return [x.strip() for x in value.split(",") if x.strip()] def setup_logging() -> None: level_name = os.getenv("LOG_LEVEL", "INFO").upper() level = getattr(logging, level_name, logging.INFO) logging.basicConfig( level=level, format="%(asctime)s [%(levelname)s] %(message)s", stream=sys.stdout, ) @dataclass class BrokerConfig: source_system: str network_name: str | None modem_preset: str | None host: str port: int username: str | None password: str | None tls: bool topics: list[str] class Database: def __init__(self, dsn: str): self.dsn = dsn def connect(self): return psycopg.connect(self.dsn, row_factory=dict_row, autocommit=True) def wait_until_ready(self, timeout_seconds: int = 120) -> None: deadline = time.time() + timeout_seconds last_error = None while time.time() < deadline: try: with self.connect() as conn: with conn.cursor() as cur: cur.execute("SELECT 1") logging.info("Postgres connection ready") return except Exception as exc: last_error = exc logging.warning("Waiting for Postgres: %s", exc) time.sleep(3) raise RuntimeError(f"Postgres not ready after {timeout_seconds}s: {last_error}") def init_schema(self) -> None: schema_path = os.path.join(os.path.dirname(__file__), "schema.sql") with open(schema_path, "r", encoding="utf-8") as f: sql = f.read() with self.connect() as conn: with conn.cursor() as cur: cur.execute(sql) logging.info("Schema initialized") def insert_raw( self, *, source_system: str, network_name: str | None, modem_preset: str | None, broker_host: str, broker_port: int, topic: str, qos: int, retain: bool, payload_raw: bytes | None, payload_text: str | None, payload_json: Any | None, parse_status: str, parser_error: str | None, ) -> int: with self.connect() as conn: with conn.cursor() as cur: cur.execute( """ INSERT INTO raw_mqtt_events ( source_system, network_name, modem_preset, broker_host, broker_port, topic, qos, retain, payload_raw, payload_text, payload_json, parse_status, parser_error ) VALUES ( %(source_system)s, %(network_name)s, %(modem_preset)s, %(broker_host)s, %(broker_port)s, %(topic)s, %(qos)s, %(retain)s, %(payload_raw)s, %(payload_text)s, %(payload_json)s, %(parse_status)s, %(parser_error)s ) RETURNING id """, { "source_system": source_system, "network_name": network_name, "modem_preset": modem_preset, "broker_host": broker_host, "broker_port": broker_port, "topic": topic, "qos": qos, "retain": retain, "payload_raw": payload_raw, "payload_text": payload_text, "payload_json": json.dumps(payload_json) if payload_json is not None else None, "parse_status": parse_status, "parser_error": parser_error, }, ) return int(cur.fetchone()["id"]) def insert_normalized( self, *, raw_event_id: int, source_system: str, network_name: str | None, modem_preset: str | None, topic: str, from_node_id: str | None, to_node_id: str | None, message_type: str | None, text_body: str | None, rssi: float | None, snr: float | None, payload: Any | None, ) -> None: with self.connect() as conn: with conn.cursor() as cur: cur.execute( """ INSERT INTO normalized_events ( raw_event_id, source_system, network_name, modem_preset, topic, from_node_id, to_node_id, message_type, text_body, rssi, snr, payload ) VALUES ( %(raw_event_id)s, %(source_system)s, %(network_name)s, %(modem_preset)s, %(topic)s, %(from_node_id)s, %(to_node_id)s, %(message_type)s, %(text_body)s, %(rssi)s, %(snr)s, %(payload)s ) """, { "raw_event_id": raw_event_id, "source_system": source_system, "network_name": network_name, "modem_preset": modem_preset, "topic": topic, "from_node_id": from_node_id, "to_node_id": to_node_id, "message_type": message_type, "text_body": text_body, "rssi": rssi, "snr": snr, "payload": json.dumps(payload) if payload is not None else None, }, ) def upsert_node(self, source_system: str, node_id: str, payload: Any | None) -> None: with self.connect() as conn: with conn.cursor() as cur: cur.execute( """ INSERT INTO nodes_seen ( source_system, node_id, first_seen, last_seen, metadata ) VALUES ( %(source_system)s, %(node_id)s, now(), now(), %(metadata)s ) ON CONFLICT (source_system, node_id) DO UPDATE SET last_seen = now(), metadata = COALESCE(EXCLUDED.metadata, nodes_seen.metadata) """, { "source_system": source_system, "node_id": node_id, "metadata": json.dumps(payload) if payload is not None else None, }, ) def try_decode_text(payload: bytes) -> str | None: try: text = payload.decode("utf-8") except UnicodeDecodeError: return None if not text: return None if any(ord(ch) < 9 for ch in text): return None return text def try_decode_json(payload_text: str | None) -> tuple[Any | None, str | None]: if payload_text is None: return None, None stripped = payload_text.strip() if not stripped: return None, None if not stripped.startswith("{") and not stripped.startswith("["): return None, None try: return orjson.loads(stripped), None except Exception as exc: return None, str(exc) def deep_get(data: Any, keys: list[str]) -> Any: if isinstance(data, dict): for key in keys: if key in data: return data[key] for value in data.values(): result = deep_get(value, keys) if result is not None: return result if isinstance(data, list): for item in data: result = deep_get(item, keys) if result is not None: return result return None def as_str(value: Any) -> str | None: if value is None: return None return str(value) def as_float(value: Any) -> float | None: if value is None: return None try: return float(value) except Exception: return None def infer_type(payload_json: Any, topic: str) -> str: explicit = deep_get( payload_json, [ "message_type", "messageType", "type", "portnum", "portNum", "port_num", "event_type", "eventType", ], ) if explicit is not None: text = str(explicit).lower() if "text" in text: return "text" if "position" in text: return "position" if "telemetry" in text: return "telemetry" if "node" in text or "user" in text: return "nodeinfo" return text body = deep_get( payload_json, [ "text", "message", "body", "decoded_text", "decodedText", "payload_text", "payloadText", "content", ], ) if body: return "text" lowered_topic = topic.lower() if "position" in lowered_topic or "/pos" in lowered_topic: return "position" if "telemetry" in lowered_topic: return "telemetry" if "nodeinfo" in lowered_topic or "node" in lowered_topic or "user" in lowered_topic: return "nodeinfo" return "unknown" def normalize_json(payload_json: Any, topic: str) -> dict[str, Any]: from_node_id = as_str( deep_get( payload_json, [ "from", "from_id", "fromId", "from_node", "fromNode", "from_node_id", "fromNodeId", "sender", "sender_id", "senderId", "node_id", "nodeId", "id", ], ) ) to_node_id = as_str( deep_get( payload_json, [ "to", "to_id", "toId", "to_node", "toNode", "to_node_id", "toNodeId", "recipient", "recipient_id", "recipientId", ], ) ) text_body = as_str( deep_get( payload_json, [ "text", "message", "body", "decoded_text", "decodedText", "payload_text", "payloadText", "content", ], ) ) rssi = as_float(deep_get(payload_json, ["rssi", "rx_rssi", "rxRssi"])) snr = as_float(deep_get(payload_json, ["snr", "rx_snr", "rxSnr"])) return { "from_node_id": from_node_id, "to_node_id": to_node_id, "message_type": infer_type(payload_json, topic), "text_body": text_body, "rssi": rssi, "snr": snr, } class MeshMqttClient: def __init__(self, db: Database, config: BrokerConfig): self.db = db self.config = config client_id = f"mesh-mqtt-observer-{config.source_system}-{os.getpid()}" self.client = mqtt.Client( mqtt.CallbackAPIVersion.VERSION2, client_id=client_id, clean_session=True, ) if config.username: self.client.username_pw_set(config.username, config.password or None) if config.tls: self.client.tls_set(cert_reqs=ssl.CERT_REQUIRED) self.client.tls_insecure_set(False) self.client.on_connect = self.on_connect self.client.on_disconnect = self.on_disconnect self.client.on_subscribe = self.on_subscribe self.client.on_message = self.on_message def on_connect(self, client, userdata, flags, reason_code, properties): logging.info( "[%s] Connected to MQTT broker %s:%s: %s", self.config.source_system, self.config.host, self.config.port, reason_code, ) for topic in self.config.topics: logging.info("[%s] Subscribing to %s", self.config.source_system, topic) client.subscribe(topic, qos=0) def on_disconnect(self, client, userdata, disconnect_flags, reason_code, properties): logging.warning( "[%s] Disconnected from MQTT broker: %s", self.config.source_system, reason_code, ) def on_subscribe(self, client, userdata, mid, reason_codes, properties): logging.info( "[%s] Subscribe ACK mid=%s reason_codes=%s", self.config.source_system, mid, reason_codes, ) def on_message(self, client, userdata, msg): payload = bytes(msg.payload or b"") payload_raw = payload if env_bool("STORE_RAW_BYTES", True) else None payload_text = try_decode_text(payload) if env_bool("STORE_TEXT_PAYLOAD", True) else None payload_json, json_error = try_decode_json(payload_text) parse_status = "json" if payload_json is not None else "raw" try: raw_id = self.db.insert_raw( source_system=self.config.source_system, network_name=self.config.network_name, modem_preset=self.config.modem_preset, broker_host=self.config.host, broker_port=self.config.port, topic=msg.topic, qos=msg.qos, retain=msg.retain, payload_raw=payload_raw, payload_text=payload_text, payload_json=payload_json, parse_status=parse_status, parser_error=json_error, ) if payload_json is not None: normalized = normalize_json(payload_json, msg.topic) self.db.insert_normalized( raw_event_id=raw_id, source_system=self.config.source_system, network_name=self.config.network_name, modem_preset=self.config.modem_preset, topic=msg.topic, from_node_id=normalized["from_node_id"], to_node_id=normalized["to_node_id"], message_type=normalized["message_type"], text_body=normalized["text_body"], rssi=normalized["rssi"], snr=normalized["snr"], payload=payload_json, ) if normalized["from_node_id"]: self.db.upsert_node( self.config.source_system, normalized["from_node_id"], payload_json, ) logging.debug( "[%s] Stored topic=%s bytes=%s status=%s", self.config.source_system, msg.topic, len(payload), parse_status, ) except Exception as exc: logging.exception( "[%s] Failed to store topic=%s: %s", self.config.source_system, msg.topic, exc, ) def start(self): logging.info( "[%s] Connecting to %s:%s", self.config.source_system, self.config.host, self.config.port, ) self.client.connect(self.config.host, self.config.port, keepalive=60) self.client.loop_start() def stop(self): logging.info("[%s] Stopping MQTT client", self.config.source_system) self.client.loop_stop() self.client.disconnect() def load_configs() -> list[BrokerConfig]: configs: list[BrokerConfig] = [] if env_bool("MESHTASTIC_ENABLED", True): configs.append( BrokerConfig( source_system="meshtastic", network_name=os.getenv("MESHTASTIC_NETWORK_NAME") or "Meshtastic", modem_preset=os.getenv("MESHTASTIC_MODEM_PRESET") or None, host=os.getenv("MESHTASTIC_MQTT_HOST", "mqtt.meshtastic.org"), port=env_int("MESHTASTIC_MQTT_PORT", 1883), username=os.getenv("MESHTASTIC_MQTT_USERNAME") or None, password=os.getenv("MESHTASTIC_MQTT_PASSWORD") or None, tls=env_bool("MESHTASTIC_MQTT_TLS", False), topics=split_topics(os.getenv("MESHTASTIC_MQTT_TOPICS", "msh/#")), ) ) if env_bool("MESHCORE_ENABLED", False): meshcore_host = os.getenv("MESHCORE_MQTT_HOST", "").strip() if meshcore_host and meshcore_host != "CHANGE_ME": configs.append( BrokerConfig( source_system="meshcore", network_name=os.getenv("MESHCORE_NETWORK_NAME") or "MeshCore", modem_preset=os.getenv("MESHCORE_MODEM_PRESET") or None, host=meshcore_host, port=env_int("MESHCORE_MQTT_PORT", 1883), username=os.getenv("MESHCORE_MQTT_USERNAME") or None, password=os.getenv("MESHCORE_MQTT_PASSWORD") or None, tls=env_bool("MESHCORE_MQTT_TLS", False), topics=split_topics(os.getenv("MESHCORE_MQTT_TOPICS", "meshcore/#")), ) ) else: logging.warning("MeshCore is enabled but MESHCORE_MQTT_HOST is not set; skipping") return configs def handle_signal(signum, frame): logging.info("Received signal %s; stopping", signum) STOP_EVENT.set() def main() -> None: setup_logging() signal.signal(signal.SIGTERM, handle_signal) signal.signal(signal.SIGINT, handle_signal) dsn = os.getenv("DATABASE_URL") if not dsn: raise RuntimeError("DATABASE_URL is required") db = Database(dsn) db.wait_until_ready() db.init_schema() configs = load_configs() if not configs: raise RuntimeError("No MQTT brokers configured") clients = [MeshMqttClient(db, config) for config in configs] for client in clients: client.start() logging.info("mesh-mqtt-observer started with %s broker(s)", len(clients)) try: while not STOP_EVENT.is_set(): time.sleep(1) finally: for client in clients: client.stop() logging.info("mesh-mqtt-observer stopped") if __name__ == "__main__": main()