mqtt_relay.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741
  1. """MQTT Relay Service for publishing BamBuddy events to external MQTT brokers.
  2. This service enables integration with external automation systems like
  3. Node-RED, Home Assistant, and other MQTT-based platforms.
  4. """
  5. import asyncio
  6. import json
  7. import logging
  8. import ssl
  9. import threading
  10. import time
  11. from datetime import datetime, timezone
  12. from typing import Any
  13. import paho.mqtt.client as mqtt
  14. from backend.app.utils.paho_teardown import retire_paho_client
  15. logger = logging.getLogger(__name__)
  16. class MQTTRelayService:
  17. """Publishes BamBuddy events to an external MQTT broker."""
  18. # Minimum interval between status updates per printer (seconds)
  19. STATUS_THROTTLE_SECONDS = 1.0
  20. def __init__(self):
  21. self.client: mqtt.Client | None = None
  22. self.enabled = False
  23. self.connected = False
  24. self.topic_prefix = "bambuddy"
  25. self._lock = threading.Lock()
  26. self._loop: asyncio.AbstractEventLoop | None = None
  27. self._broker = ""
  28. self._port = 1883
  29. self._last_printer_status: dict[int, float] = {} # printer_id -> last publish timestamp
  30. self._smart_plug_service = None # Lazy import to avoid circular dependency
  31. self._settings: dict = {} # Store settings for smart plug service
  32. self._disconnection_event: threading.Event | None = None
  33. async def configure(self, settings: dict) -> bool:
  34. """Configure MQTT connection from settings.
  35. Returns True if connection was successful or MQTT is disabled.
  36. """
  37. self.enabled = settings.get("mqtt_enabled", False)
  38. self._settings = settings # Store for smart plug service
  39. if not self.enabled:
  40. await self.disconnect()
  41. # Also configure smart plug service (will disable it)
  42. await self._configure_smart_plug_service(settings)
  43. logger.info("MQTT relay disabled")
  44. return True
  45. broker = settings.get("mqtt_broker", "")
  46. port = settings.get("mqtt_port", 1883)
  47. username = settings.get("mqtt_username", "")
  48. password = settings.get("mqtt_password", "")
  49. self.topic_prefix = settings.get("mqtt_topic_prefix", "bambuddy")
  50. use_tls = settings.get("mqtt_use_tls", False)
  51. if not broker:
  52. logger.warning("MQTT enabled but no broker configured")
  53. return False
  54. # Store for status endpoint
  55. self._broker = broker
  56. self._port = port
  57. # Disconnect existing connection if settings changed
  58. if self.client:
  59. await self.disconnect()
  60. # Create and connect client
  61. result = await self._connect(broker, port, username, password, use_tls)
  62. # Configure smart plug service with same settings
  63. await self._configure_smart_plug_service(settings)
  64. return result
  65. async def _configure_smart_plug_service(self, settings: dict):
  66. """Configure the MQTT smart plug service with the same broker settings."""
  67. try:
  68. if self._smart_plug_service is None:
  69. from backend.app.services.mqtt_smart_plug import mqtt_smart_plug_service
  70. self._smart_plug_service = mqtt_smart_plug_service
  71. await self._smart_plug_service.configure(settings)
  72. except Exception as e:
  73. logger.error("Failed to configure MQTT smart plug service: %s", e)
  74. @property
  75. def smart_plug_service(self):
  76. """Get the MQTT smart plug service instance."""
  77. if self._smart_plug_service is None:
  78. from backend.app.services.mqtt_smart_plug import mqtt_smart_plug_service
  79. self._smart_plug_service = mqtt_smart_plug_service
  80. return self._smart_plug_service
  81. async def _connect(self, broker: str, port: int, username: str, password: str, use_tls: bool) -> bool:
  82. """Establish MQTT connection."""
  83. try:
  84. # Create client with callback API version 2 (use MQTTv311 for broader compatibility)
  85. self.client = mqtt.Client(
  86. callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
  87. client_id=f"bambuddy-{id(self)}",
  88. protocol=mqtt.MQTTv311,
  89. )
  90. # Set up callbacks
  91. self.client.on_connect = self._on_connect
  92. self.client.on_disconnect = self._on_disconnect
  93. # Configure authentication
  94. if username:
  95. self.client.username_pw_set(username, password)
  96. # Configure TLS (allow self-signed certs for testing)
  97. if use_tls:
  98. self.client.tls_set(cert_reqs=ssl.CERT_NONE)
  99. self.client.tls_insecure_set(True) # Allow self-signed certs
  100. # Run connect_async in thread pool with timeout to avoid blocking
  101. # on unreachable brokers (connect_async does synchronous socket creation)
  102. try:
  103. await asyncio.wait_for(asyncio.to_thread(self.client.connect_async, broker, port, 60), timeout=3.0)
  104. except TimeoutError:
  105. logger.warning("MQTT relay connection to %s:%s timed out", broker, port)
  106. return False
  107. self.client.loop_start()
  108. # Wait briefly for connection callback
  109. await asyncio.sleep(1.0)
  110. if self.connected:
  111. logger.info("MQTT relay connected to %s:%s", broker, port)
  112. # Publish online status
  113. self._publish_status("online")
  114. return True
  115. else:
  116. logger.warning("MQTT relay connection pending to %s:%s", broker, port)
  117. return True # Connection is async, may succeed later
  118. except Exception as e:
  119. logger.error("MQTT relay connection failed: %s", e)
  120. self.connected = False
  121. return False
  122. def _on_connect(
  123. self,
  124. client: mqtt.Client,
  125. userdata: Any,
  126. flags: dict,
  127. reason_code: int | mqtt.ReasonCode,
  128. properties: mqtt.Properties | None = None,
  129. ):
  130. """Callback when connected to broker."""
  131. # Handle both MQTTv311 (int) and MQTTv5 (ReasonCode) return codes
  132. rc = reason_code if isinstance(reason_code, int) else reason_code.value
  133. if rc == 0:
  134. self.connected = True
  135. logger.info("MQTT relay connected successfully")
  136. # Publish online status
  137. self._publish_status("online")
  138. else:
  139. self.connected = False
  140. logger.error("MQTT relay connection failed: %s", reason_code)
  141. def _on_disconnect(
  142. self,
  143. client: mqtt.Client,
  144. userdata: Any,
  145. flags_or_rc: dict | int | mqtt.ReasonCode,
  146. reason_code: int | mqtt.ReasonCode | None = None,
  147. properties: mqtt.Properties | None = None,
  148. ):
  149. """Callback when disconnected from broker."""
  150. self.connected = False
  151. # Handle both MQTTv311 (rc as 3rd param) and MQTTv5 (flags, rc, props)
  152. rc = reason_code if reason_code is not None else flags_or_rc
  153. rc_val = rc if isinstance(rc, int) else getattr(rc, "value", 0)
  154. if rc_val != 0:
  155. logger.warning("MQTT relay disconnected: %s", rc)
  156. else:
  157. logger.info("MQTT relay disconnected cleanly")
  158. if self._disconnection_event:
  159. self._disconnection_event.set()
  160. async def disconnect(self, timeout: float = 0):
  161. """Disconnect from MQTT broker."""
  162. if self.client:
  163. try:
  164. # Publish offline status before disconnecting
  165. self._publish_status("offline")
  166. self._disconnection_event = threading.Event()
  167. self.client.disconnect()
  168. await asyncio.to_thread(self._disconnection_event.wait, timeout=timeout)
  169. retire_paho_client(self.client, "relay")
  170. except Exception as e:
  171. logger.debug("MQTT disconnect error (ignored): %s", e)
  172. finally:
  173. self.client = None
  174. self.connected = False
  175. def _publish_status(self, status: str):
  176. """Publish BamBuddy status (online/offline)."""
  177. self._publish(
  178. f"{self.topic_prefix}/status",
  179. {"status": status, "timestamp": datetime.now(timezone.utc).isoformat()},
  180. retain=True,
  181. )
  182. def _publish(self, topic: str, payload: dict, retain: bool = False):
  183. """Publish message to MQTT broker."""
  184. if not self.client or not self.connected:
  185. return
  186. try:
  187. with self._lock:
  188. self.client.publish(topic, json.dumps(payload, default=str), qos=1, retain=retain)
  189. except Exception as e:
  190. logger.debug("MQTT publish error: %s", e)
  191. def get_status(self) -> dict:
  192. """Get current MQTT relay status for API."""
  193. return {
  194. "enabled": self.enabled,
  195. "connected": self.connected,
  196. "broker": self._broker if self.enabled else "",
  197. "port": self._port if self.enabled else 0,
  198. "topic_prefix": self.topic_prefix,
  199. }
  200. # =========================================================================
  201. # Printer Events
  202. # =========================================================================
  203. async def on_printer_status(
  204. self,
  205. printer_id: int,
  206. state: Any,
  207. printer_name: str,
  208. printer_serial: str,
  209. awaiting_plate_clear: bool = False,
  210. ):
  211. """Publish printer status change (throttled to 1 update/sec per printer)."""
  212. if not self.enabled or not self.connected:
  213. return
  214. # Throttle status updates to avoid flooding MQTT broker
  215. now = time.time()
  216. last_publish = self._last_printer_status.get(printer_id, 0)
  217. if now - last_publish < self.STATUS_THROTTLE_SECONDS:
  218. return # Skip this update, too soon since last publish
  219. self._last_printer_status[printer_id] = now
  220. # Build status payload from PrinterState
  221. payload = {
  222. "printer_id": printer_id,
  223. "printer_name": printer_name,
  224. "printer_serial": printer_serial,
  225. "timestamp": datetime.now(timezone.utc).isoformat(),
  226. "connected": state.connected,
  227. "state": state.state,
  228. "progress": state.progress,
  229. "remaining_time": state.remaining_time,
  230. "layer_num": state.layer_num,
  231. "total_layers": state.total_layers,
  232. "current_print": state.current_print,
  233. "subtask_name": state.subtask_name,
  234. "gcode_file": state.gcode_file,
  235. "temperatures": state.temperatures,
  236. "wifi_signal": state.wifi_signal,
  237. "chamber_light": state.chamber_light,
  238. "speed_level": state.speed_level,
  239. "cooling_fan_speed": state.cooling_fan_speed,
  240. "big_fan1_speed": state.big_fan1_speed,
  241. "big_fan2_speed": state.big_fan2_speed,
  242. "heatbreak_fan_speed": state.heatbreak_fan_speed,
  243. "left_aux_fan_speed": state.left_aux_fan_speed,
  244. "exhaust_fan_present": state.exhaust_fan_present,
  245. # Bambuddy-side gate, not printer telemetry (#2525). Mirrors what the
  246. # Web UI already receives via printer_state_to_dict, so an external
  247. # automation can tell "finished" from "finished and still waiting for
  248. # someone to clear the bed". Edge changes are also published on
  249. # printers/{serial}/plate_clear — this topic only refreshes when the
  250. # printer pushes telemetry, which stops entirely after Auto Off.
  251. "awaiting_plate_clear": awaiting_plate_clear,
  252. }
  253. self._publish(
  254. f"{self.topic_prefix}/printers/{printer_serial}/status",
  255. payload,
  256. retain=True,
  257. )
  258. async def on_plate_clear_state(
  259. self,
  260. printer_id: int,
  261. printer_name: str,
  262. printer_serial: str,
  263. awaiting: bool,
  264. ):
  265. """Publish the plate-clear gate as it flips (#2525).
  266. Retained, unlike the other per-printer event topics, because this is a
  267. *state* an automation needs on subscribe rather than a moment it might
  268. have missed. The status topic carries the same field, but only refreshes
  269. when the printer pushes telemetry — after Auto Off cycles the printer the
  270. retained status payload would sit at ``awaiting_plate_clear: false``
  271. indefinitely while the gate is in fact still up.
  272. """
  273. if not self.enabled or not self.connected:
  274. return
  275. self._publish(
  276. f"{self.topic_prefix}/printers/{printer_serial}/plate_clear",
  277. {
  278. "printer_id": printer_id,
  279. "printer_name": printer_name,
  280. "printer_serial": printer_serial,
  281. "awaiting": awaiting,
  282. "timestamp": datetime.now(timezone.utc).isoformat(),
  283. },
  284. retain=True,
  285. )
  286. async def on_printer_online(self, printer_id: int, printer_name: str, printer_serial: str):
  287. """Publish printer came online event."""
  288. if not self.enabled or not self.connected:
  289. return
  290. self._publish(
  291. f"{self.topic_prefix}/printers/{printer_serial}/online",
  292. {
  293. "printer_id": printer_id,
  294. "printer_name": printer_name,
  295. "printer_serial": printer_serial,
  296. "timestamp": datetime.now(timezone.utc).isoformat(),
  297. },
  298. )
  299. async def on_printer_offline(self, printer_id: int, printer_name: str, printer_serial: str):
  300. """Publish printer went offline event."""
  301. if not self.enabled or not self.connected:
  302. return
  303. self._publish(
  304. f"{self.topic_prefix}/printers/{printer_serial}/offline",
  305. {
  306. "printer_id": printer_id,
  307. "printer_name": printer_name,
  308. "printer_serial": printer_serial,
  309. "timestamp": datetime.now(timezone.utc).isoformat(),
  310. },
  311. )
  312. async def on_print_start(
  313. self,
  314. printer_id: int,
  315. printer_name: str,
  316. printer_serial: str,
  317. filename: str,
  318. subtask_name: str,
  319. ):
  320. """Publish print started event."""
  321. if not self.enabled or not self.connected:
  322. return
  323. self._publish(
  324. f"{self.topic_prefix}/printers/{printer_serial}/print/started",
  325. {
  326. "printer_id": printer_id,
  327. "printer_name": printer_name,
  328. "printer_serial": printer_serial,
  329. "filename": filename,
  330. "subtask_name": subtask_name,
  331. "timestamp": datetime.now(timezone.utc).isoformat(),
  332. },
  333. )
  334. async def on_print_complete(
  335. self,
  336. printer_id: int,
  337. printer_name: str,
  338. printer_serial: str,
  339. filename: str,
  340. subtask_name: str,
  341. status: str,
  342. ):
  343. """Publish print completed event."""
  344. if not self.enabled or not self.connected:
  345. return
  346. # Determine topic based on status
  347. if status == "completed":
  348. topic = f"{self.topic_prefix}/printers/{printer_serial}/print/completed"
  349. else:
  350. topic = f"{self.topic_prefix}/printers/{printer_serial}/print/failed"
  351. self._publish(
  352. topic,
  353. {
  354. "printer_id": printer_id,
  355. "printer_name": printer_name,
  356. "printer_serial": printer_serial,
  357. "filename": filename,
  358. "subtask_name": subtask_name,
  359. "status": status,
  360. "timestamp": datetime.now(timezone.utc).isoformat(),
  361. },
  362. )
  363. async def on_ams_change(
  364. self,
  365. printer_id: int,
  366. printer_name: str,
  367. printer_serial: str,
  368. ams_data: list,
  369. ):
  370. """Publish AMS filament change event."""
  371. if not self.enabled or not self.connected:
  372. return
  373. self._publish(
  374. f"{self.topic_prefix}/printers/{printer_serial}/ams/changed",
  375. {
  376. "printer_id": printer_id,
  377. "printer_name": printer_name,
  378. "printer_serial": printer_serial,
  379. "ams_units": ams_data,
  380. "timestamp": datetime.now(timezone.utc).isoformat(),
  381. },
  382. )
  383. async def on_printer_error(
  384. self,
  385. printer_id: int,
  386. printer_name: str,
  387. printer_serial: str,
  388. errors: list,
  389. ):
  390. """Publish printer HMS error event."""
  391. if not self.enabled or not self.connected:
  392. return
  393. self._publish(
  394. f"{self.topic_prefix}/printers/{printer_serial}/error",
  395. {
  396. "printer_id": printer_id,
  397. "printer_name": printer_name,
  398. "printer_serial": printer_serial,
  399. "errors": errors,
  400. "timestamp": datetime.now(timezone.utc).isoformat(),
  401. },
  402. )
  403. # =========================================================================
  404. # Print Queue Events
  405. # =========================================================================
  406. async def on_queue_job_added(
  407. self,
  408. job_id: int,
  409. filename: str,
  410. printer_id: int | None,
  411. printer_name: str | None,
  412. ):
  413. """Publish job added to queue event."""
  414. if not self.enabled or not self.connected:
  415. return
  416. self._publish(
  417. f"{self.topic_prefix}/queue/job_added",
  418. {
  419. "job_id": job_id,
  420. "filename": filename,
  421. "printer_id": printer_id,
  422. "printer_name": printer_name,
  423. "timestamp": datetime.now(timezone.utc).isoformat(),
  424. },
  425. )
  426. async def on_queue_job_started(
  427. self,
  428. job_id: int,
  429. filename: str,
  430. printer_id: int,
  431. printer_name: str,
  432. printer_serial: str,
  433. ):
  434. """Publish queued job started printing event."""
  435. if not self.enabled or not self.connected:
  436. return
  437. self._publish(
  438. f"{self.topic_prefix}/queue/job_started",
  439. {
  440. "job_id": job_id,
  441. "filename": filename,
  442. "printer_id": printer_id,
  443. "printer_name": printer_name,
  444. "printer_serial": printer_serial,
  445. "timestamp": datetime.now(timezone.utc).isoformat(),
  446. },
  447. )
  448. async def on_queue_job_completed(
  449. self,
  450. job_id: int,
  451. filename: str,
  452. printer_id: int,
  453. printer_name: str,
  454. status: str,
  455. ):
  456. """Publish queued job finished event."""
  457. if not self.enabled or not self.connected:
  458. return
  459. topic = (
  460. f"{self.topic_prefix}/queue/job_completed"
  461. if status == "completed"
  462. else f"{self.topic_prefix}/queue/job_failed"
  463. )
  464. self._publish(
  465. topic,
  466. {
  467. "job_id": job_id,
  468. "filename": filename,
  469. "printer_id": printer_id,
  470. "printer_name": printer_name,
  471. "status": status,
  472. "timestamp": datetime.now(timezone.utc).isoformat(),
  473. },
  474. )
  475. # =========================================================================
  476. # Maintenance Events
  477. # =========================================================================
  478. async def on_maintenance_alert(
  479. self,
  480. printer_id: int,
  481. printer_name: str,
  482. maintenance_type: str,
  483. current_value: float,
  484. threshold: float,
  485. ):
  486. """Publish maintenance alert triggered event."""
  487. if not self.enabled or not self.connected:
  488. return
  489. self._publish(
  490. f"{self.topic_prefix}/maintenance/alert",
  491. {
  492. "printer_id": printer_id,
  493. "printer_name": printer_name,
  494. "maintenance_type": maintenance_type,
  495. "current_value": current_value,
  496. "threshold": threshold,
  497. "timestamp": datetime.now(timezone.utc).isoformat(),
  498. },
  499. )
  500. async def on_maintenance_acknowledged(
  501. self,
  502. printer_id: int,
  503. printer_name: str,
  504. maintenance_type: str,
  505. ):
  506. """Publish maintenance alert acknowledged event."""
  507. if not self.enabled or not self.connected:
  508. return
  509. self._publish(
  510. f"{self.topic_prefix}/maintenance/acknowledged",
  511. {
  512. "printer_id": printer_id,
  513. "printer_name": printer_name,
  514. "maintenance_type": maintenance_type,
  515. "timestamp": datetime.now(timezone.utc).isoformat(),
  516. },
  517. )
  518. async def on_maintenance_reset(
  519. self,
  520. printer_id: int,
  521. printer_name: str,
  522. maintenance_type: str,
  523. ):
  524. """Publish maintenance counter reset event."""
  525. if not self.enabled or not self.connected:
  526. return
  527. self._publish(
  528. f"{self.topic_prefix}/maintenance/reset",
  529. {
  530. "printer_id": printer_id,
  531. "printer_name": printer_name,
  532. "maintenance_type": maintenance_type,
  533. "timestamp": datetime.now(timezone.utc).isoformat(),
  534. },
  535. )
  536. # =========================================================================
  537. # Archive Events
  538. # =========================================================================
  539. async def on_archive_created(
  540. self,
  541. archive_id: int,
  542. print_name: str,
  543. printer_name: str,
  544. status: str,
  545. ):
  546. """Publish print archived event."""
  547. if not self.enabled or not self.connected:
  548. return
  549. self._publish(
  550. f"{self.topic_prefix}/archive/created",
  551. {
  552. "archive_id": archive_id,
  553. "print_name": print_name,
  554. "printer_name": printer_name,
  555. "status": status,
  556. "timestamp": datetime.now(timezone.utc).isoformat(),
  557. },
  558. )
  559. async def on_archive_updated(
  560. self,
  561. archive_id: int,
  562. print_name: str,
  563. status: str,
  564. ):
  565. """Publish archive record updated event."""
  566. if not self.enabled or not self.connected:
  567. return
  568. self._publish(
  569. f"{self.topic_prefix}/archive/updated",
  570. {
  571. "archive_id": archive_id,
  572. "print_name": print_name,
  573. "status": status,
  574. "timestamp": datetime.now(timezone.utc).isoformat(),
  575. },
  576. )
  577. # =========================================================================
  578. # Filament/Spoolman Events
  579. # =========================================================================
  580. async def on_filament_low(
  581. self,
  582. spool_id: int,
  583. spool_name: str,
  584. remaining_weight: float,
  585. remaining_percent: float,
  586. ):
  587. """Publish filament inventory low event."""
  588. if not self.enabled or not self.connected:
  589. return
  590. self._publish(
  591. f"{self.topic_prefix}/filament/low",
  592. {
  593. "spool_id": spool_id,
  594. "spool_name": spool_name,
  595. "remaining_weight": remaining_weight,
  596. "remaining_percent": remaining_percent,
  597. "timestamp": datetime.now(timezone.utc).isoformat(),
  598. },
  599. )
  600. # =========================================================================
  601. # Smart Plug Events
  602. # =========================================================================
  603. async def on_smart_plug_state(
  604. self,
  605. plug_id: int,
  606. plug_name: str,
  607. state: str,
  608. printer_id: int | None = None,
  609. printer_name: str | None = None,
  610. ):
  611. """Publish smart plug state change event."""
  612. if not self.enabled or not self.connected:
  613. return
  614. topic = f"{self.topic_prefix}/smart_plugs/on" if state == "on" else f"{self.topic_prefix}/smart_plugs/off"
  615. self._publish(
  616. topic,
  617. {
  618. "plug_id": plug_id,
  619. "plug_name": plug_name,
  620. "state": state,
  621. "printer_id": printer_id,
  622. "printer_name": printer_name,
  623. "timestamp": datetime.now(timezone.utc).isoformat(),
  624. },
  625. )
  626. async def on_smart_plug_energy(
  627. self,
  628. plug_id: int,
  629. plug_name: str,
  630. power: float,
  631. energy_today: float,
  632. energy_total: float,
  633. ):
  634. """Publish smart plug energy update event."""
  635. if not self.enabled or not self.connected:
  636. return
  637. self._publish(
  638. f"{self.topic_prefix}/smart_plugs/energy",
  639. {
  640. "plug_id": plug_id,
  641. "plug_name": plug_name,
  642. "power_watts": power,
  643. "energy_today_kwh": energy_today,
  644. "energy_total_kwh": energy_total,
  645. "timestamp": datetime.now(timezone.utc).isoformat(),
  646. },
  647. )
  648. # Global instance
  649. mqtt_relay = MQTTRelayService()