import json
|
import logging
|
import time
|
import uuid
|
from typing import Any
|
|
import paho.mqtt.client as mqtt
|
|
from app.config import settings
|
from app.services.device_store import DeviceStore
|
|
logger = logging.getLogger(__name__)
|
|
TOPIC_RPT_STATE = "snlp/v1/+/rpt/state"
|
TOPIC_RPT_EVENT = "snlp/v1/+/rpt/event"
|
TOPIC_RPT_ACK = "snlp/v1/+/rpt/ack"
|
TOPIC_RPT_PROGRESS = "snlp/v1/+/rpt/progress"
|
TOPIC_RPT_LOG = "snlp/v1/+/rpt/log"
|
|
|
class MqttService:
|
def __init__(self, device_store: DeviceStore, auto_init: object | None = None) -> None:
|
self.device_store = device_store
|
self.auto_init = auto_init
|
self._client: mqtt.Client | None = None
|
self._connected = False
|
|
def start(self) -> None:
|
if not settings.mqtt_enabled:
|
logger.info("MQTT disabled by configuration")
|
return
|
|
self._client = mqtt.Client(
|
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
|
client_id=settings.mqtt_client_id,
|
)
|
if settings.mqtt_username:
|
self._client.username_pw_set(settings.mqtt_username, settings.mqtt_password)
|
|
self._client.on_connect = self._on_connect
|
self._client.on_message = self._on_message
|
self._client.on_disconnect = self._on_disconnect
|
|
try:
|
self._client.connect(settings.mqtt_broker, settings.mqtt_port, keepalive=60)
|
self._client.loop_start()
|
logger.info("MQTT client connecting to %s:%s", settings.mqtt_broker, settings.mqtt_port)
|
except Exception:
|
logger.exception("Failed to connect MQTT broker; running in API-only mode")
|
|
def stop(self) -> None:
|
if self._client:
|
self._client.loop_stop()
|
self._client.disconnect()
|
|
def _on_connect(self, client: mqtt.Client, userdata: Any, flags: Any, reason_code: Any, properties: Any = None) -> None:
|
if reason_code == 0 or str(reason_code) == "Success":
|
self._connected = True
|
for topic in (TOPIC_RPT_STATE, TOPIC_RPT_EVENT, TOPIC_RPT_ACK, TOPIC_RPT_PROGRESS, TOPIC_RPT_LOG):
|
client.subscribe(topic, qos=1 if "ack" in topic else 0)
|
logger.info("MQTT connected and subscribed")
|
else:
|
logger.error("MQTT connect failed: %s", reason_code)
|
|
def _on_disconnect(self, client: mqtt.Client, userdata: Any, flags: Any, reason_code: Any, properties: Any = None) -> None:
|
self._connected = False
|
logger.warning("MQTT disconnected: %s", reason_code)
|
|
def _on_message(self, client: mqtt.Client, userdata: Any, msg: mqtt.MQTTMessage) -> None:
|
try:
|
payload = json.loads(msg.payload.decode("utf-8"))
|
parts = msg.topic.split("/")
|
device_id = parts[2] if len(parts) > 2 else payload.get("header", {}).get("device_id", "unknown")
|
msg_type = parts[-1] if len(parts) > 4 else payload.get("msg_type", "unknown")
|
header = payload.get("header", {})
|
timestamp = int(header.get("timestamp", time.time()))
|
body = payload.get("payload", {})
|
|
if msg_type == "state":
|
self.device_store.update_state(device_id, body, timestamp)
|
if self.auto_init:
|
self.auto_init.on_device_state(device_id, body, timestamp)
|
elif msg_type == "ack":
|
self.device_store.record_ack(device_id, body)
|
elif msg_type in ("event", "progress"):
|
self.device_store.record_event(device_id, {**body, "_type": msg_type})
|
elif msg_type == "log":
|
logger.info("Device %s log: %s", device_id, body)
|
except Exception:
|
logger.exception("Failed to handle MQTT message on %s", msg.topic)
|
|
def publish_ctl(self, device_id: str, action: str, payload: dict[str, Any], ttl: int = 60) -> dict[str, Any]:
|
command = self._build_command(device_id, action, payload, ttl)
|
topic = f"snlp/v1/{device_id}/cmd/ctl"
|
self._publish(topic, command, qos=1)
|
return command
|
|
def publish_task(self, device_id: str, action: str, payload: dict[str, Any], ttl: int = 3600) -> dict[str, Any]:
|
command = self._build_command(device_id, action, payload, ttl)
|
topic = f"snlp/v1/{device_id}/cmd/task"
|
self._publish(topic, command, qos=1)
|
return command
|
|
def _build_command(self, device_id: str, action: str, payload: dict[str, Any], ttl: int) -> dict[str, Any]:
|
return {
|
"header": {
|
"version": "1.0",
|
"device_id": device_id,
|
"req_id": str(uuid.uuid4()),
|
"timestamp": int(time.time()),
|
"ttl": ttl,
|
},
|
"action": action,
|
"payload": payload,
|
}
|
|
def _publish(self, topic: str, payload: dict[str, Any], qos: int = 1) -> None:
|
if not self._client or not self._connected:
|
logger.warning("MQTT not connected; command queued locally only: %s", topic)
|
return
|
self._client.publish(topic, json.dumps(payload, ensure_ascii=False), qos=qos)
|
|
@property
|
def connected(self) -> bool:
|
return self._connected
|