handler.py 50 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033
  1. """Real MQTT handler implementation."""
  2. import os
  3. import threading
  4. import time
  5. import json
  6. import uuid
  7. from typing import Dict, Callable
  8. import paho.mqtt.client as mqtt
  9. import logging
  10. import asyncio
  11. from .base import BaseMQTTHandler
  12. from modules.core.state import state
  13. from modules.core.pattern_manager import list_theta_rho_files
  14. from modules.core.playlist_manager import list_all_playlists
  15. logger = logging.getLogger(__name__)
  16. class MQTTHandler(BaseMQTTHandler):
  17. """Real implementation of MQTT handler."""
  18. def __init__(self, callback_registry: Dict[str, Callable]):
  19. # MQTT Configuration - prioritize state config over environment variables
  20. # This allows UI configuration to override .env settings
  21. self.broker = state.mqtt_broker if state.mqtt_broker else os.getenv('MQTT_BROKER')
  22. self.port = state.mqtt_port if state.mqtt_port else int(os.getenv('MQTT_PORT', '1883'))
  23. self.username = state.mqtt_username if state.mqtt_username else os.getenv('MQTT_USERNAME')
  24. self.password = state.mqtt_password if state.mqtt_password else os.getenv('MQTT_PASSWORD')
  25. self.status_topic = os.getenv('MQTT_STATUS_TOPIC', 'dune_weaver/status')
  26. self.command_topic = os.getenv('MQTT_COMMAND_TOPIC', 'dune_weaver/command')
  27. self.status_interval = int(os.getenv('MQTT_STATUS_INTERVAL', '30'))
  28. # Store callback registry
  29. self.callback_registry = callback_registry
  30. # Threading control
  31. self.running = False
  32. self.status_thread = None
  33. # Home Assistant MQTT Discovery settings - prioritize state config
  34. self.discovery_prefix = state.mqtt_discovery_prefix if state.mqtt_discovery_prefix else os.getenv('MQTT_DISCOVERY_PREFIX', 'homeassistant')
  35. self.device_name = state.mqtt_device_name if state.mqtt_device_name else os.getenv('HA_DEVICE_NAME', 'Dune Weaver')
  36. self.device_id = state.mqtt_device_id if state.mqtt_device_id else os.getenv('HA_DEVICE_ID', 'dune_weaver')
  37. # MQTT broker-level client identity. If the user hasn't set one explicitly,
  38. # generate a random suffix so two instances can never collide on the same
  39. # client_id and kick each other off the broker in a reconnect loop. The
  40. # device_id prefix keeps the random ID greppable in broker logs.
  41. self.client_id = (
  42. state.mqtt_client_id
  43. or os.getenv('MQTT_CLIENT_ID')
  44. or f"{self.device_id}-{uuid.uuid4().hex[:8]}"
  45. )
  46. # Additional topics for state
  47. self.running_state_topic = f"{self.device_id}/state/running"
  48. self.serial_state_topic = f"{self.device_id}/state/serial"
  49. self.pattern_select_topic = f"{self.device_id}/pattern/set"
  50. self.playlist_select_topic = f"{self.device_id}/playlist/set"
  51. self.speed_topic = f"{self.device_id}/speed/set"
  52. self.completion_topic = f"{self.device_id}/state/completion"
  53. self.time_remaining_topic = f"{self.device_id}/state/time_remaining"
  54. # Screen control topics
  55. self.screen_power_topic = f"{self.device_id}/screen/power/set"
  56. self.screen_brightness_topic = f"{self.device_id}/screen/brightness/set"
  57. # LED control topics
  58. self.led_power_topic = f"{self.device_id}/led/power/set"
  59. self.led_brightness_topic = f"{self.device_id}/led/brightness/set"
  60. self.led_effect_topic = f"{self.device_id}/led/effect/set"
  61. self.led_speed_topic = f"{self.device_id}/led/speed/set"
  62. self.led_intensity_topic = f"{self.device_id}/led/intensity/set"
  63. self.led_color_topic = f"{self.device_id}/led/color/set"
  64. # Store current state
  65. self.current_file = ""
  66. self.is_running_state = False
  67. self.serial_state = ""
  68. self.patterns = []
  69. self.playlists = []
  70. # Track connection state
  71. self._connected = False
  72. # Initialize MQTT client if broker is configured
  73. if self.broker:
  74. self.client = mqtt.Client(client_id=self.client_id)
  75. self.client.on_connect = self.on_connect
  76. self.client.on_disconnect = self.on_disconnect
  77. self.client.on_message = self.on_message
  78. if self.username and self.password:
  79. self.client.username_pw_set(self.username, self.password)
  80. self.state = state
  81. self.state.mqtt_handler = self # Set reference to self in state, needed so that state setters can update the state
  82. # Store the main event loop during initialization
  83. self.main_loop = asyncio.get_event_loop()
  84. def setup_ha_discovery(self):
  85. """Publish Home Assistant MQTT discovery configurations."""
  86. if not self.is_enabled:
  87. return
  88. base_device = {
  89. "identifiers": [self.device_id],
  90. "name": self.device_name,
  91. "model": "Dune Weaver",
  92. "manufacturer": "DIY"
  93. }
  94. # Serial State Sensor
  95. serial_config = {
  96. "name": f"{self.device_name} Serial State",
  97. "unique_id": f"{self.device_id}_serial_state",
  98. "state_topic": self.serial_state_topic,
  99. "device": base_device,
  100. "icon": "mdi:serial-port",
  101. "entity_category": "diagnostic"
  102. }
  103. self._publish_discovery("sensor", "serial_state", serial_config)
  104. # Running State Sensor
  105. running_config = {
  106. "name": f"{self.device_name} Running State",
  107. "unique_id": f"{self.device_id}_running_state",
  108. "state_topic": self.running_state_topic,
  109. "device": base_device,
  110. "icon": "mdi:machine",
  111. "entity_category": "diagnostic"
  112. }
  113. self._publish_discovery("sensor", "running_state", running_config)
  114. # Stop Button
  115. stop_config = {
  116. "name": "Stop pattern execution",
  117. "unique_id": f"{self.device_id}_stop",
  118. "command_topic": f"{self.device_id}/command/stop",
  119. "device": base_device,
  120. "icon": "mdi:stop",
  121. "entity_category": "config"
  122. }
  123. self._publish_discovery("button", "stop", stop_config)
  124. # Pause Button
  125. pause_config = {
  126. "name": "Pause pattern execution",
  127. "unique_id": f"{self.device_id}_pause",
  128. "command_topic": f"{self.device_id}/command/pause",
  129. "state_topic": f"{self.device_id}/command/pause/state",
  130. "device": base_device,
  131. "icon": "mdi:pause",
  132. "entity_category": "config",
  133. "enabled_by_default": True,
  134. "availability": {
  135. "topic": f"{self.device_id}/command/pause/available",
  136. "payload_available": "true",
  137. "payload_not_available": "false"
  138. }
  139. }
  140. self._publish_discovery("button", "pause", pause_config)
  141. # Play Button
  142. play_config = {
  143. "name": "Resume pattern execution",
  144. "unique_id": f"{self.device_id}_play",
  145. "command_topic": f"{self.device_id}/command/play",
  146. "state_topic": f"{self.device_id}/command/play/state",
  147. "device": base_device,
  148. "icon": "mdi:play",
  149. "entity_category": "config",
  150. "enabled_by_default": True,
  151. "availability": {
  152. "topic": f"{self.device_id}/command/play/available",
  153. "payload_available": "true",
  154. "payload_not_available": "false"
  155. }
  156. }
  157. self._publish_discovery("button", "play", play_config)
  158. # Skip Button
  159. skip_config = {
  160. "name": "Skip to next pattern",
  161. "unique_id": f"{self.device_id}_skip",
  162. "command_topic": f"{self.device_id}/command/skip",
  163. "device": base_device,
  164. "icon": "mdi:skip-next",
  165. "entity_category": "config",
  166. "enabled_by_default": True,
  167. "availability": {
  168. "topic": f"{self.device_id}/command/skip/available",
  169. "payload_available": "true",
  170. "payload_not_available": "false"
  171. }
  172. }
  173. self._publish_discovery("button", "skip", skip_config)
  174. # Speed Control
  175. speed_config = {
  176. "name": f"{self.device_name} Speed",
  177. "unique_id": f"{self.device_id}_speed",
  178. "command_topic": self.speed_topic,
  179. "state_topic": f"{self.speed_topic}/state",
  180. "device": base_device,
  181. "icon": "mdi:speedometer",
  182. "mode": "box",
  183. "min": 50,
  184. "max": 2000,
  185. "step": 50
  186. }
  187. self._publish_discovery("number", "speed", speed_config)
  188. # Pattern Select
  189. pattern_config = {
  190. "name": f"{self.device_name} Pattern",
  191. "unique_id": f"{self.device_id}_pattern",
  192. "command_topic": self.pattern_select_topic,
  193. "state_topic": f"{self.pattern_select_topic}/state",
  194. "options": self.patterns,
  195. "device": base_device,
  196. "icon": "mdi:draw"
  197. }
  198. self._publish_discovery("select", "pattern", pattern_config)
  199. # Playlist Select
  200. playlist_config = {
  201. "name": f"{self.device_name} Playlist",
  202. "unique_id": f"{self.device_id}_playlist",
  203. "command_topic": self.playlist_select_topic,
  204. "state_topic": f"{self.playlist_select_topic}/state",
  205. "options": self.playlists,
  206. "device": base_device,
  207. "icon": "mdi:playlist-play"
  208. }
  209. self._publish_discovery("select", "playlist", playlist_config)
  210. # Playlist Run Mode Select
  211. playlist_mode_config = {
  212. "name": f"{self.device_name} Playlist Mode",
  213. "unique_id": f"{self.device_id}_playlist_mode",
  214. "command_topic": f"{self.device_id}/playlist/mode/set",
  215. "state_topic": f"{self.device_id}/playlist/mode/state",
  216. "options": ["single", "loop"],
  217. "device": base_device,
  218. "icon": "mdi:repeat",
  219. "entity_category": "config"
  220. }
  221. self._publish_discovery("select", "playlist_mode", playlist_mode_config)
  222. # Playlist Pause Time Number Input
  223. pause_time_config = {
  224. "name": f"{self.device_name} Playlist Pause Time",
  225. "unique_id": f"{self.device_id}_pause_time",
  226. "command_topic": f"{self.device_id}/playlist/pause_time/set",
  227. "state_topic": f"{self.device_id}/playlist/pause_time/state",
  228. "device": base_device,
  229. "icon": "mdi:timer",
  230. "entity_category": "config",
  231. "mode": "box",
  232. "unit_of_measurement": "seconds",
  233. "min": 0,
  234. "max": 86400,
  235. }
  236. self._publish_discovery("number", "pause_time", pause_time_config)
  237. # Clear Pattern Select
  238. clear_pattern_config = {
  239. "name": f"{self.device_name} Clear Pattern",
  240. "unique_id": f"{self.device_id}_clear_pattern",
  241. "command_topic": f"{self.device_id}/playlist/clear_pattern/set",
  242. "state_topic": f"{self.device_id}/playlist/clear_pattern/state",
  243. "options": ["none", "random", "adaptive", "clear_from_in", "clear_from_out", "clear_sideway"],
  244. "device": base_device,
  245. "icon": "mdi:eraser",
  246. "entity_category": "config"
  247. }
  248. self._publish_discovery("select", "clear_pattern", clear_pattern_config)
  249. # Shuffle Switch
  250. shuffle_config = {
  251. "name": f"{self.device_name} Shuffle",
  252. "unique_id": f"{self.device_id}_shuffle",
  253. "command_topic": f"{self.device_id}/playlist/shuffle/set",
  254. "state_topic": f"{self.device_id}/playlist/shuffle/state",
  255. "payload_on": "ON",
  256. "payload_off": "OFF",
  257. "device": base_device,
  258. "icon": "mdi:shuffle-variant",
  259. "entity_category": "config"
  260. }
  261. self._publish_discovery("switch", "shuffle", shuffle_config)
  262. # Completion Percentage Sensor
  263. completion_config = {
  264. "name": f"{self.device_name} Completion",
  265. "unique_id": f"{self.device_id}_completion",
  266. "state_topic": self.completion_topic,
  267. "device": base_device,
  268. "icon": "mdi:progress-clock",
  269. "unit_of_measurement": "%",
  270. "state_class": "measurement",
  271. "entity_category": "diagnostic"
  272. }
  273. self._publish_discovery("sensor", "completion", completion_config)
  274. # Time Remaining Sensor
  275. time_remaining_config = {
  276. "name": f"{self.device_name} Time Remaining",
  277. "unique_id": f"{self.device_id}_time_remaining",
  278. "state_topic": self.time_remaining_topic,
  279. "device": base_device,
  280. "icon": "mdi:timer-sand",
  281. "unit_of_measurement": "s",
  282. "device_class": "duration",
  283. "state_class": "measurement",
  284. "entity_category": "diagnostic"
  285. }
  286. self._publish_discovery("sensor", "time_remaining", time_remaining_config)
  287. # LED Control Entities (only for DW LEDs - WLED has its own MQTT integration)
  288. if state.led_provider == "dw_leds":
  289. # LED Power Switch
  290. led_power_config = {
  291. "name": f"{self.device_name} LED Power",
  292. "unique_id": f"{self.device_id}_led_power",
  293. "command_topic": self.led_power_topic,
  294. "state_topic": f"{self.device_id}/led/power/state",
  295. "payload_on": "ON",
  296. "payload_off": "OFF",
  297. "device": base_device,
  298. "icon": "mdi:lightbulb",
  299. "optimistic": False
  300. }
  301. self._publish_discovery("switch", "led_power", led_power_config)
  302. # LED Brightness Control
  303. led_brightness_config = {
  304. "name": f"{self.device_name} LED Brightness",
  305. "unique_id": f"{self.device_id}_led_brightness",
  306. "command_topic": self.led_brightness_topic,
  307. "state_topic": f"{self.device_id}/led/brightness/state",
  308. "device": base_device,
  309. "icon": "mdi:brightness-6",
  310. "min": 0,
  311. "max": 100,
  312. "mode": "slider"
  313. }
  314. self._publish_discovery("number", "led_brightness", led_brightness_config)
  315. # LED Effect Selector
  316. led_effect_options = [
  317. "Static", "Blink", "Breathe", "Wipe", "Fade", "Scan", "Dual Scan",
  318. "Rainbow Cycle", "Rainbow", "Theater Chase", "Running Lights",
  319. "Random Color", "Dynamic", "Twinkle", "Sparkle", "Strobe", "Fire",
  320. "Comet", "Chase", "Police", "Lightning", "Fireworks", "Ripple", "Flow",
  321. "Colorloop", "Palette Flow", "Gradient", "Multi Strobe", "Waves", "BPM",
  322. "Juggle", "Meteor", "Pride", "Pacifica", "Plasma", "Dissolve", "Glitter",
  323. "Confetti", "Sinelon", "Candle", "Aurora", "Rain", "Halloween", "Noise",
  324. "Funky Plank"
  325. ]
  326. led_effect_config = {
  327. "name": f"{self.device_name} LED Effect",
  328. "unique_id": f"{self.device_id}_led_effect",
  329. "command_topic": self.led_effect_topic,
  330. "state_topic": f"{self.device_id}/led/effect/state",
  331. "options": led_effect_options,
  332. "device": base_device,
  333. "icon": "mdi:palette"
  334. }
  335. self._publish_discovery("select", "led_effect", led_effect_config)
  336. # LED Speed Control
  337. led_speed_config = {
  338. "name": f"{self.device_name} LED Speed",
  339. "unique_id": f"{self.device_id}_led_speed",
  340. "command_topic": self.led_speed_topic,
  341. "state_topic": f"{self.device_id}/led/speed/state",
  342. "device": base_device,
  343. "icon": "mdi:speedometer",
  344. "min": 0,
  345. "max": 255,
  346. "mode": "slider"
  347. }
  348. self._publish_discovery("number", "led_speed", led_speed_config)
  349. # LED Intensity Control
  350. led_intensity_config = {
  351. "name": f"{self.device_name} LED Intensity",
  352. "unique_id": f"{self.device_id}_led_intensity",
  353. "command_topic": self.led_intensity_topic,
  354. "state_topic": f"{self.device_id}/led/intensity/state",
  355. "device": base_device,
  356. "icon": "mdi:brightness-7",
  357. "min": 0,
  358. "max": 255,
  359. "mode": "slider"
  360. }
  361. self._publish_discovery("number", "led_intensity", led_intensity_config)
  362. # LED RGB Color Control (HA MQTT Light, JSON schema).
  363. # Color support must be declared via supported_color_modes;
  364. # the legacy "rgb" flag and the basic-schema rgb_*_topic keys
  365. # are ignored by the JSON schema and HA would register a plain
  366. # on/off light without a color picker.
  367. led_color_config = {
  368. "name": f"{self.device_name} LED Color",
  369. "unique_id": f"{self.device_id}_led_color",
  370. "command_topic": self.led_color_topic,
  371. "state_topic": f"{self.device_id}/led/color/state",
  372. "device": base_device,
  373. "icon": "mdi:palette-swatch",
  374. "schema": "json",
  375. "supported_color_modes": ["rgb"]
  376. }
  377. self._publish_discovery("light", "led_color", led_color_config)
  378. # Screen Control Entities (only if screen controller is available)
  379. if state.screen_controller and state.screen_controller.available:
  380. screen_status = state.screen_controller.get_status()
  381. # Screen Power Switch
  382. screen_power_config = {
  383. "name": f"{self.device_name} Screen Power",
  384. "unique_id": f"{self.device_id}_screen_power",
  385. "command_topic": self.screen_power_topic,
  386. "state_topic": f"{self.device_id}/screen/power/state",
  387. "payload_on": "ON",
  388. "payload_off": "OFF",
  389. "device": base_device,
  390. "icon": "mdi:monitor",
  391. "optimistic": False
  392. }
  393. self._publish_discovery("switch", "screen_power", screen_power_config)
  394. # Screen Brightness Number
  395. screen_brightness_config = {
  396. "name": f"{self.device_name} Screen Brightness",
  397. "unique_id": f"{self.device_id}_screen_brightness",
  398. "command_topic": self.screen_brightness_topic,
  399. "state_topic": f"{self.device_id}/screen/brightness/state",
  400. "device": base_device,
  401. "icon": "mdi:brightness-6",
  402. "min": 0,
  403. "max": screen_status.get("max_brightness", 255),
  404. "mode": "slider"
  405. }
  406. self._publish_discovery("number", "screen_brightness", screen_brightness_config)
  407. def _publish_discovery(self, component: str, config_type: str, config: dict):
  408. """Helper method to publish HA discovery configs."""
  409. if not self.is_enabled:
  410. return
  411. discovery_topic = f"{self.discovery_prefix}/{component}/{self.device_id}/{config_type}/config"
  412. self.client.publish(discovery_topic, json.dumps(config), retain=True)
  413. def _publish_running_state(self, running_state=None):
  414. """Helper to publish running state and button availability."""
  415. if running_state is None:
  416. if not self.state.current_playing_file:
  417. running_state = "idle"
  418. elif self.state.pause_requested:
  419. running_state = "paused"
  420. else:
  421. running_state = "running"
  422. self.client.publish(self.running_state_topic, running_state, retain=True)
  423. # Update button availability based on state
  424. self.client.publish(f"{self.device_id}/command/pause/available",
  425. "true" if running_state == "running" else "false",
  426. retain=True)
  427. self.client.publish(f"{self.device_id}/command/play/available",
  428. "true" if running_state == "paused" else "false",
  429. retain=True)
  430. # Skip is available when running and a playlist is active
  431. self.client.publish(f"{self.device_id}/command/skip/available",
  432. "true" if running_state in ("running", "paused") and bool(self.state.current_playlist) else "false",
  433. retain=True)
  434. def _publish_pattern_state(self, current_file=None):
  435. """Helper to publish pattern state."""
  436. if current_file is None:
  437. current_file = self.state.current_playing_file
  438. if current_file:
  439. if current_file.startswith('./patterns/'):
  440. current_file = current_file[len('./patterns/'):]
  441. else:
  442. current_file = current_file.split("/")[-1].split("\\")[-1]
  443. self.client.publish(f"{self.pattern_select_topic}/state", current_file, retain=True)
  444. else:
  445. # Clear the pattern selection
  446. self.client.publish(f"{self.pattern_select_topic}/state", "None", retain=True)
  447. def _publish_playlist_state(self, playlist_name=None):
  448. """Helper to publish playlist state."""
  449. if playlist_name is None:
  450. playlist_name = self.state.current_playlist_name
  451. if playlist_name:
  452. self.client.publish(f"{self.playlist_select_topic}/state", playlist_name, retain=True)
  453. else:
  454. # Clear the playlist selection
  455. self.client.publish(f"{self.playlist_select_topic}/state", "None", retain=True)
  456. def _publish_serial_state(self):
  457. """Helper to publish serial state."""
  458. serial_connected = (state.conn.is_connected() if state.conn else False)
  459. serial_port = state.port if serial_connected else None
  460. serial_status = f"connected to {serial_port}" if serial_connected else "disconnected"
  461. self.client.publish(self.serial_state_topic, serial_status, retain=True)
  462. def _publish_progress_state(self):
  463. """Helper to publish completion percentage and time remaining."""
  464. if state.execution_progress:
  465. current, total, remaining_time, elapsed_time = state.execution_progress
  466. completion_percentage = (current / total * 100) if total > 0 else 0
  467. # Publish completion percentage (rounded to 1 decimal place)
  468. self.client.publish(self.completion_topic, round(completion_percentage, 1), retain=True)
  469. # Publish time remaining (rounded to nearest second, defaulting to 0 if None)
  470. time_remaining_seconds = round(remaining_time) if remaining_time is not None else 0
  471. self.client.publish(self.time_remaining_topic, max(0, time_remaining_seconds), retain=True)
  472. else:
  473. # No pattern running, publish zeros
  474. self.client.publish(self.completion_topic, 0, retain=True)
  475. self.client.publish(self.time_remaining_topic, 0, retain=True)
  476. def _publish_playlist_settings_state(self):
  477. """Helper to publish playlist settings state (mode, pause_time, clear_pattern, shuffle)."""
  478. self.client.publish(f"{self.device_id}/playlist/mode/state", state.playlist_mode, retain=True)
  479. self.client.publish(f"{self.device_id}/playlist/pause_time/state", state.pause_time, retain=True)
  480. self.client.publish(f"{self.device_id}/playlist/clear_pattern/state", state.clear_pattern, retain=True)
  481. shuffle_state = "ON" if state.shuffle else "OFF"
  482. self.client.publish(f"{self.device_id}/playlist/shuffle/state", shuffle_state, retain=True)
  483. def _publish_led_state(self):
  484. """Helper to publish LED state to MQTT (DW LEDs only - WLED has its own MQTT)."""
  485. if not state.led_controller or state.led_provider != "dw_leds":
  486. return
  487. try:
  488. status = state.led_controller.check_status()
  489. if not status.get("connected", False):
  490. return
  491. # Publish power state (check both "power" for WLED compatibility and "power_on" for DW LEDs)
  492. is_powered = status.get("power_on", status.get("power", False))
  493. power_state = "ON" if is_powered else "OFF"
  494. self.client.publish(f"{self.device_id}/led/power/state", power_state, retain=True)
  495. # Publish brightness (convert from 0-1 to 0-100)
  496. if "brightness" in status:
  497. brightness = int(status["brightness"] * 100)
  498. self.client.publish(f"{self.device_id}/led/brightness/state", brightness, retain=True)
  499. # Publish effect
  500. if "effect_id" in status:
  501. effect_map = {
  502. 0: "Static", 1: "Blink", 2: "Breathe", 3: "Wipe", 4: "Fade",
  503. 5: "Scan", 6: "Dual Scan", 7: "Rainbow Cycle", 8: "Rainbow",
  504. 9: "Theater Chase", 10: "Running Lights", 11: "Random Color",
  505. 12: "Dynamic", 13: "Twinkle", 14: "Sparkle", 15: "Strobe",
  506. 16: "Fire", 17: "Comet", 18: "Chase", 19: "Police", 20: "Lightning",
  507. 21: "Fireworks", 22: "Ripple", 23: "Flow", 24: "Colorloop",
  508. 25: "Palette Flow", 26: "Gradient", 27: "Multi Strobe", 28: "Waves",
  509. 29: "BPM", 30: "Juggle", 31: "Meteor", 32: "Pride", 33: "Pacifica",
  510. 34: "Plasma", 35: "Dissolve", 36: "Glitter", 37: "Confetti",
  511. 38: "Sinelon", 39: "Candle", 40: "Aurora", 41: "Rain",
  512. 42: "Halloween", 43: "Noise", 44: "Funky Plank"
  513. }
  514. effect_name = effect_map.get(status["effect_id"], "Static")
  515. self.client.publish(f"{self.device_id}/led/effect/state", effect_name, retain=True)
  516. # Publish speed
  517. if "speed" in status:
  518. self.client.publish(f"{self.device_id}/led/speed/state", status["speed"], retain=True)
  519. # Publish intensity
  520. if "intensity" in status:
  521. self.client.publish(f"{self.device_id}/led/intensity/state", status["intensity"], retain=True)
  522. # Publish color light state (HA JSON light schema)
  523. if "colors" in status and len(status["colors"]) > 0:
  524. # colors is array of hex strings like ["#ff0000", "#00ff00", "#0000ff"]
  525. # Convert first color to RGB dict
  526. color_hex = status["colors"][0]
  527. if color_hex and color_hex.startswith('#') and len(color_hex) == 7:
  528. r = int(color_hex[1:3], 16)
  529. g = int(color_hex[3:5], 16)
  530. b = int(color_hex[5:7], 16)
  531. light_state = {
  532. "state": "ON" if is_powered else "OFF",
  533. "color_mode": "rgb",
  534. "color": {"r": r, "g": g, "b": b}
  535. }
  536. self.client.publish(f"{self.device_id}/led/color/state",
  537. json.dumps(light_state), retain=True)
  538. except Exception as e:
  539. logger.error(f"Error publishing LED state: {e}")
  540. def _publish_screen_state(self):
  541. """Helper to publish screen (LCD backlight) state to MQTT."""
  542. if not state.screen_controller or not state.screen_controller.available:
  543. return
  544. try:
  545. status = state.screen_controller.get_status()
  546. power_state = "ON" if status["power_on"] else "OFF"
  547. self.client.publish(f"{self.device_id}/screen/power/state", power_state, retain=True)
  548. self.client.publish(f"{self.device_id}/screen/brightness/state", status["brightness"], retain=True)
  549. except Exception as e:
  550. logger.error(f"Error publishing screen state: {e}")
  551. def update_state(self, current_file=None, is_running=None, playlist=None, playlist_name=None):
  552. """Update state in Home Assistant. Only publishes the attributes that are explicitly passed."""
  553. if not self.is_enabled:
  554. return
  555. # Update pattern state if current_file is provided
  556. if current_file is not None:
  557. self._publish_pattern_state(current_file)
  558. # Update running state and button availability if is_running is provided
  559. if is_running is not None:
  560. running_state = "running" if is_running else "paused" if self.state.current_playing_file else "idle"
  561. self._publish_running_state(running_state)
  562. # Update playlist state if playlist info is provided
  563. if playlist_name is not None:
  564. self._publish_playlist_state(playlist_name)
  565. def on_connect(self, client, userdata, flags, rc):
  566. """Callback when connected to MQTT broker."""
  567. if rc == 0:
  568. self._connected = True
  569. logger.info(f"MQTT Connection Accepted. client_id={self.client_id}")
  570. # Subscribe to command topics
  571. client.subscribe([
  572. (self.command_topic, 0),
  573. (self.pattern_select_topic, 0),
  574. (self.playlist_select_topic, 0),
  575. (self.speed_topic, 0),
  576. (f"{self.device_id}/command/stop", 0),
  577. (f"{self.device_id}/command/pause", 0),
  578. (f"{self.device_id}/command/play", 0),
  579. (f"{self.device_id}/command/skip", 0),
  580. (f"{self.device_id}/playlist/mode/set", 0),
  581. (f"{self.device_id}/playlist/pause_time/set", 0),
  582. (f"{self.device_id}/playlist/clear_pattern/set", 0),
  583. (f"{self.device_id}/playlist/shuffle/set", 0),
  584. (self.led_power_topic, 0),
  585. (self.led_brightness_topic, 0),
  586. (self.led_effect_topic, 0),
  587. (self.led_speed_topic, 0),
  588. (self.led_intensity_topic, 0),
  589. (self.led_color_topic, 0),
  590. (self.screen_power_topic, 0),
  591. (self.screen_brightness_topic, 0),
  592. ])
  593. # Publish discovery configurations
  594. self.setup_ha_discovery()
  595. else:
  596. self._connected = False
  597. error_messages = {
  598. 1: "Protocol level not supported",
  599. 2: "The client-identifier is not allowed by the server",
  600. 3: "The MQTT service is not available",
  601. 4: "The data in the username or password is malformed",
  602. 5: "The client is not authorized to connect"
  603. }
  604. error_msg = error_messages.get(rc, f"Unknown error code: {rc}")
  605. logger.error(f"MQTT Connection Refused. {error_msg}")
  606. def on_disconnect(self, client, userdata, rc):
  607. """Callback when disconnected from MQTT broker."""
  608. self._connected = False
  609. if rc == 0:
  610. logger.info("MQTT disconnected cleanly")
  611. else:
  612. # paho-mqtt MQTT_ERR_* codes — NOT broker CONNACK codes
  613. err_names = {
  614. 1: "NOMEM", 2: "PROTOCOL", 3: "INVAL", 4: "NO_CONN",
  615. 5: "CONN_REFUSED", 6: "NOT_FOUND", 7: "CONN_LOST",
  616. 8: "TLS", 9: "PAYLOAD_SIZE", 10: "NOT_SUPPORTED",
  617. 11: "AUTH", 12: "ACL_DENIED", 13: "UNKNOWN", 14: "ERRNO",
  618. }
  619. err_name = err_names.get(rc, f"rc={rc}")
  620. logger.warning(
  621. f"MQTT disconnected unexpectedly: {err_name} (rc={rc}) "
  622. f"client_id={self.client_id}"
  623. )
  624. def on_message(self, client, userdata, msg):
  625. """Callback when message is received."""
  626. try:
  627. if msg.topic == self.pattern_select_topic:
  628. from modules.core.pattern_manager import THETA_RHO_DIR
  629. # Handle pattern selection
  630. pattern_name = msg.payload.decode()
  631. if pattern_name in self.patterns:
  632. # Schedule the coroutine to run in the main event loop
  633. asyncio.run_coroutine_threadsafe(
  634. self.callback_registry['run_pattern'](file_path=f"{THETA_RHO_DIR}/{pattern_name}"),
  635. self.main_loop
  636. ).add_done_callback(
  637. lambda _: self._publish_pattern_state(None) # Clear pattern after execution
  638. )
  639. self.client.publish(f"{self.pattern_select_topic}/state", pattern_name, retain=True)
  640. elif msg.topic == self.playlist_select_topic:
  641. # Handle playlist selection
  642. playlist_name = msg.payload.decode()
  643. if playlist_name in self.playlists:
  644. # Schedule the coroutine to run in the main event loop
  645. asyncio.run_coroutine_threadsafe(
  646. self.callback_registry['run_playlist'](
  647. playlist_name=playlist_name,
  648. run_mode=self.state.playlist_mode,
  649. pause_time=self.state.pause_time,
  650. clear_pattern=self.state.clear_pattern,
  651. shuffle=self.state.shuffle
  652. ),
  653. self.main_loop
  654. ).add_done_callback(
  655. lambda _: self._publish_playlist_state(None) # Clear playlist after execution
  656. )
  657. self.client.publish(f"{self.playlist_select_topic}/state", playlist_name, retain=True)
  658. elif msg.topic == self.speed_topic:
  659. speed = int(msg.payload.decode())
  660. self.callback_registry['set_speed'](speed)
  661. elif msg.topic == f"{self.device_id}/command/stop":
  662. # Handle stop command
  663. callback = self.callback_registry['stop']
  664. if asyncio.iscoroutinefunction(callback):
  665. asyncio.run_coroutine_threadsafe(callback(), self.main_loop)
  666. else:
  667. callback()
  668. # Clear both pattern and playlist selections
  669. self._publish_pattern_state(None)
  670. self._publish_playlist_state(None)
  671. elif msg.topic == f"{self.device_id}/command/pause":
  672. # Handle pause command - only if in running state
  673. if bool(self.state.current_playing_file) and not self.state.pause_requested:
  674. # Check if callback is async or sync
  675. callback = self.callback_registry['pause']
  676. if asyncio.iscoroutinefunction(callback):
  677. asyncio.run_coroutine_threadsafe(callback(), self.main_loop)
  678. else:
  679. callback()
  680. elif msg.topic == f"{self.device_id}/command/play":
  681. # Handle play command - only if in paused state
  682. if bool(self.state.current_playing_file) and self.state.pause_requested:
  683. # Check if callback is async or sync
  684. callback = self.callback_registry['resume']
  685. if asyncio.iscoroutinefunction(callback):
  686. asyncio.run_coroutine_threadsafe(callback(), self.main_loop)
  687. else:
  688. callback()
  689. elif msg.topic == f"{self.device_id}/command/skip":
  690. # Handle skip command - only if a playlist is running
  691. if self.state.current_playlist:
  692. callback = self.callback_registry['skip']
  693. if asyncio.iscoroutinefunction(callback):
  694. asyncio.run_coroutine_threadsafe(callback(), self.main_loop)
  695. else:
  696. callback()
  697. elif msg.topic == f"{self.device_id}/playlist/mode/set":
  698. mode = msg.payload.decode()
  699. if mode in ["single", "loop"]:
  700. state.playlist_mode = mode
  701. self.client.publish(f"{self.device_id}/playlist/mode/state", mode, retain=True)
  702. elif msg.topic == f"{self.device_id}/playlist/pause_time/set":
  703. pause_time = float(msg.payload.decode())
  704. if 0 <= pause_time <= 60:
  705. state.pause_time = pause_time
  706. self.client.publish(f"{self.device_id}/playlist/pause_time/state", pause_time, retain=True)
  707. elif msg.topic == f"{self.device_id}/playlist/clear_pattern/set":
  708. clear_pattern = msg.payload.decode()
  709. if clear_pattern in ["none", "random", "adaptive", "clear_from_in", "clear_from_out", "clear_sideway"]:
  710. state.clear_pattern = clear_pattern
  711. self.client.publish(f"{self.device_id}/playlist/clear_pattern/state", clear_pattern, retain=True)
  712. elif msg.topic == f"{self.device_id}/playlist/shuffle/set":
  713. payload = msg.payload.decode()
  714. shuffle_value = payload == "ON"
  715. state.shuffle = shuffle_value
  716. self.client.publish(f"{self.device_id}/playlist/shuffle/state", payload, retain=True)
  717. elif msg.topic == self.led_power_topic:
  718. # Handle LED power command (DW LEDs only)
  719. payload = msg.payload.decode()
  720. if state.led_controller and state.led_provider == "dw_leds":
  721. power_state = 1 if payload == "ON" else 0
  722. state.led_controller.set_power(power_state)
  723. # Reset idle timeout when LEDs are manually powered on via MQTT (only if idle timeout is enabled)
  724. if payload == "ON" and state.dw_led_idle_timeout_enabled:
  725. state.dw_led_last_activity_time = time.time()
  726. logger.debug("LED activity time reset due to MQTT power on")
  727. self.client.publish(f"{self.device_id}/led/power/state", payload, retain=True)
  728. elif msg.topic == self.led_brightness_topic:
  729. # Handle LED brightness command (DW LEDs only)
  730. brightness = int(msg.payload.decode())
  731. if 0 <= brightness <= 100 and state.led_controller and state.led_provider == "dw_leds":
  732. controller = state.led_controller.get_controller()
  733. if controller and hasattr(controller, 'set_brightness'):
  734. # DW LED controller expects 0-100, converts internally to 0.0-1.0
  735. controller.set_brightness(brightness)
  736. self.client.publish(f"{self.device_id}/led/brightness/state", brightness, retain=True)
  737. elif msg.topic == self.led_effect_topic:
  738. # Handle LED effect command (DW LEDs only)
  739. effect_name = msg.payload.decode()
  740. if state.led_controller and state.led_provider == "dw_leds":
  741. # Map effect name to ID
  742. effect_map = {
  743. "Static": 0, "Blink": 1, "Breathe": 2, "Wipe": 3, "Fade": 4,
  744. "Scan": 5, "Dual Scan": 6, "Rainbow Cycle": 7, "Rainbow": 8,
  745. "Theater Chase": 9, "Running Lights": 10, "Random Color": 11,
  746. "Dynamic": 12, "Twinkle": 13, "Sparkle": 14, "Strobe": 15,
  747. "Fire": 16, "Comet": 17, "Chase": 18, "Police": 19, "Lightning": 20,
  748. "Fireworks": 21, "Ripple": 22, "Flow": 23, "Colorloop": 24,
  749. "Palette Flow": 25, "Gradient": 26, "Multi Strobe": 27, "Waves": 28,
  750. "BPM": 29, "Juggle": 30, "Meteor": 31, "Pride": 32, "Pacifica": 33,
  751. "Plasma": 34, "Dissolve": 35, "Glitter": 36, "Confetti": 37,
  752. "Sinelon": 38, "Candle": 39, "Aurora": 40, "Rain": 41,
  753. "Halloween": 42, "Noise": 43, "Funky Plank": 44
  754. }
  755. effect_id = effect_map.get(effect_name)
  756. if effect_id is not None:
  757. controller = state.led_controller.get_controller()
  758. if controller and hasattr(controller, 'set_effect'):
  759. controller.set_effect(effect_id)
  760. self.client.publish(f"{self.device_id}/led/effect/state", effect_name, retain=True)
  761. elif msg.topic == self.led_speed_topic:
  762. # Handle LED speed command (DW LEDs only)
  763. speed = int(msg.payload.decode())
  764. if 0 <= speed <= 255 and state.led_controller and state.led_provider == "dw_leds":
  765. controller = state.led_controller.get_controller()
  766. if controller and hasattr(controller, 'set_speed'):
  767. controller.set_speed(speed)
  768. self.client.publish(f"{self.device_id}/led/speed/state", speed, retain=True)
  769. elif msg.topic == self.led_intensity_topic:
  770. # Handle LED intensity command (DW LEDs only)
  771. intensity = int(msg.payload.decode())
  772. if 0 <= intensity <= 255 and state.led_controller and state.led_provider == "dw_leds":
  773. controller = state.led_controller.get_controller()
  774. if controller and hasattr(controller, 'set_intensity'):
  775. controller.set_intensity(intensity)
  776. self.client.publish(f"{self.device_id}/led/intensity/state", intensity, retain=True)
  777. elif msg.topic == self.led_color_topic:
  778. # Handle LED color light command (HA JSON light schema) (DW LEDs only)
  779. # HA sends e.g. {"state": "ON", "color": {"r": 255, "g": 0, "b": 0}}
  780. try:
  781. color_data = json.loads(msg.payload.decode())
  782. if state.led_controller and state.led_provider == "dw_leds":
  783. light_on = color_data.get('state', 'ON') == 'ON'
  784. state.led_controller.set_power(1 if light_on else 0)
  785. if light_on and state.dw_led_idle_timeout_enabled:
  786. state.dw_led_last_activity_time = time.time()
  787. # Accept nested JSON-schema color; fall back to legacy
  788. # top-level {"r","g","b"} for existing automations
  789. color = color_data.get('color')
  790. if color is None and all(k in color_data for k in ('r', 'g', 'b')):
  791. color = color_data
  792. light_state = {"state": "ON" if light_on else "OFF"}
  793. if light_on and color and all(k in color for k in ('r', 'g', 'b')):
  794. controller = state.led_controller.get_controller()
  795. if controller and hasattr(controller, 'set_color'):
  796. r, g, b = color['r'], color['g'], color['b']
  797. controller.set_color(r, g, b)
  798. light_state["color_mode"] = "rgb"
  799. light_state["color"] = {"r": r, "g": g, "b": b}
  800. self.client.publish(f"{self.device_id}/led/color/state",
  801. json.dumps(light_state), retain=True)
  802. except json.JSONDecodeError:
  803. logger.error(f"Invalid JSON for color command: {msg.payload}")
  804. elif msg.topic == self.screen_power_topic:
  805. # Handle screen power command
  806. payload = msg.payload.decode()
  807. if state.screen_controller and state.screen_controller.available:
  808. state.screen_controller.set_power(payload == "ON")
  809. self._publish_screen_state()
  810. elif msg.topic == self.screen_brightness_topic:
  811. # Handle screen brightness command
  812. brightness = int(msg.payload.decode())
  813. if state.screen_controller and state.screen_controller.available:
  814. state.screen_controller.set_brightness(brightness)
  815. self._publish_screen_state()
  816. else:
  817. # Handle other commands
  818. payload = json.loads(msg.payload.decode())
  819. command = payload.get('command')
  820. params = payload.get('params', {})
  821. if command in self.callback_registry:
  822. self.callback_registry[command](**params)
  823. else:
  824. logger.error(f"Unknown command received: {command}")
  825. except json.JSONDecodeError:
  826. logger.error(f"Invalid JSON payload received: {msg.payload}")
  827. except Exception as e:
  828. logger.error(f"Error processing MQTT message: {e}")
  829. def publish_status(self):
  830. """Publish status updates periodically."""
  831. while self.running:
  832. try:
  833. # Update all states
  834. self._publish_running_state()
  835. self._publish_pattern_state()
  836. self._publish_playlist_state()
  837. self._publish_serial_state()
  838. self._publish_progress_state()
  839. # Update speed state
  840. self.client.publish(f"{self.speed_topic}/state", self.state.speed, retain=True)
  841. # Update LED state
  842. self._publish_led_state()
  843. # Update screen state
  844. self._publish_screen_state()
  845. # Publish keepalive status
  846. status = {
  847. "timestamp": time.time(),
  848. "client_id": self.client_id
  849. }
  850. self.client.publish(self.status_topic, json.dumps(status))
  851. # Wait for next interval
  852. time.sleep(self.status_interval)
  853. except Exception as e:
  854. logger.error(f"Error publishing status: {e}")
  855. time.sleep(5) # Wait before retry
  856. def start(self) -> None:
  857. """Start the MQTT handler."""
  858. if not self.is_enabled:
  859. return
  860. try:
  861. self.client.connect(self.broker, self.port)
  862. self.client.loop_start()
  863. # Start status publishing thread
  864. self.running = True
  865. self.status_thread = threading.Thread(target=self.publish_status, daemon=True)
  866. self.status_thread.start()
  867. # Get initial pattern and playlist lists
  868. self.patterns = list_theta_rho_files()
  869. self.playlists = list_all_playlists()
  870. # Wait a bit for MQTT connection to establish
  871. time.sleep(1)
  872. # Publish initial states
  873. self._publish_running_state()
  874. self._publish_pattern_state()
  875. self._publish_playlist_state()
  876. self._publish_serial_state()
  877. self._publish_progress_state()
  878. self._publish_playlist_settings_state()
  879. self._publish_led_state()
  880. self._publish_screen_state()
  881. # Setup Home Assistant discovery
  882. self.setup_ha_discovery()
  883. logger.info("MQTT Handler started successfully")
  884. except Exception as e:
  885. logger.error(f"Failed to start MQTT Handler: {e}")
  886. def stop(self) -> None:
  887. """Stop the MQTT handler."""
  888. if not self.is_enabled:
  889. return
  890. # First stop the running flag to prevent new iterations
  891. self.running = False
  892. # Clean up status thread
  893. local_status_thread = self.status_thread # Keep a local reference
  894. if local_status_thread and local_status_thread.is_alive():
  895. try:
  896. local_status_thread.join(timeout=5)
  897. if local_status_thread.is_alive():
  898. logger.warning("MQTT status thread did not terminate cleanly")
  899. except Exception as e:
  900. logger.error(f"Error joining status thread: {e}")
  901. self.status_thread = None
  902. # Clean up MQTT client
  903. try:
  904. if hasattr(self, 'client'):
  905. self.client.loop_stop()
  906. self.client.disconnect()
  907. except Exception as e:
  908. logger.error(f"Error disconnecting MQTT client: {e}")
  909. # Clean up main loop reference
  910. self.main_loop = None
  911. logger.info("MQTT handler stopped")
  912. @property
  913. def is_enabled(self) -> bool:
  914. """Return whether MQTT functionality is enabled.
  915. MQTT is enabled if:
  916. 1. A broker address is configured (either via state or env var), AND
  917. 2. Either state.mqtt_enabled is True, OR no UI config exists (env-only mode)
  918. """
  919. # If no broker configured, MQTT is disabled
  920. if not self.broker:
  921. return False
  922. # If state has mqtt_enabled explicitly set (UI was used), respect that setting
  923. # If mqtt_broker is set in state, user configured via UI - use mqtt_enabled
  924. if state.mqtt_broker:
  925. return state.mqtt_enabled
  926. # Otherwise, broker came from env vars - enable if broker exists
  927. return True
  928. @property
  929. def is_connected(self) -> bool:
  930. """Return whether MQTT client is currently connected to the broker."""
  931. return self._connected and self.is_enabled