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,1579 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
import logging
|
|
7
|
+
from typing import Any, Optional, Callable, Awaitable, cast, Self
|
|
8
|
+
from collections.abc import Coroutine
|
|
9
|
+
|
|
10
|
+
from ..api import ZenProtocol, ZenController as SuperZenController, ZenAddress, ZenInstance, ZenAddressType, ZenColour, ZenColourType, ZenInstanceType
|
|
11
|
+
from ..api.protocol import ZenCallbacks
|
|
12
|
+
from ..api.types import Const
|
|
13
|
+
|
|
14
|
+
"""
|
|
15
|
+
===================================================================================
|
|
16
|
+
This module takes the ZenControl API and provides a higher level interface
|
|
17
|
+
intended for use in a control interface or home automation system written in Python.
|
|
18
|
+
===================================================================================
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
Terms:
|
|
23
|
+
ZenProtocol = A class which implements the ZenControl TPI Advanced API using zen_io.
|
|
24
|
+
ZenController = Represents a ZenControl controller.
|
|
25
|
+
ZenAddress = Represents a DALI address.
|
|
26
|
+
ZenInstance = Represents a DALI ECD instance.
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
# Constants moved to api/types.py
|
|
32
|
+
# Placeholder classes removed - real implementations are below
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# Callback type definitions moved to end of file after class definitions
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _assign_light_sub_labels(lights: list["ZenLight"] | set["ZenLight"]) -> None:
|
|
39
|
+
"""Derive ``sub_label`` for lights that share a comma-separated label.
|
|
40
|
+
|
|
41
|
+
Controllers sometimes store one label string across several ECGs that share
|
|
42
|
+
a fitting, e.g. ``"Hallway,Bathroom,,Annex"`` on addresses 31–34 meaning
|
|
43
|
+
31=Hallway, 32=Bathroom, 33 unused, 34=Annex.
|
|
44
|
+
|
|
45
|
+
Only applied when multiple lights share an identical label that contains a
|
|
46
|
+
comma. Clusters are sorted by address number; empty segments become
|
|
47
|
+
``Unused {number}``. Lights outside such clusters keep ``sub_label=None``.
|
|
48
|
+
"""
|
|
49
|
+
for light in lights:
|
|
50
|
+
light.sub_label = None
|
|
51
|
+
|
|
52
|
+
clusters: dict[tuple[str, str], list[ZenLight]] = {}
|
|
53
|
+
for light in lights:
|
|
54
|
+
label = light.label
|
|
55
|
+
if not label or "," not in label:
|
|
56
|
+
continue
|
|
57
|
+
key = (light.address.controller.name, label)
|
|
58
|
+
clusters.setdefault(key, []).append(light)
|
|
59
|
+
|
|
60
|
+
for cluster in clusters.values():
|
|
61
|
+
if len(cluster) < 2:
|
|
62
|
+
continue
|
|
63
|
+
cluster.sort(key=lambda lt: lt.address.number)
|
|
64
|
+
parts = [part.strip() for part in (cluster[0].label or "").split(",")]
|
|
65
|
+
for i, light in enumerate(cluster):
|
|
66
|
+
part = parts[i] if i < len(parts) else ""
|
|
67
|
+
light.sub_label = part if part else f"Unused {light.address.number}"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _serialize_colour(colour: Optional[ZenColour]) -> Optional[dict[str, int | str | None]]:
|
|
71
|
+
if colour is None or colour.type is None:
|
|
72
|
+
return None
|
|
73
|
+
data: dict[str, int | str | None] = {"type": colour.type.name.lower()}
|
|
74
|
+
if colour.type == ZenColourType.TC:
|
|
75
|
+
data["kelvin"] = colour.kelvin
|
|
76
|
+
elif colour.type == ZenColourType.RGBWAF:
|
|
77
|
+
data["r"] = colour.r
|
|
78
|
+
data["g"] = colour.g
|
|
79
|
+
data["b"] = colour.b
|
|
80
|
+
data["w"] = colour.w
|
|
81
|
+
data["a"] = colour.a
|
|
82
|
+
data["f"] = colour.f
|
|
83
|
+
elif colour.type == ZenColourType.XY:
|
|
84
|
+
data["x"] = colour.x
|
|
85
|
+
data["y"] = colour.y
|
|
86
|
+
return data
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _hydrate_colour(data: Optional[dict[str, Any]]) -> Optional[ZenColour]:
|
|
90
|
+
if data is None:
|
|
91
|
+
return None
|
|
92
|
+
colour_type = ZenColourType[str(data["type"]).upper()]
|
|
93
|
+
if colour_type == ZenColourType.TC:
|
|
94
|
+
return ZenColour(type=colour_type, kelvin=data.get("kelvin"))
|
|
95
|
+
if colour_type == ZenColourType.RGBWAF:
|
|
96
|
+
return ZenColour(
|
|
97
|
+
type=colour_type,
|
|
98
|
+
r=data.get("r"),
|
|
99
|
+
g=data.get("g"),
|
|
100
|
+
b=data.get("b"),
|
|
101
|
+
w=data.get("w"),
|
|
102
|
+
a=data.get("a"),
|
|
103
|
+
f=data.get("f"),
|
|
104
|
+
)
|
|
105
|
+
if colour_type == ZenColourType.XY:
|
|
106
|
+
return ZenColour(type=colour_type, x=data.get("x"), y=data.get("y"))
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _serialize_group_address(address: ZenAddress) -> dict[str, int]:
|
|
111
|
+
return {"number": address.number}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _loads_interview_data(data: str | dict[str, Any]) -> dict[str, Any]:
|
|
115
|
+
if isinstance(data, str):
|
|
116
|
+
loaded: dict[str, Any] = json.loads(data)
|
|
117
|
+
return loaded
|
|
118
|
+
return data
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class ZenControl:
|
|
122
|
+
def __init__(self,
|
|
123
|
+
logger: logging.Logger | None = None,
|
|
124
|
+
print_traffic: bool = False,
|
|
125
|
+
unicast: bool = False,
|
|
126
|
+
listen_ip: Optional[str] = None,
|
|
127
|
+
listen_port: Optional[int] = None,
|
|
128
|
+
cache: dict[bytes, dict[str, Any]] | None = None
|
|
129
|
+
):
|
|
130
|
+
self.logger = logger or logging.getLogger(__name__)
|
|
131
|
+
self.protocol: ZenProtocol = ZenProtocol(logger=self.logger, print_traffic=print_traffic, unicast=unicast, listen_ip=listen_ip, listen_port=listen_port, cache=cache if cache is not None else {})
|
|
132
|
+
self.controllers: list[ZenController] = []
|
|
133
|
+
# Each ZenControl instance gets its own callback registry so multiple
|
|
134
|
+
# instances (e.g. an integration and a test connection) cannot interfere.
|
|
135
|
+
self.protocol.callbacks = ZenCallbacks()
|
|
136
|
+
self._stopping = False
|
|
137
|
+
self._supervisor_task: Optional[asyncio.Task[None]] = None
|
|
138
|
+
self._first_connected = asyncio.Event()
|
|
139
|
+
self.reconnect_min_delay = Const.RECONNECT_MIN_DELAY
|
|
140
|
+
self.reconnect_max_delay = Const.RECONNECT_MAX_DELAY
|
|
141
|
+
self.reconnect_healthy_seconds = Const.RECONNECT_HEALTHY_SECONDS
|
|
142
|
+
|
|
143
|
+
async def __aenter__(self) -> Self:
|
|
144
|
+
return self
|
|
145
|
+
|
|
146
|
+
async def __aexit__(self, exc_type: object, exc_val: object, exc_tb: object) -> None:
|
|
147
|
+
await self.aclose()
|
|
148
|
+
|
|
149
|
+
def clear_entity_caches(self) -> None:
|
|
150
|
+
"""Clear entity singleton registries for this ZenControl instance."""
|
|
151
|
+
self.protocol.clear_entity_cache()
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def cache(self) -> dict[bytes, dict[str, Any]]:
|
|
155
|
+
return self.protocol.cache
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def on_connect(self) -> "CallbackOnConnect | None":
|
|
159
|
+
return self.protocol.callbacks.on_connect
|
|
160
|
+
@on_connect.setter
|
|
161
|
+
def on_connect(self, func: "CallbackOnConnect | None") -> None:
|
|
162
|
+
self.protocol.callbacks.on_connect = func
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
def on_disconnect(self) -> "CallbackOnDisconnect | None":
|
|
166
|
+
return self.protocol.callbacks.on_disconnect
|
|
167
|
+
@on_disconnect.setter
|
|
168
|
+
def on_disconnect(self, func: "CallbackOnDisconnect | None") -> None:
|
|
169
|
+
self.protocol.callbacks.on_disconnect = func
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def profile_change(self) -> "CallbackProfileChange | None":
|
|
173
|
+
return self.protocol.callbacks.profile_change
|
|
174
|
+
@profile_change.setter
|
|
175
|
+
def profile_change(self, func: "CallbackProfileChange | None") -> None:
|
|
176
|
+
self.protocol.callbacks.profile_change = func
|
|
177
|
+
|
|
178
|
+
@property
|
|
179
|
+
def group_change(self) -> "CallbackGroupChange | None":
|
|
180
|
+
return self.protocol.callbacks.group_change
|
|
181
|
+
@group_change.setter
|
|
182
|
+
def group_change(self, func: "CallbackGroupChange | None") -> None:
|
|
183
|
+
self.protocol.callbacks.group_change = func
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def light_change(self) -> "CallbackLightChange | None":
|
|
187
|
+
return self.protocol.callbacks.light_change
|
|
188
|
+
@light_change.setter
|
|
189
|
+
def light_change(self, func: "CallbackLightChange | None") -> None:
|
|
190
|
+
self.protocol.callbacks.light_change = func
|
|
191
|
+
|
|
192
|
+
@property
|
|
193
|
+
def button_press(self) -> "CallbackButtonPress | None":
|
|
194
|
+
return self.protocol.callbacks.button_press
|
|
195
|
+
@button_press.setter
|
|
196
|
+
def button_press(self, func: "CallbackButtonPress | None") -> None:
|
|
197
|
+
self.protocol.callbacks.button_press = func
|
|
198
|
+
|
|
199
|
+
@property
|
|
200
|
+
def button_long_press(self) -> "CallbackButtonLongPress | None":
|
|
201
|
+
return self.protocol.callbacks.button_long_press
|
|
202
|
+
@button_long_press.setter
|
|
203
|
+
def button_long_press(self, func: "CallbackButtonLongPress | None") -> None:
|
|
204
|
+
self.protocol.callbacks.button_long_press = func
|
|
205
|
+
|
|
206
|
+
@property
|
|
207
|
+
def motion_event(self) -> "CallbackMotionEvent | None":
|
|
208
|
+
return self.protocol.callbacks.motion_event
|
|
209
|
+
@motion_event.setter
|
|
210
|
+
def motion_event(self, func: "CallbackMotionEvent | None") -> None:
|
|
211
|
+
self.protocol.callbacks.motion_event = func
|
|
212
|
+
|
|
213
|
+
@property
|
|
214
|
+
def system_variable_change(self) -> "CallbackSystemVariableChange | None":
|
|
215
|
+
return self.protocol.callbacks.system_variable_change
|
|
216
|
+
@system_variable_change.setter
|
|
217
|
+
def system_variable_change(self, func: "CallbackSystemVariableChange | None") -> None:
|
|
218
|
+
self.protocol.callbacks.system_variable_change = func
|
|
219
|
+
|
|
220
|
+
# ============================
|
|
221
|
+
# Setup / Start / Stop
|
|
222
|
+
# ============================
|
|
223
|
+
|
|
224
|
+
def add_controller(self, id: int, name: str, label: str, host: str, port: int = 5108, mac: Optional[str] = None, filtering: bool = False) -> "ZenController":
|
|
225
|
+
controller = ZenController(protocol=self.protocol, id=id, name=name, label=label, host=host, port=port, mac=mac, filtering=filtering)
|
|
226
|
+
self.controllers.append(controller)
|
|
227
|
+
# list is invariant; protocol expects the API-level ZenController type
|
|
228
|
+
self.protocol.set_controllers(cast(list[SuperZenController], self.controllers))
|
|
229
|
+
return controller
|
|
230
|
+
|
|
231
|
+
async def start(self) -> None:
|
|
232
|
+
"""Start event monitoring with automatic reconnect on unexpected loss."""
|
|
233
|
+
self._stopping = False
|
|
234
|
+
self._first_connected = asyncio.Event()
|
|
235
|
+
self.protocol.set_callbacks(
|
|
236
|
+
button_press_callback = self.button_press_event,
|
|
237
|
+
button_hold_callback = self.button_hold_event,
|
|
238
|
+
absolute_input_callback = self.absolute_input_event,
|
|
239
|
+
level_change_callback = self.level_change_event,
|
|
240
|
+
group_level_change_callback = self.level_change_event,
|
|
241
|
+
scene_change_callback = self.scene_change_event,
|
|
242
|
+
is_occupied_callback = self.is_occupied_event,
|
|
243
|
+
system_variable_change_callback = self.system_variable_change_event,
|
|
244
|
+
colour_change_callback = self.colour_change_event,
|
|
245
|
+
profile_change_callback = self.profile_change_event,
|
|
246
|
+
disconnect_callback = self._protocol_disconnect_event,
|
|
247
|
+
)
|
|
248
|
+
if self._supervisor_task is None or self._supervisor_task.done():
|
|
249
|
+
self._supervisor_task = asyncio.create_task(self._event_monitor_supervisor())
|
|
250
|
+
await self._first_connected.wait()
|
|
251
|
+
|
|
252
|
+
async def stop(self) -> None:
|
|
253
|
+
"""Stop reconnect supervisor and event monitoring (keeps entity caches)."""
|
|
254
|
+
self._stopping = True
|
|
255
|
+
was_running = bool(
|
|
256
|
+
self.protocol.event_task and not self.protocol.event_task.done()
|
|
257
|
+
)
|
|
258
|
+
await self._cancel_supervisor()
|
|
259
|
+
await self.protocol.stop_event_monitoring()
|
|
260
|
+
if was_running:
|
|
261
|
+
await self.protocol.notify_disconnect()
|
|
262
|
+
|
|
263
|
+
async def aclose(self) -> None:
|
|
264
|
+
"""Stop monitoring, cancel background tasks, close UDP clients, clear entity caches."""
|
|
265
|
+
self._stopping = True
|
|
266
|
+
was_running = bool(
|
|
267
|
+
self.protocol.event_task and not self.protocol.event_task.done()
|
|
268
|
+
)
|
|
269
|
+
await self._cancel_supervisor()
|
|
270
|
+
await self.protocol.aclose()
|
|
271
|
+
if was_running:
|
|
272
|
+
await self.protocol.notify_disconnect()
|
|
273
|
+
self.clear_entity_caches()
|
|
274
|
+
|
|
275
|
+
async def _cancel_supervisor(self) -> None:
|
|
276
|
+
task = self._supervisor_task
|
|
277
|
+
self._supervisor_task = None
|
|
278
|
+
if task is None or task.done():
|
|
279
|
+
return
|
|
280
|
+
task.cancel()
|
|
281
|
+
try:
|
|
282
|
+
await task
|
|
283
|
+
except asyncio.CancelledError:
|
|
284
|
+
pass
|
|
285
|
+
|
|
286
|
+
async def _event_monitor_supervisor(self) -> None:
|
|
287
|
+
"""Keep the event listener running; reconnect with backoff after unexpected loss."""
|
|
288
|
+
delay = self.reconnect_min_delay
|
|
289
|
+
while not self._stopping:
|
|
290
|
+
try:
|
|
291
|
+
await self.protocol.start_event_monitoring()
|
|
292
|
+
except asyncio.CancelledError:
|
|
293
|
+
raise
|
|
294
|
+
except Exception as err:
|
|
295
|
+
self.logger.error(f"Failed to start event monitoring: {err}")
|
|
296
|
+
if self._stopping:
|
|
297
|
+
return
|
|
298
|
+
await asyncio.sleep(delay)
|
|
299
|
+
delay = min(delay * 2, self.reconnect_max_delay)
|
|
300
|
+
continue
|
|
301
|
+
|
|
302
|
+
if callable(self.protocol.callbacks.on_connect):
|
|
303
|
+
try:
|
|
304
|
+
await self.protocol.callbacks.on_connect()
|
|
305
|
+
except Exception as err:
|
|
306
|
+
self.logger.error(f"on_connect error: {err}")
|
|
307
|
+
self._first_connected.set()
|
|
308
|
+
delay = self.reconnect_min_delay
|
|
309
|
+
|
|
310
|
+
event_task = self.protocol.event_task
|
|
311
|
+
if event_task is None:
|
|
312
|
+
continue
|
|
313
|
+
|
|
314
|
+
session_start = time.time()
|
|
315
|
+
try:
|
|
316
|
+
await event_task
|
|
317
|
+
except asyncio.CancelledError:
|
|
318
|
+
if self._stopping:
|
|
319
|
+
return
|
|
320
|
+
except Exception as err:
|
|
321
|
+
self.logger.error(f"Event monitor task error: {err}")
|
|
322
|
+
|
|
323
|
+
if self._stopping:
|
|
324
|
+
return
|
|
325
|
+
|
|
326
|
+
session_secs = time.time() - session_start
|
|
327
|
+
if session_secs >= self.reconnect_healthy_seconds:
|
|
328
|
+
delay = self.reconnect_min_delay
|
|
329
|
+
else:
|
|
330
|
+
delay = min(max(delay, self.reconnect_min_delay) * 2, self.reconnect_max_delay)
|
|
331
|
+
|
|
332
|
+
await self._prepare_for_reconnect()
|
|
333
|
+
self.logger.warning(
|
|
334
|
+
"Event monitoring stopped; reconnecting in %.1fs", delay
|
|
335
|
+
)
|
|
336
|
+
try:
|
|
337
|
+
await asyncio.sleep(delay)
|
|
338
|
+
except asyncio.CancelledError:
|
|
339
|
+
return
|
|
340
|
+
|
|
341
|
+
async def _prepare_for_reconnect(self) -> None:
|
|
342
|
+
"""Drop stale clients and refresh DNS before binding the listener again."""
|
|
343
|
+
for controller in self.controllers:
|
|
344
|
+
controller.refresh_ip()
|
|
345
|
+
client = controller.client
|
|
346
|
+
controller.client = None
|
|
347
|
+
if client is None:
|
|
348
|
+
continue
|
|
349
|
+
try:
|
|
350
|
+
await client.close()
|
|
351
|
+
except Exception:
|
|
352
|
+
pass
|
|
353
|
+
|
|
354
|
+
async def _protocol_disconnect_event(self) -> None:
|
|
355
|
+
"""Forward protocol-level disconnect to the high-level on_disconnect hook."""
|
|
356
|
+
if callable(self.protocol.callbacks.on_disconnect):
|
|
357
|
+
await self.protocol.callbacks.on_disconnect()
|
|
358
|
+
|
|
359
|
+
# ============================
|
|
360
|
+
# ZenProtocol callbacks
|
|
361
|
+
# ============================
|
|
362
|
+
|
|
363
|
+
async def button_press_event(self, instance: ZenInstance, payload: bytes) -> None:
|
|
364
|
+
await ZenButton(protocol=self.protocol, instance=instance)._event_received()
|
|
365
|
+
|
|
366
|
+
async def button_hold_event(self, instance: ZenInstance, payload: bytes) -> None:
|
|
367
|
+
await ZenButton(protocol=self.protocol, instance=instance)._event_received(held=True)
|
|
368
|
+
|
|
369
|
+
async def absolute_input_event(self, instance: ZenInstance, payload: bytes) -> None:
|
|
370
|
+
pass
|
|
371
|
+
|
|
372
|
+
async def is_occupied_event(self, instance: ZenInstance, payload: bytes) -> None:
|
|
373
|
+
await ZenMotionSensor(protocol=self.protocol, instance=instance)._event_received()
|
|
374
|
+
|
|
375
|
+
async def level_change_event(self, address: ZenAddress, arc_level: int, payload: bytes) -> None:
|
|
376
|
+
# LEVEL_CHANGE_V2: payload[1] is the dimming destination — ignore current level in payload[0]
|
|
377
|
+
if len(payload) >= 2:
|
|
378
|
+
arc_level = payload[1]
|
|
379
|
+
if address.type == ZenAddressType.ECG:
|
|
380
|
+
light = ZenLight(protocol=self.protocol, address=address)
|
|
381
|
+
await light._event_received(level=arc_level)
|
|
382
|
+
|
|
383
|
+
# Delay the light event to allow group updates to arrive and propogate
|
|
384
|
+
# async def delayed_event():
|
|
385
|
+
# await asyncio.sleep(0.1)
|
|
386
|
+
# await light._event_received(level=arc_level)
|
|
387
|
+
# asyncio.create_task(delayed_event())
|
|
388
|
+
elif address.type == ZenAddressType.GROUP:
|
|
389
|
+
group = ZenGroup(protocol=self.protocol, address=address)
|
|
390
|
+
await group._event_received(level=arc_level)
|
|
391
|
+
|
|
392
|
+
# Don't cascade groups. Group change events are untrustworthy.
|
|
393
|
+
# for light in group.lights:
|
|
394
|
+
# await light._event_received(level=arc_level, cascaded_from=group)
|
|
395
|
+
|
|
396
|
+
async def colour_change_event(self, address: ZenAddress, colour: Optional[ZenColour], payload: bytes) -> None:
|
|
397
|
+
# Protocol already parses payload via ZenColour.from_bytes before calling us
|
|
398
|
+
if colour is None:
|
|
399
|
+
return
|
|
400
|
+
if address.type == ZenAddressType.ECG:
|
|
401
|
+
# Delay the light event to allow group updates to arrive and propogate
|
|
402
|
+
ecg = ZenLight(protocol=self.protocol, address=address)
|
|
403
|
+
async def delayed_colour_event():
|
|
404
|
+
await asyncio.sleep(0.0)
|
|
405
|
+
await ecg._event_received(colour=colour)
|
|
406
|
+
self.protocol.track_task(delayed_colour_event())
|
|
407
|
+
elif address.type == ZenAddressType.GROUP:
|
|
408
|
+
group = ZenGroup(protocol=self.protocol, address=address)
|
|
409
|
+
await group._event_received(colour=colour)
|
|
410
|
+
for light in group.lights:
|
|
411
|
+
await light._event_received(colour=colour, cascaded_from=group)
|
|
412
|
+
|
|
413
|
+
async def scene_change_event(self, address: ZenAddress, scene: int, active: bool, payload: bytes) -> None:
|
|
414
|
+
if address.type == ZenAddressType.ECG:
|
|
415
|
+
# Delay the light event to allow group updates to arrive and propogate
|
|
416
|
+
ecg = ZenLight(protocol=self.protocol, address=address)
|
|
417
|
+
# Option 3: Inline async function with shorter name
|
|
418
|
+
async def delayed_scene_event():
|
|
419
|
+
await asyncio.sleep(0.0)
|
|
420
|
+
await ecg._event_received(scene=scene, active=active)
|
|
421
|
+
self.protocol.track_task(delayed_scene_event())
|
|
422
|
+
elif address.type == ZenAddressType.GROUP:
|
|
423
|
+
group = ZenGroup(protocol=self.protocol, address=address)
|
|
424
|
+
await group._event_received(scene=scene, active=active)
|
|
425
|
+
for light in group.lights:
|
|
426
|
+
await light._event_received(scene=scene, active=active, cascaded_from=group)
|
|
427
|
+
|
|
428
|
+
async def system_variable_change_event(self, controller: ZenController, target: int, value: int, payload: bytes) -> None:
|
|
429
|
+
await ZenSystemVariable(protocol=self.protocol, controller=controller, id=target)._event_received(value)
|
|
430
|
+
|
|
431
|
+
async def profile_change_event(self, controller: ZenController, profile: int, payload: bytes) -> None:
|
|
432
|
+
await controller._event_received(profile=profile)
|
|
433
|
+
|
|
434
|
+
# ============================
|
|
435
|
+
# Abstraction layer commands
|
|
436
|
+
# ============================
|
|
437
|
+
|
|
438
|
+
async def get_profiles(self, controller: Optional["ZenController"] = None) -> set["ZenProfile"]:
|
|
439
|
+
"""Return a set of all profiles."""
|
|
440
|
+
profiles: set[ZenProfile] = set()
|
|
441
|
+
controllers = [controller] if controller else self.controllers
|
|
442
|
+
for ctrl in controllers:
|
|
443
|
+
numbers = await self.protocol.query_profile_numbers(controller=ctrl)
|
|
444
|
+
if numbers is None:
|
|
445
|
+
continue
|
|
446
|
+
for number in numbers:
|
|
447
|
+
profile = await ZenProfile.create(protocol=self.protocol, controller=ctrl, number=number)
|
|
448
|
+
profiles.add(profile)
|
|
449
|
+
return profiles
|
|
450
|
+
|
|
451
|
+
async def get_groups(self) -> set["ZenGroup"]:
|
|
452
|
+
"""Return a set of all groups."""
|
|
453
|
+
groups: set[ZenGroup] = set()
|
|
454
|
+
for controller in self.controllers:
|
|
455
|
+
addresses = await self.protocol.query_group_numbers(controller=controller)
|
|
456
|
+
for address in addresses:
|
|
457
|
+
group = await ZenGroup.create(protocol=self.protocol, address=address)
|
|
458
|
+
groups.add(group)
|
|
459
|
+
return groups
|
|
460
|
+
|
|
461
|
+
async def get_lights(self) -> set["ZenLight"]:
|
|
462
|
+
"""Return a set of all lights available."""
|
|
463
|
+
lights: set[ZenLight] = set()
|
|
464
|
+
for controller in self.controllers:
|
|
465
|
+
addresses = await self.protocol.query_control_gear_dali_addresses(controller=controller)
|
|
466
|
+
for address in addresses:
|
|
467
|
+
light = await ZenLight.create(protocol=self.protocol, address=address)
|
|
468
|
+
lights.add(light)
|
|
469
|
+
# Second pass: labels are known; split shared comma-labels into sub_labels.
|
|
470
|
+
_assign_light_sub_labels(lights)
|
|
471
|
+
return lights
|
|
472
|
+
|
|
473
|
+
async def _get_addresses_with_instances(self, controller: "ZenController") -> list[ZenAddress]:
|
|
474
|
+
"""Return all DALI addresses that have instances, scanning all address ranges.
|
|
475
|
+
|
|
476
|
+
``query_dali_addresses_with_instances`` can only return up to 60 addresses
|
|
477
|
+
per call. Iterating over start_address in steps of 60 covers the full
|
|
478
|
+
DALI address space (0-127).
|
|
479
|
+
"""
|
|
480
|
+
seen: set[tuple[str, int]] = set()
|
|
481
|
+
addresses: list[ZenAddress] = []
|
|
482
|
+
for start in range(0, 128, 60):
|
|
483
|
+
batch = await self.protocol.query_dali_addresses_with_instances(
|
|
484
|
+
controller=controller, start_address=start
|
|
485
|
+
)
|
|
486
|
+
for addr in batch:
|
|
487
|
+
key = (addr.controller.name, addr.number)
|
|
488
|
+
if key not in seen:
|
|
489
|
+
seen.add(key)
|
|
490
|
+
addresses.append(addr)
|
|
491
|
+
return addresses
|
|
492
|
+
|
|
493
|
+
async def get_buttons(self) -> set["ZenButton"]:
|
|
494
|
+
"""Return a set of all buttons available."""
|
|
495
|
+
buttons: set[ZenButton] = set()
|
|
496
|
+
for controller in self.controllers:
|
|
497
|
+
addresses = await self._get_addresses_with_instances(controller)
|
|
498
|
+
for address in addresses:
|
|
499
|
+
instances = await self.protocol.query_instances_by_address(address=address)
|
|
500
|
+
for instance in instances:
|
|
501
|
+
if instance.type == ZenInstanceType.PUSH_BUTTON:
|
|
502
|
+
button = await ZenButton.create(protocol=self.protocol, instance=instance)
|
|
503
|
+
buttons.add(button)
|
|
504
|
+
return buttons
|
|
505
|
+
|
|
506
|
+
async def get_motion_sensors(self) -> set["ZenMotionSensor"]:
|
|
507
|
+
"""Return a set of all motion sensors available."""
|
|
508
|
+
motion_sensors: set[ZenMotionSensor] = set()
|
|
509
|
+
for controller in self.controllers:
|
|
510
|
+
addresses = await self._get_addresses_with_instances(controller)
|
|
511
|
+
for address in addresses:
|
|
512
|
+
instances = await self.protocol.query_instances_by_address(address=address)
|
|
513
|
+
for instance in instances:
|
|
514
|
+
if instance.type == ZenInstanceType.OCCUPANCY_SENSOR:
|
|
515
|
+
motion_sensor = await ZenMotionSensor.create(protocol=self.protocol, instance=instance)
|
|
516
|
+
motion_sensors.add(motion_sensor)
|
|
517
|
+
return motion_sensors
|
|
518
|
+
|
|
519
|
+
async def get_system_variables(self, give_up_after: int = 10) -> set["ZenSystemVariable"]:
|
|
520
|
+
"""Return a set of all system variables. Variables must have a label. Searching will give_up_after [x] sequential IDs without a label."""
|
|
521
|
+
sysvars: set[ZenSystemVariable] = set()
|
|
522
|
+
failed_attempts = 0
|
|
523
|
+
for controller in self.controllers:
|
|
524
|
+
for variable in range(Const.MAX_SYSVAR):
|
|
525
|
+
label = await self.protocol.query_system_variable_name(controller=controller, variable=variable)
|
|
526
|
+
if label:
|
|
527
|
+
failed_attempts = 0
|
|
528
|
+
sysvar = await ZenSystemVariable.create(protocol=self.protocol, controller=controller, id=variable, label=label)
|
|
529
|
+
sysvars.add(sysvar)
|
|
530
|
+
else:
|
|
531
|
+
failed_attempts += 1
|
|
532
|
+
if failed_attempts >= give_up_after:
|
|
533
|
+
break
|
|
534
|
+
return sysvars
|
|
535
|
+
|
|
536
|
+
# ============================
|
|
537
|
+
# Abstraction layer classes
|
|
538
|
+
# ============================
|
|
539
|
+
|
|
540
|
+
class ZenController(SuperZenController):
|
|
541
|
+
# Narrow/override dataclass fields used by the interface layer
|
|
542
|
+
protocol: ZenProtocol
|
|
543
|
+
version: Optional[str] = None
|
|
544
|
+
|
|
545
|
+
connected: bool = False
|
|
546
|
+
profile: Optional["ZenProfile"] = None
|
|
547
|
+
profiles: set["ZenProfile"] = set()
|
|
548
|
+
lights: set["ZenLight"] = set()
|
|
549
|
+
groups: set["ZenGroup"] = set()
|
|
550
|
+
buttons: set["ZenButton"] = set()
|
|
551
|
+
motion_sensors: set["ZenMotionSensor"] = set()
|
|
552
|
+
sysvars: set["ZenSystemVariable"] = set()
|
|
553
|
+
client_data: dict[str, Any] = {}
|
|
554
|
+
|
|
555
|
+
def __new__(cls, protocol: ZenProtocol, id: int, name: str, label: str, host: str, port: int = 5108, mac: Optional[str] = None, filtering: bool = False) -> "ZenController":
|
|
556
|
+
# Unique per protocol + controller name
|
|
557
|
+
registry = protocol.entity_registry.controllers
|
|
558
|
+
if name not in registry:
|
|
559
|
+
inst = super().__new__(cls)
|
|
560
|
+
registry[name] = inst
|
|
561
|
+
inst.connected = False
|
|
562
|
+
inst.client = None # Will be initialized when first used
|
|
563
|
+
object.__setattr__(inst, "_ip", None)
|
|
564
|
+
object.__setattr__(inst, "mac_bytes", None)
|
|
565
|
+
inst._reset()
|
|
566
|
+
# Don't call interview() here - it will be called async later
|
|
567
|
+
inst = registry[name]
|
|
568
|
+
inst.protocol = protocol
|
|
569
|
+
inst.id = str(id)
|
|
570
|
+
inst.name = name
|
|
571
|
+
inst.label = label
|
|
572
|
+
inst.host = host
|
|
573
|
+
inst.port = port
|
|
574
|
+
inst.mac = mac
|
|
575
|
+
inst.filtering = filtering
|
|
576
|
+
inst._update_mac_bytes(mac)
|
|
577
|
+
return inst
|
|
578
|
+
|
|
579
|
+
def __init__(self, protocol: ZenProtocol, id: int, name: str, label: str, host: str, port: int = 5108, mac: Optional[str] = None, filtering: bool = False) -> None:
|
|
580
|
+
# Satisfy reportMissingSuperCall; __new__ owns singleton field assignment.
|
|
581
|
+
super().__init__(
|
|
582
|
+
id=str(id),
|
|
583
|
+
name=name,
|
|
584
|
+
label=label,
|
|
585
|
+
host=host,
|
|
586
|
+
port=port,
|
|
587
|
+
mac=mac,
|
|
588
|
+
protocol=protocol,
|
|
589
|
+
filtering=filtering,
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
@classmethod
|
|
593
|
+
async def create(cls, protocol: ZenProtocol, id: int, name: str, label: str, host: str, port: int = 5108, mac: Optional[str] = None, filtering: bool = False) -> "ZenController":
|
|
594
|
+
"""Async factory method for ZenController"""
|
|
595
|
+
controller = cls(protocol=protocol, id=id, name=name, label=label, host=host, port=port, mac=mac, filtering=filtering)
|
|
596
|
+
await controller.interview()
|
|
597
|
+
return controller
|
|
598
|
+
def __repr__(self) -> str:
|
|
599
|
+
return f"ZenController<{self.name}>"
|
|
600
|
+
def _reset(self) -> None:
|
|
601
|
+
# label is set from config in __new__ or from interview(); not runtime state
|
|
602
|
+
self.version = None
|
|
603
|
+
self.profile = None
|
|
604
|
+
self.profiles = set()
|
|
605
|
+
self.lights = set()
|
|
606
|
+
self.groups = set()
|
|
607
|
+
self.buttons = set()
|
|
608
|
+
self.motion_sensors = set()
|
|
609
|
+
self.sysvars = set()
|
|
610
|
+
self.client_data = {}
|
|
611
|
+
async def interview(self) -> bool:
|
|
612
|
+
protocol = self.protocol
|
|
613
|
+
if self.label is None or self.label == "":
|
|
614
|
+
queried = await protocol.query_controller_label(self)
|
|
615
|
+
if queried is not None:
|
|
616
|
+
self.label = queried
|
|
617
|
+
self.version = await protocol.query_controller_version_number(self)
|
|
618
|
+
current_profile = await protocol.query_current_profile_number(self)
|
|
619
|
+
if current_profile is not None:
|
|
620
|
+
self.profile = ZenProfile(protocol=protocol, controller=self, number=current_profile)
|
|
621
|
+
self.connected = True
|
|
622
|
+
return True
|
|
623
|
+
async def _event_received(self, profile: Optional[int] = None):
|
|
624
|
+
protocol = self.protocol
|
|
625
|
+
if profile is not None:
|
|
626
|
+
self.profile = ZenProfile(protocol=protocol, controller=self, number=profile)
|
|
627
|
+
cb = protocol.callbacks.profile_change
|
|
628
|
+
if callable(cb):
|
|
629
|
+
await cb(profile=self.profile)
|
|
630
|
+
def get_sysvar(self, id: int) -> "ZenSystemVariable":
|
|
631
|
+
return ZenSystemVariable(protocol=self.protocol, controller=self, id=id)
|
|
632
|
+
async def is_controller_ready(self) -> Optional[bool]:
|
|
633
|
+
return await self.protocol.query_controller_startup_complete(self)
|
|
634
|
+
async def is_dali_ready(self) -> Optional[bool]:
|
|
635
|
+
return await self.protocol.query_is_dali_ready(self)
|
|
636
|
+
async def switch_to_profile(self, profile: "ZenProfile|int|str") -> bool:
|
|
637
|
+
zp = None
|
|
638
|
+
if isinstance(profile, ZenProfile):
|
|
639
|
+
zp = profile
|
|
640
|
+
elif isinstance(profile, str):
|
|
641
|
+
for p in self.profiles:
|
|
642
|
+
if p.label == profile: zp = p
|
|
643
|
+
elif isinstance(profile, int):
|
|
644
|
+
for p in self.profiles:
|
|
645
|
+
if p.number == profile: zp = p
|
|
646
|
+
if isinstance(zp, ZenProfile):
|
|
647
|
+
self.protocol.logger.debug("Switching to profile %s", zp)
|
|
648
|
+
result = await self.protocol.change_profile_number(self, zp.number)
|
|
649
|
+
return bool(result)
|
|
650
|
+
else:
|
|
651
|
+
return False
|
|
652
|
+
async def return_to_scheduled_profile(self) -> Optional[bool]:
|
|
653
|
+
return await self.protocol.return_to_scheduled_profile(self)
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
class ZenProfile:
|
|
657
|
+
protocol: ZenProtocol
|
|
658
|
+
controller: ZenController
|
|
659
|
+
number: int
|
|
660
|
+
label: Optional[str] = None
|
|
661
|
+
client_data: dict[str, Any] = {}
|
|
662
|
+
|
|
663
|
+
def __new__(cls, protocol: ZenProtocol, controller: ZenController, number: int) -> "ZenProfile":
|
|
664
|
+
# Unique per protocol + controller + profile number
|
|
665
|
+
compound_id = f"{controller.name} {number}"
|
|
666
|
+
registry = protocol.entity_registry.profiles
|
|
667
|
+
if compound_id not in registry:
|
|
668
|
+
inst = super().__new__(cls)
|
|
669
|
+
registry[compound_id] = inst
|
|
670
|
+
inst.protocol = protocol
|
|
671
|
+
inst.controller = controller
|
|
672
|
+
inst.number = number
|
|
673
|
+
inst._reset()
|
|
674
|
+
# Don't call interview() here - it will be called async later
|
|
675
|
+
return registry[compound_id]
|
|
676
|
+
|
|
677
|
+
def __init__(self, protocol: ZenProtocol, controller: ZenController, number: int) -> None:
|
|
678
|
+
self.protocol = protocol
|
|
679
|
+
self.controller = controller
|
|
680
|
+
self.number = number
|
|
681
|
+
|
|
682
|
+
@classmethod
|
|
683
|
+
async def create(cls, protocol: ZenProtocol, controller: ZenController, number: int) -> "ZenProfile":
|
|
684
|
+
"""Async factory method for ZenProfile"""
|
|
685
|
+
profile = cls(protocol, controller, number)
|
|
686
|
+
await profile.interview()
|
|
687
|
+
return profile
|
|
688
|
+
def __repr__(self) -> str:
|
|
689
|
+
return f"ZenProfile<{self.controller.name} profile {self.number}: {self.label}>"
|
|
690
|
+
def _reset(self) -> None:
|
|
691
|
+
self.label = None
|
|
692
|
+
self.client_data = {}
|
|
693
|
+
def interview_serialize(self) -> str:
|
|
694
|
+
return json.dumps({
|
|
695
|
+
"number": self.number,
|
|
696
|
+
"label": self.label,
|
|
697
|
+
})
|
|
698
|
+
def interview_hydrate(self, data: str | dict[str, Any]) -> bool:
|
|
699
|
+
try:
|
|
700
|
+
data = _loads_interview_data(data)
|
|
701
|
+
self.label = data.get("label")
|
|
702
|
+
self.controller.profiles.add(self)
|
|
703
|
+
return True
|
|
704
|
+
except Exception:
|
|
705
|
+
return False
|
|
706
|
+
async def interview(self) -> bool:
|
|
707
|
+
self.label = await self.protocol.query_profile_label(self.controller, self.number)
|
|
708
|
+
# Add self to controller's set of profiles
|
|
709
|
+
self.controller.profiles.add(self)
|
|
710
|
+
return True
|
|
711
|
+
async def select(self) -> bool:
|
|
712
|
+
result = await self.protocol.change_profile_number(self.controller, self.number)
|
|
713
|
+
return bool(result)
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
class ZenLight:
|
|
717
|
+
protocol: ZenProtocol
|
|
718
|
+
address: ZenAddress
|
|
719
|
+
label: Optional[str] = None
|
|
720
|
+
sub_label: Optional[str] = None
|
|
721
|
+
serial: Optional[int | str] = None
|
|
722
|
+
cgtype: list[int] = []
|
|
723
|
+
groups: set["ZenGroup"] = set()
|
|
724
|
+
group_membership: list[ZenAddress] = []
|
|
725
|
+
features: dict[str, bool] = {
|
|
726
|
+
"brightness": False,
|
|
727
|
+
"temperature": False,
|
|
728
|
+
"RGB": False,
|
|
729
|
+
"RGBW": False,
|
|
730
|
+
"RGBWW": False,
|
|
731
|
+
}
|
|
732
|
+
properties: dict[str, Optional[int]] = {
|
|
733
|
+
"min_kelvin": Const.DEFAULT_WARMEST_TEMP,
|
|
734
|
+
"max_kelvin": Const.DEFAULT_COOLEST_TEMP,
|
|
735
|
+
}
|
|
736
|
+
_scene_labels: list[Optional[str]] = [None] * Const.MAX_SCENE
|
|
737
|
+
_scene_levels: list[Optional[int]] = [None] * Const.MAX_SCENE
|
|
738
|
+
_scene_colours: list[Optional[ZenColour]] = [None] * Const.MAX_SCENE
|
|
739
|
+
level: Optional[int] = None
|
|
740
|
+
colour: Optional[ZenColour] = None
|
|
741
|
+
scene: Optional[int] = None
|
|
742
|
+
client_data: dict[str, Any] = {}
|
|
743
|
+
_refresh_timer: Optional[asyncio.Task[None]] = None
|
|
744
|
+
|
|
745
|
+
def __new__(cls, protocol: ZenProtocol, address: ZenAddress) -> Self:
|
|
746
|
+
# Inherited classes should bypass ZenLight __new__
|
|
747
|
+
if cls is not ZenLight:
|
|
748
|
+
return cast(Self, super().__new__(cls))
|
|
749
|
+
# Unique per protocol + controller + address
|
|
750
|
+
compound_id = f"{address.controller.name} {address.number}"
|
|
751
|
+
registry = protocol.entity_registry.lights
|
|
752
|
+
if compound_id not in registry:
|
|
753
|
+
inst = super().__new__(cls)
|
|
754
|
+
registry[compound_id] = inst
|
|
755
|
+
inst.protocol = protocol
|
|
756
|
+
inst.address = address
|
|
757
|
+
inst._reset()
|
|
758
|
+
# Don't call interview() here - it will be called async later
|
|
759
|
+
return cast(Self, registry[compound_id])
|
|
760
|
+
|
|
761
|
+
def __init__(self, protocol: ZenProtocol, address: ZenAddress) -> None:
|
|
762
|
+
self.protocol = protocol
|
|
763
|
+
self.address = address
|
|
764
|
+
|
|
765
|
+
@classmethod
|
|
766
|
+
async def create(cls, protocol: ZenProtocol, address: ZenAddress) -> "ZenLight":
|
|
767
|
+
"""Async factory method for ZenLight"""
|
|
768
|
+
instance = cls(protocol, address)
|
|
769
|
+
await instance.interview()
|
|
770
|
+
return instance
|
|
771
|
+
def __repr__(self) -> str:
|
|
772
|
+
return f"ZenLight<{self.address.controller.name} ecg {self.address.number}: {self.label}>"
|
|
773
|
+
def _reset(self) -> None:
|
|
774
|
+
self.label = None
|
|
775
|
+
self.sub_label = None
|
|
776
|
+
self.serial = None
|
|
777
|
+
self.cgtype = []
|
|
778
|
+
self.groups = set()
|
|
779
|
+
self.group_membership = []
|
|
780
|
+
self.features = {
|
|
781
|
+
"brightness": False,
|
|
782
|
+
"temperature": False,
|
|
783
|
+
"RGB": False,
|
|
784
|
+
"RGBW": False,
|
|
785
|
+
"RGBWW": False,
|
|
786
|
+
}
|
|
787
|
+
self.properties = {
|
|
788
|
+
"min_kelvin": Const.DEFAULT_WARMEST_TEMP,
|
|
789
|
+
"max_kelvin": Const.DEFAULT_COOLEST_TEMP,
|
|
790
|
+
}
|
|
791
|
+
self._scene_labels = [None] * Const.MAX_SCENE # Scene labels (only used by ZenGroup)
|
|
792
|
+
self._scene_levels = [None] * Const.MAX_SCENE # Scene levels (only used by ZenLight)
|
|
793
|
+
self._scene_colours = [None] * Const.MAX_SCENE # Scene colours (only used by ZenLight)
|
|
794
|
+
self.level = None
|
|
795
|
+
self.colour = None
|
|
796
|
+
self.scene = None # Current scene number
|
|
797
|
+
self.client_data = {}
|
|
798
|
+
# Timer for refresh_state_from_controller after property changes
|
|
799
|
+
self._refresh_timer = None
|
|
800
|
+
def _apply_group_membership(self, membership: list[ZenAddress]) -> None:
|
|
801
|
+
for existing_group in self.groups:
|
|
802
|
+
existing_group.lights.discard(self)
|
|
803
|
+
self.groups.clear()
|
|
804
|
+
self.group_membership = list(membership)
|
|
805
|
+
for group_address in self.group_membership:
|
|
806
|
+
group = ZenGroup(protocol=self.protocol, address=group_address)
|
|
807
|
+
group.lights.add(self)
|
|
808
|
+
self.groups.add(group)
|
|
809
|
+
def interview_serialize(self) -> str:
|
|
810
|
+
return json.dumps({
|
|
811
|
+
"label": self.label,
|
|
812
|
+
"sub_label": self.sub_label,
|
|
813
|
+
"serial": self.serial,
|
|
814
|
+
"cgtype": list(self.cgtype),
|
|
815
|
+
"group_membership": [_serialize_group_address(group) for group in self.group_membership],
|
|
816
|
+
"features": dict(self.features),
|
|
817
|
+
"properties": dict(self.properties),
|
|
818
|
+
"scene_levels": list(self._scene_levels),
|
|
819
|
+
"scene_colours": [_serialize_colour(colour) for colour in self._scene_colours],
|
|
820
|
+
})
|
|
821
|
+
def interview_hydrate(self, data: str | dict[str, Any]) -> bool:
|
|
822
|
+
try:
|
|
823
|
+
data = _loads_interview_data(data)
|
|
824
|
+
self.label = data.get("label")
|
|
825
|
+
self.sub_label = data.get("sub_label")
|
|
826
|
+
self.serial = data.get("serial")
|
|
827
|
+
self.cgtype = list(data.get("cgtype", []))
|
|
828
|
+
self.features.update(data.get("features", {}))
|
|
829
|
+
self.properties.update(data.get("properties", {}))
|
|
830
|
+
self._scene_levels = list(data.get("scene_levels", []))
|
|
831
|
+
self._scene_colours = [_hydrate_colour(colour) for colour in data.get("scene_colours", [])]
|
|
832
|
+
membership = [
|
|
833
|
+
ZenAddress(controller=self.address.controller, type=ZenAddressType.GROUP, number=group["number"])
|
|
834
|
+
for group in data.get("group_membership", [])
|
|
835
|
+
]
|
|
836
|
+
self._apply_group_membership(membership)
|
|
837
|
+
cast(ZenController, self.address.controller).lights.add(self)
|
|
838
|
+
return True
|
|
839
|
+
except Exception:
|
|
840
|
+
return False
|
|
841
|
+
async def interview(self) -> bool:
|
|
842
|
+
cgstatus = await self.protocol.dali_query_control_gear_status(self.address)
|
|
843
|
+
if cgstatus:
|
|
844
|
+
self.label = await self.protocol.query_dali_device_label(self.address, generic_if_none=True)
|
|
845
|
+
self.serial = await self.protocol.query_dali_serial(self.address)
|
|
846
|
+
self.cgtype = await self.protocol.dali_query_cg_type(self.address) or []
|
|
847
|
+
|
|
848
|
+
# If cgtype contains 6, it supports brightness
|
|
849
|
+
if 6 in self.cgtype:
|
|
850
|
+
self.features["brightness"] = True
|
|
851
|
+
|
|
852
|
+
# If cgtype contains 8, it supports some kind of colour
|
|
853
|
+
if 8 in self.cgtype:
|
|
854
|
+
cgtype = await self.protocol.query_dali_colour_features(self.address)
|
|
855
|
+
if cgtype and cgtype.get("supports_tunable", False) is True:
|
|
856
|
+
self.features["brightness"] = True
|
|
857
|
+
self.features["temperature"] = True
|
|
858
|
+
colour_temp_limits = await self.protocol.query_dali_colour_temp_limits(self.address)
|
|
859
|
+
if colour_temp_limits:
|
|
860
|
+
self.properties["min_kelvin"] = colour_temp_limits.get("soft_warmest", Const.DEFAULT_WARMEST_TEMP)
|
|
861
|
+
self.properties["max_kelvin"] = colour_temp_limits.get("soft_coolest", Const.DEFAULT_COOLEST_TEMP)
|
|
862
|
+
elif cgtype and cgtype.get("rgbwaf_channels", 0) == Const.RGB_CHANNELS:
|
|
863
|
+
self.features["brightness"] = True
|
|
864
|
+
self.features["RGB"] = True
|
|
865
|
+
elif cgtype and cgtype.get("rgbwaf_channels", 0) == Const.RGBW_CHANNELS:
|
|
866
|
+
self.features["brightness"] = True
|
|
867
|
+
self.features["RGBW"] = True
|
|
868
|
+
elif cgtype and cgtype.get("rgbwaf_channels", 0) == Const.RGBWW_CHANNELS:
|
|
869
|
+
self.features["brightness"] = True
|
|
870
|
+
self.features["RGBWW"] = True
|
|
871
|
+
|
|
872
|
+
# Scenes
|
|
873
|
+
self._scene_levels = await self.protocol.query_scene_levels_by_address(self.address)
|
|
874
|
+
self._scene_colours = await self.protocol.query_scene_colours_by_address(self.address)
|
|
875
|
+
|
|
876
|
+
# Groups
|
|
877
|
+
groups = await self.protocol.query_group_membership_by_address(self.address)
|
|
878
|
+
self._apply_group_membership(groups or [])
|
|
879
|
+
|
|
880
|
+
# Add to controller's set of lights
|
|
881
|
+
cast(ZenController, self.address.controller).lights.add(self)
|
|
882
|
+
|
|
883
|
+
return True
|
|
884
|
+
else:
|
|
885
|
+
self._reset()
|
|
886
|
+
return False
|
|
887
|
+
async def refresh_state_from_controller(self, verifying: bool = False):
|
|
888
|
+
|
|
889
|
+
existing_level = self.level
|
|
890
|
+
existing_colour = self.colour
|
|
891
|
+
existing_scene = self.scene
|
|
892
|
+
|
|
893
|
+
refreshed_level = await self.protocol.dali_query_level(self.address)
|
|
894
|
+
refreshed_colour = None
|
|
895
|
+
refreshed_scene = None
|
|
896
|
+
if await self.protocol.dali_query_last_scene_is_current(self.address):
|
|
897
|
+
refreshed_scene = await self.protocol.dali_query_last_scene(self.address)
|
|
898
|
+
if self.features["temperature"] or self.features["RGB"] or self.features["RGBW"] or self.features["RGBWW"]:
|
|
899
|
+
refreshed_colour = await self.protocol.query_dali_colour(self.address)
|
|
900
|
+
|
|
901
|
+
if verifying:
|
|
902
|
+
# Level is driven by LEVEL_CHANGE_V2 dimming-to events; query returns current arc mid-fade
|
|
903
|
+
if refreshed_level is not None and self.level != refreshed_level:
|
|
904
|
+
self.protocol.logger.debug(
|
|
905
|
+
f"Light {self.address.number} queried level {refreshed_level} "
|
|
906
|
+
f"differs from tracked destination {self.level} (expected during fade)"
|
|
907
|
+
)
|
|
908
|
+
refreshed_level = None
|
|
909
|
+
if self.colour != refreshed_colour:
|
|
910
|
+
self.protocol.logger.error(f"Light {self.address.number} colour mismatch! We had {self.colour}, actual colour is {refreshed_colour}")
|
|
911
|
+
if self.scene != refreshed_scene:
|
|
912
|
+
self.protocol.logger.error(f"Light {self.address.number} scene mismatch! We had {self.scene}, actual scene is {refreshed_scene}")
|
|
913
|
+
|
|
914
|
+
# Mimic an incoming scene event when the controller reports the last
|
|
915
|
+
# scene is current. This ensures we also update `self.scene`.
|
|
916
|
+
await self._event_received(
|
|
917
|
+
level=refreshed_level,
|
|
918
|
+
colour=refreshed_colour,
|
|
919
|
+
scene=refreshed_scene,
|
|
920
|
+
active=(refreshed_scene is not None and not verifying),
|
|
921
|
+
verifying=verifying,
|
|
922
|
+
)
|
|
923
|
+
|
|
924
|
+
def _start_refresh_timer(self):
|
|
925
|
+
"""Start a 2-second timer to refresh from controller after API user changes state."""
|
|
926
|
+
# Cancel any existing timer
|
|
927
|
+
if self._refresh_timer and not self._refresh_timer.done():
|
|
928
|
+
self._refresh_timer.cancel()
|
|
929
|
+
|
|
930
|
+
# Start new timer (which quietly dies if cancelled)
|
|
931
|
+
async def delayed_refresh():
|
|
932
|
+
try:
|
|
933
|
+
await asyncio.sleep(2.0)
|
|
934
|
+
await self.refresh_state_from_controller(verifying=True)
|
|
935
|
+
except asyncio.CancelledError:
|
|
936
|
+
pass
|
|
937
|
+
|
|
938
|
+
self._refresh_timer = self.protocol.track_task(delayed_refresh())
|
|
939
|
+
|
|
940
|
+
async def _event_received(self,
|
|
941
|
+
level: int|None = 255,
|
|
942
|
+
colour: Optional["ZenColour"] = None,
|
|
943
|
+
scene: Optional[int] = None,
|
|
944
|
+
active: Optional[bool] = None,
|
|
945
|
+
cascaded_from: Optional["ZenGroup"] = None,
|
|
946
|
+
verifying: bool = False
|
|
947
|
+
):
|
|
948
|
+
# Called by ZenProtocol when a query command is issued or an event is received
|
|
949
|
+
level_changed = False
|
|
950
|
+
colour_changed = False
|
|
951
|
+
scene_changed = False
|
|
952
|
+
# `active` may be bool or int (protocol passes payload[1] as 0/1).
|
|
953
|
+
# Use truthiness — `1 is True` is False in Python.
|
|
954
|
+
if scene is not None and active:
|
|
955
|
+
self.scene = scene
|
|
956
|
+
scene_changed = True
|
|
957
|
+
scene_level = self._scene_levels[scene]
|
|
958
|
+
scene_colour = self._scene_colours[scene]
|
|
959
|
+
if scene_level is None:
|
|
960
|
+
# Some objects (e.g. groups) may not have scene level tables.
|
|
961
|
+
# Fall back to the queried `level` so we still keep runtime
|
|
962
|
+
# light/group state consistent on refresh.
|
|
963
|
+
if level is not None and level != 255 and level != self.level:
|
|
964
|
+
self.level = level
|
|
965
|
+
level_changed = True
|
|
966
|
+
elif self.level == scene_level:
|
|
967
|
+
pass # The level didn't change
|
|
968
|
+
else:
|
|
969
|
+
self.level = scene_level
|
|
970
|
+
level_changed = True
|
|
971
|
+
if scene_colour is None:
|
|
972
|
+
# Same fallback as for level: preserve queried colour when
|
|
973
|
+
# scene colour tables are unavailable.
|
|
974
|
+
if colour is not None and colour != self.colour:
|
|
975
|
+
self.colour = colour
|
|
976
|
+
colour_changed = True
|
|
977
|
+
elif self.colour == scene_colour:
|
|
978
|
+
pass # The colour didn't change
|
|
979
|
+
else:
|
|
980
|
+
self.colour = scene_colour
|
|
981
|
+
colour_changed = True
|
|
982
|
+
if type(self) is ZenGroup:
|
|
983
|
+
# print(f" Group {self.address.number} changed to scene {self.scene}")
|
|
984
|
+
pass
|
|
985
|
+
elif type(self) is ZenLight:
|
|
986
|
+
# For each group it's a member of, it must declare the same scene, else we declare it discoordinated
|
|
987
|
+
# print(f" Light {self.address.number} changed to scene {self.scene}" + f" cascaded from group {cascaded_from.address.number}" if cascaded_from else "")
|
|
988
|
+
for group in self.groups:
|
|
989
|
+
if group.scene != self.scene:
|
|
990
|
+
# print(f" Group {group.address.number} discoordinated after scene set" + f" cascaded from group {cascaded_from.address.number}" if cascaded_from else "")
|
|
991
|
+
await group.declare_discoordination()
|
|
992
|
+
else:
|
|
993
|
+
if level is not None and level != 255 and level != self.level:
|
|
994
|
+
self.level = level
|
|
995
|
+
level_changed = True
|
|
996
|
+
if self.scene is not None:
|
|
997
|
+
self.scene = None
|
|
998
|
+
scene_changed = True
|
|
999
|
+
if colour is not None and colour != self.colour:
|
|
1000
|
+
self.colour = colour
|
|
1001
|
+
colour_changed = True
|
|
1002
|
+
if self.scene is not None:
|
|
1003
|
+
self.scene = None
|
|
1004
|
+
scene_changed = True
|
|
1005
|
+
# For each group it's a member of, it must declare the same levels, else we declare it discoordinated
|
|
1006
|
+
if type(self) is ZenGroup:
|
|
1007
|
+
# print(f" Group {self.address.number} changed to {self.level} {self.colour}")
|
|
1008
|
+
pass
|
|
1009
|
+
elif type(self) is ZenLight:
|
|
1010
|
+
# print(f" Light {self.address.number} changed to {self.level} {self.colour}" + f" cascaded from group {cascaded_from.address.number}" if cascaded_from else "")
|
|
1011
|
+
for group in self.groups:
|
|
1012
|
+
if (level_changed and group.level != self.level) or (colour_changed and self.colour is not None and group.colour != self.colour):
|
|
1013
|
+
await group.declare_discoordination()
|
|
1014
|
+
# Send callbacks to the application
|
|
1015
|
+
if type(self) is ZenGroup:
|
|
1016
|
+
if level_changed or colour_changed or scene_changed:
|
|
1017
|
+
if callable(self.protocol.callbacks.group_change):
|
|
1018
|
+
await self.protocol.callbacks.group_change(group=self,
|
|
1019
|
+
level=self.level if level_changed else None,
|
|
1020
|
+
colour=self.colour if colour_changed else None,
|
|
1021
|
+
scene=self.scene if scene_changed else None)
|
|
1022
|
+
elif type(self) is ZenLight:
|
|
1023
|
+
if level_changed or colour_changed or scene_changed:
|
|
1024
|
+
if callable(self.protocol.callbacks.light_change):
|
|
1025
|
+
await self.protocol.callbacks.light_change(light=self,
|
|
1026
|
+
level=self.level if level_changed else None,
|
|
1027
|
+
colour=self.colour if colour_changed else None,
|
|
1028
|
+
scene=self.scene if scene_changed else None)
|
|
1029
|
+
def supports_colour(self, colour: "ZenColourType|ZenColour") -> bool:
|
|
1030
|
+
if type(colour) == ZenColour:
|
|
1031
|
+
colour_type = colour.type
|
|
1032
|
+
elif type(colour) == ZenColourType:
|
|
1033
|
+
colour_type = colour
|
|
1034
|
+
else:
|
|
1035
|
+
return False;
|
|
1036
|
+
if (colour_type == ZenColourType.TC and self.features["temperature"]) or \
|
|
1037
|
+
(colour_type == ZenColourType.RGBWAF and self.features["RGB"]) or \
|
|
1038
|
+
(colour_type == ZenColourType.RGBWAF and self.features["RGBW"]) or \
|
|
1039
|
+
(colour_type == ZenColourType.RGBWAF and self.features["RGBWW"]):
|
|
1040
|
+
return True
|
|
1041
|
+
return False
|
|
1042
|
+
# -----------------------------------------------------------------------------------------
|
|
1043
|
+
# REMINDER: None of the following methods should update the internal object state directly.
|
|
1044
|
+
# These methods send commands to the controller. The controller sends events back.
|
|
1045
|
+
# The events update the internal state.
|
|
1046
|
+
# -----------------------------------------------------------------------------------------
|
|
1047
|
+
async def on(self, fade: bool = True) -> Optional[bool]:
|
|
1048
|
+
self._start_refresh_timer()
|
|
1049
|
+
if not fade: await self.protocol.dali_enable_dapc_sequence(self.address)
|
|
1050
|
+
return await self.protocol.dali_go_to_last_active_level(self.address)
|
|
1051
|
+
async def off(self, fade: bool = True) -> Optional[bool]:
|
|
1052
|
+
self._start_refresh_timer()
|
|
1053
|
+
if fade: return await self.protocol.dali_arc_level(self.address, 0)
|
|
1054
|
+
else: return await self.protocol.dali_off(self.address)
|
|
1055
|
+
async def set_scene(self, scene: int|str|dict[str, Any], fade: bool = True) -> Optional[bool]:
|
|
1056
|
+
self._start_refresh_timer()
|
|
1057
|
+
if type(scene) == str:
|
|
1058
|
+
scene = next((i for i, s in enumerate(self._scene_labels) if s == scene), False)
|
|
1059
|
+
if type(scene) == int:
|
|
1060
|
+
if not fade: await self.protocol.dali_enable_dapc_sequence(self.address)
|
|
1061
|
+
return await self.protocol.dali_scene(self.address, scene)
|
|
1062
|
+
return False
|
|
1063
|
+
async def set(self, level: int = 255, colour: Optional["ZenColour"] = None, fade: bool = True) -> Optional[bool]:
|
|
1064
|
+
self._start_refresh_timer()
|
|
1065
|
+
if colour is not None and self.supports_colour(colour):
|
|
1066
|
+
if not fade: await self.protocol.dali_enable_dapc_sequence(self.address)
|
|
1067
|
+
return await self.protocol.dali_colour(self.address, colour, level)
|
|
1068
|
+
if 0 <= level <= 254:
|
|
1069
|
+
if fade:
|
|
1070
|
+
return await self.protocol.dali_arc_level(self.address, level)
|
|
1071
|
+
else:
|
|
1072
|
+
return await self.protocol.dali_custom_fade(self.address, level, 0)
|
|
1073
|
+
return False
|
|
1074
|
+
async def dali_on_step_up(self) -> Optional[bool]:
|
|
1075
|
+
self._start_refresh_timer()
|
|
1076
|
+
return await self.protocol.dali_on_step_up(self.address)
|
|
1077
|
+
async def dali_step_down_off(self) -> Optional[bool]:
|
|
1078
|
+
self._start_refresh_timer()
|
|
1079
|
+
return await self.protocol.dali_step_down_off(self.address)
|
|
1080
|
+
async def dali_up(self) -> Optional[bool]:
|
|
1081
|
+
self._start_refresh_timer()
|
|
1082
|
+
return await self.protocol.dali_up(self.address)
|
|
1083
|
+
async def dali_down(self) -> Optional[bool]:
|
|
1084
|
+
self._start_refresh_timer()
|
|
1085
|
+
return await self.protocol.dali_down(self.address)
|
|
1086
|
+
async def dali_recall_max(self) -> Optional[bool]:
|
|
1087
|
+
self._start_refresh_timer()
|
|
1088
|
+
return await self.protocol.dali_recall_max(self.address)
|
|
1089
|
+
async def dali_recall_min(self) -> Optional[bool]:
|
|
1090
|
+
self._start_refresh_timer()
|
|
1091
|
+
return await self.protocol.dali_recall_min(self.address)
|
|
1092
|
+
async def dali_go_to_last_active_level(self) -> Optional[bool]:
|
|
1093
|
+
self._start_refresh_timer()
|
|
1094
|
+
return await self.protocol.dali_go_to_last_active_level(self.address)
|
|
1095
|
+
async def dali_off(self) -> Optional[bool]:
|
|
1096
|
+
self._start_refresh_timer()
|
|
1097
|
+
return await self.protocol.dali_off(self.address)
|
|
1098
|
+
async def dali_custom_fade(self, level: int, duration: int) -> Optional[bool]:
|
|
1099
|
+
self._start_refresh_timer()
|
|
1100
|
+
return await self.protocol.dali_custom_fade(self.address, level, duration)
|
|
1101
|
+
async def dali_stop_fade(self) -> Optional[bool]:
|
|
1102
|
+
self._start_refresh_timer()
|
|
1103
|
+
return await self.protocol.dali_stop_fade(self.address)
|
|
1104
|
+
async def dali_enable_dapc_sequence(self) -> Optional[bool]:
|
|
1105
|
+
return await self.protocol.dali_enable_dapc_sequence(self.address)
|
|
1106
|
+
async def dali_inhibit(self, inhibit: bool = True) -> Optional[bool]:
|
|
1107
|
+
time_seconds = 65535 if inhibit else 0
|
|
1108
|
+
return await self.protocol.dali_inhibit(self.address, time_seconds)
|
|
1109
|
+
|
|
1110
|
+
|
|
1111
|
+
class ZenGroup(ZenLight):
|
|
1112
|
+
lights: set[ZenLight] = set()
|
|
1113
|
+
|
|
1114
|
+
def __new__(cls, protocol: ZenProtocol, address: ZenAddress) -> "ZenGroup":
|
|
1115
|
+
# Unique per protocol + controller + group address
|
|
1116
|
+
compound_id = f"{address.controller.name} g{address.number}"
|
|
1117
|
+
registry = protocol.entity_registry.groups
|
|
1118
|
+
if compound_id not in registry:
|
|
1119
|
+
inst = super().__new__(cls, protocol=protocol, address=address)
|
|
1120
|
+
registry[compound_id] = inst
|
|
1121
|
+
inst.protocol = protocol
|
|
1122
|
+
inst.address = address
|
|
1123
|
+
inst.lights = set() # member lights; managed via ZenLight._apply_group_membership
|
|
1124
|
+
inst._reset()
|
|
1125
|
+
# Don't call interview() here - it will be called async later
|
|
1126
|
+
return registry[compound_id]
|
|
1127
|
+
|
|
1128
|
+
def __init__(self, protocol: ZenProtocol, address: ZenAddress) -> None:
|
|
1129
|
+
super().__init__(protocol, address)
|
|
1130
|
+
|
|
1131
|
+
@classmethod
|
|
1132
|
+
async def create(cls, protocol: ZenProtocol, address: ZenAddress) -> "ZenGroup":
|
|
1133
|
+
"""Async factory method for ZenGroup"""
|
|
1134
|
+
group = cls(protocol, address)
|
|
1135
|
+
await group.interview()
|
|
1136
|
+
return group
|
|
1137
|
+
def __repr__(self) -> str:
|
|
1138
|
+
return f"ZenGroup<{self.address.controller.name} group {self.address.number}: {self.label}>"
|
|
1139
|
+
def interview_serialize(self) -> str:
|
|
1140
|
+
return json.dumps({
|
|
1141
|
+
"label": self.label,
|
|
1142
|
+
"scene_labels": list(self._scene_labels),
|
|
1143
|
+
})
|
|
1144
|
+
def interview_hydrate(self, data: str | dict[str, Any]) -> bool:
|
|
1145
|
+
try:
|
|
1146
|
+
data = _loads_interview_data(data)
|
|
1147
|
+
self.label = data.get("label")
|
|
1148
|
+
self._scene_labels = list(data.get("scene_labels", []))
|
|
1149
|
+
cast(ZenController, self.address.controller).groups.add(self)
|
|
1150
|
+
return True
|
|
1151
|
+
except Exception:
|
|
1152
|
+
return False
|
|
1153
|
+
async def interview(self) -> bool:
|
|
1154
|
+
self.label = await self.protocol.query_group_label(self.address, generic_if_none=True)
|
|
1155
|
+
self._scene_labels = await self.protocol.query_scenes_for_group(self.address, generic_if_none=True)
|
|
1156
|
+
# Add to controller's set of groups
|
|
1157
|
+
cast(ZenController, self.address.controller).groups.add(self)
|
|
1158
|
+
return True
|
|
1159
|
+
def supports_colour(self, colour: "ZenColourType|ZenColour") -> bool:
|
|
1160
|
+
# If at least one light in the group supports this colour, return True
|
|
1161
|
+
for light in self.lights:
|
|
1162
|
+
if light.supports_colour(colour):
|
|
1163
|
+
return True
|
|
1164
|
+
return False
|
|
1165
|
+
def get_scene_number_from_label(self, label: str) -> Optional[int]:
|
|
1166
|
+
# return list index of label in self._scene_labels
|
|
1167
|
+
return next((i for i, s in enumerate(self._scene_labels) if s == label), None)
|
|
1168
|
+
def get_scene_label_from_number(self, number: int) -> Optional[str]:
|
|
1169
|
+
# return label at index number in self._scene_labels
|
|
1170
|
+
return self._scene_labels[number]
|
|
1171
|
+
def get_scene_labels(self, exclude_none: bool = False) -> list[Optional[str]]:
|
|
1172
|
+
if exclude_none:
|
|
1173
|
+
return [label for label in self._scene_labels if label is not None]
|
|
1174
|
+
else:
|
|
1175
|
+
return self._scene_labels
|
|
1176
|
+
# -----------------------------------------------------------------------------------------
|
|
1177
|
+
# REMINDER: None of the following methods should update the internal object state directly.
|
|
1178
|
+
# These methods send commands to the controller. The controller sends events back.
|
|
1179
|
+
# The events update the internal state.
|
|
1180
|
+
# -----------------------------------------------------------------------------------------
|
|
1181
|
+
async def declare_discoordination(self):
|
|
1182
|
+
# Only do something if the group claims to be coordinated
|
|
1183
|
+
if self.level is None and self.colour is None and self.scene is None:
|
|
1184
|
+
return
|
|
1185
|
+
# This is called when members of the group are no longer in a uniform state
|
|
1186
|
+
self.level = None
|
|
1187
|
+
self.colour = None
|
|
1188
|
+
self.scene = None
|
|
1189
|
+
if callable(self.protocol.callbacks.group_change):
|
|
1190
|
+
await self.protocol.callbacks.group_change(group=self,
|
|
1191
|
+
discoordinated=True)
|
|
1192
|
+
def contains_dimmable_lights(self) -> bool:
|
|
1193
|
+
# Is there at least one ZenLight in self.lights that supports dimming?
|
|
1194
|
+
for light in self.lights:
|
|
1195
|
+
if light.features["brightness"]:
|
|
1196
|
+
return True
|
|
1197
|
+
return False
|
|
1198
|
+
def contains_temperature_lights(self) -> bool:
|
|
1199
|
+
# Is there at least one ZenLight in self.lights that supports temperature?
|
|
1200
|
+
for light in self.lights:
|
|
1201
|
+
if light.features["temperature"]:
|
|
1202
|
+
return True
|
|
1203
|
+
return False
|
|
1204
|
+
|
|
1205
|
+
class ZenButton:
|
|
1206
|
+
protocol: ZenProtocol
|
|
1207
|
+
instance: ZenInstance
|
|
1208
|
+
serial: Optional[int | str] = None
|
|
1209
|
+
label: Optional[str] = None
|
|
1210
|
+
instance_label: Optional[str] = None
|
|
1211
|
+
last_press_time: float = 0.0
|
|
1212
|
+
long_press_count: int = 0
|
|
1213
|
+
client_data: dict[str, Any] = {}
|
|
1214
|
+
|
|
1215
|
+
def __new__(cls, protocol: ZenProtocol, instance: ZenInstance) -> "ZenButton":
|
|
1216
|
+
# Unique per protocol + controller + address + instance
|
|
1217
|
+
compound_id = f"{instance.address.controller.name} {instance.address.number} {instance.number}"
|
|
1218
|
+
registry = protocol.entity_registry.buttons
|
|
1219
|
+
if compound_id not in registry:
|
|
1220
|
+
inst = super().__new__(cls)
|
|
1221
|
+
registry[compound_id] = inst
|
|
1222
|
+
inst.protocol = protocol
|
|
1223
|
+
inst.instance = instance
|
|
1224
|
+
inst._reset()
|
|
1225
|
+
# Don't call interview() here - it will be called async later
|
|
1226
|
+
return registry[compound_id]
|
|
1227
|
+
|
|
1228
|
+
def __init__(self, protocol: ZenProtocol, instance: ZenInstance) -> None:
|
|
1229
|
+
self.protocol = protocol
|
|
1230
|
+
self.instance = instance
|
|
1231
|
+
|
|
1232
|
+
@classmethod
|
|
1233
|
+
async def create(cls, protocol: ZenProtocol, instance: ZenInstance) -> "ZenButton":
|
|
1234
|
+
"""Async factory method for ZenButton"""
|
|
1235
|
+
button = cls(protocol, instance)
|
|
1236
|
+
await button.interview()
|
|
1237
|
+
return button
|
|
1238
|
+
def __repr__(self) -> str:
|
|
1239
|
+
return f"ZenButton<{self.instance.address.controller.name} ecd {self.instance.address.number} inst {self.instance.number}: {self.label} / {self.instance_label}>"
|
|
1240
|
+
def _reset(self) -> None:
|
|
1241
|
+
self.serial = None
|
|
1242
|
+
self.label = None
|
|
1243
|
+
self.instance_label = None
|
|
1244
|
+
self.last_press_time = time.time()
|
|
1245
|
+
self.long_press_count = 0
|
|
1246
|
+
self.client_data = {}
|
|
1247
|
+
def interview_serialize(self) -> str:
|
|
1248
|
+
return json.dumps({
|
|
1249
|
+
"serial": self.serial,
|
|
1250
|
+
"label": self.label,
|
|
1251
|
+
"instance_label": self.instance_label,
|
|
1252
|
+
})
|
|
1253
|
+
def interview_hydrate(self, data: str | dict[str, Any]) -> bool:
|
|
1254
|
+
try:
|
|
1255
|
+
data = _loads_interview_data(data)
|
|
1256
|
+
self.serial = data.get("serial")
|
|
1257
|
+
self.label = data.get("label")
|
|
1258
|
+
self.instance_label = data.get("instance_label")
|
|
1259
|
+
self.instance.address.label = self.label
|
|
1260
|
+
self.instance.address.serial = cast(Optional[str], self.serial)
|
|
1261
|
+
cast(ZenController, self.instance.address.controller).buttons.add(self)
|
|
1262
|
+
return True
|
|
1263
|
+
except Exception:
|
|
1264
|
+
return False
|
|
1265
|
+
async def interview(self) -> bool:
|
|
1266
|
+
inst = self.instance
|
|
1267
|
+
addr = inst.address
|
|
1268
|
+
ctrl = cast(ZenController, addr.controller)
|
|
1269
|
+
if addr.label is None: addr.label = await self.protocol.query_dali_device_label(addr, generic_if_none=True)
|
|
1270
|
+
if addr.serial is None: addr.serial = cast(Optional[str], await self.protocol.query_dali_serial(addr))
|
|
1271
|
+
self.label = addr.label
|
|
1272
|
+
self.serial = addr.serial
|
|
1273
|
+
self.instance_label = await self.protocol.query_dali_instance_label(inst, generic_if_none=True)
|
|
1274
|
+
# Add to controller's set of buttons
|
|
1275
|
+
ctrl.buttons.add(self)
|
|
1276
|
+
return True
|
|
1277
|
+
async def _event_received(self, held: bool = False):
|
|
1278
|
+
if not held:
|
|
1279
|
+
if callable(self.protocol.callbacks.button_press):
|
|
1280
|
+
await self.protocol.callbacks.button_press(button=self)
|
|
1281
|
+
else:
|
|
1282
|
+
seconds_since_last_press = time.time() - self.last_press_time
|
|
1283
|
+
# if there's been less than 500 msec between the last hold message, increment the hold count
|
|
1284
|
+
if seconds_since_last_press < 0.5:
|
|
1285
|
+
self.long_press_count += 1
|
|
1286
|
+
else:
|
|
1287
|
+
self.long_press_count = 0
|
|
1288
|
+
self.last_press_time = time.time()
|
|
1289
|
+
# if the hold count is exactly Const.LONG_PRESS_COUNT, call the long press callback
|
|
1290
|
+
if self.long_press_count == Const.LONG_PRESS_COUNT:
|
|
1291
|
+
if callable(self.protocol.callbacks.button_long_press):
|
|
1292
|
+
await self.protocol.callbacks.button_long_press(button=self)
|
|
1293
|
+
|
|
1294
|
+
|
|
1295
|
+
|
|
1296
|
+
class ZenMotionSensor:
|
|
1297
|
+
protocol: ZenProtocol
|
|
1298
|
+
instance: ZenInstance
|
|
1299
|
+
hold_time: int = Const.DEFAULT_HOLD_TIME
|
|
1300
|
+
hold_expiry_task: Optional[asyncio.Task[None]] = None
|
|
1301
|
+
serial: Optional[int | str] = None
|
|
1302
|
+
label: Optional[str] = None
|
|
1303
|
+
instance_label: Optional[str] = None
|
|
1304
|
+
deadtime: Optional[int] = None
|
|
1305
|
+
last_detect: Optional[float] = None
|
|
1306
|
+
_occupied: Optional[bool] = None
|
|
1307
|
+
client_data: dict[str, Any] = {}
|
|
1308
|
+
|
|
1309
|
+
def __new__(cls, protocol: ZenProtocol, instance: ZenInstance) -> "ZenMotionSensor":
|
|
1310
|
+
# Unique per protocol + controller + address + instance
|
|
1311
|
+
compound_id = f"{instance.address.controller.name} {instance.address.number} {instance.number}"
|
|
1312
|
+
registry = protocol.entity_registry.motion_sensors
|
|
1313
|
+
if compound_id not in registry:
|
|
1314
|
+
inst = super().__new__(cls)
|
|
1315
|
+
registry[compound_id] = inst
|
|
1316
|
+
inst.protocol = protocol
|
|
1317
|
+
inst.instance = instance
|
|
1318
|
+
inst._reset()
|
|
1319
|
+
# Don't call interview() here - it will be called async later
|
|
1320
|
+
return registry[compound_id]
|
|
1321
|
+
|
|
1322
|
+
def __init__(self, protocol: ZenProtocol, instance: ZenInstance) -> None:
|
|
1323
|
+
self.protocol = protocol
|
|
1324
|
+
self.instance = instance
|
|
1325
|
+
|
|
1326
|
+
@classmethod
|
|
1327
|
+
async def create(cls, protocol: ZenProtocol, instance: ZenInstance) -> "ZenMotionSensor":
|
|
1328
|
+
"""Async factory method for ZenMotionSensor"""
|
|
1329
|
+
sensor = cls(protocol, instance)
|
|
1330
|
+
await sensor.interview()
|
|
1331
|
+
return sensor
|
|
1332
|
+
def __repr__(self) -> str:
|
|
1333
|
+
return f"ZenMotionSensor<{self.instance.address.controller.name} ecd {self.instance.address.number} inst {self.instance.number}: {self.label} / {self.instance_label}>"
|
|
1334
|
+
def _reset(self) -> None:
|
|
1335
|
+
self.hold_time = Const.DEFAULT_HOLD_TIME
|
|
1336
|
+
self.hold_expiry_task = None
|
|
1337
|
+
#
|
|
1338
|
+
self.serial = None
|
|
1339
|
+
self.label = None
|
|
1340
|
+
self.instance_label = None
|
|
1341
|
+
self.deadtime = None
|
|
1342
|
+
self.last_detect = None
|
|
1343
|
+
self._occupied = None
|
|
1344
|
+
#
|
|
1345
|
+
self.client_data = {}
|
|
1346
|
+
def interview_serialize(self) -> str:
|
|
1347
|
+
return json.dumps({
|
|
1348
|
+
"serial": self.serial,
|
|
1349
|
+
"label": self.label,
|
|
1350
|
+
"instance_label": self.instance_label,
|
|
1351
|
+
"deadtime": self.deadtime,
|
|
1352
|
+
"hold_time": self.hold_time,
|
|
1353
|
+
})
|
|
1354
|
+
def interview_hydrate(self, data: str | dict[str, Any]) -> bool:
|
|
1355
|
+
try:
|
|
1356
|
+
data = _loads_interview_data(data)
|
|
1357
|
+
self.serial = data.get("serial")
|
|
1358
|
+
self.label = data.get("label")
|
|
1359
|
+
self.instance_label = data.get("instance_label")
|
|
1360
|
+
self.deadtime = data.get("deadtime")
|
|
1361
|
+
self.hold_time = data.get("hold_time", Const.DEFAULT_HOLD_TIME)
|
|
1362
|
+
self._occupied = None
|
|
1363
|
+
self.instance.address.label = self.label
|
|
1364
|
+
self.instance.address.serial = cast(Optional[str], self.serial)
|
|
1365
|
+
cast(ZenController, self.instance.address.controller).motion_sensors.add(self)
|
|
1366
|
+
return True
|
|
1367
|
+
except Exception:
|
|
1368
|
+
return False
|
|
1369
|
+
async def interview(self) -> bool:
|
|
1370
|
+
inst = self.instance
|
|
1371
|
+
addr = inst.address
|
|
1372
|
+
ctrl = cast(ZenController, addr.controller)
|
|
1373
|
+
occupancy_timers = await self.protocol.query_occupancy_instance_timers(inst)
|
|
1374
|
+
if occupancy_timers is not None:
|
|
1375
|
+
self.serial = await self.protocol.query_dali_serial(addr)
|
|
1376
|
+
self.label = await self.protocol.query_dali_device_label(addr, generic_if_none=True)
|
|
1377
|
+
self.instance_label = await self.protocol.query_dali_instance_label(inst, generic_if_none=True)
|
|
1378
|
+
self.deadtime = occupancy_timers["deadtime"]
|
|
1379
|
+
self.hold_time = occupancy_timers["hold"]
|
|
1380
|
+
self.last_detect = time.time() - occupancy_timers["last_detect"]
|
|
1381
|
+
self._occupied = None
|
|
1382
|
+
else:
|
|
1383
|
+
self._reset()
|
|
1384
|
+
return False
|
|
1385
|
+
# Add to controller's set of motion sensors
|
|
1386
|
+
ctrl.motion_sensors.add(self)
|
|
1387
|
+
return True
|
|
1388
|
+
|
|
1389
|
+
async def refresh_state_from_controller(self) -> bool:
|
|
1390
|
+
"""Query controller and update runtime occupancy fields."""
|
|
1391
|
+
inst = self.instance
|
|
1392
|
+
occupancy_timers = await self.protocol.query_occupancy_instance_timers(inst)
|
|
1393
|
+
if occupancy_timers is None:
|
|
1394
|
+
self.last_detect = None
|
|
1395
|
+
self._occupied = None
|
|
1396
|
+
self.hold_expiry_task = None
|
|
1397
|
+
self.deadtime = None
|
|
1398
|
+
self.hold_time = Const.DEFAULT_HOLD_TIME
|
|
1399
|
+
return False
|
|
1400
|
+
|
|
1401
|
+
# `last_detect` is stored as "time when last motion happened"
|
|
1402
|
+
# converted into a duration since last motion (same as interview()).
|
|
1403
|
+
self.deadtime = occupancy_timers["deadtime"]
|
|
1404
|
+
self.hold_time = occupancy_timers["hold"]
|
|
1405
|
+
self.last_detect = time.time() - occupancy_timers["last_detect"]
|
|
1406
|
+
self._occupied = None
|
|
1407
|
+
return True
|
|
1408
|
+
async def _event_received(self):
|
|
1409
|
+
# Capture old state before the setter updates it so we can fire the
|
|
1410
|
+
# callback with await instead of asyncio.create_task (fire-and-forget).
|
|
1411
|
+
was_occupied = self._occupied or False
|
|
1412
|
+
self.occupied = True
|
|
1413
|
+
if not was_occupied and callable(self.protocol.callbacks.motion_event):
|
|
1414
|
+
await self.protocol.callbacks.motion_event(sensor=self, occupied=True)
|
|
1415
|
+
@property
|
|
1416
|
+
def occupied(self) -> bool:
|
|
1417
|
+
if self.last_detect is None:
|
|
1418
|
+
return False
|
|
1419
|
+
seconds_since_last_motion = time.time() - self.last_detect
|
|
1420
|
+
within_hold_time = seconds_since_last_motion < self.hold_time
|
|
1421
|
+
# if occupied but a hold task isn't running, start one with the time remaining
|
|
1422
|
+
if within_hold_time and self.hold_expiry_task is None:
|
|
1423
|
+
seconds_until_hold_time_expires = self.hold_time - seconds_since_last_motion
|
|
1424
|
+
self.hold_expiry_task = self.protocol.track_task(self._timeout_after_delay(seconds_until_hold_time_expires))
|
|
1425
|
+
return within_hold_time
|
|
1426
|
+
async def _timeout_after_delay(self, delay: float):
|
|
1427
|
+
"""Async method to handle motion sensor timeout"""
|
|
1428
|
+
await asyncio.sleep(delay)
|
|
1429
|
+
self._occupied = False
|
|
1430
|
+
self.last_detect = None
|
|
1431
|
+
self.hold_expiry_task = None
|
|
1432
|
+
# Trigger motion event callback
|
|
1433
|
+
if callable(self.protocol.callbacks.motion_event):
|
|
1434
|
+
await self.protocol.callbacks.motion_event(sensor=self, occupied=False)
|
|
1435
|
+
|
|
1436
|
+
@occupied.setter
|
|
1437
|
+
def occupied(self, new_value: bool):
|
|
1438
|
+
old_value = self._occupied or False
|
|
1439
|
+
# Cancel any hold time task
|
|
1440
|
+
if self.hold_expiry_task is not None:
|
|
1441
|
+
self.hold_expiry_task.cancel()
|
|
1442
|
+
self.hold_expiry_task = None
|
|
1443
|
+
# Start a new task
|
|
1444
|
+
if new_value:
|
|
1445
|
+
# Update last detect time, begin a task, and set occupied to True.
|
|
1446
|
+
# The occupied=True callback is fired by _event_received (which is
|
|
1447
|
+
# async and can await it properly).
|
|
1448
|
+
self.last_detect = time.time()
|
|
1449
|
+
self.hold_expiry_task = self.protocol.track_task(self._timeout_after_delay(self.hold_time))
|
|
1450
|
+
self._occupied = True
|
|
1451
|
+
else:
|
|
1452
|
+
self._occupied = False
|
|
1453
|
+
self.last_detect = None
|
|
1454
|
+
# If we're going from True to False, trigger motion event callback.
|
|
1455
|
+
# This branch is only reached when occupied is set to False directly
|
|
1456
|
+
# (not via _timeout_after_delay which handles the callback itself).
|
|
1457
|
+
if old_value is True:
|
|
1458
|
+
cb = self.protocol.callbacks.motion_event
|
|
1459
|
+
if callable(cb):
|
|
1460
|
+
self.protocol.track_task(cast(Coroutine[Any, Any, None], cb(sensor=self, occupied=False)))
|
|
1461
|
+
|
|
1462
|
+
|
|
1463
|
+
class ZenSystemVariable:
|
|
1464
|
+
protocol: ZenProtocol
|
|
1465
|
+
controller: ZenController
|
|
1466
|
+
id: int
|
|
1467
|
+
label: Optional[str] = None
|
|
1468
|
+
_value: Optional[int] = None
|
|
1469
|
+
_future_value: Optional[int] = None
|
|
1470
|
+
client_data: dict[str, Any] = {}
|
|
1471
|
+
|
|
1472
|
+
def __new__(cls, protocol: ZenProtocol, controller: ZenController, id: int, value: Optional[int] = None, label: Optional[str] = None) -> "ZenSystemVariable":
|
|
1473
|
+
# Unique per protocol + controller + id
|
|
1474
|
+
compound_id = f"{controller.name} {id}"
|
|
1475
|
+
registry = protocol.entity_registry.system_variables
|
|
1476
|
+
if compound_id not in registry:
|
|
1477
|
+
inst = super().__new__(cls)
|
|
1478
|
+
registry[compound_id] = inst
|
|
1479
|
+
inst.protocol = protocol
|
|
1480
|
+
inst.controller = controller
|
|
1481
|
+
inst.id = id
|
|
1482
|
+
inst._reset()
|
|
1483
|
+
inst._value = value
|
|
1484
|
+
inst.label = label
|
|
1485
|
+
# Don't call interview() here - it will be called async later
|
|
1486
|
+
return registry[compound_id]
|
|
1487
|
+
|
|
1488
|
+
def __init__(self, protocol: ZenProtocol, controller: ZenController, id: int, value: Optional[int] = None, label: Optional[str] = None) -> None:
|
|
1489
|
+
self.protocol = protocol
|
|
1490
|
+
self.controller = controller
|
|
1491
|
+
self.id = id
|
|
1492
|
+
if value is not None:
|
|
1493
|
+
self._value = value
|
|
1494
|
+
if label is not None:
|
|
1495
|
+
self.label = label
|
|
1496
|
+
|
|
1497
|
+
@classmethod
|
|
1498
|
+
async def create(cls, protocol: ZenProtocol, controller: ZenController, id: int, value: Optional[int] = None, label: Optional[str] = None) -> "ZenSystemVariable":
|
|
1499
|
+
"""Async factory method for ZenSystemVariable"""
|
|
1500
|
+
sysvar = cls(protocol, controller, id, value, label)
|
|
1501
|
+
await sysvar.interview()
|
|
1502
|
+
return sysvar
|
|
1503
|
+
def __repr__(self) -> str:
|
|
1504
|
+
return f"ZenSystemVariable<{self.controller.name} sv {self.id}: {self.label}>"
|
|
1505
|
+
def _reset(self) -> None:
|
|
1506
|
+
self.label = None
|
|
1507
|
+
self._value = None
|
|
1508
|
+
self._future_value = None
|
|
1509
|
+
self.client_data = {}
|
|
1510
|
+
def interview_serialize(self) -> str:
|
|
1511
|
+
return json.dumps({
|
|
1512
|
+
"label": self.label,
|
|
1513
|
+
})
|
|
1514
|
+
def interview_hydrate(self, data: str | dict[str, Any]) -> bool:
|
|
1515
|
+
try:
|
|
1516
|
+
data = _loads_interview_data(data)
|
|
1517
|
+
self.label = data.get("label")
|
|
1518
|
+
self._future_value = None
|
|
1519
|
+
self.controller.sysvars.add(self)
|
|
1520
|
+
return True
|
|
1521
|
+
except Exception:
|
|
1522
|
+
return False
|
|
1523
|
+
async def interview(self) -> bool:
|
|
1524
|
+
ctrl = self.controller
|
|
1525
|
+
if self.label is None:
|
|
1526
|
+
self.label = await self.protocol.query_system_variable_name(ctrl, self.id)
|
|
1527
|
+
if self._value is None:
|
|
1528
|
+
self._value = await self.protocol.query_system_variable(ctrl, self.id)
|
|
1529
|
+
# Add to controller's set of system variables
|
|
1530
|
+
ctrl.sysvars.add(self)
|
|
1531
|
+
return True
|
|
1532
|
+
async def _event_received(self, new_value: Optional[int]):
|
|
1533
|
+
changed = (new_value != self._value)
|
|
1534
|
+
by_me = (new_value == self._future_value)
|
|
1535
|
+
self._value = new_value
|
|
1536
|
+
self._future_value = None
|
|
1537
|
+
if changed:
|
|
1538
|
+
if callable(self.protocol.callbacks.system_variable_change):
|
|
1539
|
+
await self.protocol.callbacks.system_variable_change(system_variable=self,
|
|
1540
|
+
value=self._value,
|
|
1541
|
+
changed=changed,
|
|
1542
|
+
by_me=by_me)
|
|
1543
|
+
# -----------------------------------------------------------------------------------------
|
|
1544
|
+
# REMINDER: None of the following methods should update the internal object state directly.
|
|
1545
|
+
# These methods send commands to the controller. The controller sends events back.
|
|
1546
|
+
# The events update the internal state.
|
|
1547
|
+
# -----------------------------------------------------------------------------------------
|
|
1548
|
+
@property
|
|
1549
|
+
def value(self) -> Optional[int]:
|
|
1550
|
+
"""Return the last-known value without querying the controller."""
|
|
1551
|
+
return self._value
|
|
1552
|
+
|
|
1553
|
+
async def get_value(self) -> Optional[int]:
|
|
1554
|
+
"""Get the current value of the system variable, querying the controller if unknown."""
|
|
1555
|
+
if self._value is None:
|
|
1556
|
+
self._value = await self.protocol.query_system_variable(self.controller, self.id)
|
|
1557
|
+
return self._value
|
|
1558
|
+
|
|
1559
|
+
async def refresh_state_from_controller(self) -> None:
|
|
1560
|
+
"""Query the controller and update this system variable's runtime value."""
|
|
1561
|
+
new_value = await self.protocol.query_system_variable(self.controller, self.id)
|
|
1562
|
+
await self._event_received(new_value)
|
|
1563
|
+
|
|
1564
|
+
async def set_value(self, new_value: int) -> None:
|
|
1565
|
+
"""Set the value of the system variable"""
|
|
1566
|
+
self._future_value = new_value # If we get this value back as an event, we'll know it's from us
|
|
1567
|
+
await self.protocol.set_system_variable(self.controller, self.id, new_value)
|
|
1568
|
+
|
|
1569
|
+
|
|
1570
|
+
# Callback type definitions (moved here after class definitions)
|
|
1571
|
+
CallbackOnConnect = Callable[[], Awaitable[None]]
|
|
1572
|
+
CallbackOnDisconnect = Callable[[], Awaitable[None]]
|
|
1573
|
+
CallbackProfileChange = Callable[[ZenProfile], Awaitable[None]]
|
|
1574
|
+
CallbackGroupChange = Callable[[ZenGroup, int], Awaitable[None]]
|
|
1575
|
+
CallbackLightChange = Callable[[ZenLight, int, ZenColour, int], Awaitable[None]]
|
|
1576
|
+
CallbackButtonPress = Callable[[ZenButton], Awaitable[None]]
|
|
1577
|
+
CallbackButtonLongPress = Callable[[ZenButton], Awaitable[None]]
|
|
1578
|
+
CallbackMotionEvent = Callable[[ZenMotionSensor, bool], Awaitable[None]]
|
|
1579
|
+
CallbackSystemVariableChange = Callable[[ZenSystemVariable, int, bool, bool], Awaitable[None]]
|