Files
root 6c19f82a0a Move reranker to Serenity, add monitoring and mesh-mqtt-observer stacks
- ai: remove reranker service (moved to Serenity at 10.5.1.5:9787)
- ai: add LiteLLM reranker proxy entry for Serenity
- ai: configure OpenWebUI to use LiteLLM for reranking
- monitoring: add Grafana + Prometheus stack
- mesh-mqtt-observer: add new service
- automation, documents, meshtastic: misc updates
2026-05-09 10:42:54 -05:00

686 lines
20 KiB
Python

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()