zencontrol-python 0.1.3__py3-none-any.whl → 0.1.6__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.
zencontrol/__init__.py CHANGED
@@ -40,6 +40,7 @@ from .interface import (
40
40
  ZenLight,
41
41
  ZenGroup,
42
42
  ZenButton,
43
+ ZenAbsoluteInput,
43
44
  ZenMotionSensor,
44
45
  ZenSystemVariable,
45
46
  )
@@ -58,7 +59,7 @@ from .exceptions import ZenError, ZenTimeoutError, ZenResponseError, ZenConnecti
58
59
  # Utilities
59
60
  from .utils import run_with_keyboard_interrupt
60
61
 
61
- __version__ = "0.1.3"
62
+ __version__ = "0.1.6"
62
63
  __author__ = "Simon Wright"
63
64
 
64
65
  # Public API - these are the main classes users should import
@@ -72,6 +73,7 @@ __all__ = [
72
73
  "ZenLight",
73
74
  "ZenGroup",
74
75
  "ZenButton",
76
+ "ZenAbsoluteInput",
75
77
  "ZenMotionSensor",
76
78
  "ZenSystemVariable",
77
79
 
@@ -57,6 +57,7 @@ class ZenCallbacks:
57
57
  self.light_change: Callable[..., Awaitable[None]] | None = None
58
58
  self.button_press: Callable[..., Awaitable[None]] | None = None
59
59
  self.button_long_press: Callable[..., Awaitable[None]] | None = None
60
+ self.absolute_input_change: Callable[..., Awaitable[None]] | None = None
60
61
  self.motion_event: Callable[..., Awaitable[None]] | None = None
61
62
  self.system_variable_change: Callable[..., Awaitable[None]] | None = None
62
63
  self.controller_discovered: Callable[[DiscoveredController], Awaitable[None]] | None = None
@@ -75,6 +76,7 @@ class EntityRegistry:
75
76
  self.lights: dict[str, Any] = {}
76
77
  self.groups: dict[str, Any] = {}
77
78
  self.buttons: dict[str, Any] = {}
79
+ self.absolute_inputs: dict[str, Any] = {}
78
80
  self.motion_sensors: dict[str, Any] = {}
79
81
  self.system_variables: dict[str, Any] = {}
80
82
 
@@ -84,9 +86,26 @@ class EntityRegistry:
84
86
  self.lights.clear()
85
87
  self.groups.clear()
86
88
  self.buttons.clear()
89
+ self.absolute_inputs.clear()
87
90
  self.motion_sensors.clear()
88
91
  self.system_variables.clear()
89
92
 
93
+ def purge_controller(self, controller_name: str) -> None:
94
+ """Drop cached entities that belong to ``controller_name``."""
95
+ self.controllers.pop(controller_name, None)
96
+ prefix = f"{controller_name} "
97
+ for store in (
98
+ self.profiles,
99
+ self.lights,
100
+ self.groups,
101
+ self.buttons,
102
+ self.absolute_inputs,
103
+ self.motion_sensors,
104
+ self.system_variables,
105
+ ):
106
+ for key in [k for k in store if k == controller_name or k.startswith(prefix)]:
107
+ store.pop(key, None)
108
+
90
109
 
91
110
  class ZenProtocol:
92
111
 
@@ -260,6 +279,10 @@ class ZenProtocol:
260
279
  """Drop all interface entity singletons owned by this protocol."""
261
280
  self.entity_registry.clear()
262
281
 
282
+ def purge_controller_entities(self, controller_name: str) -> None:
283
+ """Drop interface-layer singletons for one controller."""
284
+ self.entity_registry.purge_controller(controller_name)
285
+
263
286
  def track_task(self, coro: Coroutine[Any, Any, Any]) -> asyncio.Task[Any]:
264
287
  """Schedule a background task and track it for cancellation on aclose."""
265
288
  task = asyncio.create_task(coro)
@@ -854,7 +877,7 @@ class ZenProtocol:
854
877
  min_payload = {
855
878
  ZenEventCode.BUTTON_PRESS: 1,
856
879
  ZenEventCode.BUTTON_HOLD: 1,
857
- ZenEventCode.ABSOLUTE_INPUT: 1,
880
+ ZenEventCode.ABSOLUTE_INPUT: 3,
858
881
  ZenEventCode.LEVEL_CHANGE: 1,
859
882
  ZenEventCode.LEVEL_CHANGE_V2: 2,
860
883
  ZenEventCode.GROUP_LEVEL_CHANGE: 1,
@@ -902,10 +925,10 @@ class ZenProtocol:
902
925
  await self.button_hold_callback(instance=instance, payload=payload)
903
926
 
904
927
  case ZenEventCode.ABSOLUTE_INPUT:
928
+ value = (payload[1] << 8) | payload[2]
905
929
  if self.print_traffic:
906
930
  print(Fore.MAGENTA + f"{typecast.upper()} {ip_address}:" +
907
- Fore.CYAN + f" Absolute {target-64}" +
908
- Style.DIM + f" [{' '.join(f'0x{b:02X}' for b in payload)}]" +
931
+ Fore.CYAN + f" Absolute {target-64}.{payload[0]} = {value}" +
909
932
  Style.RESET_ALL)
910
933
  if self.absolute_input_callback:
911
934
  address = self._ecd_address_from_target(controller, target)
zencontrol/api/types.py CHANGED
@@ -203,3 +203,7 @@ class Const:
203
203
  RECONNECT_MIN_DELAY = 1.0
204
204
  RECONNECT_MAX_DELAY = 30.0
205
205
  RECONNECT_HEALTHY_SECONDS = 60.0
206
+
207
+ # Periodic emit-state check — controllers that reboot while our listener
208
+ # stays up lose TPI event config until we re-assert it.
209
+ EVENT_KEEPALIVE_INTERVAL = 30.0
@@ -14,6 +14,7 @@ from .interface import (
14
14
  ZenLight,
15
15
  ZenGroup,
16
16
  ZenButton,
17
+ ZenAbsoluteInput,
17
18
  ZenMotionSensor,
18
19
  ZenSystemVariable,
19
20
  )
@@ -28,6 +29,7 @@ __all__ = [
28
29
  "ZenLight",
29
30
  "ZenGroup",
30
31
  "ZenButton",
32
+ "ZenAbsoluteInput",
31
33
  "ZenMotionSensor",
32
34
  "ZenSystemVariable",
33
35
  ]
@@ -4,13 +4,13 @@ import asyncio
4
4
  import json
5
5
  import time
6
6
  import logging
7
- from typing import Any, cast, Self
7
+ from typing import Any, Literal, cast, Self
8
8
  from collections.abc import Coroutine, Callable, Awaitable
9
9
 
10
10
  from ..api import ZenProtocol, ZenController as SuperZenController, ZenAddress, ZenInstance, ZenAddressType, ZenColour, ZenColourType, ZenInstanceType
11
11
  from ..api.models import DiscoveredController
12
12
  from ..api.protocol import ZenCallbacks
13
- from ..api.types import Const
13
+ from ..api.types import Const, ZenEventMode
14
14
  from ..exceptions import ZenConnectionError
15
15
 
16
16
  """
@@ -140,10 +140,13 @@ class ZenControl:
140
140
  self.protocol.callbacks = ZenCallbacks()
141
141
  self._stopping = False
142
142
  self._supervisor_task: asyncio.Task[None] | None = None
143
+ self._keepalive_task: asyncio.Task[None] | None = None
143
144
  self._first_connected = asyncio.Event()
145
+ self._controller_status_change: CallbackControllerStatusChange | None = None
144
146
  self.reconnect_min_delay = Const.RECONNECT_MIN_DELAY
145
147
  self.reconnect_max_delay = Const.RECONNECT_MAX_DELAY
146
148
  self.reconnect_healthy_seconds = Const.RECONNECT_HEALTHY_SECONDS
149
+ self.event_keepalive_interval = Const.EVENT_KEEPALIVE_INTERVAL
147
150
 
148
151
  async def __aenter__(self) -> Self:
149
152
  return self
@@ -207,7 +210,14 @@ class ZenControl:
207
210
  @button_long_press.setter
208
211
  def button_long_press(self, func: CallbackButtonLongPress | None) -> None:
209
212
  self.protocol.callbacks.button_long_press = func
210
-
213
+
214
+ @property
215
+ def absolute_input_change(self) -> CallbackAbsoluteInputChange | None:
216
+ return self.protocol.callbacks.absolute_input_change
217
+ @absolute_input_change.setter
218
+ def absolute_input_change(self, func: CallbackAbsoluteInputChange | None) -> None:
219
+ self.protocol.callbacks.absolute_input_change = func
220
+
211
221
  @property
212
222
  def motion_event(self) -> CallbackMotionEvent | None:
213
223
  return self.protocol.callbacks.motion_event
@@ -229,6 +239,15 @@ class ZenControl:
229
239
  def controller_discovered(self, func: CallbackControllerDiscovered | None) -> None:
230
240
  self.protocol.callbacks.controller_discovered = func
231
241
 
242
+ @property
243
+ def controller_status_change(self) -> CallbackControllerStatusChange | None:
244
+ return self._controller_status_change
245
+ @controller_status_change.setter
246
+ def controller_status_change(
247
+ self, func: CallbackControllerStatusChange | None
248
+ ) -> None:
249
+ self._controller_status_change = func
250
+
232
251
  @property
233
252
  def discovered_controllers(self) -> list[DiscoveredController]:
234
253
  """Controllers identified from multicast but not yet registered."""
@@ -245,6 +264,137 @@ class ZenControl:
245
264
  self.protocol.set_controllers(cast(list[SuperZenController], self.controllers))
246
265
  return controller
247
266
 
267
+ async def remove_controller(self, controller: ZenController | str) -> None:
268
+ """Detach a controller and close its command client.
269
+
270
+ Safe to call while event monitoring is running. Does not stop the shared
271
+ listener; callers that own the last controller should ``aclose()``.
272
+ """
273
+ name = controller if isinstance(controller, str) else controller.name
274
+ removed = [c for c in self.controllers if c.name == name]
275
+ self.controllers = [c for c in self.controllers if c.name != name]
276
+ self.protocol.set_controllers(cast(list[SuperZenController], self.controllers))
277
+ self.protocol.purge_controller_entities(name)
278
+ for ctrl in removed:
279
+ client = ctrl.client
280
+ ctrl.client = None
281
+ if client is None:
282
+ continue
283
+ try:
284
+ await client.close()
285
+ except Exception:
286
+ pass
287
+
288
+ async def configure_controller_events(self, controller: ZenController) -> bool:
289
+ """Enable TPI event emit for one controller using this client's listen mode.
290
+
291
+ Call after ``add_controller`` when event monitoring is already running so a
292
+ newly attached controller joins the shared listener. Returns True when the
293
+ emit-enable command succeeds.
294
+ """
295
+ if self.protocol.event_listener is None:
296
+ return False
297
+ unicast = self.protocol.unicast
298
+ await self.protocol.set_tpi_event_unicast_address(
299
+ controller,
300
+ ipaddr=self.protocol.local_ip if unicast else None,
301
+ port=self.protocol.listen_port if unicast else None,
302
+ )
303
+ return bool(
304
+ await self.protocol.tpi_event_emit(
305
+ controller,
306
+ ZenEventMode(
307
+ enabled=True,
308
+ filtering=controller.filtering,
309
+ unicast=unicast,
310
+ multicast=not unicast,
311
+ ),
312
+ )
313
+ )
314
+
315
+ async def assert_controller_events(self, controller: ZenController) -> bool:
316
+ """Ping event emit state and re-assert config if the controller lost it.
317
+
318
+ Controllers that reboot while our listener stays up typically come back
319
+ with events disabled (or with a stale unicast target). Returns True when
320
+ the controller is reachable and events are confirmed/enabled, False when
321
+ the ping timed out / failed or re-assert could not enable emit.
322
+
323
+ Never re-asserts while ``is_controller_ready()`` is false — the startup
324
+ sequence can take several minutes after a reboot.
325
+ """
326
+ if self.protocol.event_listener is None:
327
+ return False
328
+
329
+ ready = await controller.is_controller_ready()
330
+ if ready is None:
331
+ self.logger.debug(
332
+ "No response from %s during event keepalive ping",
333
+ controller.name,
334
+ )
335
+ await self._notify_controller_status(controller, "unreachable")
336
+ return False
337
+ if ready is not True:
338
+ self.logger.debug(
339
+ "Controller %s still starting — deferring event re-assert",
340
+ controller.name,
341
+ )
342
+ await self._notify_controller_status(controller, "starting")
343
+ return True
344
+
345
+ unicast = self.protocol.unicast
346
+ needs_reassert = False
347
+ info = await self.protocol.query_tpi_event_unicast_address(controller)
348
+ if info is not None:
349
+ mode = info["mode"]
350
+ if not mode.enabled or bool(mode.unicast) != unicast:
351
+ needs_reassert = True
352
+ elif unicast and (
353
+ info.get("port") != self.protocol.listen_port
354
+ or info.get("ip") != self.protocol.local_ip
355
+ ):
356
+ needs_reassert = True
357
+ else:
358
+ enabled = await self.protocol.query_tpi_event_emit_state(controller)
359
+ if enabled is None:
360
+ self.logger.debug(
361
+ "No response from %s during event keepalive ping",
362
+ controller.name,
363
+ )
364
+ await self._notify_controller_status(controller, "unreachable")
365
+ return False
366
+ needs_reassert = not enabled
367
+
368
+ if needs_reassert:
369
+ self.logger.info(
370
+ "Controller %s TPI events not correctly enabled — re-asserting",
371
+ controller.name,
372
+ )
373
+ if not await self.configure_controller_events(controller):
374
+ self.logger.warning(
375
+ "Failed to re-assert TPI events for %s",
376
+ controller.name,
377
+ )
378
+ await self._notify_controller_status(controller, "unreachable")
379
+ return False
380
+ await self._notify_controller_status(controller, "online")
381
+ return True
382
+
383
+ async def _notify_controller_status(
384
+ self, controller: ZenController, status: ControllerRuntimeStatus
385
+ ) -> None:
386
+ """Notify listeners of online / starting / unreachable."""
387
+ if not callable(self._controller_status_change):
388
+ return
389
+ try:
390
+ await self._controller_status_change(controller, status)
391
+ except Exception as err:
392
+ self.logger.debug(
393
+ "controller_status_change error for %s: %s",
394
+ controller.name,
395
+ err,
396
+ )
397
+
248
398
  async def discover(self, timeout: float = 5.0) -> list[DiscoveredController]:
249
399
  """Listen for multicast and return controllers identified within ``timeout`` seconds.
250
400
 
@@ -289,6 +439,8 @@ class ZenControl:
289
439
  )
290
440
  if self._supervisor_task is None or self._supervisor_task.done():
291
441
  self._supervisor_task = asyncio.create_task(self._event_monitor_supervisor())
442
+ if self._keepalive_task is None or self._keepalive_task.done():
443
+ self._keepalive_task = asyncio.create_task(self._event_keepalive_loop())
292
444
  try:
293
445
  await asyncio.wait_for(
294
446
  self._first_connected.wait(),
@@ -306,7 +458,7 @@ class ZenControl:
306
458
  was_running = bool(
307
459
  self.protocol.event_task and not self.protocol.event_task.done()
308
460
  )
309
- await self._cancel_supervisor()
461
+ await self._cancel_background_tasks()
310
462
  await self.protocol.stop_event_monitoring()
311
463
  if was_running:
312
464
  await self.protocol.notify_disconnect()
@@ -317,15 +469,19 @@ class ZenControl:
317
469
  was_running = bool(
318
470
  self.protocol.event_task and not self.protocol.event_task.done()
319
471
  )
320
- await self._cancel_supervisor()
472
+ await self._cancel_background_tasks()
321
473
  await self.protocol.aclose()
322
474
  if was_running:
323
475
  await self.protocol.notify_disconnect()
324
476
  self.clear_entity_caches()
325
477
 
326
- async def _cancel_supervisor(self) -> None:
327
- task = self._supervisor_task
328
- self._supervisor_task = None
478
+ async def _cancel_background_tasks(self) -> None:
479
+ await self._cancel_task("_supervisor_task")
480
+ await self._cancel_task("_keepalive_task")
481
+
482
+ async def _cancel_task(self, attr: str) -> None:
483
+ task: asyncio.Task[None] | None = getattr(self, attr)
484
+ setattr(self, attr, None)
329
485
  if task is None or task.done():
330
486
  return
331
487
  task.cancel()
@@ -403,6 +559,33 @@ class ZenControl:
403
559
  except Exception:
404
560
  pass
405
561
 
562
+ async def _event_keepalive_loop(self) -> None:
563
+ """Periodically ping controllers and re-enable TPI events if needed."""
564
+ try:
565
+ await self._first_connected.wait()
566
+ except asyncio.CancelledError:
567
+ raise
568
+ while not self._stopping:
569
+ try:
570
+ await asyncio.sleep(self.event_keepalive_interval)
571
+ except asyncio.CancelledError:
572
+ raise
573
+ if self._stopping or self.protocol.event_listener is None:
574
+ continue
575
+ for controller in list(self.controllers):
576
+ if self._stopping:
577
+ return
578
+ try:
579
+ await self.assert_controller_events(controller)
580
+ except asyncio.CancelledError:
581
+ raise
582
+ except Exception as err:
583
+ self.logger.debug(
584
+ "Event keepalive failed for %s: %s",
585
+ controller.name,
586
+ err,
587
+ )
588
+
406
589
  async def _protocol_disconnect_event(self) -> None:
407
590
  """Forward protocol-level disconnect to the high-level on_disconnect hook."""
408
591
  if callable(self.protocol.callbacks.on_disconnect):
@@ -419,7 +602,9 @@ class ZenControl:
419
602
  await ZenButton(protocol=self.protocol, instance=instance)._event_received(held=True)
420
603
 
421
604
  async def absolute_input_event(self, instance: ZenInstance, payload: bytes) -> None:
422
- pass
605
+ await ZenAbsoluteInput(protocol=self.protocol, instance=instance)._event_received(
606
+ payload
607
+ )
423
608
 
424
609
  async def is_occupied_event(self, instance: ZenInstance, payload: bytes) -> None:
425
610
  await ZenMotionSensor(protocol=self.protocol, instance=instance)._event_received()
@@ -500,21 +685,23 @@ class ZenControl:
500
685
  profiles.add(profile)
501
686
  return profiles
502
687
 
503
- async def get_groups(self) -> set[ZenGroup]:
504
- """Return a set of all groups."""
688
+ async def get_groups(self, controller: ZenController | None = None) -> set[ZenGroup]:
689
+ """Return a set of all groups (optionally for one controller)."""
505
690
  groups: set[ZenGroup] = set()
506
- for controller in self.controllers:
507
- addresses = await self.protocol.query_group_numbers(controller=controller)
691
+ controllers = [controller] if controller else self.controllers
692
+ for ctrl in controllers:
693
+ addresses = await self.protocol.query_group_numbers(controller=ctrl)
508
694
  for address in addresses:
509
695
  group = await ZenGroup.create(protocol=self.protocol, address=address)
510
696
  groups.add(group)
511
697
  return groups
512
698
 
513
- async def get_lights(self) -> set[ZenLight]:
514
- """Return a set of all lights available."""
699
+ async def get_lights(self, controller: ZenController | None = None) -> set[ZenLight]:
700
+ """Return a set of all lights available (optionally for one controller)."""
515
701
  lights: set[ZenLight] = set()
516
- for controller in self.controllers:
517
- addresses = await self.protocol.query_control_gear_dali_addresses(controller=controller)
702
+ controllers = [controller] if controller else self.controllers
703
+ for ctrl in controllers:
704
+ addresses = await self.protocol.query_control_gear_dali_addresses(controller=ctrl)
518
705
  for address in addresses:
519
706
  light = await ZenLight.create(protocol=self.protocol, address=address)
520
707
  lights.add(light)
@@ -542,11 +729,12 @@ class ZenControl:
542
729
  addresses.append(addr)
543
730
  return addresses
544
731
 
545
- async def get_buttons(self) -> set[ZenButton]:
546
- """Return a set of all buttons available."""
732
+ async def get_buttons(self, controller: ZenController | None = None) -> set[ZenButton]:
733
+ """Return a set of all buttons available (optionally for one controller)."""
547
734
  buttons: set[ZenButton] = set()
548
- for controller in self.controllers:
549
- addresses = await self._get_addresses_with_instances(controller)
735
+ controllers = [controller] if controller else self.controllers
736
+ for ctrl in controllers:
737
+ addresses = await self._get_addresses_with_instances(ctrl)
550
738
  for address in addresses:
551
739
  instances = await self.protocol.query_instances_by_address(address=address)
552
740
  for instance in instances:
@@ -555,11 +743,12 @@ class ZenControl:
555
743
  buttons.add(button)
556
744
  return buttons
557
745
 
558
- async def get_motion_sensors(self) -> set[ZenMotionSensor]:
559
- """Return a set of all motion sensors available."""
746
+ async def get_motion_sensors(self, controller: ZenController | None = None) -> set[ZenMotionSensor]:
747
+ """Return a set of all motion sensors available (optionally for one controller)."""
560
748
  motion_sensors: set[ZenMotionSensor] = set()
561
- for controller in self.controllers:
562
- addresses = await self._get_addresses_with_instances(controller)
749
+ controllers = [controller] if controller else self.controllers
750
+ for ctrl in controllers:
751
+ addresses = await self._get_addresses_with_instances(ctrl)
563
752
  for address in addresses:
564
753
  instances = await self.protocol.query_instances_by_address(address=address)
565
754
  for instance in instances:
@@ -568,16 +757,39 @@ class ZenControl:
568
757
  motion_sensors.add(motion_sensor)
569
758
  return motion_sensors
570
759
 
571
- async def get_system_variables(self, give_up_after: int = 10) -> set[ZenSystemVariable]:
572
- """Return a set of all system variables. Variables must have a label. Searching will give_up_after [x] sequential IDs without a label."""
760
+ async def get_absolute_inputs(
761
+ self, controller: ZenController | None = None
762
+ ) -> set[ZenAbsoluteInput]:
763
+ """Return absolute (numerical) ECD instances (optionally for one controller)."""
764
+ absolute_inputs: set[ZenAbsoluteInput] = set()
765
+ controllers = [controller] if controller else self.controllers
766
+ for ctrl in controllers:
767
+ addresses = await self._get_addresses_with_instances(ctrl)
768
+ for address in addresses:
769
+ instances = await self.protocol.query_instances_by_address(address=address)
770
+ for instance in instances:
771
+ if instance.type == ZenInstanceType.ABSOLUTE_INPUT:
772
+ absolute_input = await ZenAbsoluteInput.create(
773
+ protocol=self.protocol, instance=instance
774
+ )
775
+ absolute_inputs.add(absolute_input)
776
+ return absolute_inputs
777
+
778
+ async def get_system_variables(
779
+ self,
780
+ give_up_after: int = 10,
781
+ controller: ZenController | None = None,
782
+ ) -> set[ZenSystemVariable]:
783
+ """Return labelled system variables (optionally for one controller)."""
573
784
  sysvars: set[ZenSystemVariable] = set()
574
- failed_attempts = 0
575
- for controller in self.controllers:
785
+ controllers = [controller] if controller else self.controllers
786
+ for ctrl in controllers:
787
+ failed_attempts = 0
576
788
  for variable in range(Const.MAX_SYSVAR):
577
- label = await self.protocol.query_system_variable_name(controller=controller, variable=variable)
789
+ label = await self.protocol.query_system_variable_name(controller=ctrl, variable=variable)
578
790
  if label:
579
791
  failed_attempts = 0
580
- sysvar = await ZenSystemVariable.create(protocol=self.protocol, controller=controller, id=variable, label=label)
792
+ sysvar = await ZenSystemVariable.create(protocol=self.protocol, controller=ctrl, id=variable, label=label)
581
793
  sysvars.add(sysvar)
582
794
  else:
583
795
  failed_attempts += 1
@@ -600,6 +812,7 @@ class ZenController(SuperZenController):
600
812
  lights: set[ZenLight] = set()
601
813
  groups: set[ZenGroup] = set()
602
814
  buttons: set[ZenButton] = set()
815
+ absolute_inputs: set[ZenAbsoluteInput] = set()
603
816
  motion_sensors: set[ZenMotionSensor] = set()
604
817
  sysvars: set[ZenSystemVariable] = set()
605
818
  client_data: dict[str, Any] = {}
@@ -662,6 +875,7 @@ class ZenController(SuperZenController):
662
875
  self.lights = set()
663
876
  self.groups = set()
664
877
  self.buttons = set()
878
+ self.absolute_inputs = set()
665
879
  self.motion_sensors = set()
666
880
  self.sysvars = set()
667
881
  self.client_data = {}
@@ -785,6 +999,7 @@ class ZenLight:
785
999
  "RGB": False,
786
1000
  "RGBW": False,
787
1001
  "RGBWW": False,
1002
+ "XY": False,
788
1003
  }
789
1004
  properties: dict[str, int | None] = {
790
1005
  "min_kelvin": Const.DEFAULT_WARMEST_TEMP,
@@ -840,6 +1055,7 @@ class ZenLight:
840
1055
  "RGB": False,
841
1056
  "RGBW": False,
842
1057
  "RGBWW": False,
1058
+ "XY": False,
843
1059
  }
844
1060
  self.properties = {
845
1061
  "min_kelvin": Const.DEFAULT_WARMEST_TEMP,
@@ -909,6 +1125,10 @@ class ZenLight:
909
1125
  # If cgtype contains 8, it supports some kind of colour
910
1126
  if 8 in self.cgtype:
911
1127
  cgtype = await self.protocol.query_dali_colour_features(self.address)
1128
+ # XY is independent of TC/RGBWAF; a fixture may support more than one.
1129
+ if cgtype and cgtype.get("supports_xy", False) is True:
1130
+ self.features["brightness"] = True
1131
+ self.features["XY"] = True
912
1132
  if cgtype and cgtype.get("supports_tunable", False) is True:
913
1133
  self.features["brightness"] = True
914
1134
  self.features["temperature"] = True
@@ -952,7 +1172,13 @@ class ZenLight:
952
1172
  refreshed_scene = None
953
1173
  if await self.protocol.dali_query_last_scene_is_current(self.address):
954
1174
  refreshed_scene = await self.protocol.dali_query_last_scene(self.address)
955
- if self.features["temperature"] or self.features["RGB"] or self.features["RGBW"] or self.features["RGBWW"]:
1175
+ if (
1176
+ self.features.get("temperature")
1177
+ or self.features.get("RGB")
1178
+ or self.features.get("RGBW")
1179
+ or self.features.get("RGBWW")
1180
+ or self.features.get("XY")
1181
+ ):
956
1182
  refreshed_colour = await self.protocol.query_dali_colour(self.address)
957
1183
 
958
1184
  if verifying:
@@ -1090,10 +1316,11 @@ class ZenLight:
1090
1316
  colour_type = colour
1091
1317
  else:
1092
1318
  return False;
1093
- if (colour_type == ZenColourType.TC and self.features["temperature"]) or \
1094
- (colour_type == ZenColourType.RGBWAF and self.features["RGB"]) or \
1095
- (colour_type == ZenColourType.RGBWAF and self.features["RGBW"]) or \
1096
- (colour_type == ZenColourType.RGBWAF and self.features["RGBWW"]):
1319
+ if (colour_type == ZenColourType.TC and self.features.get("temperature")) or \
1320
+ (colour_type == ZenColourType.RGBWAF and self.features.get("RGB")) or \
1321
+ (colour_type == ZenColourType.RGBWAF and self.features.get("RGBW")) or \
1322
+ (colour_type == ZenColourType.RGBWAF and self.features.get("RGBWW")) or \
1323
+ (colour_type == ZenColourType.XY and self.features.get("XY")):
1097
1324
  return True
1098
1325
  return False
1099
1326
  # -----------------------------------------------------------------------------------------
@@ -1349,6 +1576,110 @@ class ZenButton:
1349
1576
  await self.protocol.callbacks.button_long_press(button=self)
1350
1577
 
1351
1578
 
1579
+ class ZenAbsoluteInput:
1580
+ """DALI ECD absolute (numerical) input instance — dials, sliders, etc.
1581
+
1582
+ Controllers emit value-change events only; TPI has no query/set command for
1583
+ the current value, so ``value`` stays ``None`` until the first event.
1584
+ Payload matches ``_protocol.txt``: ``[instance, value_hi, value_lo]``.
1585
+ """
1586
+
1587
+ protocol: ZenProtocol
1588
+ instance: ZenInstance
1589
+ serial: (int | str) | None = None
1590
+ label: str | None = None
1591
+ instance_label: str | None = None
1592
+ _value: int | None = None
1593
+ client_data: dict[str, Any] = {}
1594
+
1595
+ def __new__(cls, protocol: ZenProtocol, instance: ZenInstance) -> ZenAbsoluteInput:
1596
+ compound_id = f"{instance.address.controller.name} {instance.address.number} {instance.number}"
1597
+ registry = protocol.entity_registry.absolute_inputs
1598
+ if compound_id not in registry:
1599
+ inst = super().__new__(cls)
1600
+ registry[compound_id] = inst
1601
+ inst.protocol = protocol
1602
+ inst.instance = instance
1603
+ inst._reset()
1604
+ return registry[compound_id]
1605
+
1606
+ def __init__(self, protocol: ZenProtocol, instance: ZenInstance) -> None:
1607
+ self.protocol = protocol
1608
+ self.instance = instance
1609
+
1610
+ @classmethod
1611
+ async def create(cls, protocol: ZenProtocol, instance: ZenInstance) -> ZenAbsoluteInput:
1612
+ """Async factory method for ZenAbsoluteInput."""
1613
+ absolute_input = cls(protocol, instance)
1614
+ await absolute_input.interview()
1615
+ return absolute_input
1616
+
1617
+ def __repr__(self) -> str:
1618
+ return (
1619
+ f"ZenAbsoluteInput<{self.instance.address.controller.name} "
1620
+ f"ecd {self.instance.address.number} inst {self.instance.number}: "
1621
+ f"{self.label} / {self.instance_label}>"
1622
+ )
1623
+
1624
+ def _reset(self) -> None:
1625
+ self.serial = None
1626
+ self.label = None
1627
+ self.instance_label = None
1628
+ self._value = None
1629
+ self.client_data = {}
1630
+
1631
+ def interview_serialize(self) -> str:
1632
+ return json.dumps({
1633
+ "serial": self.serial,
1634
+ "label": self.label,
1635
+ "instance_label": self.instance_label,
1636
+ })
1637
+
1638
+ def interview_hydrate(self, data: str | dict[str, Any]) -> bool:
1639
+ try:
1640
+ data = _loads_interview_data(data)
1641
+ self.serial = data.get("serial")
1642
+ self.label = data.get("label")
1643
+ self.instance_label = data.get("instance_label")
1644
+ self.instance.address.label = self.label
1645
+ self.instance.address.serial = cast(str | None, self.serial)
1646
+ cast(ZenController, self.instance.address.controller).absolute_inputs.add(self)
1647
+ return True
1648
+ except Exception:
1649
+ return False
1650
+
1651
+ async def interview(self) -> bool:
1652
+ inst = self.instance
1653
+ addr = inst.address
1654
+ ctrl = cast(ZenController, addr.controller)
1655
+ if addr.label is None:
1656
+ addr.label = await self.protocol.query_dali_device_label(addr, generic_if_none=True)
1657
+ if addr.serial is None:
1658
+ addr.serial = cast(str | None, await self.protocol.query_dali_serial(addr))
1659
+ self.label = addr.label
1660
+ self.serial = addr.serial
1661
+ self.instance_label = await self.protocol.query_dali_instance_label(
1662
+ inst, generic_if_none=True
1663
+ )
1664
+ ctrl.absolute_inputs.add(self)
1665
+ return True
1666
+
1667
+ @property
1668
+ def value(self) -> int | None:
1669
+ """Last-known 16-bit value from an absolute-input event, or None."""
1670
+ return self._value
1671
+
1672
+ async def _event_received(self, payload: bytes) -> None:
1673
+ if len(payload) < 3:
1674
+ return
1675
+ new_value = (payload[1] << 8) | payload[2]
1676
+ changed = new_value != self._value
1677
+ self._value = new_value
1678
+ if changed and callable(self.protocol.callbacks.absolute_input_change):
1679
+ await self.protocol.callbacks.absolute_input_change(
1680
+ absolute_input=self, value=new_value
1681
+ )
1682
+
1352
1683
 
1353
1684
  class ZenMotionSensor:
1354
1685
  protocol: ZenProtocol
@@ -1625,6 +1956,7 @@ class ZenSystemVariable:
1625
1956
 
1626
1957
 
1627
1958
  # Callback type definitions (moved here after class definitions)
1959
+ type ControllerRuntimeStatus = Literal["online", "starting", "unreachable"]
1628
1960
  type CallbackOnConnect = Callable[[], Awaitable[None]]
1629
1961
  type CallbackOnDisconnect = Callable[[], Awaitable[None]]
1630
1962
  type CallbackProfileChange = Callable[[ZenProfile], Awaitable[None]]
@@ -1632,6 +1964,10 @@ type CallbackGroupChange = Callable[[ZenGroup, int], Awaitable[None]]
1632
1964
  type CallbackLightChange = Callable[[ZenLight, int, ZenColour, int], Awaitable[None]]
1633
1965
  type CallbackButtonPress = Callable[[ZenButton], Awaitable[None]]
1634
1966
  type CallbackButtonLongPress = Callable[[ZenButton], Awaitable[None]]
1967
+ type CallbackAbsoluteInputChange = Callable[[ZenAbsoluteInput, int], Awaitable[None]]
1635
1968
  type CallbackMotionEvent = Callable[[ZenMotionSensor, bool], Awaitable[None]]
1636
1969
  type CallbackSystemVariableChange = Callable[[ZenSystemVariable, int, bool, bool], Awaitable[None]]
1637
1970
  type CallbackControllerDiscovered = Callable[[DiscoveredController], Awaitable[None]]
1971
+ type CallbackControllerStatusChange = Callable[
1972
+ [ZenController, ControllerRuntimeStatus], Awaitable[None]
1973
+ ]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: zencontrol-python
3
- Version: 0.1.3
3
+ Version: 0.1.6
4
4
  Summary: Python implementation of the Zencontrol TPI Advanced protocol for DALI lighting controllers.
5
5
  Author: Simon Wright
6
6
  License-Expression: MIT
@@ -44,6 +44,32 @@ This library has now undergone validation in multiple environments. There is an
44
44
  [zencontrol-simulator](https://github.com/sjwright/zencontrol-simulator), a nearly feature-complete simulator of zencontrol hardware.
45
45
  A practical demonstration is [zencontrol-homeassistant](https://github.com/sjwright/zencontrol-homeassistant), a comprehensive Home Assistant integration.
46
46
 
47
+ ## Features
48
+
49
+ Beyond basic lighting control, this library supports:
50
+
51
+ * **Broad command surface** — inhibit, custom fade, step/up/down helpers, colour scene membership queries, EAN/serial, and most related TPI Advanced commands
52
+ * **Object-based entity model** — Optional. Expresses lights, groups, profiles, buttons, motion sensors, absolute inputs, and system variables as rich objects with interview/discovery helpers
53
+ * **UDP transport resilience** — request retries and queue-failure backoff
54
+ * **Event keepalive** — periodic emit-state ping; re-enables TPI events (and unicast target) if a controller reboots while the listener stays up
55
+ * **Multicast controller discovery** — find controllers on the LAN without a preconfigured host
56
+ * **Button events** — discovery of control-device button instances, plus press and long-press event callbacks
57
+ * **Absolute inputs** — discovery of numerical ECD instances (dials/sliders) with 16-bit value-change event callbacks
58
+ * **Event filtering** — configure which TPI events the controller emits
59
+ * **System variables** — labelled SV discovery, read/write, and change events
60
+ * **Profiles** — query, change, and return to the scheduled profile
61
+ * **Simulator-backed tests** — protocol path exercised against [zencontrol-simulator](https://github.com/sjwright/zencontrol-simulator)
62
+
63
+ ## Known limitations
64
+
65
+ * RGB+ and XY colour commands have not been tested with hardware
66
+ * Numerical (absolute) instances have not been tested with hardware
67
+
68
+ ## Out of scope
69
+
70
+ * Any commands involving DMX, Control4, or virtual instances (I don't have licenses for any of these so I couldn't test them even if I wanted to, but the scaffolding is there if anyone wishes to add support)
71
+ * Any commands described in the documentation as "legacy" (they aren't useful)
72
+
47
73
  ## Requirements
48
74
 
49
75
  * Python 3.14 (or later)
@@ -68,16 +94,6 @@ pytest -m "not simulator"
68
94
  pytest
69
95
  ```
70
96
 
71
- ## Limitations
72
-
73
- * RGB+ and XY colour commands are not tested (I don't have any compatible lights)
74
- * Numerical (absolute) instances are not tested (I don't have any such ECDs)
75
-
76
- ## Out of scope
77
-
78
- * Any commands involving DMX, Control4, or virtual instances (I don't have licenses for any of these so I couldn't test them even if I wanted to, but the scaffolding is there if anyone wishes to add support)
79
- * Any commands described in the documentation as "legacy" (they aren't useful)
80
-
81
97
  ## TPI Advanced wishlist
82
98
 
83
99
  * Command to return a controller's MAC address used for multicast packets _(There are other ways to get or infer the MAC access, but they're unreliable.)_
@@ -15,22 +15,22 @@ examples/live/multicast_raw.py,sha256=EFRM7xA-xlZFS_0WXkQmqECBITG41W4jUEmTd9sIA1
15
15
  examples/live/profiles.py,sha256=Z0jZg8EEznMzSH5JJhhgtJRA_dazNSkuDHnfLqxpR44,1323
16
16
  examples/live/tpi_event_listener.py,sha256=phWI7Ib_g_nMGcPMjIZY6ukBpQpp9YdI3l5lAseYSjA,9309
17
17
  examples/live/variables.py,sha256=hio4vefJLgvCvoVpxLTrU0kxzStWYn6artoJbGnVyd4,1554
18
- zencontrol/__init__.py,sha256=PHBe_JAOkTtbT9WbWTtzpXFURnOhJ60LNd6haH3MrVM,2938
18
+ zencontrol/__init__.py,sha256=bS69eXmifqSmw6XCZ5ml534IIft07evyzioVHz4Y7x8,2984
19
19
  zencontrol/exceptions.py,sha256=tqaLDYWu5J-j2ZKFdfTOcXLH9vULi5pZn8o_Y86bHRE,862
20
20
  zencontrol/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
21
21
  zencontrol/utils.py,sha256=cipUc2KXUfrgTRbCyGwNLhk7um2tjAw6I7kpU_eOu44,2005
22
22
  zencontrol/api/__init__.py,sha256=kZd1T3IKziyQc9qAfOJdYeeTPaB4L2Wkhfu3FKmRw-I,875
23
23
  zencontrol/api/models.py,sha256=W3kCFiJSxc9Z_5Bzfx7A1iICLL8PRmMroW1WReEzAfs,12334
24
- zencontrol/api/protocol.py,sha256=HD3IEDNfTB_b5qpXgcIYh9j1N20ft_nQhuof8n-u0Q4,101518
25
- zencontrol/api/types.py,sha256=cJtKY4Qer8u0QQ8J9Jcumg1C1Dee5DtJ9JGcSEfhIKw,6862
26
- zencontrol/interface/__init__.py,sha256=gw_-LSlEwUL0vrOYTxTBwPHZmuNsNqXRqVFbz1ZBQuA,662
27
- zencontrol/interface/interface.py,sha256=d3pjWDRBEaHXUEWPEjrc0Bak9ApPljuAzV6BlIoVN3s,75318
24
+ zencontrol/api/protocol.py,sha256=zBPCcuvBc6F51LtPbPfFBHzdUro0P1fdNAKprQ61fC8,102471
25
+ zencontrol/api/types.py,sha256=DxCi4dI_iBF5bPo-YqzYnmxA9fbTwp-bsHbD1fOSnVE,7042
26
+ zencontrol/interface/__init__.py,sha256=HNGCBYlJ5W2Pap4DmaQe0EI06j0ZWyy4PCW3HzLLJm8,708
27
+ zencontrol/interface/interface.py,sha256=SVFjlPIyf3oiDtQmET0v0VVAWV1MD1RC-lEGUYbp-x4,89504
28
28
  zencontrol/io/__init__.py,sha256=cBfOKr0BqOI8E2LicAwcDzLhdlz7KHHrtcn-iUzRmcU,570
29
29
  zencontrol/io/command.py,sha256=HVyMgYGSm166adIIU4KRSIfzPnfB9gnmw3pia_nZ4U8,15366
30
30
  zencontrol/io/event.py,sha256=dsjxFCGNljZOMwFqez9aYKvK1WF2w55X7iwsX93mpvQ,12994
31
- zencontrol_python-0.1.3.dist-info/licenses/LICENSE,sha256=cxmGKzqqXAjXOOw0D0tt5pVkNU9HCormEhNR8dEAYc4,1069
32
- zencontrol_python-0.1.3.dist-info/METADATA,sha256=oX53gJt4VEeoIAx0dcjgCxoMdHDCnu5FFYdJG2C6No8,4379
33
- zencontrol_python-0.1.3.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
34
- zencontrol_python-0.1.3.dist-info/entry_points.txt,sha256=bowyQt57_veMMZzF7LvDwDajG2HcY4-99qYHJ_hasw4,62
35
- zencontrol_python-0.1.3.dist-info/top_level.txt,sha256=cKZS8nB2cZlXavIXB_0tzE16PfGGw6WZDqNzemZKZ4w,20
36
- zencontrol_python-0.1.3.dist-info/RECORD,,
31
+ zencontrol_python-0.1.6.dist-info/licenses/LICENSE,sha256=cxmGKzqqXAjXOOw0D0tt5pVkNU9HCormEhNR8dEAYc4,1069
32
+ zencontrol_python-0.1.6.dist-info/METADATA,sha256=-SqcQjeXr4oeskshMZ5FGo1OEPwM_S6sW4xpd3gbgBM,5698
33
+ zencontrol_python-0.1.6.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
34
+ zencontrol_python-0.1.6.dist-info/entry_points.txt,sha256=bowyQt57_veMMZzF7LvDwDajG2HcY4-99qYHJ_hasw4,62
35
+ zencontrol_python-0.1.6.dist-info/top_level.txt,sha256=cKZS8nB2cZlXavIXB_0tzE16PfGGw6WZDqNzemZKZ4w,20
36
+ zencontrol_python-0.1.6.dist-info/RECORD,,