zencontrol-python 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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]]