mqtt_smart_plug.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533
  1. """MQTT Smart Plug Service for subscribing to external MQTT topics and extracting power/energy data.
  2. This service enables integration with Shelly, Zigbee2MQTT, and other MQTT-based energy monitoring devices.
  3. """
  4. import asyncio
  5. import json
  6. import logging
  7. import threading
  8. from dataclasses import dataclass, field
  9. from datetime import datetime, timedelta, timezone
  10. from typing import Any
  11. import paho.mqtt.client as mqtt
  12. from backend.app.utils.paho_teardown import retire_paho_client
  13. logger = logging.getLogger(__name__)
  14. @dataclass
  15. class SmartPlugMQTTData:
  16. """Latest data received from an MQTT smart plug."""
  17. plug_id: int
  18. power: float | None = None # Current power in watts
  19. energy: float | None = None # Energy in kWh (today)
  20. state: str | None = None # "ON" or "OFF"
  21. # Aware, like the value a received message sets: is_reachable subtracts it
  22. # from an aware now, which raised TypeError before the first message.
  23. last_seen: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
  24. @dataclass
  25. class MQTTDataSourceConfig:
  26. """Configuration for a single MQTT data source (power, energy, or state)."""
  27. topic: str
  28. path: str
  29. multiplier: float = 1.0 # For power/energy
  30. on_value: str | None = None # For state (what value means "ON")
  31. class MQTTSmartPlugService:
  32. """Subscribes to MQTT topics for smart plug energy monitoring."""
  33. # Consider plug unreachable if no message received in this time
  34. REACHABLE_TIMEOUT_MINUTES = 5
  35. def __init__(self):
  36. self.client: mqtt.Client | None = None
  37. self.connected = False
  38. self._lock = threading.Lock()
  39. # topic -> list of (plug_id, data_type) where data_type is "power", "energy", or "state"
  40. self.subscriptions: dict[str, list[tuple[int, str]]] = {}
  41. # plug_id -> {data_type: MQTTDataSourceConfig}
  42. self.plug_configs: dict[int, dict[str, MQTTDataSourceConfig]] = {}
  43. # plug_id -> latest data
  44. self.plug_data: dict[int, SmartPlugMQTTData] = {}
  45. self._disconnection_event: threading.Event | None = None
  46. self._configured = False
  47. self._broker = ""
  48. self._port = 1883
  49. self._username = ""
  50. self._password = ""
  51. self._use_tls = False
  52. def is_configured(self) -> bool:
  53. """Check if the MQTT service is configured and connected."""
  54. return self._configured and self.connected
  55. def has_broker_settings(self) -> bool:
  56. """Check if broker settings are available (even if not connected yet)."""
  57. return bool(self._broker)
  58. async def configure(self, settings: dict) -> bool:
  59. """Configure MQTT connection from settings.
  60. Uses the same broker settings as the MQTT relay service.
  61. Returns True if connection was successful or MQTT is disabled.
  62. """
  63. enabled = settings.get("mqtt_enabled", False)
  64. if not enabled:
  65. await self.disconnect()
  66. self._configured = False
  67. logger.debug("MQTT smart plug service disabled (MQTT relay not enabled)")
  68. return True
  69. broker = settings.get("mqtt_broker", "")
  70. port = settings.get("mqtt_port", 1883)
  71. username = settings.get("mqtt_username", "")
  72. password = settings.get("mqtt_password", "")
  73. use_tls = settings.get("mqtt_use_tls", False)
  74. if not broker:
  75. logger.warning("MQTT smart plug service: no broker configured")
  76. self._configured = False
  77. return False
  78. # Check if settings changed
  79. settings_changed = (
  80. self._broker != broker
  81. or self._port != port
  82. or self._username != username
  83. or self._password != password
  84. or self._use_tls != use_tls
  85. )
  86. self._broker = broker
  87. self._port = port
  88. self._username = username
  89. self._password = password
  90. self._use_tls = use_tls
  91. self._configured = True
  92. # Disconnect and reconnect if settings changed
  93. if settings_changed and self.client:
  94. await self.disconnect()
  95. # Connect if not already connected
  96. if not self.client or not self.connected:
  97. return await self._connect()
  98. return True
  99. async def _connect(self) -> bool:
  100. """Establish MQTT connection."""
  101. import asyncio
  102. import ssl
  103. try:
  104. # Create client with callback API version 2
  105. self.client = mqtt.Client(
  106. callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
  107. client_id=f"bambuddy-smartplug-{id(self)}",
  108. protocol=mqtt.MQTTv311,
  109. )
  110. # Set up callbacks
  111. self.client.on_connect = self._on_connect
  112. self.client.on_disconnect = self._on_disconnect
  113. self.client.on_message = self._on_message
  114. # Configure authentication
  115. if self._username:
  116. self.client.username_pw_set(self._username, self._password)
  117. # Configure TLS
  118. if self._use_tls:
  119. self.client.tls_set(cert_reqs=ssl.CERT_NONE)
  120. self.client.tls_insecure_set(True)
  121. # Connect with timeout
  122. try:
  123. await asyncio.wait_for(
  124. asyncio.to_thread(self.client.connect_async, self._broker, self._port, 60),
  125. timeout=3.0,
  126. )
  127. except TimeoutError:
  128. logger.warning("MQTT smart plug connection to %s:%s timed out", self._broker, self._port)
  129. return False
  130. self.client.loop_start()
  131. # Wait briefly for connection
  132. await asyncio.sleep(1.0)
  133. if self.connected:
  134. logger.info("MQTT smart plug service connected to %s:%s", self._broker, self._port)
  135. # Resubscribe to all topics
  136. self._resubscribe_all()
  137. return True
  138. else:
  139. logger.warning("MQTT smart plug connection pending to %s:%s", self._broker, self._port)
  140. return True # Connection is async
  141. except Exception as e:
  142. logger.error("MQTT smart plug connection failed: %s", e)
  143. self.connected = False
  144. return False
  145. def _on_connect(
  146. self,
  147. client: mqtt.Client,
  148. userdata: Any,
  149. flags: dict,
  150. reason_code: int | mqtt.ReasonCode,
  151. properties: mqtt.Properties | None = None,
  152. ):
  153. """Callback when connected to broker."""
  154. rc = reason_code if isinstance(reason_code, int) else reason_code.value
  155. if rc == 0:
  156. self.connected = True
  157. logger.info("MQTT smart plug service connected successfully")
  158. # Resubscribe to all topics
  159. self._resubscribe_all()
  160. else:
  161. self.connected = False
  162. logger.error("MQTT smart plug connection failed: %s", reason_code)
  163. def _on_disconnect(
  164. self,
  165. client: mqtt.Client,
  166. userdata: Any,
  167. flags_or_rc: dict | int | mqtt.ReasonCode,
  168. reason_code: int | mqtt.ReasonCode | None = None,
  169. properties: mqtt.Properties | None = None,
  170. ):
  171. """Callback when disconnected from broker."""
  172. self.connected = False
  173. rc = reason_code if reason_code is not None else flags_or_rc
  174. rc_val = rc if isinstance(rc, int) else getattr(rc, "value", 0)
  175. if rc_val != 0:
  176. logger.warning("MQTT smart plug service disconnected: %s", rc)
  177. else:
  178. logger.info("MQTT smart plug service disconnected cleanly")
  179. if self._disconnection_event:
  180. self._disconnection_event.set()
  181. def _on_message(self, client: mqtt.Client, userdata: Any, msg: mqtt.MQTTMessage):
  182. """Handle incoming MQTT message, extract data using JSON path."""
  183. topic = msg.topic
  184. with self._lock:
  185. subscriptions = self.subscriptions.get(topic, [])
  186. if not subscriptions:
  187. return
  188. # Parse JSON payload (or treat as raw value)
  189. try:
  190. payload = json.loads(msg.payload.decode("utf-8"))
  191. is_json = True
  192. except (json.JSONDecodeError, UnicodeDecodeError):
  193. # Not JSON - treat the whole payload as a raw value
  194. payload = msg.payload.decode("utf-8").strip()
  195. is_json = False
  196. # Process for each subscribed (plug_id, data_type)
  197. for plug_id, data_type in subscriptions:
  198. configs = self.plug_configs.get(plug_id, {})
  199. config = configs.get(data_type)
  200. if not config:
  201. continue
  202. # Extract value using path (or use raw payload if no path)
  203. if is_json and config.path:
  204. raw_value = self._extract_json_path(payload, config.path)
  205. elif is_json and not config.path:
  206. # JSON but no path - if it's a simple value use it, otherwise skip
  207. if isinstance(payload, (int, float, str, bool)):
  208. raw_value = payload
  209. else:
  210. # Can't use a dict/list as a value
  211. logger.debug("MQTT plug %s: JSON payload is object/array but no path configured", plug_id)
  212. continue
  213. else:
  214. # Raw value (non-JSON)
  215. raw_value = payload
  216. if raw_value is None:
  217. continue
  218. # Initialize plug data if needed
  219. if plug_id not in self.plug_data:
  220. self.plug_data[plug_id] = SmartPlugMQTTData(plug_id=plug_id)
  221. data = self.plug_data[plug_id]
  222. data.last_seen = datetime.now(timezone.utc)
  223. # Process based on data type
  224. if data_type == "power":
  225. try:
  226. data.power = float(raw_value) * config.multiplier
  227. logger.debug("MQTT smart plug %s: power=%s", plug_id, data.power)
  228. except (ValueError, TypeError):
  229. pass # Ignore unparseable power reading from MQTT
  230. elif data_type == "energy":
  231. try:
  232. data.energy = float(raw_value) * config.multiplier
  233. logger.debug("MQTT smart plug %s: energy=%s", plug_id, data.energy)
  234. except (ValueError, TypeError):
  235. pass # Ignore unparseable energy reading from MQTT
  236. elif data_type == "state":
  237. state_str = str(raw_value)
  238. # Check against configured ON value if set
  239. if config.on_value:
  240. # Case-insensitive comparison
  241. if state_str.lower() == config.on_value.lower():
  242. data.state = "ON"
  243. else:
  244. data.state = "OFF"
  245. else:
  246. # Default behavior: normalize common values
  247. upper_state = state_str.upper()
  248. if upper_state in ("ON", "1", "TRUE"):
  249. data.state = "ON"
  250. elif upper_state in ("OFF", "0", "FALSE"):
  251. data.state = "OFF"
  252. else:
  253. data.state = state_str
  254. logger.debug("MQTT smart plug %s: state=%s", plug_id, data.state)
  255. def _extract_json_path(self, data: dict, path: str) -> Any:
  256. """Extract value using dot notation (e.g., 'power_l1' or 'data.power').
  257. Supports simple dot notation for nested objects.
  258. """
  259. if not path:
  260. return None
  261. parts = path.split(".")
  262. current = data
  263. for part in parts:
  264. if isinstance(current, dict) and part in current:
  265. current = current[part]
  266. else:
  267. return None
  268. return current
  269. def _resubscribe_all(self):
  270. """Resubscribe to all registered topics after reconnection."""
  271. if not self.client or not self.connected:
  272. return
  273. with self._lock:
  274. for topic in self.subscriptions:
  275. if self.subscriptions[topic]: # Only if there are subscribers
  276. try:
  277. self.client.subscribe(topic, qos=1)
  278. logger.debug("MQTT smart plug: resubscribed to %s", topic)
  279. except Exception as e:
  280. logger.error("MQTT smart plug: failed to resubscribe to %s: %s", topic, e)
  281. def subscribe(
  282. self,
  283. plug_id: int,
  284. # Power source
  285. power_topic: str | None = None,
  286. power_path: str | None = None,
  287. power_multiplier: float = 1.0,
  288. # Energy source
  289. energy_topic: str | None = None,
  290. energy_path: str | None = None,
  291. energy_multiplier: float = 1.0,
  292. # State source
  293. state_topic: str | None = None,
  294. state_path: str | None = None,
  295. state_on_value: str | None = None,
  296. # Legacy: single topic/path/multiplier (for backward compatibility)
  297. topic: str | None = None,
  298. multiplier: float = 1.0,
  299. ):
  300. """Subscribe to MQTT topics for a plug.
  301. Each data type (power, energy, state) can have its own topic.
  302. For backward compatibility, if power_topic is not set but topic is,
  303. topic will be used for all data types that have paths configured.
  304. """
  305. with self._lock:
  306. # Initialize config for this plug
  307. self.plug_configs[plug_id] = {}
  308. # Determine topics (new fields take priority, fall back to legacy)
  309. effective_power_topic = power_topic or topic
  310. effective_energy_topic = energy_topic or topic
  311. effective_state_topic = state_topic or topic
  312. # Use new multipliers or fall back to legacy
  313. effective_power_mult = power_multiplier if power_multiplier != 1.0 else multiplier
  314. effective_energy_mult = energy_multiplier if energy_multiplier != 1.0 else multiplier
  315. # Configure power subscription (path is optional - empty means use raw payload)
  316. if effective_power_topic:
  317. config = MQTTDataSourceConfig(
  318. topic=effective_power_topic,
  319. path=power_path or "",
  320. multiplier=effective_power_mult,
  321. )
  322. self.plug_configs[plug_id]["power"] = config
  323. self._add_subscription(plug_id, effective_power_topic, "power")
  324. # Configure energy subscription (path is optional - empty means use raw payload)
  325. if effective_energy_topic:
  326. config = MQTTDataSourceConfig(
  327. topic=effective_energy_topic,
  328. path=energy_path or "",
  329. multiplier=effective_energy_mult,
  330. )
  331. self.plug_configs[plug_id]["energy"] = config
  332. self._add_subscription(plug_id, effective_energy_topic, "energy")
  333. # Configure state subscription (path is optional - empty means use raw payload)
  334. if effective_state_topic:
  335. config = MQTTDataSourceConfig(
  336. topic=effective_state_topic,
  337. path=state_path or "",
  338. on_value=state_on_value,
  339. )
  340. self.plug_configs[plug_id]["state"] = config
  341. self._add_subscription(plug_id, effective_state_topic, "state")
  342. # Initialize data entry
  343. if plug_id not in self.plug_data:
  344. self.plug_data[plug_id] = SmartPlugMQTTData(plug_id=plug_id)
  345. logger.info(
  346. f"MQTT smart plug {plug_id}: configured with "
  347. f"power={effective_power_topic if power_path else None}, "
  348. f"energy={effective_energy_topic if energy_path else None}, "
  349. f"state={effective_state_topic if state_path else None}"
  350. )
  351. def _add_subscription(self, plug_id: int, topic: str, data_type: str):
  352. """Add a subscription for a plug/data_type to a topic."""
  353. if topic not in self.subscriptions:
  354. self.subscriptions[topic] = []
  355. # Actually subscribe if connected
  356. if self.client and self.connected:
  357. try:
  358. self.client.subscribe(topic, qos=1)
  359. logger.info("MQTT smart plug: subscribed to %s", topic)
  360. except Exception as e:
  361. logger.error("MQTT smart plug: failed to subscribe to %s: %s", topic, e)
  362. entry = (plug_id, data_type)
  363. if entry not in self.subscriptions[topic]:
  364. self.subscriptions[topic].append(entry)
  365. def unsubscribe(self, plug_id: int):
  366. """Unsubscribe when plug is deleted/updated."""
  367. with self._lock:
  368. # Get all configs for this plug
  369. configs = self.plug_configs.pop(plug_id, {})
  370. if not configs:
  371. # Still clean up any stray subscriptions
  372. pass
  373. # Collect all topics this plug was subscribed to
  374. topics_to_check = set()
  375. for _data_type, config in configs.items():
  376. topics_to_check.add(config.topic)
  377. # Also scan subscriptions to remove any entries for this plug
  378. for topic in list(self.subscriptions.keys()):
  379. # Remove all entries for this plug_id
  380. self.subscriptions[topic] = [(pid, dtype) for pid, dtype in self.subscriptions[topic] if pid != plug_id]
  381. topics_to_check.add(topic)
  382. # Unsubscribe from topics with no more subscribers
  383. for topic in topics_to_check:
  384. if topic in self.subscriptions and not self.subscriptions[topic]:
  385. del self.subscriptions[topic]
  386. if self.client and self.connected:
  387. try:
  388. self.client.unsubscribe(topic)
  389. logger.info("MQTT smart plug: unsubscribed from %s", topic)
  390. except Exception as e:
  391. logger.error("MQTT smart plug: failed to unsubscribe from %s: %s", topic, e)
  392. # Remove data
  393. self.plug_data.pop(plug_id, None)
  394. def get_plug_data(self, plug_id: int) -> SmartPlugMQTTData | None:
  395. """Get latest data for a plug (called by status endpoint)."""
  396. with self._lock:
  397. return self.plug_data.get(plug_id)
  398. def is_reachable(self, plug_id: int) -> bool:
  399. """Check if a plug has received data recently."""
  400. data = self.get_plug_data(plug_id)
  401. if not data:
  402. return False
  403. timeout = timedelta(minutes=self.REACHABLE_TIMEOUT_MINUTES)
  404. return datetime.now(timezone.utc) - data.last_seen < timeout
  405. async def disconnect(self, timeout: float = 0):
  406. """Disconnect from MQTT broker."""
  407. if self.client:
  408. try:
  409. self._disconnection_event = threading.Event()
  410. self.client.disconnect()
  411. await asyncio.to_thread(self._disconnection_event.wait, timeout=timeout)
  412. retire_paho_client(self.client, "smart-plugs")
  413. except Exception as e:
  414. logger.debug("MQTT smart plug disconnect error (ignored): %s", e)
  415. finally:
  416. self.client = None
  417. self.connected = False
  418. def subscribe_plug_to_mqtt(service: "MQTTSmartPlugService", plug: Any) -> list[str]:
  419. """Resolve per-type topic fields on a SmartPlug and register it with the service.
  420. The SmartPlug model carries both a legacy single `mqtt_topic` and newer
  421. per-type `mqtt_{power,energy,state}_topic` fields. Three code paths used
  422. to open-code this resolution (startup restore, create, update) and they
  423. drifted — the startup path skipped plugs that only had per-type topics
  424. set, leaving them unsubscribed after every restart (#1010). Funnelling
  425. all three through this helper keeps them in sync.
  426. Returns the list of topics subscribed (empty if nothing was configured).
  427. """
  428. power_topic = plug.mqtt_power_topic or plug.mqtt_topic
  429. energy_topic = plug.mqtt_energy_topic or plug.mqtt_topic
  430. state_topic = plug.mqtt_state_topic or plug.mqtt_topic
  431. if not (power_topic or energy_topic or state_topic):
  432. return []
  433. legacy_mult = plug.mqtt_multiplier or 1.0
  434. service.subscribe(
  435. plug_id=plug.id,
  436. power_topic=power_topic,
  437. power_path=plug.mqtt_power_path,
  438. power_multiplier=plug.mqtt_power_multiplier or legacy_mult,
  439. energy_topic=energy_topic,
  440. energy_path=plug.mqtt_energy_path,
  441. energy_multiplier=plug.mqtt_energy_multiplier or legacy_mult,
  442. state_topic=state_topic,
  443. state_path=plug.mqtt_state_path,
  444. state_on_value=plug.mqtt_state_on_value,
  445. )
  446. return [t for t in {power_topic, energy_topic, state_topic} if t]
  447. # Global instance
  448. mqtt_smart_plug_service = MQTTSmartPlugService()