Files
Remote-Terminal-for-MeshCore/app/mqtt.py
2026-03-02 14:25:44 -08:00

112 lines
3.8 KiB
Python

"""MQTT publisher for forwarding mesh network events to an MQTT broker."""
from __future__ import annotations
import asyncio
import logging
import ssl
from typing import Any
from app.models import AppSettings
from app.mqtt_base import BaseMqttPublisher
logger = logging.getLogger(__name__)
class MqttPublisher(BaseMqttPublisher):
"""Manages an MQTT connection and publishes mesh network events."""
_backoff_max = 30
_log_prefix = "MQTT"
def _is_configured(self) -> bool:
"""Check if MQTT is configured and has something to publish."""
return bool(
self._settings
and self._settings.mqtt_broker_host
and (self._settings.mqtt_publish_messages or self._settings.mqtt_publish_raw_packets)
)
def _build_client_kwargs(self, settings: AppSettings) -> dict[str, Any]:
return {
"hostname": settings.mqtt_broker_host,
"port": settings.mqtt_broker_port,
"username": settings.mqtt_username or None,
"password": settings.mqtt_password or None,
"tls_context": self._build_tls_context(settings),
}
def _on_connected(self, settings: AppSettings) -> tuple[str, str]:
return ("MQTT connected", f"{settings.mqtt_broker_host}:{settings.mqtt_broker_port}")
def _on_error(self) -> tuple[str, str]:
return ("MQTT connection failure", "Please correct the settings or disable.")
@staticmethod
def _build_tls_context(settings: AppSettings) -> ssl.SSLContext | None:
"""Build TLS context from settings, or None if TLS is disabled."""
if not settings.mqtt_use_tls:
return None
ctx = ssl.create_default_context()
if settings.mqtt_tls_insecure:
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
return ctx
# Module-level singleton
mqtt_publisher = MqttPublisher()
def mqtt_broadcast(event_type: str, data: dict[str, Any]) -> None:
"""Fire-and-forget MQTT publish, matching broadcast_event's pattern."""
if event_type not in ("message", "raw_packet"):
return
if not mqtt_publisher.connected or mqtt_publisher._settings is None:
return
asyncio.create_task(_mqtt_maybe_publish(event_type, data))
async def _mqtt_maybe_publish(event_type: str, data: dict[str, Any]) -> None:
"""Check settings and build topic, then publish."""
settings = mqtt_publisher._settings
if settings is None:
return
try:
if event_type == "message" and settings.mqtt_publish_messages:
topic = _build_message_topic(settings.mqtt_topic_prefix, data)
await mqtt_publisher.publish(topic, data)
elif event_type == "raw_packet" and settings.mqtt_publish_raw_packets:
topic = _build_raw_packet_topic(settings.mqtt_topic_prefix, data)
await mqtt_publisher.publish(topic, data)
except Exception as e:
logger.warning("MQTT broadcast error: %s", e)
def _build_message_topic(prefix: str, data: dict[str, Any]) -> str:
"""Build MQTT topic for a decrypted message."""
msg_type = data.get("type", "")
conversation_key = data.get("conversation_key", "unknown")
if msg_type == "PRIV":
return f"{prefix}/dm:{conversation_key}"
elif msg_type == "CHAN":
return f"{prefix}/gm:{conversation_key}"
return f"{prefix}/message:{conversation_key}"
def _build_raw_packet_topic(prefix: str, data: dict[str, Any]) -> str:
"""Build MQTT topic for a raw packet."""
info = data.get("decrypted_info")
if info and isinstance(info, dict):
contact_key = info.get("contact_key")
channel_key = info.get("channel_key")
if contact_key:
return f"{prefix}/raw/dm:{contact_key}"
if channel_key:
return f"{prefix}/raw/gm:{channel_key}"
return f"{prefix}/raw/unrouted"