wangrong
2026-08-24 beef6536e7c8dd3f5cbe8f73585611479910bea7
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
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