zencontrol-python 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1142 @@
1
+ import asyncio
2
+ import ipaddress
3
+ import time
4
+ import json
5
+ import yaml
6
+ import re
7
+ from pathlib import Path
8
+ from typing import Optional, Any
9
+ import zencontrol
10
+ from zencontrol import ZenController, ZenProtocol, ZenClient, ZenColour, ZenColourType, ZenProfile, ZenLight, ZenGroup, ZenButton, ZenMotionSensor, ZenSystemVariable, ZenTimeoutError, ZenAddressType
11
+ from zencontrol.api.types import Const as ApiConst
12
+ import aiomqtt
13
+ from colorama import Fore, Back, Style
14
+ import logging
15
+ from logging.handlers import RotatingFileHandler
16
+ import math
17
+ import pickle
18
+ import traceback
19
+
20
+ class RateLimiter:
21
+ """Rate limiter to control concurrent coroutine execution"""
22
+
23
+ def __init__(self, max_concurrent: int = 5, delay_between_batches: float = 0.1):
24
+ self.semaphore = asyncio.Semaphore(max_concurrent)
25
+ self.delay_between_batches = delay_between_batches
26
+ self.last_batch_time = 0
27
+
28
+ async def execute(self, coro):
29
+ """Execute a coroutine with rate limiting"""
30
+ # Ensure minimum delay between batches
31
+ current_time = time.time()
32
+ time_since_last_batch = current_time - self.last_batch_time
33
+ if time_since_last_batch < self.delay_between_batches:
34
+ await asyncio.sleep(self.delay_between_batches - time_since_last_batch)
35
+
36
+ async with self.semaphore:
37
+ self.last_batch_time = time.time()
38
+ return await coro
39
+
40
+ async def execute_batch(self, coros, batch_size: int = None):
41
+ """Execute multiple coroutines in controlled batches"""
42
+ if batch_size is None:
43
+ batch_size = self.semaphore._value # Use semaphore limit as batch size
44
+
45
+ results = []
46
+ for i in range(0, len(coros), batch_size):
47
+ batch = coros[i:i + batch_size]
48
+ batch_results = await asyncio.gather(*[self.execute(coro) for coro in batch])
49
+ results.extend(batch_results)
50
+
51
+ return results
52
+
53
+ class Const:
54
+
55
+ STARTUP_POLL_DELAY = 10
56
+ STARTUP_RETRY_MIN_DELAY = 5
57
+ STARTUP_RETRY_MAX_DELAY = 60
58
+
59
+ # MQTT settings
60
+ MQTT_RECONNECT_MIN_DELAY = 1
61
+ MQTT_RECONNECT_MAX_DELAY = 10
62
+ MQTT_SERVICE_PREFIX = "zencontrol-python"
63
+
64
+ # Logging
65
+ LOG_FILE = 'mqtt.log'
66
+ DEBUG_FILE = 'mqtt.debug.log'
67
+ LOG_MAX_BYTES = 5 * 1024 * 1024 # 5MB
68
+ LOG_BACKUP_COUNT = 5
69
+
70
+ # Logarithmic constants
71
+
72
+ # Based on curves seen in DALI documentation
73
+ # LOG_A = 44.74
74
+ # LOG_B = 37.77
75
+
76
+ # Attempt at a better fit, less extreme
77
+ LOG_A = -59.53
78
+ LOG_B = 56.58
79
+
80
+ # Default hold time for motion sensors, in seconds
81
+ DEFAULT_HOLD_TIME = 15
82
+
83
+ # Default long press time for buttons, in msec
84
+ DEFAULT_LONG_PRESS_TIME = 1000
85
+
86
+ class ZenMQTTBridge:
87
+ """Bridge between Zen lighting control system and MQTT/Home Assistant.
88
+
89
+ Handles bidirectional communication between Zen lighting controllers and
90
+ Home Assistant via MQTT, including auto-discovery and state management.
91
+ """
92
+
93
+ # ================================
94
+ # INIT & RUN
95
+ # ================================
96
+
97
+ def __init__(self, config_path: str | None = None) -> None:
98
+ self.example_dir = Path(__file__).resolve().parent
99
+ config_path = config_path or str(self.example_dir / "config.yaml")
100
+ self.config: dict[str, Any]
101
+ with open(config_path) as f:
102
+ self.config = yaml.safe_load(f)
103
+
104
+ self.logger: logging.Logger
105
+ self.discovery_prefix: str
106
+ self.control: list[ZenController] = []
107
+ self.zen: zencontrol.ZenControl
108
+ self.mqttc: aiomqtt.Client
109
+ self.setup_started: bool = False
110
+ self.setup_complete: bool = False
111
+ self.system_variables: list[ZenSystemVariable] = []
112
+ self.sv_config: list[dict] = []
113
+
114
+ self.config_topics_to_delete: list[str] = [] # List of topics to delete after completing setup
115
+ self.topic_object: dict[str, Any] = {} # Map of topics to objects
116
+ self._zen_online = False
117
+
118
+ # Rate limiter for controlling concurrent operations
119
+ self.rate_limiter = RateLimiter(max_concurrent=5, delay_between_batches=0.1)
120
+
121
+ self.global_config: dict[str, Any] = {
122
+ "origin": {
123
+ "name": "zencontrol-python",
124
+ "sw": zencontrol.__version__,
125
+ "url": "https://github.com/sjwright/zencontrol-python"
126
+ }
127
+ }
128
+
129
+ async def run(self) -> None:
130
+ self.setup_logging()
131
+ self.setup_config()
132
+ self.logger.info("==================================== Starting ZenMQTTBridge ====================================")
133
+ await self.setup_zen()
134
+ await self.setup_mqtt()
135
+
136
+ # Start MQTT first so HA can see availability while Zen controllers come up
137
+ self.mqtt_task = asyncio.create_task(self._mqtt_message_handler())
138
+ await asyncio.sleep(0.5)
139
+
140
+ await self._wait_controllers_ready()
141
+
142
+ # Generate config topics
143
+ self.setup_started = True
144
+ await self.setup_profiles()
145
+ await self.setup_lights()
146
+ await self.setup_groups()
147
+ await self.setup_buttons()
148
+ await self.setup_motion_sensors()
149
+ await self.setup_system_variables()
150
+ await self.delete_retained_topics()
151
+ self.setup_complete = True
152
+
153
+ # Begin listening for zen events (reconnects automatically on loss)
154
+ await self.zen.start()
155
+
156
+ with open(self.example_dir / "cache.pkl", "wb") as f:
157
+ pickle.dump(self.zen.cache, f)
158
+
159
+
160
+
161
+ """
162
+ controllers:
163
+ - id: 1
164
+ name: zen1
165
+ label: Zencontrol 1
166
+ groups:
167
+ - id: 0
168
+ name: All Lights
169
+ hide_group: True
170
+ hide_members: True
171
+ - id: 1
172
+ name: Ground
173
+ lights:
174
+ - ecg: 14
175
+ name: Lounge NW
176
+ room: Downstairs
177
+ groups:
178
+ - 15
179
+ - ecg: 21
180
+ name: Guest NE
181
+ room: Guest Bedroom
182
+ groups:
183
+ - 15
184
+ - ecg: 42
185
+ name: Garage
186
+ override_name: Garage Downlights
187
+ room: Garage
188
+ """
189
+
190
+ clist = []
191
+ for c in sorted(self.control, key=lambda x: x.id):
192
+ c: ZenController
193
+ glist = []
194
+ llist = []
195
+ for g in sorted(c.groups, key=lambda x: x.address.number):
196
+ g: ZenGroup
197
+ glist.append({
198
+ "id": g.address.number,
199
+ "label": g.label,
200
+ })
201
+ for l in sorted(c.lights, key=lambda x: x.address.number):
202
+ l: ZenLight
203
+ llist.append({
204
+ "ecg": l.address.number,
205
+ "label": l.label,
206
+ })
207
+ clist.append({
208
+ "id": c.id,
209
+ "name": c.name,
210
+ "label": c.label,
211
+ "groups": glist,
212
+ "lights": llist
213
+ })
214
+ # Dump to yaml file
215
+ with open(self.example_dir / "dump.yaml", "w") as f:
216
+ yaml.dump(clist, f, sort_keys=False)
217
+
218
+ while True:
219
+ await asyncio.sleep(1)
220
+
221
+ async def _wait_controllers_ready(self) -> None:
222
+ """Poll controllers until ready; retry with backoff instead of aborting."""
223
+ delay = Const.STARTUP_RETRY_MIN_DELAY
224
+ attempt = 0
225
+ while True:
226
+ attempt += 1
227
+ try:
228
+ for ctrl in self.control:
229
+ self.logger.info(
230
+ f"Connecting to Zen controller {ctrl.name} on {ctrl.host}:{ctrl.port}..."
231
+ )
232
+ while True:
233
+ ready = await ctrl.is_controller_ready()
234
+ if ready is None:
235
+ raise ZenTimeoutError(
236
+ f"cannot query startup state for {ctrl.name}"
237
+ )
238
+ if ready:
239
+ break
240
+ self.logger.info(f"Controller {ctrl.label} still starting up...")
241
+ await asyncio.sleep(Const.STARTUP_POLL_DELAY)
242
+ await ctrl.interview()
243
+ self.logger.info("All Zen controllers ready")
244
+ return
245
+ except (ZenTimeoutError, OSError, ConnectionError) as e:
246
+ self.logger.warning(
247
+ "Zen controllers not ready (attempt %d): %s; retrying in %ds",
248
+ attempt,
249
+ e,
250
+ delay,
251
+ )
252
+ except Exception as e:
253
+ self.logger.warning(
254
+ "Error reaching Zen controllers (attempt %d): %s; retrying in %ds",
255
+ attempt,
256
+ e,
257
+ delay,
258
+ )
259
+ self._zen_online = False
260
+ await self._publish_zen_availability()
261
+ for ctrl in self.control:
262
+ ctrl.refresh_ip()
263
+ client = ctrl.client
264
+ ctrl.client = None
265
+ if client is not None:
266
+ try:
267
+ await client.close()
268
+ except Exception:
269
+ pass
270
+ await asyncio.sleep(delay)
271
+ delay = min(delay * 2, Const.STARTUP_RETRY_MAX_DELAY)
272
+
273
+ async def stop(self) -> None:
274
+ """Clean shutdown of the bridge"""
275
+ if hasattr(self, "zen"):
276
+ await self.zen.aclose()
277
+ if hasattr(self, 'mqtt_task'):
278
+ self.mqtt_task.cancel()
279
+ try:
280
+ await self.mqtt_task
281
+ except asyncio.CancelledError:
282
+ pass
283
+ # MQTT connection is automatically closed by the context manager
284
+
285
+
286
+ # ================================
287
+ # CONFIG
288
+ # ================================
289
+
290
+ def setup_config(self) -> None:
291
+ try:
292
+
293
+ # Improved config validation
294
+ required_sections = ['homeassistant', 'mqtt', 'zencontrol']
295
+ missing = [s for s in required_sections if s not in self.config]
296
+ if missing:
297
+ raise ValueError(f"Missing required config sections: {', '.join(missing)}")
298
+
299
+ # Validate MQTT config
300
+ mqtt_required = ['host', 'port', 'user', 'password', 'keepalive']
301
+ missing = [f for f in mqtt_required if f not in self.config['mqtt']]
302
+ if missing:
303
+ raise ValueError(f"Missing MQTT config fields: {', '.join(missing)}")
304
+
305
+ # Validate Zencontrol config
306
+ if not isinstance(self.config['zencontrol'], list):
307
+ raise ValueError("zencontrol config must be a list")
308
+
309
+ zencontrol_required = ['id', 'name', 'label', 'mac', 'host', 'port']
310
+ for i, config in enumerate(self.config['zencontrol']):
311
+
312
+ # Check for required fields
313
+ missing = [f for f in zencontrol_required if f not in config]
314
+ if missing:
315
+ raise ValueError(f"Missing Zencontrol config fields in entry {i}: {', '.join(missing)}")
316
+
317
+ # Validate name format (alphanumeric only)
318
+ name = config['name']
319
+ if not re.match(r'^[A-Za-z0-9]+$', name):
320
+ raise ValueError(f"Invalid name format in Zencontrol config {i}: {name}. Use only letters and numbers.")
321
+
322
+ # Validate MAC address format (xx:xx:xx:xx:xx:xx)
323
+ mac = config['mac']
324
+ if not re.match(r'^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$', mac):
325
+ raise ValueError(f"Invalid MAC address format in Zencontrol config {i}: {mac}")
326
+
327
+ # Validate IP address
328
+ host = config['host']
329
+ try:
330
+ ipaddress.ip_address(host)
331
+ except ValueError:
332
+ raise ValueError(f"Invalid IP address in Zencontrol config {i}: {host}")
333
+
334
+ # Validate port number (1-65535)
335
+ port = config['port']
336
+ if not isinstance(port, int) or port < 1 or port > 65535:
337
+ raise ValueError(f"Invalid port number in Zencontrol config {i}: {port}")
338
+
339
+ except ValueError as e:
340
+ self.logger.error(f"Failed to load config file: {e}")
341
+ raise
342
+
343
+ # ================================
344
+ # LOGGING
345
+ # ================================
346
+
347
+ def setup_logging(self) -> None:
348
+ """Configure logging with both file and console handlers."""
349
+ self.logger = logging.getLogger('ZenMQTTBridge')
350
+ self.logger.setLevel(logging.DEBUG)
351
+
352
+ # File handler
353
+ file_handler = RotatingFileHandler(
354
+ Const.LOG_FILE,
355
+ maxBytes=Const.LOG_MAX_BYTES,
356
+ backupCount=Const.LOG_BACKUP_COUNT
357
+ )
358
+ # Exclude debug messages
359
+ file_handler.addFilter(lambda record: record.levelno != logging.DEBUG)
360
+ file_handler.setFormatter(logging.Formatter(fmt="%(asctime)s\t%(levelname)s\t%(message)s", datefmt="%Y-%m-%d %H:%M:%S"))
361
+ self.logger.addHandler(file_handler)
362
+
363
+ # Debug only handler
364
+ debug_handler = logging.FileHandler(Const.DEBUG_FILE)
365
+ debug_handler.setLevel(logging.DEBUG)
366
+ debug_handler.setFormatter(logging.Formatter(fmt="%(asctime)s\t%(levelname)s\t%(message)s", datefmt="%Y-%m-%d %H:%M:%S"))
367
+ self.logger.addHandler(debug_handler)
368
+
369
+ # Console handler
370
+ console_handler = logging.StreamHandler()
371
+ console_handler.setLevel(logging.INFO)
372
+ console_handler.setFormatter(logging.Formatter(
373
+ '%(asctime)s.%(msecs)03d %(levelname)s: %(message)s', datefmt='%H:%M:%S'
374
+ ))
375
+ self.logger.addHandler(console_handler)
376
+
377
+ # ================================
378
+ # ZEN
379
+ # ================================
380
+
381
+ def _load_cache(self) -> dict:
382
+ """Load and validate persisted protocol query cache, or return empty dict."""
383
+ cache_path = self.example_dir / "cache.pkl"
384
+ try:
385
+ with open(cache_path, "rb") as infile:
386
+ raw = pickle.load(infile)
387
+ except FileNotFoundError:
388
+ return {}
389
+ except (OSError, pickle.UnpicklingError, EOFError, AttributeError) as e:
390
+ self.logger.warning(f"Ignoring corrupt cache {cache_path}: {e}")
391
+ return {}
392
+
393
+ if not isinstance(raw, dict):
394
+ self.logger.warning(f"Ignoring invalid cache {cache_path}: expected dict, got {type(raw).__name__}")
395
+ return {}
396
+
397
+ cache: dict = {}
398
+ now = time.time()
399
+ for key, entry in raw.items():
400
+ if not isinstance(key, bytes) or not isinstance(entry, dict):
401
+ continue
402
+ data = entry.get("d")
403
+ resp_type = entry.get("c")
404
+ timestamp = entry.get("t")
405
+ if not isinstance(data, bytes) or not isinstance(resp_type, int) or not isinstance(timestamp, (int, float)):
406
+ continue
407
+ if now - timestamp >= ApiConst.CACHE_TIMEOUT:
408
+ continue
409
+ cache[key] = {"d": data, "c": resp_type, "t": timestamp}
410
+
411
+ skipped = len(raw) - len(cache)
412
+ if skipped:
413
+ self.logger.info(f"Loaded {len(cache)} cache entries from {cache_path} ({skipped} invalid or expired)")
414
+ elif cache:
415
+ self.logger.info(f"Loaded {len(cache)} cache entries from {cache_path}")
416
+ return cache
417
+
418
+ async def setup_zen(self) -> None:
419
+ try:
420
+ cache = self._load_cache()
421
+ self.zen: zencontrol.ZenControl = zencontrol.ZenControl(logger=self.logger, print_traffic=True, cache=cache)
422
+ self.zen.on_connect = self._zen_on_connect
423
+ self.zen.on_disconnect = self._zen_on_disconnect
424
+ self.zen.profile_change = self._zen_profile_change
425
+ self.zen.group_change = self._zen_group_change
426
+ self.zen.light_change = self._zen_light_change
427
+ self.zen.button_press = self._zen_button_press
428
+ self.zen.button_long_press = self._zen_button_long_press
429
+ self.zen.motion_event = self._zen_motion_event
430
+ self.zen.system_variable_change = self._zen_system_variable_change
431
+ for config in self.config['zencontrol']:
432
+ ctrl = self.zen.add_controller(
433
+ id=config['id'],
434
+ name=config['name'],
435
+ label=config['label'],
436
+ host=config['host'],
437
+ port=config['port'],
438
+ mac=config['mac']
439
+ )
440
+ # Add to my own internal list
441
+ self.control.append(ctrl)
442
+ # Add system variables to list
443
+ for sv in config.get('system_variables', []):
444
+ sv['controller'] = ctrl
445
+ self.sv_config.append(sv)
446
+ except Exception as e:
447
+ self.logger.error(f"Failed to initialize Zen: {e}")
448
+ raise
449
+
450
+ async def _zen_on_connect(self) -> None:
451
+ self.logger.info("Connected to Zen controllers")
452
+ self._zen_online = True
453
+ await self._publish_zen_availability()
454
+
455
+ async def _zen_on_disconnect(self) -> None:
456
+ self.logger.info("Disconnected from Zen controllers")
457
+ self._zen_online = False
458
+ await self._publish_zen_availability()
459
+
460
+ async def _publish_zen_availability(self) -> None:
461
+ """Publish retained availability reflecting Zen event-listener state."""
462
+ mqttc = getattr(self, "mqttc", None)
463
+ if mqttc is None:
464
+ return
465
+ payload = "online" if self._zen_online else "offline"
466
+ for ctrl in self.control:
467
+ try:
468
+ await mqttc.publish(
469
+ f"{Const.MQTT_SERVICE_PREFIX}/{ctrl.name}/availability",
470
+ payload,
471
+ retain=True,
472
+ )
473
+ except Exception as e:
474
+ self.logger.debug(f"Failed to publish availability for {ctrl.name}: {e}")
475
+
476
+ # ================================
477
+ # ZEN PUBLISHING
478
+ # ================================
479
+
480
+ def kelvin_to_mireds(self, kelvin: int) -> int:
481
+ """Convert color temperature in Kelvin to Mireds (micro reciprocal degrees)"""
482
+ if kelvin <= 0: return 0
483
+ return round(1000000 / kelvin)
484
+
485
+ def mireds_to_kelvin(self, mireds: int) -> int:
486
+ """Convert Mireds (micro reciprocal degrees) to color temperature in Kelvin"""
487
+ if mireds <= 0: return 0
488
+ return round(1000000 / mireds)
489
+
490
+ def arc_to_brightness(self, value):
491
+ """Convert logarithmic DALI ARC value (0-254) to linear brightness (0-255)"""
492
+ if value <= 0: return 0
493
+ X = round(math.exp((value - Const.LOG_A) / Const.LOG_B))
494
+ # print(f"arc_to_brightness({value}) = {X}")
495
+ return X
496
+
497
+ def brightness_to_arc(self, value):
498
+ """Convert linear brightness (0-255) to logarithmic DALI ARC value (0-254)"""
499
+ if value <= 0: return 0
500
+ X = round(Const.LOG_A + Const.LOG_B * math.log(value))
501
+ # print(f"brightness_to_arc({value}) = {X}")
502
+ return X
503
+
504
+ # ================================
505
+ # MQTT
506
+ # ================================
507
+
508
+ async def setup_mqtt(self) -> None:
509
+ """Set up MQTT configuration. Connection is handled by the message handler."""
510
+ try:
511
+ self.discovery_prefix = self.config['homeassistant']['discovery_prefix']
512
+ self.logger.info("MQTT configuration set up successfully")
513
+
514
+ except Exception as e:
515
+ self.logger.error(f"Failed to set up MQTT configuration: {e}")
516
+ raise
517
+
518
+ async def _mqtt_message_handler(self) -> None:
519
+ """Handle incoming MQTT messages with automatic reconnection per aiomqtt docs."""
520
+ interval = Const.MQTT_RECONNECT_MIN_DELAY
521
+
522
+ while True:
523
+ try:
524
+ # Create a new client for each connection attempt
525
+ mqtt_config = self.config["mqtt"]
526
+
527
+ # Create will messages for availability
528
+ will_messages = []
529
+ for ctrl in self.control:
530
+ will_messages.append(aiomqtt.Will(
531
+ topic=f"{Const.MQTT_SERVICE_PREFIX}/{ctrl.name}/availability",
532
+ payload="offline",
533
+ retain=True
534
+ ))
535
+
536
+ # Create client with will messages
537
+ if will_messages:
538
+ client = aiomqtt.Client(
539
+ hostname=mqtt_config["host"],
540
+ port=mqtt_config["port"],
541
+ username=mqtt_config["user"],
542
+ password=mqtt_config["password"],
543
+ keepalive=mqtt_config["keepalive"],
544
+ will=will_messages[0] # aiomqtt only supports one will message
545
+ )
546
+ else:
547
+ client = aiomqtt.Client(
548
+ hostname=mqtt_config["host"],
549
+ port=mqtt_config["port"],
550
+ username=mqtt_config["user"],
551
+ password=mqtt_config["password"],
552
+ keepalive=mqtt_config["keepalive"]
553
+ )
554
+
555
+ # Use the client context manager for automatic connection handling
556
+ async with client:
557
+ self.mqttc = client # Store reference for publishing methods
558
+
559
+ # Subscribe to topics
560
+ for ctrl in self.control:
561
+ await client.subscribe(f"{self.discovery_prefix}/light/{ctrl.name}/#")
562
+ await client.subscribe(f"{self.discovery_prefix}/binary_sensor/{ctrl.name}/#")
563
+ await client.subscribe(f"{self.discovery_prefix}/sensor/{ctrl.name}/#")
564
+ await client.subscribe(f"{self.discovery_prefix}/switch/{ctrl.name}/#")
565
+ await client.subscribe(f"{self.discovery_prefix}/event/{ctrl.name}/#")
566
+ await client.subscribe(f"{self.discovery_prefix}/select/{ctrl.name}/#")
567
+ await client.subscribe(f"{self.discovery_prefix}/device_automation/{ctrl.name}/#")
568
+
569
+ self.logger.info("Successfully connected to MQTT broker")
570
+ # Reflect current Zen state (offline until zen.start / reconnect)
571
+ await self._publish_zen_availability()
572
+
573
+ # Process messages
574
+ async for message in client.messages:
575
+ await self._mqtt_on_message(message)
576
+
577
+ except asyncio.CancelledError:
578
+ self.logger.info("MQTT message handler cancelled")
579
+ break
580
+ except aiomqtt.MqttError as e:
581
+ self.logger.warning(f"MQTT connection lost: {e}")
582
+ self.logger.info(f"Reconnecting in {interval} seconds...")
583
+ await asyncio.sleep(interval)
584
+ # Reset interval for next attempt
585
+ interval = Const.MQTT_RECONNECT_MIN_DELAY
586
+ except Exception as e:
587
+ self.logger.error(f"Unexpected error in MQTT message handler: {e}")
588
+ self.logger.info(f"Retrying in {interval} seconds...")
589
+ await asyncio.sleep(interval)
590
+ # Exponential backoff for unexpected errors
591
+ interval = min(interval * 2, Const.MQTT_RECONNECT_MAX_DELAY)
592
+
593
+ async def _mqtt_on_message(self, msg: aiomqtt.Message) -> None:
594
+ """Handle incoming MQTT messages with improved error handling."""
595
+ try:
596
+
597
+ # Debug
598
+ payload_str = msg.payload.decode('UTF-8') if msg.payload else ""
599
+ topic_str = str(msg.topic)
600
+ # self.logger.debug(f"MQTT received - {topic_str}: {payload_str}")
601
+ # print(Fore.YELLOW + f"MQTT received - {topic_str}: " + Style.DIM + f"{payload_str}" + Style.RESET_ALL)
602
+
603
+ # Get the last part of the topic
604
+ command = topic_str.split('/')[-1]
605
+
606
+ # Config commands are ignored
607
+ if command == "config":
608
+ # If we haven't started setup yet, it's a retained topic, so add to delete list
609
+ if not self.setup_started:
610
+ self.config_topics_to_delete.append(topic_str)
611
+ return
612
+
613
+ # State commands are always ignored
614
+ if command == "state":
615
+ return
616
+
617
+ # Only set commands from here onwards
618
+ if command != "set":
619
+ return
620
+
621
+ # Get the base topic from the message
622
+ base_topic = topic_str.rsplit('/', 1)[0]
623
+
624
+ # Find the matching object in our map
625
+ target_object = self.topic_object.get(base_topic)
626
+
627
+ # If we don't have an object, ignore the message
628
+ if not target_object:
629
+ self.logger.debug(f"No matching object found for {base_topic}")
630
+ return
631
+
632
+ # If setup is not complete, ignore the message
633
+ if not self.setup_complete:
634
+ self.logger.debug(f"Setup not complete, ignoring message {msg.topic}")
635
+ return
636
+
637
+ # Convert payload to str
638
+ payload = msg.payload.decode('UTF-8') if msg.payload else ""
639
+
640
+ # Match on object type
641
+ if isinstance(target_object, ZenController):
642
+ await self._mqtt_profile_change(target_object, payload)
643
+ elif isinstance(target_object, ZenGroup):
644
+ if "/select/" in base_topic:
645
+ await self._mqtt_groupscene_change(target_object, payload)
646
+ elif "/light/" in base_topic:
647
+ await self._mqtt_light_change(target_object, json.loads(payload))
648
+ elif isinstance(target_object, ZenLight):
649
+ await self._mqtt_light_change(target_object, json.loads(payload))
650
+ elif isinstance(target_object, ZenSystemVariable):
651
+ await self._mqtt_system_variable_change(target_object, payload)
652
+ elif isinstance(target_object, ZenMotionSensor):
653
+ return # Read only
654
+ else:
655
+ print(f"Unknown object type found in self.topic_object: {type(target_object)}")
656
+
657
+ except json.JSONDecodeError as e:
658
+ self.logger.error(f"Invalid JSON payload: {e}")
659
+
660
+ # ================================
661
+ # MQTT PUBLISHING
662
+ # ================================
663
+
664
+ def _client_data_for_object(self, object: Any, component: str, attributes: dict = {}) -> dict:
665
+ if isinstance(object, ZenController):
666
+ ctrl = object
667
+ serial = ctrl.mac
668
+ mqtt_target = "profile"
669
+ elif isinstance(object, ZenGroup): # ZenGroup inherits from ZenLight, so needs to be before ZenLight
670
+ group = object
671
+ addr = group.address
672
+ ctrl = addr.controller
673
+ serial = ""
674
+ mqtt_target = f"group{addr.number}"
675
+ elif isinstance(object, ZenLight):
676
+ light = object
677
+ addr = light.address
678
+ ctrl = addr.controller
679
+ serial = light.serial
680
+ mqtt_target = f"ecg{addr.number}"
681
+ elif isinstance(object, ZenButton):
682
+ button = object
683
+ inst = button.instance
684
+ addr = inst.address
685
+ ctrl = addr.controller
686
+ serial = button.serial
687
+ mqtt_target = f"ecd{addr.number}_inst{inst.number}"
688
+ elif isinstance(object, ZenMotionSensor):
689
+ sensor = object
690
+ inst = sensor.instance
691
+ addr = inst.address
692
+ ctrl = addr.controller
693
+ serial = sensor.serial
694
+ mqtt_target = f"ecd{addr.number}_inst{inst.number}"
695
+ elif isinstance(object, ZenSystemVariable):
696
+ sysvar = object
697
+ ctrl = sysvar.controller
698
+ serial = component
699
+ mqtt_target = f"sv{sysvar.id}"
700
+ else:
701
+ raise ValueError(f"Unknown object type: {type(object)}")
702
+ # Build default attributes - passed attributes can override default_entity_id
703
+ default_attrs = {
704
+ "component": component,
705
+ "default_entity_id": f"{component}.{ctrl.name}_{mqtt_target}",
706
+ "unique_id": f"{ctrl.name}_{mqtt_target}_{serial}",
707
+ "device": {
708
+ "manufacturer": "Zencontrol",
709
+ "identifiers": f"zencontrol-{ctrl.name}",
710
+ "sw_version": ctrl.version,
711
+ "name": ctrl.label,
712
+ },
713
+ "availability_topic": f"{Const.MQTT_SERVICE_PREFIX}/{ctrl.name}/availability",
714
+ }
715
+ # Merge: attributes take precedence over default_attrs
716
+ object.client_data[component] = object.client_data.get(component, {}) | {
717
+ "component": component,
718
+ "attributes": default_attrs | attributes,
719
+ "mqtt_target": mqtt_target,
720
+ "mqtt_topic": f"{self.discovery_prefix}/{component}/{ctrl.name}/{mqtt_target}",
721
+ }
722
+ return object.client_data[component]
723
+
724
+ async def _publish_config(self, topic: str, config: dict, object: Any = None, retain: bool = True) -> None:
725
+ if object:
726
+ self.topic_object[topic] = object
727
+ config_topic = f"{topic}/config"
728
+ config_json = json.dumps(config)
729
+ await self.mqttc.publish(config_topic, config_json, retain=retain)
730
+ if config_topic in self.config_topics_to_delete: self.config_topics_to_delete.remove(config_topic)
731
+ # self.logger.debug(f"MQTT sent - {topic}/config: {config_json}")
732
+ # print(Fore.LIGHTRED_EX + f"MQTT sent - {topic}/config: " + Style.DIM + f"{config_json}" + Style.RESET_ALL)
733
+
734
+ async def _publish_state(self, topic: str, state: str|dict|None, retain: bool = False) -> None:
735
+ if isinstance(state, dict): state = json.dumps(state)
736
+ await self.mqttc.publish(f"{topic}/state", state, retain=retain)
737
+
738
+ if "/light/" in topic or "/group/" in topic:
739
+ self.logger.debug(f"MQTT sent - {topic}/state: {state}")
740
+ # self.logger.debug(f"MQTT sent - {topic}/state: {state}")
741
+ # print(Fore.LIGHTRED_EX + f"MQTT sent - {topic}/state: " + Style.DIM + f"{state}" + Style.RESET_ALL)
742
+
743
+ async def _publish_event(self, topic: str, event: str, retain: bool = False) -> None:
744
+ await self.mqttc.publish(f"{topic}/event", event, retain=retain)
745
+ # self.logger.debug(f"MQTT sent - {topic}/event: {event}")
746
+ # print(Fore.LIGHTRED_EX + f"MQTT sent - {topic}/event: " + Style.DIM + f"{event}" + Style.RESET_ALL)
747
+
748
+ async def delete_retained_topics(self) -> None:
749
+ for topic in self.config_topics_to_delete:
750
+ await self.mqttc.publish(topic, None, retain=True)
751
+ # self.logger.debug(f"MQTT deleted - {topic}")
752
+ # print(Fore.RED + f"•• MQTT DELETED •• " + Style.DIM + f"{topic}" + Style.RESET_ALL)
753
+
754
+ # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
755
+
756
+ # ================================
757
+ # PROFILES
758
+ # ================================
759
+
760
+ async def setup_profiles(self) -> set[ZenProfile]:
761
+ """Initialize all profiles for Home Assistant auto-discovery."""
762
+ all_profiles = set()
763
+ for ctrl in self.control:
764
+ client_data = self._client_data_for_object(ctrl, "select")
765
+ mqtt_topic = client_data['mqtt_topic']
766
+ profiles = await self.zen.get_profiles(ctrl)
767
+ config_dict = self.global_config | client_data.get("attributes",{}) | {
768
+ "name": f"{ctrl.label} Profile",
769
+ "command_topic": f"{mqtt_topic}/set",
770
+ "state_topic": f"{mqtt_topic}/state",
771
+ "options": [
772
+ profile.label for profile in profiles
773
+ ]
774
+ }
775
+ await self._publish_config(mqtt_topic, config_dict, object=ctrl)
776
+ await self._publish_state(mqtt_topic, ctrl.profile.label)
777
+ all_profiles.update(profiles)
778
+ return all_profiles
779
+
780
+ async def _mqtt_profile_change(self, ctrl: ZenController, payload: str) -> None:
781
+ print(f"HA asking to change profile of {ctrl.name} to {payload}")
782
+ await ctrl.switch_to_profile(payload)
783
+
784
+ async def _zen_profile_change(self, profile: ZenProfile) -> None:
785
+ print(f"Zen to HA: profile changed to {profile}")
786
+
787
+ ctrl = profile.controller
788
+ mqtt_topic = ctrl.client_data.get("select", {}).get('mqtt_topic', None)
789
+ if not mqtt_topic:
790
+ self.logger.error(f"Controller {ctrl} has no MQTT topic")
791
+ return
792
+
793
+ await self._publish_state(mqtt_topic, profile.label)
794
+
795
+ # ================================
796
+ # LIGHTS
797
+ # ================================
798
+
799
+ async def setup_lights(self) -> set[ZenLight]:
800
+ """Initialize all lights for Home Assistant auto-discovery."""
801
+ lights = await self.zen.get_lights()
802
+
803
+ # First, publish all configs (this is fast and doesn't need rate limiting)
804
+ for light in lights:
805
+ client_data = self._client_data_for_object(light, "light")
806
+ mqtt_topic = client_data['mqtt_topic']
807
+ config_dict = self.global_config | client_data.get("attributes",{}) | {
808
+ "name": light.label,
809
+ "schema": "json",
810
+ "payload_off": "OFF",
811
+ "payload_on": "ON",
812
+ "command_topic": f"{mqtt_topic}/set",
813
+ "state_topic": f"{mqtt_topic}/state",
814
+ "json_attributes_topic": f"{mqtt_topic}/attributes",
815
+ "effect": False,
816
+ "retain": False,
817
+ "brightness": light.features["brightness"],
818
+ "supported_color_modes": [],
819
+ }
820
+ if light.features["RGBWW"]:
821
+ config_dict["supported_color_modes"] = ["rgbww"]
822
+ elif light.features["RGBW"]:
823
+ config_dict["supported_color_modes"] = ["rgbw"]
824
+ elif light.features["RGB"]:
825
+ config_dict["supported_color_modes"] = ["rgb"]
826
+ elif light.features["temperature"]:
827
+ config_dict["supported_color_modes"] = ["color_temp"]
828
+ config_dict["min_mireds"] = self.kelvin_to_mireds(light.properties["max_kelvin"]) if light.properties["max_kelvin"] is not None else None
829
+ config_dict["max_mireds"] = self.kelvin_to_mireds(light.properties["min_kelvin"]) if light.properties["min_kelvin"] is not None else None
830
+ elif light.features["brightness"]:
831
+ config_dict["supported_color_modes"] = ["brightness"]
832
+ else:
833
+ config_dict["supported_color_modes"] = ["onoff"]
834
+
835
+ await self._publish_config(mqtt_topic, config_dict, object=light)
836
+
837
+ # Then, sync all lights with rate limiting to prevent server overload
838
+ sync_coros = [light.refresh_state_from_controller() for light in lights]
839
+ await self.rate_limiter.execute_batch(sync_coros)
840
+
841
+ # Return all lights
842
+ return lights
843
+
844
+ async def _mqtt_light_change(self, light: ZenLight|ZenGroup, payload: dict[str, Any]) -> None:
845
+ addr = light.address
846
+ ctrl = addr.controller
847
+ state: Optional[str] = payload.get("state", None)
848
+ brightness: Optional[int] = payload.get("brightness", None)
849
+ mireds: Optional[int] = payload.get("color_temp", None)
850
+
851
+ # If brightness or temperature is set
852
+ if brightness is not None or mireds is not None:
853
+ args = {}
854
+ if brightness is not None: args["level"] = self.brightness_to_arc(brightness)
855
+ if mireds is not None: args["colour"] = ZenColour(type=ZenColourType.TC, kelvin=self.mireds_to_kelvin(mireds))
856
+ self.logger.info(f"♥️💡 Command from HA: {ctrl.name} setting gear {addr.number} to {args}")
857
+ await light.set(**args)
858
+ return
859
+
860
+ # If switched on/off in HA
861
+ if state == "OFF":
862
+ self.logger.info(f"♥️💡 Command from HA: {ctrl.name} turning gear {addr.number} OFF")
863
+ await light.off(fade=True)
864
+ elif state == "ON":
865
+ self.logger.info(f"♥️💡 Command from HA: {ctrl.name} turning gear {addr.number} ON")
866
+ await light.on()
867
+
868
+ async def _zen_light_change(self, light: ZenLight, level: Optional[int] = None, colour: Optional[ZenColour] = None, scene: Optional[int] = None) -> None:
869
+ typestr = "group" if light.address.type == ZenAddressType.GROUP else "light"
870
+ emoji = "👥" if light.address.type == ZenAddressType.GROUP else "💡"
871
+ self.logger.info(f"🩵{emoji} Event from Zen: {typestr} {light.address.number} level {level if level is not None else '--'} colour {colour if colour is not None else '--'} scene {scene if scene is not None else '--'}")
872
+
873
+ # log traceback
874
+ # self.logger.debug(f" stack trace: {traceback.format_stack()}")
875
+
876
+ mqtt_topic = light.client_data.get("light", {}).get('mqtt_topic', None)
877
+ if not mqtt_topic:
878
+ self.logger.error(f"Light {light} has no MQTT topic")
879
+ return
880
+
881
+ new_state = {
882
+ "state": "OFF" if light.level == 0 else "ON"
883
+ }
884
+
885
+ if light.level is not None and light.level > 0:
886
+ new_state["brightness"] = self.arc_to_brightness(light.level)
887
+
888
+ if light.colour and light.colour.type == ZenColourType.TC:
889
+ new_state["color_mode"] = "color_temp"
890
+ if light.colour.kelvin is not None:
891
+ new_state["color_temp"] = self.kelvin_to_mireds(light.colour.kelvin)
892
+
893
+ await self._publish_state(mqtt_topic, new_state)
894
+
895
+ # ================================
896
+ # GROUPS
897
+ # ================================
898
+
899
+ async def setup_groups(self) -> set[ZenGroup]:
900
+ """Initialize all groups for Home Assistant auto-discovery."""
901
+ groups = await self.zen.get_groups()
902
+ # Group-lights
903
+ for group in groups:
904
+ if group.lights:
905
+ client_data = self._client_data_for_object(group, "light")
906
+ mqtt_topic = client_data['mqtt_topic']
907
+ config_dict = self.global_config | client_data.get("attributes",{}) | {
908
+ "name": group.label,
909
+ "schema": "json",
910
+ "payload_off": "OFF",
911
+ "payload_on": "ON",
912
+ "command_topic": f"{mqtt_topic}/set",
913
+ "state_topic": f"{mqtt_topic}/state",
914
+ "json_attributes_topic": f"{mqtt_topic}/attributes",
915
+ "effect": False,
916
+ "retain": False,
917
+ "brightness": False,
918
+ }
919
+ if group.contains_temperature_lights():
920
+ config_dict = config_dict | {
921
+ "brightness": True,
922
+ "supported_color_modes": ["color_temp"],
923
+ "min_mireds": self.kelvin_to_mireds(group.properties["max_kelvin"]),
924
+ "max_mireds": self.kelvin_to_mireds(group.properties["min_kelvin"]),
925
+ }
926
+ elif group.contains_dimmable_lights():
927
+ config_dict = config_dict | {
928
+ "brightness": True,
929
+ "supported_color_modes": ["brightness"],
930
+ }
931
+ else:
932
+ config_dict = config_dict | {
933
+ "supported_color_modes": ["onoff"],
934
+ }
935
+ await self._publish_config(mqtt_topic, config_dict, object=group)
936
+ # Get the latest state from the controller and trigger an event, which then sends a state update
937
+ await group.refresh_state_from_controller()
938
+
939
+ # Group-scenes
940
+ for group in groups:
941
+ if group.lights:
942
+ client_data = self._client_data_for_object(group, "select")
943
+ mqtt_topic = client_data['mqtt_topic']
944
+ config_dict = self.global_config | client_data.get("attributes",{}) | {
945
+ "name": group.label,
946
+ "command_topic": f"{mqtt_topic}/set",
947
+ "state_topic": f"{mqtt_topic}/state",
948
+ "options": group.get_scene_labels(exclude_none=True),
949
+ }
950
+ await self._publish_config(mqtt_topic, config_dict, object=group)
951
+ scene_label = group.get_scene_label_from_number(group.scene) if group.scene is not None else None
952
+ await self._publish_state(mqtt_topic, scene_label if scene_label is not None else "None")
953
+
954
+ # Return all groups
955
+ return groups
956
+
957
+ async def _mqtt_groupscene_change(self, group: ZenGroup, payload: str) -> None:
958
+ self.logger.info(f"♥️👥 Command from HA: group {group.address.number} to scene {payload}")
959
+ await group.set_scene(payload)
960
+
961
+ # mqtt group light change calls _mqtt_light_change
962
+
963
+ async def _zen_group_change(self, group: ZenGroup, level: Optional[int] = None, colour: Optional[ZenColour] = None, scene: Optional[int] = None, discoordinated: Optional[bool] = None) -> None:
964
+ select_mqtt_topic = group.client_data.get("select", {}).get('mqtt_topic', None)
965
+
966
+ # Get the scene label for the ID from the group
967
+ if select_mqtt_topic and scene is not None:
968
+ scene_label = group.get_scene_label_from_number(scene)
969
+ if scene_label is not None:
970
+ await self._publish_state(select_mqtt_topic, scene_label)
971
+ else:
972
+ await self._publish_state(select_mqtt_topic, "None")
973
+ self.logger.warning(f"Group {group} has no scene with ID {scene}")
974
+
975
+ # If discoordinated, set the group-light's state to null and return
976
+ if discoordinated:
977
+ self.logger.info(f"🩵👥 Event from Zen: group {group.address.number} discoordinated")
978
+ await self._publish_state(select_mqtt_topic, "None")
979
+ light_mqtt_topic = group.client_data.get("light", {}).get('mqtt_topic', None)
980
+ await self._publish_state(light_mqtt_topic, {"state": None})
981
+ return
982
+
983
+ # Do light stuff
984
+ await self._zen_light_change(light=group, level=level, colour=colour, scene=scene)
985
+
986
+ # ================================
987
+ # BUTTONS
988
+ # ================================
989
+
990
+ async def setup_buttons(self) -> set[ZenButton]:
991
+ """Initialize all buttons found on the DALI bus for Home Assistant auto-discovery."""
992
+ buttons = await self.zen.get_buttons()
993
+ for button in buttons:
994
+ client_data = self._client_data_for_object(button, "device_automation")
995
+ button.long_press_time = Const.DEFAULT_LONG_PRESS_TIME
996
+ mqtt_topic = client_data['mqtt_topic']
997
+ config_dict = self.global_config | client_data.get("attributes",{}) | {
998
+ "automation_type": "trigger",
999
+ "subtype": re.sub(r'[^a-z0-9]', '_', button.label.lower()) + "_" + re.sub(r'[^a-z0-9]', '_', button.instance_label.lower()),
1000
+ "type": "button_short_press",
1001
+ "payload": "button_short_press",
1002
+ "topic": f"{mqtt_topic}/event",
1003
+ }
1004
+ await self._publish_config(mqtt_topic, config_dict, object=button)
1005
+ # For long press, we use a different topic for the config, but the same topic for the event payload
1006
+ config_dict = config_dict | {
1007
+ "type": "button_long_press",
1008
+ "payload": "button_long_press",
1009
+ }
1010
+ await self._publish_config(mqtt_topic + "_long_press", config_dict, object=button)
1011
+
1012
+ # Return all buttons
1013
+ return buttons
1014
+
1015
+ async def _zen_button_press(self, button: ZenButton) -> None:
1016
+ self.logger.debug(f"🩵👆 Event from Zen: button press {button}")
1017
+ mqtt_topic = button.client_data.get("device_automation", {}).get("mqtt_topic", None)
1018
+ if not mqtt_topic:
1019
+ self.logger.error(f"Button {button} has no MQTT topic")
1020
+ return
1021
+ await self._publish_event(mqtt_topic, "button_short_press")
1022
+
1023
+ async def _zen_button_long_press(self, button: ZenButton) -> None:
1024
+ self.logger.debug(f"🩵👆 Event from Zen: button LONG press {button}")
1025
+ mqtt_topic = button.client_data.get("device_automation", {}).get("mqtt_topic", None)
1026
+ if not mqtt_topic:
1027
+ self.logger.error(f"Button {button} has no MQTT topic")
1028
+ return
1029
+ await self._publish_event(mqtt_topic, "button_long_press")
1030
+
1031
+ # ================================
1032
+ # MOTION SENSORS
1033
+ # ================================
1034
+
1035
+ async def setup_motion_sensors(self) -> set[ZenMotionSensor]:
1036
+ """Initialize all motion sensors found on the DALI bus for Home Assistant auto-discovery."""
1037
+ sensors = await self.zen.get_motion_sensors()
1038
+ for sensor in sensors:
1039
+ client_data = self._client_data_for_object(sensor, "binary_sensor")
1040
+ mqtt_topic = client_data['mqtt_topic']
1041
+ config_dict = self.global_config | client_data.get("attributes",{}) | {
1042
+ "name": sensor.instance_label,
1043
+ "device_class": "motion",
1044
+ "payload_off": "OFF",
1045
+ "payload_on": "ON",
1046
+ "state_topic": f"{mqtt_topic}/state",
1047
+ "json_attributes_topic": f"{mqtt_topic}/attributes",
1048
+ "retain": False,
1049
+ }
1050
+ await self._publish_config(mqtt_topic, config_dict, object=sensor)
1051
+ await self._publish_state(mqtt_topic, "ON" if sensor.occupied else "OFF")
1052
+
1053
+ # Return all motion sensors
1054
+ return sensors
1055
+
1056
+ async def _zen_motion_event(self, sensor: ZenMotionSensor, occupied: bool) -> None:
1057
+ self.logger.debug(f"🩵👀 Event from Zen: sensor {sensor} occupied: {occupied}")
1058
+ mqtt_topic = sensor.client_data.get("binary_sensor", {}).get("mqtt_topic", None)
1059
+ if not mqtt_topic:
1060
+ self.logger.error(f"Sensor {sensor} has no MQTT topic")
1061
+ return
1062
+ await self._publish_state(mqtt_topic, "ON" if occupied else "OFF")
1063
+
1064
+ # ================================
1065
+ # SYSTEM VARIABLES
1066
+ # ================================
1067
+
1068
+ async def setup_system_variables(self) -> set[ZenSystemVariable]:
1069
+ """Initialize system variables in config.yaml for Home Assistant auto-discovery."""
1070
+
1071
+ # On first run, prep system variables with client_data
1072
+ if not self.system_variables:
1073
+ for sv in self.sv_config:
1074
+ ctrl: ZenController = sv['controller']
1075
+ zsv = ctrl.get_sysvar(sv['id'])
1076
+ attr = sv['attributes'] | {
1077
+ "default_entity_id": f"{sv['component']}.{sv['object_id']}",
1078
+ "unique_id": f"{ctrl.name}_{sv['object_id']}"
1079
+ }
1080
+ self._client_data_for_object(zsv, sv['component'], attributes=attr)
1081
+ self.system_variables.append(zsv)
1082
+
1083
+ for zsv in self.system_variables:
1084
+ if zsv.client_data.get("switch", None):
1085
+ client_data = zsv.client_data["switch"]
1086
+ mqtt_topic = client_data["mqtt_topic"]
1087
+ config_dict = self.global_config | client_data.get("attributes",{}) | {
1088
+ "component": "switch",
1089
+ "state_topic": f"{mqtt_topic}/state",
1090
+ "command_topic": f"{mqtt_topic}/set",
1091
+ "payload_off": "OFF",
1092
+ "payload_on": "ON",
1093
+ "retain": False,
1094
+ }
1095
+ elif zsv.client_data.get("sensor", None):
1096
+ client_data = zsv.client_data["sensor"]
1097
+ mqtt_topic = client_data["mqtt_topic"]
1098
+ config_dict = self.global_config | client_data.get("attributes",{}) | {
1099
+ "component": "sensor",
1100
+ "state_topic": f"{mqtt_topic}/state",
1101
+ "retain": False,
1102
+ }
1103
+ else:
1104
+ continue
1105
+
1106
+ await self._publish_config(mqtt_topic, config_dict, object=zsv)
1107
+ await self._publish_state(mqtt_topic, await zsv.get_value())
1108
+
1109
+ # Return all system variables
1110
+ return self.system_variables
1111
+
1112
+ async def _mqtt_system_variable_change(self, sysvar: ZenSystemVariable, payload: str) -> None:
1113
+ self.logger.debug(f"♥️⚡️ Command from HA: {sysvar.controller.name} system variable {sysvar.id} set to {payload}")
1114
+ if sysvar.client_data.get("switch", None):
1115
+ await sysvar.set_value(1 if payload == "ON" else 0)
1116
+ elif sysvar.client_data.get("sensor", None):
1117
+ return # Read only
1118
+
1119
+ async def _zen_system_variable_change(self, system_variable: ZenSystemVariable, value:int, changed: bool, by_me: bool) -> None:
1120
+ self.logger.debug(f"🩵⚡️ Event from Zen: {system_variable.controller.name} system variable {system_variable.id} set to {value}")
1121
+ # print(f"System Variable Change Event - controller {system_variable.controller.name} system_variable {system_variable.id} value {value} changed {changed} by_me {by_me}")
1122
+ if system_variable.client_data.get("switch", None):
1123
+ mqtt_topic = system_variable.client_data["switch"]["mqtt_topic"]
1124
+ await self._publish_state(mqtt_topic, "OFF" if value == 0 else "ON")
1125
+ elif system_variable.client_data.get("sensor", None):
1126
+ mqtt_topic = system_variable.client_data["sensor"]["mqtt_topic"]
1127
+ await self._publish_state(mqtt_topic, value)
1128
+ else:
1129
+ self.logger.error(f"Ignoring system variable {system_variable}")
1130
+ return
1131
+
1132
+ # Usage
1133
+ async def main():
1134
+ bridge = ZenMQTTBridge()
1135
+ try:
1136
+ await bridge.run()
1137
+ except KeyboardInterrupt:
1138
+ print("Shutting down...")
1139
+ await bridge.stop()
1140
+
1141
+ if __name__ == "__main__":
1142
+ asyncio.run(main())