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
This commit is contained in:
root
2026-05-09 10:42:54 -05:00
parent b2cbe4444c
commit 6c19f82a0a
17 changed files with 1115 additions and 23 deletions

View File

@@ -0,0 +1,17 @@
FROM python:3.12-slim
ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends gcc libpq-dev curl \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir -r /app/requirements.txt
COPY app /app/app
CMD ["python", "-m", "app.main"]

View File

View File

@@ -0,0 +1,25 @@
import os
import sys
import psycopg
def main() -> int:
dsn = os.getenv("DATABASE_URL")
if not dsn:
print("DATABASE_URL missing")
return 1
try:
with psycopg.connect(dsn, autocommit=True) as conn:
with conn.cursor() as cur:
cur.execute("SELECT 1")
cur.fetchone()
return 0
except Exception as exc:
print(exc)
return 1
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,685 @@
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()

View File

@@ -0,0 +1,146 @@
CREATE TABLE IF NOT EXISTS raw_mqtt_events (
id BIGSERIAL PRIMARY KEY,
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
source_system TEXT NOT NULL,
network_name TEXT,
modem_preset TEXT,
broker_host TEXT,
broker_port INTEGER,
topic TEXT NOT NULL,
qos INTEGER,
retain BOOLEAN,
payload_raw BYTEA,
payload_text TEXT,
payload_json JSONB,
parse_status TEXT NOT NULL DEFAULT 'raw',
parser_error TEXT
);
CREATE INDEX IF NOT EXISTS idx_raw_mqtt_events_received_at
ON raw_mqtt_events(received_at DESC);
CREATE INDEX IF NOT EXISTS idx_raw_mqtt_events_source_received
ON raw_mqtt_events(source_system, received_at DESC);
CREATE INDEX IF NOT EXISTS idx_raw_mqtt_events_topic
ON raw_mqtt_events(topic);
CREATE TABLE IF NOT EXISTS normalized_events (
id BIGSERIAL PRIMARY KEY,
raw_event_id BIGINT REFERENCES raw_mqtt_events(id) ON DELETE CASCADE,
event_time TIMESTAMPTZ NOT NULL DEFAULT now(),
source_system TEXT NOT NULL,
network_name TEXT,
modem_preset TEXT,
topic TEXT NOT NULL,
from_node_id TEXT,
to_node_id TEXT,
message_type TEXT,
text_body TEXT,
rssi NUMERIC,
snr NUMERIC,
payload JSONB
);
CREATE INDEX IF NOT EXISTS idx_normalized_events_event_time
ON normalized_events(event_time DESC);
CREATE INDEX IF NOT EXISTS idx_normalized_events_source_time
ON normalized_events(source_system, event_time DESC);
CREATE INDEX IF NOT EXISTS idx_normalized_events_from_node
ON normalized_events(source_system, from_node_id);
CREATE INDEX IF NOT EXISTS idx_normalized_events_message_type
ON normalized_events(message_type);
CREATE TABLE IF NOT EXISTS nodes_seen (
id BIGSERIAL PRIMARY KEY,
source_system TEXT NOT NULL,
node_id TEXT NOT NULL,
first_seen TIMESTAMPTZ NOT NULL DEFAULT now(),
last_seen TIMESTAMPTZ NOT NULL DEFAULT now(),
metadata JSONB,
UNIQUE(source_system, node_id)
);
CREATE INDEX IF NOT EXISTS idx_nodes_seen_last_seen
ON nodes_seen(last_seen DESC);
CREATE OR REPLACE VIEW v_packets_per_hour AS
SELECT
date_trunc('hour', event_time) AS hour,
source_system,
network_name,
modem_preset,
COUNT(*) AS packets
FROM normalized_events
GROUP BY 1, 2, 3, 4
ORDER BY 1 DESC;
CREATE OR REPLACE VIEW v_text_messages_per_day AS
SELECT
date_trunc('day', event_time) AS day,
source_system,
network_name,
modem_preset,
COUNT(*) AS text_messages
FROM normalized_events
WHERE message_type = 'text'
GROUP BY 1, 2, 3, 4
ORDER BY 1 DESC;
CREATE OR REPLACE VIEW v_unique_nodes_per_day AS
SELECT
date_trunc('day', event_time) AS day,
source_system,
network_name,
modem_preset,
COUNT(DISTINCT from_node_id) AS unique_nodes
FROM normalized_events
WHERE from_node_id IS NOT NULL
GROUP BY 1, 2, 3, 4
ORDER BY 1 DESC;
CREATE OR REPLACE VIEW v_utilization_hourly AS
SELECT
date_trunc('hour', event_time) AS hour,
CASE
WHEN source_system = 'meshtastic' AND modem_preset IS NOT NULL AND modem_preset <> ''
THEN 'Meshtastic ' || modem_preset
WHEN source_system = 'meshtastic'
THEN 'Meshtastic'
WHEN source_system = 'meshcore'
THEN 'MeshCore'
ELSE source_system
END AS network,
COUNT(*) AS packets,
COUNT(*) FILTER (WHERE message_type = 'text') AS text_messages,
COUNT(DISTINCT from_node_id) AS unique_nodes
FROM normalized_events
GROUP BY 1, 2
ORDER BY 1 DESC;
CREATE OR REPLACE VIEW v_top_talkers_24h AS
SELECT
source_system,
network_name,
modem_preset,
from_node_id,
COUNT(*) AS packets,
COUNT(*) FILTER (WHERE message_type = 'text') AS text_messages,
MAX(event_time) AS last_seen
FROM normalized_events
WHERE event_time >= now() - interval '24 hours'
AND from_node_id IS NOT NULL
GROUP BY 1, 2, 3, 4
ORDER BY packets DESC;

View File

@@ -0,0 +1,24 @@
services:
mesh-mqtt-observer:
build:
context: .
dockerfile: Dockerfile
container_name: mesh-mqtt-observer
restart: unless-stopped
env_file:
- .env
volumes:
- /mnt/tank/docker/appdata/mesh-mqtt-observer:/data
networks:
- shared_postgres
healthcheck:
test: ["CMD", "python", "-m", "app.healthcheck"]
interval: 30s
timeout: 10s
retries: 5
start_period: 20s
networks:
shared_postgres:
external: true
name: ${SHARED_POSTGRES_NETWORK}

View File

@@ -0,0 +1,4 @@
paho-mqtt==2.1.0
psycopg[binary,pool]==3.2.3
python-dotenv==1.0.1
orjson==3.10.12