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.
- examples/__init__.py +0 -0
- examples/mqtt_bridge.py +1142 -0
- zencontrol/__init__.py +108 -0
- zencontrol/api/__init__.py +30 -0
- zencontrol/api/models.py +273 -0
- zencontrol/api/protocol.py +1700 -0
- zencontrol/api/types.py +203 -0
- zencontrol/exceptions.py +30 -0
- zencontrol/interface/__init__.py +33 -0
- zencontrol/interface/interface.py +1579 -0
- zencontrol/io/__init__.py +24 -0
- zencontrol/io/command.py +387 -0
- zencontrol/io/event.py +316 -0
- zencontrol/py.typed +0 -0
- zencontrol/utils.py +67 -0
- zencontrol_python-0.1.0.dist-info/METADATA +79 -0
- zencontrol_python-0.1.0.dist-info/RECORD +21 -0
- zencontrol_python-0.1.0.dist-info/WHEEL +5 -0
- zencontrol_python-0.1.0.dist-info/entry_points.txt +2 -0
- zencontrol_python-0.1.0.dist-info/licenses/LICENSE +504 -0
- zencontrol_python-0.1.0.dist-info/top_level.txt +2 -0
|
@@ -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)
|