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,1700 @@
1
+ import asyncio
2
+ import socket
3
+ import struct
4
+ import time
5
+ import logging
6
+ import traceback
7
+ from datetime import datetime as dt
8
+ from typing import Any, Optional, Self, Callable, Awaitable, Coroutine, Literal, overload
9
+ from enum import Enum
10
+ from colorama import Fore, Back, Style
11
+ from dataclasses import dataclass, field
12
+
13
+ from ..io import ZenClient, ZenListener, ZenEvent, Request, Response, ResponseType, RequestType, EventConst, ClientConst
14
+ from .models import ZenController, ZenAddress, ZenInstance, ZenColour, ZenProfile
15
+ from .types import ZenAddressType, ZenInstanceType, ZenColourType, ZenEventCode, ZenEventMask, ZenEventMode, ZenErrorCode, Const
16
+ from ..exceptions import ZenError, ZenTimeoutError, ZenResponseError
17
+ from ..utils import local_ip_for_remote
18
+
19
+ """
20
+ ===================================================================================
21
+ This module implements the ZenControl TPI Advanced API using zen_io.
22
+ ===================================================================================
23
+ """
24
+
25
+
26
+ class ZenCallbacks:
27
+ """Per-ZenControl high-level callback registry.
28
+
29
+ Stored on ``ZenProtocol.callbacks`` so entity singletons can reach their
30
+ owning integration's callbacks via ``self.protocol.callbacks``.
31
+ """
32
+
33
+ def __init__(self) -> None:
34
+ self.on_connect: Optional[Callable[[], Awaitable[None]]] = None
35
+ self.on_disconnect: Optional[Callable[[], Awaitable[None]]] = None
36
+ self.profile_change: Optional[Callable[..., Awaitable[None]]] = None
37
+ self.group_change: Optional[Callable[..., Awaitable[None]]] = None
38
+ self.light_change: Optional[Callable[..., Awaitable[None]]] = None
39
+ self.button_press: Optional[Callable[..., Awaitable[None]]] = None
40
+ self.button_long_press: Optional[Callable[..., Awaitable[None]]] = None
41
+ self.motion_event: Optional[Callable[..., Awaitable[None]]] = None
42
+ self.system_variable_change: Optional[Callable[..., Awaitable[None]]] = None
43
+
44
+
45
+ class EntityRegistry:
46
+ """Per-protocol caches for interface-layer entity identity.
47
+
48
+ Entities keyed here are unique within one ``ZenProtocol`` / ``ZenControl``
49
+ instance, not process-wide.
50
+ """
51
+
52
+ def __init__(self) -> None:
53
+ self.controllers: dict[str, Any] = {}
54
+ self.profiles: dict[str, Any] = {}
55
+ self.lights: dict[str, Any] = {}
56
+ self.groups: dict[str, Any] = {}
57
+ self.buttons: dict[str, Any] = {}
58
+ self.motion_sensors: dict[str, Any] = {}
59
+ self.system_variables: dict[str, Any] = {}
60
+
61
+ def clear(self) -> None:
62
+ self.controllers.clear()
63
+ self.profiles.clear()
64
+ self.lights.clear()
65
+ self.groups.clear()
66
+ self.buttons.clear()
67
+ self.motion_sensors.clear()
68
+ self.system_variables.clear()
69
+
70
+
71
+ class ZenProtocol:
72
+
73
+ callbacks: ZenCallbacks
74
+
75
+ # Define commands as a dictionary
76
+ CMD: dict[str, int] = {
77
+ # Controller
78
+ "QUERY_CONTROLLER_VERSION_NUMBER": 0x1C, # Query ZenController Version Number
79
+ "QUERY_CONTROLLER_LABEL": 0x24, # Query the label of the controller
80
+ "QUERY_CONTROLLER_FITTING_NUMBER": 0x25, # Query the fitting number of the controller itself
81
+ "QUERY_CONTROLLER_STARTUP_COMPLETE": 0x27, # Query whether controller startup is complete
82
+ "QUERY_IS_DALI_READY": 0x26, # Query whether DALI bus is ready (or has a fault)
83
+ # System variables
84
+ "SET_SYSTEM_VARIABLE": 0x36, # Set a system variable value
85
+ "QUERY_SYSTEM_VARIABLE": 0x37, # Query system variable
86
+ "QUERY_SYSTEM_VARIABLE_NAME": 0x42, # Query the name of a system variable
87
+ # TPI settings
88
+ "ENABLE_TPI_EVENT_EMIT": 0x08, # Enable or disable TPI Events
89
+ "QUERY_TPI_EVENT_EMIT_STATE": 0x07, # Query whether TPI Events are enabled or disabled
90
+ "DALI_ADD_TPI_EVENT_FILTER": 0x31, # Request that filters be added for DALI TPI Events
91
+ "QUERY_DALI_TPI_EVENT_FILTERS": 0x32, # Query DALI TPI Event filters on a address
92
+ "DALI_CLEAR_TPI_EVENT_FILTERS": 0x33, # Request that DALI TPI Event filters be cleared
93
+ "SET_TPI_EVENT_UNICAST_ADDRESS": 0x40, # Set a TPI Events unicast address and port
94
+ "QUERY_TPI_EVENT_UNICAST_ADDRESS": 0x41, # Query TPI Events State, unicast address and port
95
+ # Any address
96
+ "QUERY_OPERATING_MODE_BY_ADDRESS": 0x28, # Query the operating mode for a device
97
+ "QUERY_DALI_DEVICE_LABEL": 0x03, # Query the label for a DALI ECD or ECG by address
98
+ "QUERY_DALI_SERIAL": 0xB9, # Query the Serial Number at a address
99
+ "QUERY_DALI_FITTING_NUMBER": 0x22, # Query the fitting number for control gear/devices
100
+ "QUERY_DALI_EAN": 0xB8, # Query the DALI European Article Number at an address
101
+ # Groups / Group-scenes
102
+ "QUERY_GROUP_MEMBERSHIP_BY_ADDRESS": 0x15, # Query DALI Group membership by address
103
+ "QUERY_GROUP_NUMBERS": 0x09, # Query the DALI Group numbers
104
+ "QUERY_GROUP_LABEL": 0x01, # Query the label for a DALI Group by Group Number
105
+ "QUERY_SCENE_NUMBERS_FOR_GROUP": 0x1A, # Query Scene Numbers attributed to a group
106
+ "QUERY_SCENE_LABEL_FOR_GROUP": 0x1B, # Query Scene Labels attributed to a group scene
107
+ "QUERY_GROUP_BY_NUMBER": 0x12, # Query DALI Group information by Group Number
108
+ # Profiles
109
+ "QUERY_PROFILE_INFORMATION": 0x43, # Query profile numbers, behaviours etc
110
+ "QUERY_PROFILE_NUMBERS": 0x0B, # Query all available Profile numbers (superseded by QUERY_PROFILE_INFORMATION)
111
+ "QUERY_PROFILE_LABEL": 0x04, # Query the label for a controller profile
112
+ "QUERY_CURRENT_PROFILE_NUMBER": 0x05, # Query the current profile number
113
+ "CHANGE_PROFILE_NUMBER": 0xC0, # Request a Profile Change on the controller
114
+ # Instances
115
+ "QUERY_DALI_ADDRESSES_WITH_INSTANCES": 0x16, # Query DALI addresses that have instances
116
+ "QUERY_INSTANCES_BY_ADDRESS": 0x0D, # Query information of instances
117
+ "QUERY_DALI_INSTANCE_FITTING_NUMBER": 0x23, # Query the fitting number for an instance
118
+ "QUERY_DALI_INSTANCE_LABEL": 0xB7, # Query DALI Instance for its label
119
+ "QUERY_INSTANCE_GROUPS": 0x21, # Query group targets related to an instance
120
+ "QUERY_OCCUPANCY_INSTANCE_TIMERS": 0x0C, # Query an occupancy instance for its timer values
121
+ # ECG (Lights)
122
+ "QUERY_CONTROL_GEAR_DALI_ADDRESSES": 0x1D, # Query Control Gear present in database
123
+ "DALI_QUERY_LEVEL": 0xAA, # Query the the level on a address
124
+ "DALI_QUERY_CG_TYPE": 0xAC, # Query Control Gear type data on a address
125
+ "QUERY_DALI_COLOUR_FEATURES": 0x35, # Query the DALI colour features/capabilities
126
+ "QUERY_DALI_COLOUR_TEMP_LIMITS": 0x38, # Query Colour Temperature max/min + step in Kelvin
127
+ "DALI_QUERY_CONTROL_GEAR_STATUS": 0xAB, # Query status data on a address, group or broadcast
128
+ "QUERY_DALI_COLOUR": 0x34, # Query the Colour information on a DALI target
129
+ "DALI_COLOUR": 0x0E, # Set a DALI target to a colour
130
+ "DALI_INHIBIT": 0xA0, # Inhibit sensors from affecting a target for n seconds
131
+ "DALI_ARC_LEVEL": 0xA2, # Set an Arc-Level on a address
132
+ "DALI_ON_STEP_UP": 0xA3, # On-if-Off and Step Up on a address
133
+ "DALI_STEP_DOWN_OFF": 0xA4, # Step Down and off-at-min on a address
134
+ "DALI_UP": 0xA5, # Step Up on a address
135
+ "DALI_DOWN": 0xA6, # Step Down on a address
136
+ "DALI_RECALL_MAX": 0xA7, # Recall the max level on a address
137
+ "DALI_RECALL_MIN": 0xA8, # Recall the min level on a address
138
+ "DALI_OFF": 0xA9, # Set a address to Off
139
+ "DALI_QUERY_MIN_LEVEL": 0xAF, # Query the min level for a DALI device
140
+ "DALI_QUERY_MAX_LEVEL": 0xB0, # Query the max level for a DALI device
141
+ "DALI_QUERY_FADE_RUNNING": 0xB1, # Query whether a fade is running on a address
142
+ "DALI_ENABLE_DAPC_SEQ": 0xB2, # Begin a DALI DAPC sequence
143
+ "DALI_CUSTOM_FADE": 0xB4, # Call a DALI Arc Level with a custom fade-length
144
+ "DALI_GO_TO_LAST_ACTIVE_LEVEL": 0xB5, # Command DALI addresses to go to last active level
145
+ "DALI_STOP_FADE": 0xC1, # Request a running DALI fade be stopped
146
+ # Scenes
147
+ "QUERY_SCENE_NUMBERS_BY_ADDRESS": 0x14, # Query for DALI Scenes an address has levels for
148
+ "QUERY_SCENE_LEVELS_BY_ADDRESS": 0x1E, # Query Scene level values for a given address
149
+ "DALI_SCENE": 0xA1, # Call a DALI Scene on a address
150
+ "DALI_QUERY_LAST_SCENE": 0xAD, # Query Last heard DALI Scene
151
+ "DALI_QUERY_LAST_SCENE_IS_CURRENT": 0xAE, # Query if last heard Scene is current scene
152
+ "QUERY_COLOUR_SCENE_MEMBERSHIP_BY_ADDR": 0x44, # Query a list of scenes with colour change data for an address
153
+ "QUERY_COLOUR_SCENE_0_7_DATA_FOR_ADDR": 0x45, # Query the colour control data for scenes 0-7
154
+ "QUERY_COLOUR_SCENE_8_11_DATA_FOR_ADDR": 0x46, # Query the colour control data for scenes 8-11
155
+
156
+ # Implemented but not tested
157
+ "OVERRIDE_DALI_BUTTON_LED_STATE": 0x29, # Override a button LED state
158
+ "QUERY_LAST_KNOWN_DALI_BUTTON_LED_STATE": 0x30, # Query button last known button LED state
159
+
160
+ # Won't implement (because I can't test)
161
+ "TRIGGER_SDDP_IDENTIFY": 0x06, # Trigger a Control4 SDDP Identify
162
+ "DMX_COLOUR": 0x10, # Send values to a set of DMX channels and configure fading
163
+ "QUERY_DMX_DEVICE_NUMBERS": 0x17, # Query DMX Device information
164
+ "QUERY_DMX_DEVICE_BY_NUMBER": 0x18, # Query for DMX Device information by channel number
165
+ "QUERY_DMX_LEVEL_BY_CHANNEL": 0x19, # Query DMX Channel value by Channel number
166
+ "QUERY_DMX_DEVICE_LABEL_BY_NUMBER": 0x20, # Query DMX Device for its label
167
+ "VIRTUAL_INSTANCE": 0xB3, # Perform an action on a Virtual Instance
168
+ "QUERY_VIRTUAL_INSTANCES": 0xB6, # Query for virtual instances and their types
169
+
170
+ # Deprecated (described as a legacy command in docs)
171
+ "QUERY_SCENE_LABEL": 0x02,
172
+ "QUERY_SCENE_NUMBERS": 0x0A,
173
+ "QUERY_SCENE_BY_NUMBER": 0x13,
174
+ }
175
+
176
+ def __init__(self,
177
+ logger: Optional[logging.Logger] = None,
178
+ print_traffic: bool = False,
179
+ unicast: bool = False,
180
+ listen_ip: Optional[str] = None,
181
+ listen_port: Optional[int] = None,
182
+ cache: dict[bytes, dict[str, Any]] | None = None):
183
+ self.logger = logger or logging.getLogger('null')
184
+ if logger is None:
185
+ self.logger.addHandler(logging.NullHandler())
186
+ self.print_traffic = print_traffic
187
+ self.unicast = unicast
188
+ self.listen_ip = (listen_ip if listen_ip else "0.0.0.0") if unicast else None
189
+ self.listen_port = (listen_port if listen_port else 0) if unicast else None
190
+
191
+ # Cache object
192
+ self.cache: dict[bytes, dict[str, Any]] = cache if cache is not None else {}
193
+
194
+ # Advertised unicast IP resolved lazily in start_event_monitoring (needs controllers)
195
+ self.local_ip: Optional[str] = None
196
+ if self.unicast and self.listen_ip and self.listen_ip != "0.0.0.0":
197
+ self.local_ip = self.listen_ip
198
+ # Setup event monitoring using ZenListener
199
+ self.event_listener: Optional[ZenListener] = None
200
+ self.event_task = None
201
+ # Prevents double on_disconnect if stop races with listener death
202
+ self._disconnect_notified = False
203
+
204
+ # Setup event listeners
205
+ self.button_press_callback: Optional[Callable[..., Awaitable[None]]] = None
206
+ self.button_hold_callback: Optional[Callable[..., Awaitable[None]]] = None
207
+ self.absolute_input_callback: Optional[Callable[..., Awaitable[None]]] = None
208
+ self.level_change_callback: Optional[Callable[..., Awaitable[None]]] = None
209
+ self.group_level_change_callback: Optional[Callable[..., Awaitable[None]]] = None
210
+ self.scene_change_callback: Optional[Callable[..., Awaitable[None]]] = None
211
+ self.is_occupied_callback: Optional[Callable[..., Awaitable[None]]] = None
212
+ self.colour_change_callback: Optional[Callable[..., Awaitable[None]]] = None
213
+ self.profile_change_callback: Optional[Callable[..., Awaitable[None]]] = None
214
+ self.system_variable_change_callback: Optional[Callable[..., Awaitable[None]]] = None
215
+ self.disconnect_callback: Optional[Callable[[], Awaitable[None]]] = None
216
+
217
+ # Controllers will be assigned later
218
+ self.controllers = []
219
+ # High-level callback registry (replaced per ZenControl instance)
220
+ self.callbacks = ZenCallbacks()
221
+ # Interface-layer entity identity (scoped to this protocol)
222
+ self.entity_registry = EntityRegistry()
223
+ # Fire-and-forget work (delayed events, refresh timers, motion holds)
224
+ self._bg_tasks: set[asyncio.Task[Any]] = set()
225
+
226
+ async def __aenter__(self):
227
+ """Async context manager entry"""
228
+ return self
229
+
230
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
231
+ """Async context manager exit"""
232
+ await self.aclose()
233
+
234
+ def clear_entity_cache(self) -> None:
235
+ """Drop all interface entity singletons owned by this protocol."""
236
+ self.entity_registry.clear()
237
+
238
+ def track_task(self, coro: Coroutine[Any, Any, Any]) -> asyncio.Task[Any]:
239
+ """Schedule a background task and track it for cancellation on aclose."""
240
+ task = asyncio.create_task(coro)
241
+ self._bg_tasks.add(task)
242
+ task.add_done_callback(self._bg_task_done)
243
+ return task
244
+
245
+ def _bg_task_done(self, task: asyncio.Task[Any]) -> None:
246
+ self._bg_tasks.discard(task)
247
+ if task.cancelled():
248
+ return
249
+ exc = task.exception()
250
+ if exc is not None:
251
+ self.logger.error(f"Background task failed: {exc}", exc_info=exc)
252
+
253
+ async def _cancel_background_tasks(self) -> None:
254
+ tasks = list(self._bg_tasks)
255
+ for task in tasks:
256
+ task.cancel()
257
+ if tasks:
258
+ await asyncio.gather(*tasks, return_exceptions=True)
259
+ self._bg_tasks.clear()
260
+
261
+ async def aclose(self):
262
+ """Stop monitoring, cancel background tasks, and close UDP clients."""
263
+ await self.stop_event_monitoring()
264
+ await self._cancel_background_tasks()
265
+ for controller in self.controllers:
266
+ client = controller.client
267
+ controller.client = None
268
+ if client is None:
269
+ continue
270
+ try:
271
+ await client.close()
272
+ except Exception:
273
+ pass
274
+ self.clear_entity_cache()
275
+
276
+ def set_controllers(self, controllers: list[ZenController]):
277
+ self.controllers = controllers # Used to match events to controllers, and include controller objects in callbacks
278
+
279
+ # ============================
280
+ # PACKET SENDING
281
+ # ============================
282
+
283
+ @staticmethod
284
+ def _checksum(packet: list[int]) -> int:
285
+ acc = 0x00
286
+ for d in packet:
287
+ acc = d ^ acc
288
+ return acc
289
+
290
+ @overload
291
+ async def _send_basic(
292
+ self,
293
+ controller: ZenController,
294
+ command: int,
295
+ address: int = 0x00,
296
+ data: list[int] | None = None,
297
+ *,
298
+ return_type: Literal["str"],
299
+ cacheable: bool = False,
300
+ ) -> Optional[str]: ...
301
+
302
+ @overload
303
+ async def _send_basic(
304
+ self,
305
+ controller: ZenController,
306
+ command: int,
307
+ address: int = 0x00,
308
+ data: list[int] | None = None,
309
+ *,
310
+ return_type: Literal["bytes"] = "bytes",
311
+ cacheable: bool = False,
312
+ ) -> Optional[bytes]: ...
313
+
314
+ @overload
315
+ async def _send_basic(
316
+ self,
317
+ controller: ZenController,
318
+ command: int,
319
+ address: int = 0x00,
320
+ data: list[int] | None = None,
321
+ *,
322
+ return_type: Literal["list"],
323
+ cacheable: bool = False,
324
+ ) -> Optional[list[int]]: ...
325
+
326
+ @overload
327
+ async def _send_basic(
328
+ self,
329
+ controller: ZenController,
330
+ command: int,
331
+ address: int = 0x00,
332
+ data: list[int] | None = None,
333
+ *,
334
+ return_type: Literal["int"],
335
+ cacheable: bool = False,
336
+ ) -> Optional[int]: ...
337
+
338
+ @overload
339
+ async def _send_basic(
340
+ self,
341
+ controller: ZenController,
342
+ command: int,
343
+ address: int = 0x00,
344
+ data: list[int] | None = None,
345
+ *,
346
+ return_type: Literal["bool", "ok"],
347
+ cacheable: bool = False,
348
+ ) -> Optional[bool]: ...
349
+
350
+ async def _send_basic(self,
351
+ controller: ZenController,
352
+ command: int,
353
+ address: int = 0x00,
354
+ data: list[int] | None = None,
355
+ return_type: str = 'bytes',
356
+ cacheable: bool = False
357
+ ) -> Optional[bytes | str | list[int] | int | bool]:
358
+ request: Request = Request(command=command, data=[address] + (data or []), request_type=RequestType.BASIC)
359
+ response_data, response_code = await self._send_packet(controller, request, cacheable=cacheable)
360
+ if response_data is None and response_code is None:
361
+ return None
362
+ match response_code:
363
+ case 0xA0: # OK
364
+ match return_type:
365
+ case 'ok':
366
+ return True
367
+ case _:
368
+ raise ValueError(f"Invalid return_type '{return_type}' for response code {response_code}")
369
+ case 0xA1: # ANSWER
370
+ match return_type:
371
+ case 'bytes':
372
+ return response_data
373
+ case 'str':
374
+ if response_data is None:
375
+ return None
376
+ try:
377
+ return response_data.decode('ascii')
378
+ except UnicodeDecodeError:
379
+ return None
380
+ case 'list':
381
+ if response_data: return list(response_data)
382
+ case 'int':
383
+ if response_data and len(response_data) == 1: return int(response_data[0])
384
+ case 'bool':
385
+ if response_data and len(response_data) == 1: return bool(response_data[0])
386
+ case _:
387
+ raise ValueError(f"Invalid return_type '{return_type}' for response code {response_code}")
388
+ case 0xA2: # NO_ANSWER
389
+ match return_type:
390
+ case 'ok':
391
+ return False
392
+ case _:
393
+ return None
394
+ case 0xA3: # ERROR
395
+ if response_data:
396
+ error_code = ZenErrorCode(response_data[0]) if response_data[0] in ZenErrorCode._value2member_map_ else None
397
+ error_label = error_code.name if error_code else f"Unknown error code: {hex(response_data[0])}"
398
+ self.logger.error(f"Command error code: {error_label}")
399
+ else:
400
+ self.logger.error("Command error (no error code)")
401
+ case 0xAE: # TIMEOUT
402
+ self.logger.error("Command timed out")
403
+ return None
404
+ case 0xAF: # INVALID
405
+ self.logger.error("Invalid response code")
406
+ return None
407
+ case _:
408
+ self.logger.error(f"Unknown response code: {response_code}")
409
+ return None
410
+
411
+ async def _send_colour(self, controller: ZenController, command: int, address: int, colour: ZenColour, level: int = 255) -> Optional[bool]:
412
+ """Send a DALI colour command."""
413
+ data = [address, level & 0xFF] + list(colour.command_payload())
414
+ request: Request = Request(command=command, data=data, request_type=RequestType.DALI_COLOUR)
415
+ response_data, response_code = await self._send_packet(controller, request)
416
+ match response_code:
417
+ case 0xA0: # OK
418
+ return True
419
+ case 0xA2: # NO_ANSWER
420
+ return False
421
+ return None
422
+
423
+ async def _send_dynamic(self, controller: ZenController, command: int, data: list[int]) -> Optional[bytes]:
424
+ # Calculate data length and prepend it to data
425
+ request: Request = Request(command=command, data=data, request_type=RequestType.DYNAMIC)
426
+ response_data, response_code = await self._send_packet(controller, request)
427
+ # Check response type
428
+ match response_code:
429
+ case 0xA0: # OK
430
+ pass # Request processed successfully
431
+ case 0xA1: # ANSWER
432
+ pass # Answer is in data bytes
433
+ case 0xA2: # NO_ANSWER
434
+ if response_data is not None and len(response_data) > 0:
435
+ self.logger.error(f"No answer with code: {response_data}")
436
+ return None
437
+ case 0xA3: # ERROR
438
+ if response_data:
439
+ error_code = ZenErrorCode(response_data[0]) if response_data[0] in ZenErrorCode._value2member_map_ else None
440
+ error_label = error_code.name if error_code else f"Unknown error code: {hex(response_data[0])}"
441
+ self.logger.error(f"Command error code: {error_label}")
442
+ else:
443
+ self.logger.error("Command error (no error code)")
444
+ return None
445
+ case _:
446
+ self.logger.error(f"Unknown response type: {response_code}")
447
+ return None
448
+ if response_data:
449
+ return response_data
450
+ return None
451
+
452
+ async def _send_packet(self, controller: ZenController, request: Request, cacheable: bool = False) -> tuple[Optional[bytes], int]:
453
+ cache_key: Optional[bytes] = None
454
+ # Read from cache?
455
+ if cacheable:
456
+ request_data = bytes(request.data) if not isinstance(request.data, bytes) else request.data
457
+ cache_key = controller.id.encode("utf-8") + bytes([request.command & 0xFF]) + request_data
458
+ if cache_key in self.cache:
459
+ c = self.cache[cache_key]
460
+ cached_timestamp = c.get('t', None) # timestamp
461
+ cached_data = c.get('d', None) # data
462
+ cached_response_type = c.get('c', None) # response_type
463
+ cache_valid = (
464
+ cached_timestamp is not None
465
+ and time.time() - cached_timestamp < Const.CACHE_TIMEOUT
466
+ and cached_response_type == ResponseType.ANSWER
467
+ and cached_data is not None
468
+ )
469
+ if cache_valid:
470
+ assert isinstance(cached_data, bytes)
471
+ assert isinstance(cached_response_type, int)
472
+ if self.print_traffic:
473
+ cached_packet = bytes([cached_response_type & 0xFF, len(cached_data) & 0xFF]) + cached_data
474
+ print(Fore.MAGENTA + f"FOUND: [----, {' '.join(f'0x{b:02X}' for b in cache_key)}, ----] "
475
+ + Fore.RED + Style.DIM + f" CACHE HIT"
476
+ + Style.BRIGHT + Fore.CYAN + f" [{' '.join(f'0x{b:02X}' for b in cached_packet)}, ----]"
477
+ + Style.RESET_ALL)
478
+ return cached_data, cached_response_type
479
+ else:
480
+ del self.cache[cache_key]
481
+
482
+ # Ensure client is properly initialized and not closed
483
+ await self._ensure_client(controller)
484
+ assert controller.client is not None
485
+
486
+ response: Response = await controller.client.send_request_with_retries(
487
+ request,
488
+ retries=ClientConst.DEFAULT_RETRIES,
489
+ )
490
+
491
+ # Timeout?
492
+ # Work out how many msec we waited for
493
+ wait_time_ms = (time.time() - request.timestamp) * 1000
494
+ if response.response_type == ResponseType.TIMEOUT:
495
+ raw_sent = request.raw_sent or (response.request.raw_sent if response.request else None)
496
+ raw_sent_str = f"[{' '.join(f'0x{b:02X}' for b in raw_sent)}]" if raw_sent else "[]"
497
+ self.logger.error(f"UDP packet response from {controller.host}:{controller.port} not received after {wait_time_ms:.0f}ms, probably offline {raw_sent_str}")
498
+ await self._invalidate_client(controller)
499
+ controller.refresh_ip()
500
+ raise ZenTimeoutError(f"No response from {controller.host}:{controller.port} after {wait_time_ms:.0f}ms")
501
+
502
+ # Write to cache?
503
+ if cacheable and cache_key is not None:
504
+ if response.response_type == ResponseType.ANSWER and response.data is not None:
505
+ self.cache[cache_key] = {'d': response.data, 'c': response.response_type.value, 't': time.time()}
506
+
507
+ # print_traffic
508
+ req = response.request
509
+ if self.print_traffic and req is not None and req.raw_sent and response.raw_rcvd:
510
+ rtt_ms = (response.timestamp - req.timestamp) * 1000
511
+ cmd_name = next((name for name, code in self.CMD.items() if code == req.command), f"0x{req.command:02X}")
512
+ print(Fore.MAGENTA + f"REQUEST: [{' '.join(f'0x{b:02X}' for b in req.raw_sent)}]"
513
+ + Style.RESET_ALL + Fore.GREEN + f" {cmd_name}"
514
+ + Style.RESET_ALL + Fore.CYAN + f" RESPONSE: [{' '.join(f'0x{b:02X}' for b in response.raw_rcvd)}]"
515
+ + Style.RESET_ALL + Fore.WHITE + Style.DIM + f" RTT: {rtt_ms:.0f}ms".ljust(10)
516
+ + Style.RESET_ALL)
517
+
518
+ return response.data, response.response_type.value
519
+
520
+ async def _ensure_client(self, controller: ZenController) -> None:
521
+ """Create or replace the UDP client when missing or disconnected."""
522
+ if controller.client is not None and controller.client.is_connected():
523
+ return
524
+ if controller.client is not None:
525
+ await self._invalidate_client(controller)
526
+ controller.client = await ZenClient.create(
527
+ (controller.ip, controller.port),
528
+ logger=self.logger,
529
+ )
530
+
531
+ async def _invalidate_client(self, controller: ZenController) -> None:
532
+ """Close and drop a stale client so the next send recreates it."""
533
+ client = controller.client
534
+ controller.client = None
535
+ if client is None:
536
+ return
537
+ try:
538
+ await client.close()
539
+ except Exception:
540
+ pass
541
+
542
+ def _resolve_unicast_advertise_ip(self) -> str:
543
+ """Local IPv4 to advertise to controllers for unicast event delivery."""
544
+ if self.listen_ip and self.listen_ip != "0.0.0.0":
545
+ return self.listen_ip
546
+ if self.controllers:
547
+ return local_ip_for_remote(self.controllers[0].ip)
548
+ # No controllers yet — best-effort via a public DNS address for routing
549
+ return local_ip_for_remote("8.8.8.8")
550
+
551
+ # ============================
552
+ # EVENT LISTENING
553
+ # ============================
554
+
555
+ def set_callbacks(self,
556
+ button_press_callback: Optional[Callable[..., Awaitable[None]]] = None,
557
+ button_hold_callback: Optional[Callable[..., Awaitable[None]]] = None,
558
+ absolute_input_callback: Optional[Callable[..., Awaitable[None]]] = None,
559
+ level_change_callback: Optional[Callable[..., Awaitable[None]]] = None,
560
+ group_level_change_callback: Optional[Callable[..., Awaitable[None]]] = None,
561
+ scene_change_callback: Optional[Callable[..., Awaitable[None]]] = None,
562
+ is_occupied_callback: Optional[Callable[..., Awaitable[None]]] = None,
563
+ colour_change_callback: Optional[Callable[..., Awaitable[None]]] = None,
564
+ profile_change_callback: Optional[Callable[..., Awaitable[None]]] = None,
565
+ system_variable_change_callback: Optional[Callable[..., Awaitable[None]]] = None,
566
+ disconnect_callback: Optional[Callable[[], Awaitable[None]]] = None,
567
+ ):
568
+ self.button_press_callback = button_press_callback
569
+ self.button_hold_callback = button_hold_callback
570
+ self.absolute_input_callback = absolute_input_callback
571
+ self.level_change_callback = level_change_callback
572
+ self.group_level_change_callback = group_level_change_callback
573
+ self.scene_change_callback = scene_change_callback
574
+ self.is_occupied_callback = is_occupied_callback
575
+ self.colour_change_callback = colour_change_callback
576
+ self.profile_change_callback = profile_change_callback
577
+ self.system_variable_change_callback = system_variable_change_callback
578
+ self.disconnect_callback = disconnect_callback
579
+
580
+ async def start_event_monitoring(self):
581
+ if self.event_task and not self.event_task.done():
582
+ # Event monitoring already running
583
+ return
584
+
585
+ self._disconnect_notified = False
586
+
587
+ if self.unicast:
588
+ self.local_ip = self._resolve_unicast_advertise_ip()
589
+ self.logger.info(f"Advertising unicast event address {self.local_ip}")
590
+
591
+ # Bind listener first so unicast mode uses the actual assigned port (not 0)
592
+ self.event_listener = await ZenListener.create(
593
+ unicast=self.unicast,
594
+ listen_ip=self.listen_ip or "0.0.0.0",
595
+ listen_port=(self.listen_port or 0) if self.unicast else EventConst.MULTICAST_PORT,
596
+ logger=self.logger
597
+ )
598
+ if self.unicast:
599
+ self.listen_port = self.event_listener.listen_port
600
+
601
+ # For the sake of our sanity, all controllers must send event packets in the same way: either multicast or unicast (on one port)
602
+ for controller in self.controllers:
603
+ await self.set_tpi_event_unicast_address(controller, ipaddr=self.local_ip if self.unicast else None, port=self.listen_port if self.unicast else None)
604
+ await self.tpi_event_emit(controller, ZenEventMode(enabled=True, filtering=controller.filtering, unicast=self.unicast, multicast=not self.unicast))
605
+
606
+ self.event_task = asyncio.create_task(self._async_event_listener())
607
+
608
+ async def notify_disconnect(self) -> None:
609
+ """Fire disconnect_callback at most once per monitoring session."""
610
+ if self._disconnect_notified:
611
+ return
612
+ self._disconnect_notified = True
613
+ if not callable(self.disconnect_callback):
614
+ return
615
+ try:
616
+ await self.disconnect_callback()
617
+ except Exception as err:
618
+ self.logger.error(f"disconnect_callback error: {err}")
619
+
620
+ async def _async_event_listener(self):
621
+ """Async event listener using ZenListener"""
622
+ # True unless stop_event_monitoring cancelled us intentionally
623
+ unexpected = True
624
+ event_listener = self.event_listener
625
+ if event_listener is None:
626
+ return
627
+ try:
628
+ async with event_listener:
629
+ async for event in event_listener.events():
630
+ # Process the event using the new ZenEvent structure
631
+ await self._process_zen_event(event)
632
+ self.logger.error("Async event listener stopped unexpectedly")
633
+ except asyncio.CancelledError:
634
+ unexpected = False
635
+ raise
636
+ except Exception as e:
637
+ self.logger.error(f"Async event listener error: {e}")
638
+ self.logger.error(f"Stack trace: {traceback.format_stack()}")
639
+ finally:
640
+ if self.event_listener:
641
+ try:
642
+ await self.event_listener.close()
643
+ except Exception:
644
+ pass
645
+ self.event_listener = None
646
+ if unexpected:
647
+ await self.notify_disconnect()
648
+
649
+ def get_controller_by_ip_mac(self, ip: Optional[str] = None, mac: Optional[bytes] = None):
650
+ """Find a controller by IP address or MAC address"""
651
+ if ip is not None:
652
+ for ctrl in self.controllers:
653
+ if ctrl.ip == ip:
654
+ return ctrl
655
+ if mac is not None:
656
+ for ctrl in self.controllers:
657
+ if ctrl.mac_bytes == mac:
658
+ return ctrl
659
+ return None
660
+
661
+ async def _process_zen_event(self, event: ZenEvent):
662
+ """Process received ZenEvent from ZenListener"""
663
+ typecast = "unicast" if self.unicast else "multicast"
664
+
665
+ # Find the controller that sent this event
666
+ controller = self.get_controller_by_ip_mac(event.ip_address, event.mac_address)
667
+ if not controller:
668
+ self.logger.warning(f"Received {typecast} packet from unknown controller: {event.ip_address}: [{' '.join(f'0x{b:02X}' for b in event.raw_data)}]")
669
+ return
670
+
671
+ ip_address = event.ip_address
672
+ ip_port = event.ip_port
673
+ target = event.target
674
+ payload = event.payload
675
+ event_code = event.event_code
676
+ try:
677
+ event_enum = ZenEventCode(event_code)
678
+ except ValueError:
679
+ self.logger.warning(f"Unknown event code {event_code} from {ip_address}")
680
+ return
681
+
682
+ min_payload = {
683
+ ZenEventCode.BUTTON_PRESS: 1,
684
+ ZenEventCode.BUTTON_HOLD: 1,
685
+ ZenEventCode.ABSOLUTE_INPUT: 1,
686
+ ZenEventCode.LEVEL_CHANGE: 1,
687
+ ZenEventCode.LEVEL_CHANGE_V2: 2,
688
+ ZenEventCode.GROUP_LEVEL_CHANGE: 1,
689
+ ZenEventCode.SCENE_CHANGE: 2,
690
+ ZenEventCode.IS_OCCUPIED: 1,
691
+ ZenEventCode.SYSTEM_VARIABLE_CHANGE: 5,
692
+ ZenEventCode.COLOUR_CHANGE: 1,
693
+ ZenEventCode.PROFILE_CHANGE: 1,
694
+ }.get(event_enum, 0)
695
+ if len(payload) < min_payload:
696
+ self.logger.warning(f"Event {event_enum.name} payload too short: {len(payload)} < {min_payload}")
697
+ return
698
+ # Get event name from ZenEventCode, with fallback to unknown, underscores replaced with spaces, and first letter capitalized
699
+ event_name = event_enum.name.replace("_", " ").title()
700
+
701
+ # Print the raw packet
702
+ # if self.print_traffic:
703
+ # print(Fore.MAGENTA + f"{typecast.upper()} {ip_address}:" +
704
+ # Fore.CYAN + f" [{' '.join(f'0x{b:02X}' for b in event.raw_data)}]" +
705
+ # Style.RESET_ALL)
706
+
707
+ match event_enum:
708
+ case ZenEventCode.BUTTON_PRESS:
709
+ if self.print_traffic:
710
+ print(Fore.MAGENTA + f"{typecast.upper()} {ip_address}:" +
711
+ Fore.CYAN + f" Button {target-64}.{payload[0]} pressed" +
712
+ Style.RESET_ALL)
713
+ if self.button_press_callback:
714
+ address = ZenAddress(controller=controller, type=ZenAddressType.ECD, number=target-64)
715
+ instance = ZenInstance(address=address, type=ZenInstanceType.PUSH_BUTTON, number=payload[0])
716
+ await self.button_press_callback(instance=instance, payload=payload)
717
+
718
+ case ZenEventCode.BUTTON_HOLD:
719
+ if self.print_traffic:
720
+ print(Fore.MAGENTA + f"{typecast.upper()} {ip_address}:" +
721
+ Fore.CYAN + f" Button {target-64}.{payload[0]} held" +
722
+ Style.RESET_ALL)
723
+ if self.button_hold_callback:
724
+ address = ZenAddress(controller=controller, type=ZenAddressType.ECD, number=target-64)
725
+ instance = ZenInstance(address=address, type=ZenInstanceType.PUSH_BUTTON, number=payload[0])
726
+ await self.button_hold_callback(instance=instance, payload=payload)
727
+
728
+ case ZenEventCode.ABSOLUTE_INPUT:
729
+ if self.print_traffic:
730
+ print(Fore.MAGENTA + f"{typecast.upper()} {ip_address}:" +
731
+ Fore.CYAN + f" Absolute {target-64}" +
732
+ Style.DIM + f" [{' '.join(f'0x{b:02X}' for b in payload)}]" +
733
+ Style.RESET_ALL)
734
+ if self.absolute_input_callback:
735
+ address = ZenAddress(controller=controller, type=ZenAddressType.ECD, number=target-64)
736
+ instance = ZenInstance(address=address, type=ZenInstanceType.ABSOLUTE_INPUT, number=payload[0])
737
+ await self.absolute_input_callback(instance=instance, payload=payload)
738
+
739
+ case ZenEventCode.LEVEL_CHANGE:
740
+ # Deprecated — superseded by LEVEL_CHANGE_V2; spec recommends filtering this event type
741
+ pass
742
+
743
+ case ZenEventCode.LEVEL_CHANGE_V2:
744
+ # payload[0] = current arc level, payload[1] = arc level dimming to (authoritative destination)
745
+ current_level = payload[0]
746
+ target_level = payload[1]
747
+ if self.print_traffic:
748
+ addr_label = f"{'' if target <= 63 else 'group '}{target if target <= 63 else target-64}"
749
+ print(Fore.MAGENTA + f"{typecast.upper()} {ip_address}: [{' '.join(f'0x{b:02X}' for b in event.raw_data)}]" +
750
+ Fore.GREEN + f" LEVEL_CHANGE_V2 {addr_label} to {target_level}, currently {current_level}" +
751
+ Style.RESET_ALL)
752
+ if self.level_change_callback:
753
+ if target <= 63:
754
+ address = ZenAddress(controller=controller, type=ZenAddressType.ECG, number=target)
755
+ await self.level_change_callback(address=address, arc_level=target_level, payload=payload)
756
+ elif 64 <= target <= 79:
757
+ address = ZenAddress(controller=controller, type=ZenAddressType.GROUP, number=target-64)
758
+ if self.group_level_change_callback:
759
+ await self.group_level_change_callback(address=address, arc_level=target_level, payload=payload)
760
+ else:
761
+ self.logger.error(f"Invalid level change V2 event target: {target}")
762
+ return
763
+
764
+ case ZenEventCode.GROUP_LEVEL_CHANGE:
765
+ # Deprecated — superseded by LEVEL_CHANGE_V2 for group targets (64–79)
766
+ pass
767
+
768
+ case ZenEventCode.SCENE_CHANGE:
769
+ # Ignore event if it doesn't contain the "at scene" flag
770
+ if len(payload) < 2:
771
+ return
772
+ if self.print_traffic:
773
+ addr_label = f"{'' if target <= 63 else 'group '}{target if target <= 63 else target-64}"
774
+ print(Fore.MAGENTA + f"{typecast.upper()} {ip_address}: [{' '.join(f'0x{b:02X}' for b in event.raw_data)}]" +
775
+ Fore.GREEN + f" SCENE_CHANGE {addr_label} to {'' if payload[1] == 1 else 'inactive-'}{payload[0]}" +
776
+ Style.RESET_ALL)
777
+ if self.scene_change_callback:
778
+ if target <= 63:
779
+ address = ZenAddress(controller=controller, type=ZenAddressType.ECG, number=target)
780
+ elif 64 <= target <= 79:
781
+ address = ZenAddress(controller=controller, type=ZenAddressType.GROUP, number=target-64)
782
+ else:
783
+ self.logger.error(f"Invalid scene change event target: {target}")
784
+ return
785
+ await self.scene_change_callback(
786
+ address=address,
787
+ scene=payload[0],
788
+ active=bool(payload[1]),
789
+ payload=payload,
790
+ )
791
+
792
+ case ZenEventCode.IS_OCCUPIED:
793
+ if self.print_traffic:
794
+ print(Fore.MAGENTA + f"{typecast.upper()} {ip_address}:" +
795
+ Fore.CYAN + f" Motion sensor {target-64}.{payload[0]}" +
796
+ Style.DIM + f" [{' '.join(f'0x{b:02X}' for b in payload)}]" +
797
+ Style.RESET_ALL)
798
+ if self.is_occupied_callback:
799
+ address = ZenAddress(controller=controller, type=ZenAddressType.ECD, number=target-64)
800
+ instance = ZenInstance(address=address, type=ZenInstanceType.OCCUPANCY_SENSOR, number=payload[0])
801
+ await self.is_occupied_callback(instance=instance, payload=payload)
802
+
803
+ case ZenEventCode.SYSTEM_VARIABLE_CHANGE:
804
+ if not 0 <= target < Const.MAX_SYSVAR:
805
+ self.logger.error(f"Variable number must be between 0 and {Const.MAX_SYSVAR}, received {target}")
806
+ return
807
+ raw_value = int.from_bytes(payload[0:4], byteorder='big', signed=True)
808
+ magnitude = int.from_bytes([payload[4]], byteorder='big', signed=True)
809
+ value = raw_value * (10 ** magnitude)
810
+ if self.print_traffic:
811
+ print(Fore.MAGENTA + f"{typecast.upper()} {ip_address}:" +
812
+ Fore.CYAN + f" System Variable {target} set to {value}" +
813
+ Style.RESET_ALL)
814
+ if self.system_variable_change_callback:
815
+ await self.system_variable_change_callback(controller=controller, target=target, value=value, payload=payload)
816
+
817
+ case ZenEventCode.COLOUR_CHANGE:
818
+ if self.print_traffic:
819
+ print(Fore.MAGENTA + f"{typecast.upper()} {ip_address}:" +
820
+ Fore.CYAN + f" Colour change {'' if target <= 63 else 'group '}{target if target <= 63 else target-64}" +
821
+ Style.DIM + f" [{' '.join(f'0x{b:02X}' for b in payload)}]" +
822
+ Style.RESET_ALL)
823
+ if self.colour_change_callback:
824
+ if target < 64:
825
+ address = ZenAddress(controller=controller, type=ZenAddressType.ECG, number=target)
826
+ elif 64 <= target <= 79:
827
+ address = ZenAddress(controller=controller, type=ZenAddressType.GROUP, number=target-64)
828
+ else:
829
+ self.logger.error(f"Invalid colour change event target: {target}")
830
+ return
831
+ colour = ZenColour.from_bytes(payload)
832
+ if colour is None:
833
+ self.logger.warning(
834
+ f"Unparseable colour change payload from {ip_address}: "
835
+ f"[{' '.join(f'0x{b:02X}' for b in payload)}]"
836
+ )
837
+ return
838
+ await self.colour_change_callback(address=address, colour=colour, payload=payload)
839
+
840
+ case ZenEventCode.PROFILE_CHANGE:
841
+ payload_int = int.from_bytes(payload, byteorder='big')
842
+ if self.print_traffic:
843
+ print(Fore.MAGENTA + f"{typecast.upper()} {ip_address}:" +
844
+ Fore.CYAN + f" Profile change {payload_int}" +
845
+ Style.RESET_ALL)
846
+ if self.profile_change_callback:
847
+ await self.profile_change_callback(controller=controller, profile=payload_int, payload=payload)
848
+
849
+ case ZenEventCode.GROUP_OCCUPIED:
850
+ # Do nothing
851
+ pass
852
+
853
+
854
+ async def stop_event_monitoring(self):
855
+ """Stop listening for events"""
856
+ if self.event_task:
857
+ task = self.event_task
858
+ self.event_task = None
859
+ if not task.done():
860
+ task.cancel()
861
+ try:
862
+ await task
863
+ except asyncio.CancelledError:
864
+ pass
865
+ except Exception:
866
+ # Listener may have already failed; ignore so stop stays clean
867
+ pass
868
+ if self.event_listener:
869
+ await self.event_listener.close()
870
+ self.event_listener = None
871
+
872
+
873
+ # ============================
874
+ # API COMMANDS
875
+ # ============================
876
+
877
+ async def query_group_label(self, address: ZenAddress, generic_if_none: bool=False) -> Optional[str]:
878
+ """Get the label for a DALI Group. Returns a string, or None if no label is set."""
879
+ label = await self._send_basic(address.controller, self.CMD["QUERY_GROUP_LABEL"], address.group(), return_type='str', cacheable=True)
880
+ if label is None and generic_if_none: return f"Group {address.number}"
881
+ return label
882
+
883
+ async def query_dali_device_label(self, address: ZenAddress, generic_if_none: bool=False) -> Optional[str]:
884
+ """Query the label for a DALI device (control gear or control device). Returns a string, or None if no label is set."""
885
+ label = await self._send_basic(address.controller, self.CMD["QUERY_DALI_DEVICE_LABEL"], address.ecg_or_ecd(), return_type='str', cacheable=True)
886
+ if label is None and generic_if_none: label = f"{address.controller.label} ECD {address.number}"
887
+ return label
888
+
889
+ async def query_profile_label(self, controller: ZenController, profile: int) -> Optional[str]:
890
+ """Get the label for a Profile number (0-65535). Returns a string if a label exists, else None."""
891
+ # Profile numbers are 2 bytes long, so check valid range
892
+ if not 0 <= profile <= 65535:
893
+ raise ValueError("Profile number must be between 0 and 65535")
894
+ # Split profile number into upper and lower bytes
895
+ profile_upper = (profile >> 8) & 0xFF
896
+ profile_lower = profile & 0xFF
897
+ # Send request
898
+ return await self._send_basic(controller, self.CMD["QUERY_PROFILE_LABEL"], 0x00, [0x00, profile_upper, profile_lower], return_type='str', cacheable=True)
899
+
900
+ async def query_current_profile_number(self, controller: ZenController) -> Optional[int]:
901
+ """Get the current/active Profile number for a controller. Returns int, else None if query fails."""
902
+ response = await self._send_basic(controller, self.CMD["QUERY_CURRENT_PROFILE_NUMBER"])
903
+ if response and len(response) >= 2: # Profile number is 2 bytes, combine them into a single integer. First byte is high byte, second is low byte
904
+ return (response[0] << 8) | response[1]
905
+ return None
906
+
907
+ async def query_tpi_event_emit_state(self, controller: ZenController) -> Optional[bool]:
908
+ """Get the current TPI Event multicast emitter state for a controller. Returns True if enabled, False if disabled, None if query fails."""
909
+ response = await self._send_basic(controller, self.CMD["QUERY_TPI_EVENT_EMIT_STATE"])
910
+ if not response:
911
+ return None
912
+ return ZenEventMode.from_byte(response[0]).enabled
913
+
914
+ async def dali_add_tpi_event_filter(self, address: ZenAddress|ZenInstance, filter: ZenEventMask = ZenEventMask.all_events()) -> Optional[bool]:
915
+ """Stop specific events from an address/instance from being sent. Events in mask will be muted. Returns true if filter was added successfully."""
916
+ instance_number = 0xFF
917
+ if isinstance(address, ZenInstance):
918
+ instance: ZenInstance = address
919
+ instance_number = instance.number
920
+ address = instance.address
921
+ return await self._send_basic(address.controller,
922
+ self.CMD["DALI_ADD_TPI_EVENT_FILTER"],
923
+ address.ecg_or_ecd_or_broadcast(),
924
+ [instance_number, filter.upper(), filter.lower()],
925
+ return_type='bool')
926
+
927
+ async def dali_clear_tpi_event_filter(self, address: ZenAddress|ZenInstance, unfilter: ZenEventMask = ZenEventMask.all_events()) -> Optional[bool]:
928
+ """Allow specific events from an address/instance to be sent again. Events in mask will be unmuted. Returns true if filter was cleared successfully."""
929
+ instance_number = 0xFF
930
+ if isinstance(address, ZenInstance):
931
+ instance: ZenInstance = address
932
+ instance_number = instance.number
933
+ address = instance.address
934
+ return await self._send_basic(address.controller,
935
+ self.CMD["DALI_CLEAR_TPI_EVENT_FILTERS"],
936
+ address.ecg_or_ecd_or_broadcast(),
937
+ [instance_number, unfilter.upper(), unfilter.lower()],
938
+ return_type='bool')
939
+
940
+ async def query_dali_tpi_event_filters(self, address: ZenAddress|ZenInstance) -> list[dict[str, Any]]:
941
+ """Query active event filters for an address (or a specific instance). Returns a list of dictionaries containing filter info, or None if query fails."""
942
+ instance_number = 0xFF
943
+ if isinstance(address, ZenInstance):
944
+ instance: ZenInstance = address
945
+ instance_number = instance.number
946
+ address = instance.address
947
+
948
+ # As the data payload can only be up to 64 bytes and there are up to 64 event filters, it may be necessary to query several times.
949
+ # If you have all 64 event filters active, you will receive results 0-14 in the first response.
950
+ results = []
951
+ start_at = 0
952
+ while True:
953
+
954
+ response = await self._send_basic(address.controller,
955
+ self.CMD["QUERY_DALI_TPI_EVENT_FILTERS"],
956
+ address.ecg_or_ecd_or_broadcast(),
957
+ [start_at, 0x00, instance_number])
958
+
959
+ # Byte 0: TPI event modes active, ignored here.
960
+ # modes_active = response[0]
961
+
962
+ if response and len(response) >= 5: # Need at least modes + one result
963
+
964
+ # Starting from the second byte (1), process results in groups of 4 bytes
965
+ for i in range(1, len(response)-3, 4):
966
+ result = {
967
+ 'address': response[i],
968
+ 'instance': response[i+1],
969
+ 'event_mask': ZenEventMask.from_upper_lower(response[i+2], response[i+3])
970
+ }
971
+ results.append(result)
972
+
973
+ page_results = (len(response) - 1) // 4
974
+ if page_results < 15: # fewer than 15 results in this page — no more pages
975
+ break
976
+
977
+ else:
978
+ break # If there are no more results, stop querying
979
+
980
+ # To complete the set, you would request 15, 30, 45, 60 as starting numbers or until you receive None (NO_ANSWER).
981
+ start_at += 15
982
+
983
+ return results
984
+
985
+ async def tpi_event_emit(self, controller: ZenController, mode: ZenEventMode = ZenEventMode(enabled=True, filtering=False, unicast=False, multicast=True)) -> bool:
986
+ """Enable or disable TPI Event emission. Returns True if successful, else False."""
987
+ mask = mode.bitmask()
988
+ # response = await self._send_basic(controller, self.CMD["ENABLE_TPI_EVENT_EMIT"], 0x00) # disable first to clear any existing state... I think this is a bug?
989
+ response = await self._send_basic(controller, self.CMD["ENABLE_TPI_EVENT_EMIT"], mask)
990
+ if response:
991
+ if response[0] == mask:
992
+ return True
993
+ return False
994
+
995
+ async def set_tpi_event_unicast_address(self, controller: ZenController, ipaddr: Optional[str] = None, port: Optional[int] = None):
996
+ """Configure TPI Events for Unicast mode with IP and port as defined in the ZenController instance."""
997
+ data = [0,0,0,0,0,0]
998
+ if port is not None:
999
+ # Valid port number
1000
+ if not 0 <= port <= 65535: raise ValueError("Port must be between 0 and 65535")
1001
+
1002
+ # Split port into upper and lower bytes
1003
+ data[0] = (port >> 8) & 0xFF
1004
+ data[1] = port & 0xFF
1005
+
1006
+ # Convert IP string to bytes
1007
+ if ipaddr is None:
1008
+ raise ValueError("IP address required when port is set")
1009
+ try:
1010
+ ip_bytes = [int(x) for x in ipaddr.split('.')]
1011
+ if len(ip_bytes) != 4 or not all(0 <= x <= 255 for x in ip_bytes):
1012
+ raise ValueError
1013
+ data[2:6] = ip_bytes
1014
+ except ValueError:
1015
+ raise ValueError("Invalid IP address format")
1016
+
1017
+ return await self._send_dynamic(controller, self.CMD["SET_TPI_EVENT_UNICAST_ADDRESS"], data)
1018
+
1019
+ async def query_tpi_event_unicast_address(self, controller: ZenController) -> Optional[dict[str, Any]]:
1020
+ """Query TPI Events state and unicast configuration.
1021
+ Sends a Basic frame to query the TPI Event emit state, Unicast Port and Unicast Address.
1022
+
1023
+ Args:
1024
+ controller: ZenController instance
1025
+
1026
+ Returns:
1027
+ Optional dict containing:
1028
+ - bool: Whether TPI Events are enabled
1029
+ - bool: Whether Unicast mode is enabled
1030
+ - int: Configured unicast port
1031
+ - str: Configured unicast IP address
1032
+
1033
+ Returns None if query fails
1034
+ """
1035
+ response = await self._send_basic(controller, self.CMD["QUERY_TPI_EVENT_UNICAST_ADDRESS"])
1036
+ if response and len(response) >= 7:
1037
+ return {
1038
+ 'mode': ZenEventMode.from_byte(response[0]),
1039
+ 'port': (response[1] << 8) | response[2],
1040
+ 'ip': f"{response[3]}.{response[4]}.{response[5]}.{response[6]}"
1041
+ }
1042
+ return None
1043
+
1044
+ async def query_group_numbers(self, controller: ZenController) -> list[ZenAddress]:
1045
+ """Query a controller for groups."""
1046
+ groups = await self._send_basic(controller, self.CMD["QUERY_GROUP_NUMBERS"], return_type='list')
1047
+ zen_groups = []
1048
+ if groups is not None:
1049
+ groups.sort()
1050
+ for group in groups:
1051
+ zen_groups.append(ZenAddress(controller=controller, type=ZenAddressType.GROUP, number=group))
1052
+ return zen_groups
1053
+
1054
+ async def query_dali_colour(self, address: ZenAddress) -> Optional[ZenColour]:
1055
+ """Query colour information from a DALI address."""
1056
+ response = await self._send_basic(address.controller, self.CMD["QUERY_DALI_COLOUR"], address.ecg())
1057
+ if response is None:
1058
+ return None
1059
+ return ZenColour.from_bytes(response)
1060
+
1061
+ async def query_profile_information(self, controller: ZenController) -> Optional[tuple[dict[str, Any], dict[int, dict[str, bool | int | str]]]]:
1062
+ """Query a controller for profile information. Returns a tuple of two dicts, or None if query fails."""
1063
+ response = await self._send_basic(controller, self.CMD["QUERY_PROFILE_INFORMATION"], cacheable=True)
1064
+ if not response or len(response) < 12:
1065
+ return None
1066
+ # Initial 12 bytes:
1067
+ # 0-1 0x00 Current Active Profile Number
1068
+ # 2-3 0x00 Last Scheduled Profile Number
1069
+ # 4-7 0x22334455 Last Overridden Profile UTC
1070
+ # 8-11 0x44556677 Last Scheduled Profile UTC
1071
+ unpacked = struct.unpack('>HHII', response[0:12])
1072
+ state: dict[str, Any] = {
1073
+ 'current_active_profile': unpacked[0],
1074
+ 'last_scheduled_profile': unpacked[1],
1075
+ 'last_overridden_profile_utc': dt.fromtimestamp(unpacked[2]),
1076
+ 'last_scheduled_profile_utc': dt.fromtimestamp(unpacked[3])
1077
+ }
1078
+ # Process profiles in groups of 3 bytes (2 bytes for profile number, 1 byte for profile behaviour)
1079
+ profiles: dict[int, dict[str, bool | int | str]] = {}
1080
+ for i in range(12, len(response), 3):
1081
+ profile_number = struct.unpack('>H', response[i:i+2])[0]
1082
+ profile_behaviour = response[i+2]
1083
+ # bit 0: disabled flag — 1 = disabled, 0 = enabled
1084
+ # bit 1-2: priority — 0 = scheduled, 1 = medium, 2 = high, 3 = emergency
1085
+ enabled = not bool(profile_behaviour & 0x01)
1086
+ priority = (profile_behaviour >> 1) & 0x03
1087
+ priority_label = ["Scheduled", "Medium", "High", "Emergency"][priority]
1088
+ profiles[profile_number] = {"enabled": enabled, "priority": priority, "priority_label": priority_label}
1089
+ # Return tuple of state and profiles
1090
+ return state, profiles
1091
+
1092
+ async def query_profile_numbers(self, controller: ZenController) -> Optional[list[int]]:
1093
+ """Query a controller for a list of available Profile Numbers. Returns a list of profile numbers, or None if query fails."""
1094
+ response = await self._send_basic(controller, self.CMD["QUERY_PROFILE_NUMBERS"])
1095
+ if response and len(response) >= 2:
1096
+ # Response contains pairs of bytes for each profile number
1097
+ profile_numbers = []
1098
+ for i in range(0, len(response), 2):
1099
+ if i + 1 < len(response):
1100
+ profile_num = (response[i] << 8) | response[i+1]
1101
+ profile_numbers.append(profile_num)
1102
+ return profile_numbers
1103
+ return None
1104
+
1105
+ async def query_occupancy_instance_timers(self, instance: ZenInstance) -> Optional[dict[str, Any]]:
1106
+ """Query timer values for a DALI occupancy sensor instance. Returns dict, or None if query fails.
1107
+
1108
+ Returns:
1109
+ dict:
1110
+ - int: Deadtime in seconds (0-255)
1111
+ - int: Hold time in seconds (0-255)
1112
+ - int: Report time in seconds (0-255)
1113
+ - int: Seconds since last occupied status (0-65535)
1114
+ """
1115
+ response = await self._send_basic(instance.address.controller, self.CMD["QUERY_OCCUPANCY_INSTANCE_TIMERS"], instance.address.ecd(), [0x00, 0x00, instance.number])
1116
+ if response and len(response) >= 5:
1117
+ return {
1118
+ 'deadtime': response[0],
1119
+ 'hold': response[1],
1120
+ 'report': response[2],
1121
+ 'last_detect': (response[3] << 8) | response[4]
1122
+ }
1123
+ return None
1124
+
1125
+ async def query_instances_by_address(self, address: ZenAddress) -> list[ZenInstance]:
1126
+ """Query a DALI address (ECD) for associated instances. Returns a list of ZenInstance, or an empty list if nothing found."""
1127
+ response = await self._send_basic(address.controller, self.CMD["QUERY_INSTANCES_BY_ADDRESS"], address.ecd(), cacheable=True)
1128
+ if response and len(response) >= 4:
1129
+ instances = []
1130
+ # Process groups of 4 bytes for each instance
1131
+ for i in range(0, len(response), 4):
1132
+ if i + 3 < len(response):
1133
+ instance_type = ZenInstanceType(response[i+1]) if response[i+1] in ZenInstanceType._value2member_map_ else None
1134
+ if instance_type is None:
1135
+ continue
1136
+ instances.append(ZenInstance(
1137
+ address=address,
1138
+ number=response[i], # first byte
1139
+ type=instance_type, # second byte
1140
+ active=bool(response[i+2] & 0x02), # third byte, second bit
1141
+ error=bool(response[i+2] & 0x01), # third byte, first bit
1142
+ ))
1143
+ return instances
1144
+ return []
1145
+
1146
+ async def query_operating_mode_by_address(self, address: ZenAddress) -> Optional[int]:
1147
+ """Query a DALI address (ECG or ECD) for its operating mode. Returns an int containing the operating mode value, or None if the query fails."""
1148
+ response = await self._send_basic(address.controller, self.CMD["QUERY_OPERATING_MODE_BY_ADDRESS"], address.ecg_or_ecd(), cacheable=True)
1149
+ if response and len(response) == 1:
1150
+ return response[0] # Operating mode is in first byte
1151
+ return None
1152
+
1153
+ async def dali_colour(self, address: ZenAddress, colour: ZenColour, level: int = 255) -> Optional[bool]:
1154
+ """Set a DALI address (ECG, group, broadcast) to a colour. Returns True if command succeeded, False otherwise."""
1155
+ return await self._send_colour(address.controller, self.CMD["DALI_COLOUR"], address.ecg_or_group_or_broadcast(), colour, level)
1156
+
1157
+ async def query_group_by_number(self, address: ZenAddress) -> Optional[tuple[int, bool, int]]: # TODO: change to a dict or special class?
1158
+ """Query a DALI group for its occupancy status and level. Returns a tuple containing group number, occupancy status, and actual level."""
1159
+ response = await self._send_basic(address.controller, self.CMD["QUERY_GROUP_BY_NUMBER"], address.group())
1160
+ if response and len(response) == 3:
1161
+ group_num = response[0]
1162
+ occupancy = bool(response[1])
1163
+ level = response[2]
1164
+ return (group_num, occupancy, level)
1165
+ return None
1166
+
1167
+ async def query_scene_numbers_by_address(self, address: ZenAddress) -> Optional[list[int]]:
1168
+ """Query a DALI address (ECG) for associated scenes. Returns a list of scene numbers where levels have been set."""
1169
+ return await self._send_basic(address.controller, self.CMD["QUERY_SCENE_NUMBERS_BY_ADDRESS"], address.ecg(), return_type='list')
1170
+
1171
+ async def query_scene_levels_by_address(self, address: ZenAddress) -> list[Optional[int]]:
1172
+ """Query a DALI address (ECG) for its DALI scene levels. Returns a list of 16 scene level values (0-254, or None if not part of scene)."""
1173
+ response = await self._send_basic(address.controller, self.CMD["QUERY_SCENE_LEVELS_BY_ADDRESS"], address.ecg(), return_type='list', cacheable=True)
1174
+ if response:
1175
+ return [None if x == 255 else x for x in response]
1176
+ return [None] * Const.MAX_SCENE
1177
+
1178
+ async def query_colour_scene_membership_by_address(self, address: ZenAddress) -> list[int]:
1179
+ """Query a DALI address (ECG) for which scenes have colour change data. Returns a list of scene numbers."""
1180
+ response = await self._send_basic(address.controller, self.CMD["QUERY_COLOUR_SCENE_MEMBERSHIP_BY_ADDR"], address.ecg(), return_type='list', cacheable=True)
1181
+ if response:
1182
+ return response
1183
+ return []
1184
+
1185
+ async def query_scene_colours_by_address(self, address: ZenAddress) -> list[Optional[ZenColour]]:
1186
+ """Query a DALI address (ECG) for its colour scene data. Returns a list of 16 scene level values (0-254, or None if not part of scene)."""
1187
+ # Create a list of 12 ZenColour instances
1188
+ output: list[Optional[ZenColour]] = [None] * Const.MAX_SCENE
1189
+ # Queries
1190
+ response = await self._send_basic(address.controller, self.CMD["QUERY_COLOUR_SCENE_0_7_DATA_FOR_ADDR"], address.ecg(), cacheable=True)
1191
+ if response is None:
1192
+ return output
1193
+ response2 = await self._send_basic(address.controller, self.CMD["QUERY_COLOUR_SCENE_8_11_DATA_FOR_ADDR"], address.ecg(), cacheable=True)
1194
+ if response2 is None:
1195
+ return output
1196
+ response += response2
1197
+ # Combined result should always be exactly 7*12 = 84 bytes
1198
+ if len(response) != 84:
1199
+ print(f"Warning: QUERY_COLOUR_SCENE_***_DATA_FOR_ADDR returned {len(response)} bytes, expected 84")
1200
+ return output
1201
+ # Data is in 7 byte segments
1202
+ for i in range(0, Const.MAX_SCENE):
1203
+ offset = i*7
1204
+ output[i] = ZenColour.from_bytes(response[offset:offset+7])
1205
+ return output
1206
+
1207
+ async def query_group_membership_by_address(self, address: ZenAddress) -> list[ZenAddress]:
1208
+ """Query an address (ECG) for which DALI groups it belongs to. Returns a list of ZenAddress group instances."""
1209
+ response = await self._send_basic(address.controller, self.CMD["QUERY_GROUP_MEMBERSHIP_BY_ADDRESS"], address.ecg(), cacheable=True)
1210
+ if response and len(response) == 2:
1211
+ groups = []
1212
+ # Process high byte (groups 8-15)
1213
+ for i in range(8):
1214
+ if response[0] & (1 << i):
1215
+ groups.append(i + 8)
1216
+ # Process low byte (groups 0-7)
1217
+ for i in range(8):
1218
+ if response[1] & (1 << i):
1219
+ groups.append(i)
1220
+ # Process into ZenAddress instances
1221
+ groups.sort()
1222
+ zen_groups = []
1223
+ for number in groups:
1224
+ zen_groups.append(ZenAddress(
1225
+ controller=address.controller,
1226
+ type=ZenAddressType.GROUP,
1227
+ number=number
1228
+ ))
1229
+ return zen_groups
1230
+ return []
1231
+
1232
+ async def query_dali_addresses_with_instances(self, controller: ZenController, start_address: int=0) -> list[ZenAddress]: # TODO: automate iteration over start_address=0, start_address=60, etc.
1233
+ """Query for DALI addresses that have instances associated with them.
1234
+
1235
+ Due to payload restrictions, this needs to be called multiple times with different
1236
+ start addresses to check all possible devices (e.g. start_address=0, then start_address=60)
1237
+
1238
+ Args:
1239
+ controller: ZenController instance
1240
+ start_address: Starting DALI address to begin searching from (0-127)
1241
+
1242
+ Returns:
1243
+ List of DALI addresses that have instances, or None if query fails
1244
+ """
1245
+ addresses = await self._send_basic(controller, self.CMD["QUERY_DALI_ADDRESSES_WITH_INSTANCES"], 0, [0,0,start_address], return_type='list')
1246
+ if not addresses:
1247
+ return []
1248
+ zen_addresses = []
1249
+ for number in addresses:
1250
+ if 64 <= number <= 127: # Only process valid device addresses (64-127)
1251
+ zen_addresses.append(ZenAddress(
1252
+ controller=controller,
1253
+ type=ZenAddressType.ECD,
1254
+ number=number-64 # subtract 64 to get actual DALI device address
1255
+ ))
1256
+ return zen_addresses
1257
+
1258
+ async def query_scene_numbers_for_group(self, address: ZenAddress) -> list[int]:
1259
+ """Query which DALI scenes are associated with a given group number. Returns list of scene numbers."""
1260
+ response = await self._send_basic(address.controller, self.CMD["QUERY_SCENE_NUMBERS_FOR_GROUP"], address.group(), cacheable=True)
1261
+ if response and len(response) == 2:
1262
+ scenes = []
1263
+ # Process high byte (scenes 8-15)
1264
+ for i in range(8):
1265
+ if response[0] & (1 << i):
1266
+ scenes.append(i + 8)
1267
+ # Process low byte (scenes 0-7)
1268
+ for i in range(8):
1269
+ if response[1] & (1 << i):
1270
+ scenes.append(i)
1271
+ return sorted(scenes)
1272
+ return []
1273
+
1274
+ async def query_scene_label_for_group(self, address: ZenAddress, scene: int, generic_if_none: bool=False) -> Optional[str]:
1275
+ """Query the label for a scene (0-11) and group number combination. Returns string, or None if no label is set."""
1276
+ if not 0 <= scene < Const.MAX_SCENE: raise ValueError("Scene must be between 0 and 11")
1277
+ label = await self._send_basic(address.controller, self.CMD["QUERY_SCENE_LABEL_FOR_GROUP"], address.group(), [scene], return_type='str', cacheable=True)
1278
+ if label is None and generic_if_none:
1279
+ return f"Scene {scene}"
1280
+ return label
1281
+
1282
+ async def query_scenes_for_group(self, address: ZenAddress, generic_if_none: bool=False) -> list[Optional[str]]:
1283
+ """Compound command to query the labels for all scenes for a group. Returns list of scene labels, where None indicates no label is set."""
1284
+ scenes: list[Optional[str]] = [None] * Const.MAX_SCENE
1285
+ numbers = await self.query_scene_numbers_for_group(address)
1286
+ if numbers:
1287
+ for scene in numbers:
1288
+ scenes[scene] = await self.query_scene_label_for_group(address, scene, generic_if_none=generic_if_none)
1289
+ return scenes
1290
+
1291
+ async def query_controller_version_number(self, controller: ZenController) -> Optional[str]:
1292
+ """Query the controller's version number. Returns string, or None if query fail s."""
1293
+ response = await self._send_basic(controller, self.CMD["QUERY_CONTROLLER_VERSION_NUMBER"])
1294
+ if response and len(response) == 3:
1295
+ return f"{response[0]}.{response[1]}.{response[2]}"
1296
+ return None
1297
+
1298
+ async def query_control_gear_dali_addresses(self, controller: ZenController) -> list[ZenAddress]:
1299
+ """Query which DALI control gear addresses are present in the database. Returns a list of ZenAddress instances."""
1300
+ response = await self._send_basic(controller, self.CMD["QUERY_CONTROL_GEAR_DALI_ADDRESSES"])
1301
+ if response and len(response) == 8: # 8 data bytes representing addresses 0-63
1302
+ addresses = []
1303
+ # Process each byte which represents 8 addresses
1304
+ for byte_index, byte_value in enumerate(response):
1305
+ # Check each bit in the byte
1306
+ for bit_index in range(8):
1307
+ if byte_value & (1 << bit_index):
1308
+ # Calculate actual address from byte and bit position
1309
+ number = byte_index * 8 + bit_index
1310
+ addresses.append(
1311
+ ZenAddress(
1312
+ controller=controller,
1313
+ type=ZenAddressType.ECG,
1314
+ number=number
1315
+ )
1316
+ )
1317
+ return addresses
1318
+ return []
1319
+
1320
+ async def dali_inhibit(self, address: ZenAddress, time_seconds: int) -> Optional[bool]:
1321
+ """Inhibit sensors from changing a DALI address (ECG or group or broadcast) for specified time in seconds (0-65535). Returns True if acknowledged, else False."""
1322
+ time_hi = (time_seconds >> 8) & 0xFF # Convert time to 16-bit value
1323
+ time_lo = time_seconds & 0xFF
1324
+ return await self._send_basic(address.controller, self.CMD["DALI_INHIBIT"], address.ecg_or_group_or_broadcast(), [0x00, time_hi, time_lo], return_type='ok')
1325
+
1326
+ async def dali_scene(self, address: ZenAddress, scene: int) -> Optional[bool]:
1327
+ """Send RECALL SCENE (0-11) to an address (ECG or group or broadcast). Returns True if acknowledged, else False."""
1328
+ if not 0 <= scene < Const.MAX_SCENE: raise ValueError(f"Scene number must be between 0 and {Const.MAX_SCENE}, got {scene}")
1329
+ return await self._send_basic(address.controller, self.CMD["DALI_SCENE"], address.ecg_or_group_or_broadcast(), [0x00, 0x00, scene], return_type='ok')
1330
+
1331
+ async def dali_arc_level(self, address: ZenAddress, level: int) -> Optional[bool]:
1332
+ """Send DIRECT ARC level (0-254) to an address (ECG or group or broadcast). Will fade to the new level. Returns True if acknowledged, else False."""
1333
+ if not 0 <= level <= Const.MAX_LEVEL: raise ValueError(f"Level must be between 0 and {Const.MAX_LEVEL}, got {level}")
1334
+ return await self._send_basic(address.controller, self.CMD["DALI_ARC_LEVEL"], address.ecg_or_group_or_broadcast(), [0x00, 0x00, level], return_type='ok')
1335
+
1336
+ async def dali_on_step_up(self, address: ZenAddress) -> Optional[bool]:
1337
+ """Send ON AND STEP UP to an address (ECG or group or broadcast). If a device is off, it will turn it on. If a device is on, it will step up. No fade."""
1338
+ return await self._send_basic(address.controller, self.CMD["DALI_ON_STEP_UP"], address.ecg_or_group_or_broadcast(), return_type='ok')
1339
+
1340
+ async def dali_step_down_off(self, address: ZenAddress) -> Optional[bool]:
1341
+ """Send STEP DOWN AND OFF to an address (ECG or group or broadcast). If a device is at min, it will turn off. If a device isn't yet at min, it will step down. No fade."""
1342
+ return await self._send_basic(address.controller, self.CMD["DALI_STEP_DOWN_OFF"], address.ecg_or_group_or_broadcast(), return_type='ok')
1343
+
1344
+ async def dali_up(self, address: ZenAddress) -> Optional[bool]:
1345
+ """Send DALI UP to an address (ECG or group or broadcast). Will fade to the new level. Returns True if acknowledged, else False."""
1346
+ return await self._send_basic(address.controller, self.CMD["DALI_UP"], address.ecg_or_group_or_broadcast(), return_type='ok')
1347
+
1348
+ async def dali_down(self, address: ZenAddress) -> Optional[bool]:
1349
+ """Send DALI DOWN to an address (ECG or group or broadcast). Will fade to the new level. Returns True if acknowledged, else False."""
1350
+ return await self._send_basic(address.controller, self.CMD["DALI_DOWN"], address.ecg_or_group_or_broadcast(), return_type='ok')
1351
+
1352
+ async def dali_recall_max(self, address: ZenAddress) -> Optional[bool]:
1353
+ """Send RECALL MAX to an address (ECG or group or broadcast). No fade. Returns True if acknowledged, else False."""
1354
+ return await self._send_basic(address.controller, self.CMD["DALI_RECALL_MAX"], address.ecg_or_group_or_broadcast(), return_type='ok')
1355
+
1356
+ async def dali_recall_min(self, address: ZenAddress) -> Optional[bool]:
1357
+ """Send RECALL MIN to an address (ECG or group or broadcast). No fade. Returns True if acknowledged, else False."""
1358
+ return await self._send_basic(address.controller, self.CMD["DALI_RECALL_MIN"], address.ecg_or_group_or_broadcast(), return_type='ok')
1359
+
1360
+ async def dali_off(self, address: ZenAddress) -> Optional[bool]:
1361
+ """Send OFF to an address (ECG or group or broadcast). No fade. Returns True if acknowledged, else False."""
1362
+ return await self._send_basic(address.controller, self.CMD["DALI_OFF"], address.ecg_or_group_or_broadcast(), return_type='ok')
1363
+
1364
+ async def dali_query_level(self, address: ZenAddress) -> Optional[int]:
1365
+ """Query the Arc Level for a DALI address (ECG or group). Returns arc level as int, or None if mixed levels."""
1366
+ response = await self._send_basic(address.controller, self.CMD["DALI_QUERY_LEVEL"], address.ecg_or_group(), return_type='int')
1367
+ if response == 255: return None # 255 indicates mixed levels
1368
+ return response
1369
+
1370
+ async def dali_query_control_gear_status(self, address: ZenAddress) -> Optional[dict[str, Any]]:
1371
+ """Query the Status for a DALI address (ECG or group or broadcast). Returns a dictionary of status flags."""
1372
+ response = await self._send_basic(address.controller, self.CMD["DALI_QUERY_CONTROL_GEAR_STATUS"], address.ecg_or_group_or_broadcast(), cacheable=True)
1373
+ if response and len(response) == 1:
1374
+ return {
1375
+ "cg_failure": bool(response[0] & 0x01),
1376
+ "lamp_failure": bool(response[0] & 0x02),
1377
+ "lamp_power_on": bool(response[0] & 0x04),
1378
+ "limit_error": bool(response[0] & 0x08), # (an Arc-level > Max or < Min requested)
1379
+ "fade_running": bool(response[0] & 0x10),
1380
+ "reset": bool(response[0] & 0x20),
1381
+ "missing_short_address": bool(response[0] & 0x40),
1382
+ "power_failure": bool(response[0] & 0x80)
1383
+ }
1384
+ return None
1385
+
1386
+ async def dali_query_cg_type(self, address: ZenAddress) -> Optional[list[int]]:
1387
+ """Query device type information for a DALI address (ECG).
1388
+
1389
+ Returns:
1390
+ Optional[list[int]]: List of device type numbers that the control gear belongs to.
1391
+ Returns empty list if device doesn't exist.
1392
+ Returns None if query fails.
1393
+ """
1394
+ response = await self._send_basic(address.controller, self.CMD["DALI_QUERY_CG_TYPE"], address.ecg(), cacheable=True)
1395
+ if response and len(response) == 4:
1396
+ device_types = []
1397
+ # Process each byte which represents 8 device types
1398
+ for byte_index, byte_value in enumerate(response):
1399
+ # Check each bit in the byte
1400
+ for bit in range(8):
1401
+ if byte_value & (1 << bit):
1402
+ # Calculate actual device type number
1403
+ device_type = byte_index * 8 + bit
1404
+ device_types.append(device_type)
1405
+ return device_types
1406
+ return None
1407
+
1408
+ async def dali_query_last_scene(self, address: ZenAddress) -> Optional[int]:
1409
+ """Query the last heard Scene for a DALI address (ECG or group or broadcast). Returns scene number, or None if query fails.
1410
+
1411
+ Note:
1412
+ Changes to a single DALI device done through group or broadcast scene commands
1413
+ also change the last heard scene for the individual device address. For example,
1414
+ if A10 is member of G0 and we send a scene command to G0, A10 will show the
1415
+ same last heard scene as G0.
1416
+ """
1417
+ return await self._send_basic(address.controller, self.CMD["DALI_QUERY_LAST_SCENE"], address.ecg_or_group_or_broadcast(), return_type='int')
1418
+
1419
+ async def dali_query_last_scene_is_current(self, address: ZenAddress) -> Optional[bool]:
1420
+ """Query if the last heard scene is the current active scene for a DALI address (ECG or group or broadcast).
1421
+ Returns True if still active, False if another command has been issued since, or None if query fails."""
1422
+ return await self._send_basic(address.controller, self.CMD["DALI_QUERY_LAST_SCENE_IS_CURRENT"], address.ecg_or_group_or_broadcast(), return_type='bool')
1423
+
1424
+ async def dali_query_min_level(self, address: ZenAddress) -> Optional[int]:
1425
+ """Query a DALI address (ECG) for its minimum level (0-254). Returns the minimum level if successful, None if query fails."""
1426
+ return await self._send_basic(address.controller, self.CMD["DALI_QUERY_MIN_LEVEL"], address.ecg(), return_type='int')
1427
+
1428
+ async def dali_query_max_level(self, address: ZenAddress) -> Optional[int]:
1429
+ """Query a DALI address (ECG) for its maximum level (0-254). Returns the maximum level if successful, None if query fails."""
1430
+ return await self._send_basic(address.controller, self.CMD["DALI_QUERY_MAX_LEVEL"], address.ecg(), return_type='int')
1431
+
1432
+ async def dali_query_fade_running(self, address: ZenAddress) -> Optional[bool]:
1433
+ """Query a DALI address (ECG) if a fade is currently running. Returns True if a fade is currently running, False if not, None if query fails."""
1434
+ return await self._send_basic(address.controller, self.CMD["DALI_QUERY_FADE_RUNNING"], address.ecg(), return_type='bool')
1435
+
1436
+ async def dali_enable_dapc_sequence(self, address: ZenAddress) -> Optional[bool]:
1437
+ """Begin a DALI Direct Arc Power Control (DAPC) Sequence.
1438
+
1439
+ DAPC allows overriding of the fade rate for immediate level setting. The sequence
1440
+ continues for 250ms. If no arc levels are received within 250ms, the sequence ends
1441
+ and normal fade rates resume.
1442
+
1443
+ Args:
1444
+ address: ZenAddress instance (ECG address)
1445
+
1446
+ Returns:
1447
+ Optional[bool]: True if successful, False if failed, None if no response
1448
+ """
1449
+ return await self._send_basic(address.controller, self.CMD["DALI_ENABLE_DAPC_SEQ"], address.ecg(), return_type='bool')
1450
+
1451
+ async def query_dali_ean(self, address: ZenAddress) -> Optional[int]:
1452
+ """Query a DALI address (ECG or ECD) for its European Article Number (EAN/GTIN). Returns an integer if successful, None if query fails."""
1453
+ response = await self._send_basic(address.controller, self.CMD["QUERY_DALI_EAN"], address.ecg_or_ecd())
1454
+ if response and len(response) == 6:
1455
+ ean = 0
1456
+ for byte in response:
1457
+ ean = (ean << 8) | byte
1458
+ return ean
1459
+ return None
1460
+
1461
+ async def query_dali_serial(self, address: ZenAddress) -> Optional[int]:
1462
+ """Query a DALI address (ECG or ECD) for its Serial Number. Returns an integer if successful, None if query fails."""
1463
+ response = await self._send_basic(address.controller, self.CMD["QUERY_DALI_SERIAL"], address.ecg_or_ecd(), cacheable=True)
1464
+ if response and len(response) == 8:
1465
+ # Convert 8 bytes to decimal integer
1466
+ serial = 0
1467
+ for byte in response:
1468
+ serial = (serial << 8) | byte
1469
+ return serial
1470
+ return None
1471
+
1472
+ async def dali_custom_fade(self, address: ZenAddress, level: int, seconds: int) -> Optional[bool]:
1473
+ """Fade a DALI address (ECG or group) to a level (0-254) with a custom fade time in seconds (0-65535). Returns True if successful, else False."""
1474
+ if not 0 <= level <= Const.MAX_LEVEL:
1475
+ raise ValueError(f"Target level must be between 0 and {Const.MAX_LEVEL}, got {level}")
1476
+ if not 0 <= seconds <= 65535:
1477
+ raise ValueError("Fade time must be between 0 and 65535 seconds")
1478
+
1479
+ # Convert fade time to integer seconds and split into high/low bytes
1480
+ seconds_hi = (seconds >> 8) & 0xFF
1481
+ seconds_lo = seconds & 0xFF
1482
+
1483
+ return await self._send_basic(
1484
+ address.controller,
1485
+ self.CMD["DALI_CUSTOM_FADE"],
1486
+ address.ecg_or_group(),
1487
+ [level, seconds_hi, seconds_lo],
1488
+ return_type='ok'
1489
+ )
1490
+
1491
+ async def dali_go_to_last_active_level(self, address: ZenAddress) -> Optional[bool]:
1492
+ """Command a DALI Address (ECG or group) to go to its "Last Active" level. Returns True if successful, else False."""
1493
+ return await self._send_basic(address.controller, self.CMD["DALI_GO_TO_LAST_ACTIVE_LEVEL"], address.ecg_or_group(), return_type='ok')
1494
+
1495
+ async def query_dali_instance_label(self, instance: ZenInstance, generic_if_none: bool=False) -> Optional[str]:
1496
+ """Query the label for a DALI Instance. Returns a string, or None if not set. Optionally, returns a generic label if the instance label is not set."""
1497
+ label = await self._send_basic(instance.address.controller, self.CMD["QUERY_DALI_INSTANCE_LABEL"], instance.address.ecd(), [0x00, 0x00, instance.number], return_type='str', cacheable=True)
1498
+ if label is None and generic_if_none:
1499
+ label = instance.type.name.title().replace("_", " ") + " " + str(instance.number)
1500
+ return label
1501
+
1502
+ async def change_profile_number(self, controller: ZenController, profile: int) -> Optional[bool]:
1503
+ """Change the active profile number (0-65535). Returns True if successful, else False."""
1504
+ if not 0 <= profile <= 0xFFFF: raise ValueError("Profile number must be between 0 and 65535")
1505
+ profile_hi = (profile >> 8) & 0xFF
1506
+ profile_lo = profile & 0xFF
1507
+ return await self._send_basic(controller, self.CMD["CHANGE_PROFILE_NUMBER"], 0x00, [0x00, profile_hi, profile_lo], return_type='ok')
1508
+
1509
+ async def return_to_scheduled_profile(self, controller: ZenController) -> Optional[bool]:
1510
+ """Return to the scheduled profile. Returns True if successful, else False."""
1511
+ return await self.change_profile_number(controller, 0xFFFF) # See docs page 91, 0xFFFF returns to scheduled profile
1512
+
1513
+ async def query_instance_groups(self, instance: ZenInstance) -> Optional[tuple[Optional[int], Optional[int], Optional[int]]]: # TODO: replace Tuple with dict
1514
+ """Query the group targets associated with a DALI instance.
1515
+
1516
+ Returns:
1517
+ Optional tuple containing:
1518
+ - int: Primary group number (0-15, or 255 if not configured)
1519
+ - int: First group number (0-15, or 255 if not configured)
1520
+ - int: Second group number (0-15, or 255 if not configured)
1521
+
1522
+ Returns None if query fails
1523
+
1524
+ The Primary group typically represents where the physical device resides.
1525
+ A group number of 255 (0xFF) indicates that no group has been configured.
1526
+ """
1527
+ response = await self._send_basic(
1528
+ instance.address.controller,
1529
+ self.CMD["QUERY_INSTANCE_GROUPS"],
1530
+ instance.address.ecd(),
1531
+ [0x00, 0x00, instance.number],
1532
+ return_type='list'
1533
+ )
1534
+ if response and len(response) == 3:
1535
+ return (
1536
+ response[0] if response[0] != 0xFF else None,
1537
+ response[1] if response[1] != 0xFF else None,
1538
+ response[2] if response[2] != 0xFF else None
1539
+ )
1540
+ return None
1541
+
1542
+ async def query_dali_fitting_number(self, address: ZenAddress) -> Optional[str]:
1543
+ """Query a DALI address (ECG or ECD) for its fitting number. Returns the fitting number (e.g. '1.2') or a generic identifier if the address doesn't exist, or None if the query fails."""
1544
+ return await self._send_basic(address.controller, self.CMD["QUERY_DALI_FITTING_NUMBER"], address.ecg_or_ecd(), return_type='str', cacheable=True)
1545
+
1546
+ async def query_dali_instance_fitting_number(self, instance: ZenInstance) -> Optional[str]:
1547
+ """Query a DALI instance for its fitting number. Returns a string (e.g. '1.2.0') or None if query fails."""
1548
+ return await self._send_basic(instance.address.controller, self.CMD["QUERY_DALI_INSTANCE_FITTING_NUMBER"], instance.address.ecd(), [0x00, 0x00, instance.number], return_type='str')
1549
+
1550
+ async def query_controller_label(self, controller: ZenController) -> Optional[str]:
1551
+ """Request the label for the controller. Returns the controller's label string, or None if query fails."""
1552
+ return await self._send_basic(controller, self.CMD["QUERY_CONTROLLER_LABEL"], return_type='str', cacheable=True)
1553
+
1554
+ async def query_controller_fitting_number(self, controller: ZenController) -> Optional[str]:
1555
+ """Request the fitting number string for the controller itself. Returns the controller's fitting number (e.g. '1'), or None if query fails."""
1556
+ return await self._send_basic(controller, self.CMD["QUERY_CONTROLLER_FITTING_NUMBER"], return_type='str')
1557
+
1558
+ async def query_is_dali_ready(self, controller: ZenController) -> Optional[bool]:
1559
+ """Query whether the DALI line is ready or has a fault. Returns True if DALI line is ready, False if there is a fault."""
1560
+ return await self._send_basic(controller, self.CMD["QUERY_IS_DALI_READY"], return_type='ok')
1561
+
1562
+ async def query_controller_startup_complete(self, controller: ZenController) -> Optional[bool]:
1563
+ """Query whether the controller has finished its startup sequence. Returns True if startup is complete, False if still in progress, None if the query fails.
1564
+
1565
+ The startup sequence performs DALI queries such as device type, current arc-level, GTIN,
1566
+ serial number, etc. The more devices on a DALI line, the longer startup will take to complete.
1567
+ For a line with only a handful of devices, expect it to take approximately 1 minute.
1568
+ Waiting for the startup sequence to complete is particularly important if you wish to
1569
+ perform queries about DALI.
1570
+ """
1571
+ return await self._send_basic(controller, self.CMD["QUERY_CONTROLLER_STARTUP_COMPLETE"], return_type='ok')
1572
+
1573
+ async def override_dali_button_led_state(self, instance: ZenInstance, led_state: bool) -> Optional[bool]:
1574
+ """Override the LED state for a DALI push button. State is True for LED on, False for LED off. Returns true if command succeeded, else False."""
1575
+ return await self._send_basic(instance.address.controller,
1576
+ self.CMD["OVERRIDE_DALI_BUTTON_LED_STATE"],
1577
+ instance.address.ecd(),
1578
+ [0x00, 0x02 if led_state else 0x01, instance.number],
1579
+ return_type='ok')
1580
+
1581
+ async def query_last_known_dali_button_led_state(self, instance: ZenInstance) -> Optional[bool]:
1582
+ """Query the last known LED state for a DALI push button. Returns True if LED is on, False if LED is off, None if query failed
1583
+
1584
+ Note: The "last known" LED state may not be the actual physical LED state.
1585
+ This only works for LED modes where the controller or TPI caller is managing
1586
+ the LED state. In many cases, the control device itself manages its own LED.
1587
+ """
1588
+ response = await self._send_basic(instance.address.controller,
1589
+ self.CMD["QUERY_LAST_KNOWN_DALI_BUTTON_LED_STATE"],
1590
+ instance.address.ecd(),
1591
+ [0x00, 0x00, instance.number])
1592
+ if response and len(response) == 1:
1593
+ match response[0]:
1594
+ case 0x01: return False
1595
+ case 0x02: return True
1596
+ return None
1597
+
1598
+ async def dali_stop_fade(self, address: ZenAddress) -> Optional[bool]:
1599
+ """Tell a DALI address (ECG, ECD, broadcast) to stop running a fade. Returns True if command succeeded, else False.
1600
+
1601
+ Caution: this literally stops the fade. It doesn't jump to the target level.
1602
+
1603
+ Note: For custom fades started via DALI_CUSTOM_FADE, this can only stop
1604
+ fades that were started with the same target address. For example, you
1605
+ cannot stop a custom fade on a single address if it was started as part
1606
+ of a group or broadcast fade.
1607
+ """
1608
+ return await self._send_basic(address.controller, self.CMD["DALI_STOP_FADE"], address.ecg_or_group_or_broadcast(), return_type='ok')
1609
+
1610
+ async def query_dali_colour_features(self, address: ZenAddress) -> Optional[dict[str, Any]]:
1611
+ """Query the colour features/capabilities of a DALI device.
1612
+
1613
+ Args:
1614
+ address: ZenAddress
1615
+
1616
+ Returns:
1617
+ Dictionary containing colour capabilities, or None if query failed:
1618
+ {
1619
+ 'supports_xy': bool, # Supports CIE 1931 XY coordinates
1620
+ 'primary_count': int, # Number of primaries (0-7)
1621
+ 'rgbwaf_channels': int, # Number of RGBWAF channels (0-7)
1622
+ }
1623
+ """
1624
+ response = await self._send_basic(address.controller, self.CMD["QUERY_DALI_COLOUR_FEATURES"], address.ecg(), cacheable=True)
1625
+ if response and len(response) == 1:
1626
+ features = response[0]
1627
+ return {
1628
+ 'supports_xy': bool(features & 0x01), # Bit 0
1629
+ 'supports_tunable': bool(features & 0x02), # Bit 1
1630
+ 'primary_count': (features & 0x1C) >> 2, # Bits 2-4
1631
+ 'rgbwaf_channels': (features & 0xE0) >> 5, # Bits 5-7
1632
+ }
1633
+ elif response is None:
1634
+ return {
1635
+ 'supports_xy': False,
1636
+ 'supports_tunable': False,
1637
+ 'primary_count': 0,
1638
+ 'rgbwaf_channels': 0,
1639
+ }
1640
+ return None
1641
+
1642
+ async def query_dali_colour_temp_limits(self, address: ZenAddress) -> Optional[dict[str, Any]]:
1643
+ """Query the colour temperature limits of a DALI device.
1644
+
1645
+ Args:
1646
+ controller: ZenController instance
1647
+ gear: DALI address (0-63)
1648
+
1649
+ Returns:
1650
+ Dictionary containing colour temperature limits in Kelvin, or None if query failed:
1651
+ {
1652
+ 'physical_warmest': int, # Physical warmest temp limit (K)
1653
+ 'physical_coolest': int, # Physical coolest temp limit (K)
1654
+ 'soft_warmest': int, # Configured warmest temp limit (K)
1655
+ 'soft_coolest': int, # Configured coolest temp limit (K)
1656
+ 'step_value': int # Step value (K)
1657
+ }
1658
+ """
1659
+ response = await self._send_basic(address.controller, self.CMD["QUERY_DALI_COLOUR_TEMP_LIMITS"], address.ecg(), cacheable=True)
1660
+ if response and len(response) == 10:
1661
+ return {
1662
+ 'physical_warmest': (response[0] << 8) | response[1],
1663
+ 'physical_coolest': (response[2] << 8) | response[3],
1664
+ 'soft_warmest': (response[4] << 8) | response[5],
1665
+ 'soft_coolest': (response[6] << 8) | response[7],
1666
+ 'step_value': (response[8] << 8) | response[9]
1667
+ }
1668
+ return None
1669
+
1670
+ async def set_system_variable(self, controller: ZenController, variable: int, value: int) -> Optional[bool]:
1671
+ """Set a system variable (0-147) value (-32768-32767) on the controller. Returns True if successful, else False."""
1672
+ if not 0 <= variable < Const.MAX_SYSVAR:
1673
+ raise ValueError(f"Variable number must be between 0 and {Const.MAX_SYSVAR}, received {variable}")
1674
+ if not -32768 <= value <= 32767:
1675
+ raise ValueError(f"Value must be between -32768 and 32767, received {value}")
1676
+ bytes = value.to_bytes(length=2, byteorder="big", signed=True)
1677
+ return await self._send_basic(controller, self.CMD["SET_SYSTEM_VARIABLE"], variable, [0x00, bytes[0], bytes[1]], return_type='ok')
1678
+
1679
+ # If abs(value) is less than 32760,
1680
+ # If value has 2 decimal places, use magitude -2 (signed 0xfe)
1681
+ # Else if value has 1 decimal place, use magitude -1 (signed 0xff)
1682
+ # Else use magitude 0 (signed 0x00)
1683
+ # Else if abs(value) is less than 327600, use magitude 1 (signed 0x01)
1684
+ # Else if abs(value) is less than 3276000, use magitude 2 (signed 0x02)
1685
+
1686
+ async def query_system_variable(self, controller: ZenController, variable: int) -> Optional[int]:
1687
+ """Query the controller for the value of a system variable (0-147). Returns the variable's value (-32768-32767) if successful, else None."""
1688
+ if not 0 <= variable < Const.MAX_SYSVAR:
1689
+ raise ValueError(f"Variable number must be between 0 and {Const.MAX_SYSVAR}, received {variable}")
1690
+ response = await self._send_basic(controller, self.CMD["QUERY_SYSTEM_VARIABLE"], variable)
1691
+ if response and len(response) == 2:
1692
+ return int.from_bytes(response, byteorder="big", signed=True)
1693
+ else: # Value is unset
1694
+ return None
1695
+
1696
+ async def query_system_variable_name(self, controller: ZenController, variable: int) -> Optional[str]:
1697
+ """Query the name of a system variable (0-147). Returns the variable's name, or None if query fails."""
1698
+ if not 0 <= variable < Const.MAX_SYSVAR:
1699
+ raise ValueError(f"Variable number must be between 0 and {Const.MAX_SYSVAR}, received {variable}")
1700
+ return await self._send_basic(controller, self.CMD["QUERY_SYSTEM_VARIABLE_NAME"], variable, return_type='str', cacheable=True)