zencontrol-python 0.1.4__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.4"
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,6 +86,7 @@ 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
 
@@ -96,6 +99,7 @@ class EntityRegistry:
96
99
  self.lights,
97
100
  self.groups,
98
101
  self.buttons,
102
+ self.absolute_inputs,
99
103
  self.motion_sensors,
100
104
  self.system_variables,
101
105
  ):
@@ -873,7 +877,7 @@ class ZenProtocol:
873
877
  min_payload = {
874
878
  ZenEventCode.BUTTON_PRESS: 1,
875
879
  ZenEventCode.BUTTON_HOLD: 1,
876
- ZenEventCode.ABSOLUTE_INPUT: 1,
880
+ ZenEventCode.ABSOLUTE_INPUT: 3,
877
881
  ZenEventCode.LEVEL_CHANGE: 1,
878
882
  ZenEventCode.LEVEL_CHANGE_V2: 2,
879
883
  ZenEventCode.GROUP_LEVEL_CHANGE: 1,
@@ -921,10 +925,10 @@ class ZenProtocol:
921
925
  await self.button_hold_callback(instance=instance, payload=payload)
922
926
 
923
927
  case ZenEventCode.ABSOLUTE_INPUT:
928
+ value = (payload[1] << 8) | payload[2]
924
929
  if self.print_traffic:
925
930
  print(Fore.MAGENTA + f"{typecast.upper()} {ip_address}:" +
926
- Fore.CYAN + f" Absolute {target-64}" +
927
- Style.DIM + f" [{' '.join(f'0x{b:02X}' for b in payload)}]" +
931
+ Fore.CYAN + f" Absolute {target-64}.{payload[0]} = {value}" +
928
932
  Style.RESET_ALL)
929
933
  if self.absolute_input_callback:
930
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,7 +4,7 @@ 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
@@ -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."""
@@ -266,30 +285,116 @@ class ZenControl:
266
285
  except Exception:
267
286
  pass
268
287
 
269
- async def configure_controller_events(self, controller: ZenController) -> None:
288
+ async def configure_controller_events(self, controller: ZenController) -> bool:
270
289
  """Enable TPI event emit for one controller using this client's listen mode.
271
290
 
272
291
  Call after ``add_controller`` when event monitoring is already running so a
273
- newly attached controller joins the shared listener.
292
+ newly attached controller joins the shared listener. Returns True when the
293
+ emit-enable command succeeds.
274
294
  """
275
295
  if self.protocol.event_listener is None:
276
- return
296
+ return False
277
297
  unicast = self.protocol.unicast
278
298
  await self.protocol.set_tpi_event_unicast_address(
279
299
  controller,
280
300
  ipaddr=self.protocol.local_ip if unicast else None,
281
301
  port=self.protocol.listen_port if unicast else None,
282
302
  )
283
- await self.protocol.tpi_event_emit(
284
- controller,
285
- ZenEventMode(
286
- enabled=True,
287
- filtering=controller.filtering,
288
- unicast=unicast,
289
- multicast=not unicast,
290
- ),
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
+ )
291
313
  )
292
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
+
293
398
  async def discover(self, timeout: float = 5.0) -> list[DiscoveredController]:
294
399
  """Listen for multicast and return controllers identified within ``timeout`` seconds.
295
400
 
@@ -334,6 +439,8 @@ class ZenControl:
334
439
  )
335
440
  if self._supervisor_task is None or self._supervisor_task.done():
336
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())
337
444
  try:
338
445
  await asyncio.wait_for(
339
446
  self._first_connected.wait(),
@@ -351,7 +458,7 @@ class ZenControl:
351
458
  was_running = bool(
352
459
  self.protocol.event_task and not self.protocol.event_task.done()
353
460
  )
354
- await self._cancel_supervisor()
461
+ await self._cancel_background_tasks()
355
462
  await self.protocol.stop_event_monitoring()
356
463
  if was_running:
357
464
  await self.protocol.notify_disconnect()
@@ -362,15 +469,19 @@ class ZenControl:
362
469
  was_running = bool(
363
470
  self.protocol.event_task and not self.protocol.event_task.done()
364
471
  )
365
- await self._cancel_supervisor()
472
+ await self._cancel_background_tasks()
366
473
  await self.protocol.aclose()
367
474
  if was_running:
368
475
  await self.protocol.notify_disconnect()
369
476
  self.clear_entity_caches()
370
477
 
371
- async def _cancel_supervisor(self) -> None:
372
- task = self._supervisor_task
373
- 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)
374
485
  if task is None or task.done():
375
486
  return
376
487
  task.cancel()
@@ -448,6 +559,33 @@ class ZenControl:
448
559
  except Exception:
449
560
  pass
450
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
+
451
589
  async def _protocol_disconnect_event(self) -> None:
452
590
  """Forward protocol-level disconnect to the high-level on_disconnect hook."""
453
591
  if callable(self.protocol.callbacks.on_disconnect):
@@ -464,7 +602,9 @@ class ZenControl:
464
602
  await ZenButton(protocol=self.protocol, instance=instance)._event_received(held=True)
465
603
 
466
604
  async def absolute_input_event(self, instance: ZenInstance, payload: bytes) -> None:
467
- pass
605
+ await ZenAbsoluteInput(protocol=self.protocol, instance=instance)._event_received(
606
+ payload
607
+ )
468
608
 
469
609
  async def is_occupied_event(self, instance: ZenInstance, payload: bytes) -> None:
470
610
  await ZenMotionSensor(protocol=self.protocol, instance=instance)._event_received()
@@ -617,6 +757,24 @@ class ZenControl:
617
757
  motion_sensors.add(motion_sensor)
618
758
  return motion_sensors
619
759
 
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
+
620
778
  async def get_system_variables(
621
779
  self,
622
780
  give_up_after: int = 10,
@@ -654,6 +812,7 @@ class ZenController(SuperZenController):
654
812
  lights: set[ZenLight] = set()
655
813
  groups: set[ZenGroup] = set()
656
814
  buttons: set[ZenButton] = set()
815
+ absolute_inputs: set[ZenAbsoluteInput] = set()
657
816
  motion_sensors: set[ZenMotionSensor] = set()
658
817
  sysvars: set[ZenSystemVariable] = set()
659
818
  client_data: dict[str, Any] = {}
@@ -716,6 +875,7 @@ class ZenController(SuperZenController):
716
875
  self.lights = set()
717
876
  self.groups = set()
718
877
  self.buttons = set()
878
+ self.absolute_inputs = set()
719
879
  self.motion_sensors = set()
720
880
  self.sysvars = set()
721
881
  self.client_data = {}
@@ -839,6 +999,7 @@ class ZenLight:
839
999
  "RGB": False,
840
1000
  "RGBW": False,
841
1001
  "RGBWW": False,
1002
+ "XY": False,
842
1003
  }
843
1004
  properties: dict[str, int | None] = {
844
1005
  "min_kelvin": Const.DEFAULT_WARMEST_TEMP,
@@ -894,6 +1055,7 @@ class ZenLight:
894
1055
  "RGB": False,
895
1056
  "RGBW": False,
896
1057
  "RGBWW": False,
1058
+ "XY": False,
897
1059
  }
898
1060
  self.properties = {
899
1061
  "min_kelvin": Const.DEFAULT_WARMEST_TEMP,
@@ -963,6 +1125,10 @@ class ZenLight:
963
1125
  # If cgtype contains 8, it supports some kind of colour
964
1126
  if 8 in self.cgtype:
965
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
966
1132
  if cgtype and cgtype.get("supports_tunable", False) is True:
967
1133
  self.features["brightness"] = True
968
1134
  self.features["temperature"] = True
@@ -1006,7 +1172,13 @@ class ZenLight:
1006
1172
  refreshed_scene = None
1007
1173
  if await self.protocol.dali_query_last_scene_is_current(self.address):
1008
1174
  refreshed_scene = await self.protocol.dali_query_last_scene(self.address)
1009
- 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
+ ):
1010
1182
  refreshed_colour = await self.protocol.query_dali_colour(self.address)
1011
1183
 
1012
1184
  if verifying:
@@ -1144,10 +1316,11 @@ class ZenLight:
1144
1316
  colour_type = colour
1145
1317
  else:
1146
1318
  return False;
1147
- if (colour_type == ZenColourType.TC and self.features["temperature"]) or \
1148
- (colour_type == ZenColourType.RGBWAF and self.features["RGB"]) or \
1149
- (colour_type == ZenColourType.RGBWAF and self.features["RGBW"]) or \
1150
- (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")):
1151
1324
  return True
1152
1325
  return False
1153
1326
  # -----------------------------------------------------------------------------------------
@@ -1403,6 +1576,110 @@ class ZenButton:
1403
1576
  await self.protocol.callbacks.button_long_press(button=self)
1404
1577
 
1405
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
+
1406
1683
 
1407
1684
  class ZenMotionSensor:
1408
1685
  protocol: ZenProtocol
@@ -1679,6 +1956,7 @@ class ZenSystemVariable:
1679
1956
 
1680
1957
 
1681
1958
  # Callback type definitions (moved here after class definitions)
1959
+ type ControllerRuntimeStatus = Literal["online", "starting", "unreachable"]
1682
1960
  type CallbackOnConnect = Callable[[], Awaitable[None]]
1683
1961
  type CallbackOnDisconnect = Callable[[], Awaitable[None]]
1684
1962
  type CallbackProfileChange = Callable[[ZenProfile], Awaitable[None]]
@@ -1686,6 +1964,10 @@ type CallbackGroupChange = Callable[[ZenGroup, int], Awaitable[None]]
1686
1964
  type CallbackLightChange = Callable[[ZenLight, int, ZenColour, int], Awaitable[None]]
1687
1965
  type CallbackButtonPress = Callable[[ZenButton], Awaitable[None]]
1688
1966
  type CallbackButtonLongPress = Callable[[ZenButton], Awaitable[None]]
1967
+ type CallbackAbsoluteInputChange = Callable[[ZenAbsoluteInput, int], Awaitable[None]]
1689
1968
  type CallbackMotionEvent = Callable[[ZenMotionSensor, bool], Awaitable[None]]
1690
1969
  type CallbackSystemVariableChange = Callable[[ZenSystemVariable, int, bool, bool], Awaitable[None]]
1691
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.4
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
@@ -49,10 +49,12 @@ A practical demonstration is [zencontrol-homeassistant](https://github.com/sjwri
49
49
  Beyond basic lighting control, this library supports:
50
50
 
51
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, and system variables as rich objects with interview/discovery helpers
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
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
54
55
  * **Multicast controller discovery** — find controllers on the LAN without a preconfigured host
55
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
56
58
  * **Event filtering** — configure which TPI events the controller emits
57
59
  * **System variables** — labelled SV discovery, read/write, and change events
58
60
  * **Profiles** — query, change, and return to the scheduled profile
@@ -1,5 +1,4 @@
1
1
  examples/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- examples/dump_simulator_config.py,sha256=9788XLn8_-Hmc7YN2Eylnx7QnIjf50Umar0F44DLDrg,18288
3
2
  examples/mqtt_bridge.py,sha256=ldSK_zv0vlie6znebyNyqWJdGSoZRO6fijJbvERKRZ0,49882
4
3
  examples/live/controller.py,sha256=jFRgkNcribyg4fviJyQkiWfNI5O3lQFhtblFoe8UPiU,1809
5
4
  examples/live/devices.py,sha256=UiIVwEpyELfl2IOA-9vR2-pMKn9JLwyOV3htTX7XeD8,1681
@@ -16,22 +15,22 @@ examples/live/multicast_raw.py,sha256=EFRM7xA-xlZFS_0WXkQmqECBITG41W4jUEmTd9sIA1
16
15
  examples/live/profiles.py,sha256=Z0jZg8EEznMzSH5JJhhgtJRA_dazNSkuDHnfLqxpR44,1323
17
16
  examples/live/tpi_event_listener.py,sha256=phWI7Ib_g_nMGcPMjIZY6ukBpQpp9YdI3l5lAseYSjA,9309
18
17
  examples/live/variables.py,sha256=hio4vefJLgvCvoVpxLTrU0kxzStWYn6artoJbGnVyd4,1554
19
- zencontrol/__init__.py,sha256=9Lg0TqiTAwcJRDrus9mbALr6lBT5hsav0wiAPKWaKzA,2938
18
+ zencontrol/__init__.py,sha256=bS69eXmifqSmw6XCZ5ml534IIft07evyzioVHz4Y7x8,2984
20
19
  zencontrol/exceptions.py,sha256=tqaLDYWu5J-j2ZKFdfTOcXLH9vULi5pZn8o_Y86bHRE,862
21
20
  zencontrol/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
22
21
  zencontrol/utils.py,sha256=cipUc2KXUfrgTRbCyGwNLhk7um2tjAw6I7kpU_eOu44,2005
23
22
  zencontrol/api/__init__.py,sha256=kZd1T3IKziyQc9qAfOJdYeeTPaB4L2Wkhfu3FKmRw-I,875
24
23
  zencontrol/api/models.py,sha256=W3kCFiJSxc9Z_5Bzfx7A1iICLL8PRmMroW1WReEzAfs,12334
25
- zencontrol/api/protocol.py,sha256=AUxj_uFWScSn7Q7X7ZlMW10ZKVcUAJjPKGwgpNTRS5I,102277
26
- zencontrol/api/types.py,sha256=cJtKY4Qer8u0QQ8J9Jcumg1C1Dee5DtJ9JGcSEfhIKw,6862
27
- zencontrol/interface/__init__.py,sha256=gw_-LSlEwUL0vrOYTxTBwPHZmuNsNqXRqVFbz1ZBQuA,662
28
- zencontrol/interface/interface.py,sha256=qE7ZAD4rxW26V-HggG6tppB5ECzVGljdrWJqO4t2SOs,77752
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
29
28
  zencontrol/io/__init__.py,sha256=cBfOKr0BqOI8E2LicAwcDzLhdlz7KHHrtcn-iUzRmcU,570
30
29
  zencontrol/io/command.py,sha256=HVyMgYGSm166adIIU4KRSIfzPnfB9gnmw3pia_nZ4U8,15366
31
30
  zencontrol/io/event.py,sha256=dsjxFCGNljZOMwFqez9aYKvK1WF2w55X7iwsX93mpvQ,12994
32
- zencontrol_python-0.1.4.dist-info/licenses/LICENSE,sha256=cxmGKzqqXAjXOOw0D0tt5pVkNU9HCormEhNR8dEAYc4,1069
33
- zencontrol_python-0.1.4.dist-info/METADATA,sha256=zpc1a3g3_r4VXVbz9p3vHa0iEJR03mjMXXCmE_I97V0,5414
34
- zencontrol_python-0.1.4.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
35
- zencontrol_python-0.1.4.dist-info/entry_points.txt,sha256=bowyQt57_veMMZzF7LvDwDajG2HcY4-99qYHJ_hasw4,62
36
- zencontrol_python-0.1.4.dist-info/top_level.txt,sha256=cKZS8nB2cZlXavIXB_0tzE16PfGGw6WZDqNzemZKZ4w,20
37
- zencontrol_python-0.1.4.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,,
@@ -1,474 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Dump a live Zencontrol controller into a zencontrol-simulator config.yaml.
3
-
4
- Read-only: queries labels, levels, colour, scenes, groups, ECDs/instances,
5
- profiles, and system variables. Does not send control commands.
6
-
7
- Example:
8
- cd zencontrol-python
9
- python examples/dump_simulator_config.py \\
10
- -c examples/config.yaml \\
11
- -o ../zencontrol-simulator/config.from-live.yaml
12
- """
13
-
14
- import argparse
15
- import asyncio
16
- import logging
17
- import sys
18
- from pathlib import Path
19
- from typing import Any
20
-
21
- import yaml
22
-
23
- from zencontrol import (
24
- ZenColour,
25
- ZenColourType,
26
- ZenController,
27
- ZenInstanceType,
28
- ZenProtocol,
29
- run_with_keyboard_interrupt,
30
- )
31
- from zencontrol.api.types import Const
32
-
33
- LOGGER = logging.getLogger("dump_simulator_config")
34
-
35
- INSTANCE_TYPE_NAMES = {
36
- ZenInstanceType.PUSH_BUTTON: "push_button",
37
- ZenInstanceType.ABSOLUTE_INPUT: "absolute_input",
38
- ZenInstanceType.OCCUPANCY_SENSOR: "occupancy_sensor",
39
- ZenInstanceType.LIGHT_SENSOR: "light_sensor",
40
- ZenInstanceType.GENERAL_SENSOR: "general_sensor",
41
- }
42
-
43
- def _hex_int(value: int) -> str:
44
- return f"0x{value:X}"
45
-
46
- def _colour_dict(colour: ZenColour | None) -> dict[str, Any] | None:
47
- if colour is None or colour.type is None:
48
- return None
49
- if colour.type == ZenColourType.TC:
50
- return {"type": "tc", "kelvin": colour.kelvin}
51
- if colour.type == ZenColourType.RGBWAF:
52
- return {
53
- "type": "rgbwaf",
54
- "r": colour.r,
55
- "g": colour.g,
56
- "b": colour.b,
57
- "w": colour.w if colour.w is not None else 0,
58
- "a": colour.a if colour.a is not None else 0,
59
- "f": colour.f if colour.f is not None else 0,
60
- }
61
- if colour.type == ZenColourType.XY:
62
- return {"type": "xy", "x": colour.x, "y": colour.y}
63
- return None
64
-
65
- def _scene_levels(levels: list[int | None] | None) -> list[int | None]:
66
- out: list[int | None] = [None] * 12
67
- if not levels:
68
- return out
69
- for i, level in enumerate(levels[:12]):
70
- out[i] = None if level is None else int(level)
71
- return out
72
-
73
- def _scene_colours(colours: list[ZenColour | None] | None) -> list[dict[str, Any] | None]:
74
- out: list[dict[str, Any] | None] = [None] * 12
75
- if not colours:
76
- return out
77
- for i, colour in enumerate(colours[:12]):
78
- out[i] = _colour_dict(colour)
79
- return out
80
-
81
- async def _raw_byte(tpi: ZenProtocol, controller: ZenController, command: int, address: int = 0) -> int | None:
82
- response = await tpi._send_basic(controller, command, address)
83
- if response and len(response) >= 1:
84
- return int(response[0])
85
- return None
86
-
87
- async def dump_controller(tpi: ZenProtocol, controller: ZenController) -> dict[str, Any]:
88
- LOGGER.info("Querying controller %s (%s:%s)", controller.mac, controller.host, controller.port)
89
-
90
- version = await tpi.query_controller_version_number(controller)
91
- version_parts = [2, 2, 0]
92
- if isinstance(version, str):
93
- try:
94
- version_parts = [int(x) for x in version.split(".")[:3]]
95
- while len(version_parts) < 3:
96
- version_parts.append(0)
97
- except ValueError:
98
- pass
99
- label = await tpi.query_controller_label(controller) or controller.label or "Controller"
100
- startup = await tpi.query_controller_startup_complete(controller)
101
- dali_ready = await tpi.query_is_dali_ready(controller)
102
- event_mode = await _raw_byte(tpi, controller, tpi.CMD["QUERY_TPI_EVENT_EMIT_STATE"])
103
-
104
- current_profile = await tpi.query_current_profile_number(controller)
105
- last_scheduled = current_profile or 0
106
- profile_info = await tpi.query_profile_information(controller)
107
- if profile_info:
108
- state, _profiles_detail = profile_info
109
- current_profile = int(state.get("current_active_profile", current_profile or 0))
110
- last_scheduled = int(state.get("last_scheduled_profile", last_scheduled))
111
-
112
- profile_numbers = await tpi.query_profile_numbers(controller) or []
113
- if current_profile is not None and current_profile not in profile_numbers:
114
- profile_numbers = sorted(set(profile_numbers) | {current_profile})
115
- profile_items = []
116
- for number in sorted(profile_numbers):
117
- plabel = await tpi.query_profile_label(controller, number)
118
- profile_items.append({
119
- "number": int(number),
120
- "label": plabel or f"Profile {number}",
121
- })
122
- LOGGER.info(" profile %s: %s", number, plabel)
123
-
124
- mac = controller.mac.replace("-", ":").lower()
125
- world: dict[str, Any] = {
126
- "controller": {
127
- "bind_host": "0.0.0.0",
128
- "bind_port": 5108,
129
- "mac": mac,
130
- "label": label,
131
- "version": version_parts,
132
- "startup_complete": bool(startup) if startup is not None else True,
133
- "dali_ready": bool(dali_ready) if dali_ready is not None else True,
134
- "event_mode": event_mode if event_mode is not None else 0x01,
135
- # Dump is a static snapshot — disable occupancy heartbeat by default.
136
- "heartbeat_interval": 0,
137
- },
138
- "lights": [],
139
- "groups": [],
140
- "devices": [],
141
- "profiles": {
142
- "current": int(current_profile or 0),
143
- "last_scheduled": int(last_scheduled or 0),
144
- "items": profile_items,
145
- },
146
- "system_variables": [],
147
- }
148
-
149
- # --- Groups (labels / scenes first so light membership can reference them) ---
150
- group_addrs = await tpi.query_group_numbers(controller) or []
151
- LOGGER.info("Groups: %d", len(group_addrs))
152
- for gaddr in sorted(group_addrs, key=lambda a: a.number):
153
- glabel = await tpi.query_group_label(gaddr)
154
- glevel = await tpi.dali_query_level(gaddr)
155
- scene_nums = await tpi.query_scene_numbers_for_group(gaddr) or []
156
- scenes: dict[int, str] = {}
157
- for scene in sorted(scene_nums):
158
- if not 0 <= scene <= 11:
159
- continue
160
- slabel = await tpi.query_scene_label_for_group(gaddr, scene)
161
- if slabel:
162
- scenes[int(scene)] = slabel
163
- world["groups"].append({
164
- "number": int(gaddr.number),
165
- "label": glabel or f"Group {gaddr.number}",
166
- "level": 0 if glevel is None else int(glevel),
167
- "scenes": scenes,
168
- })
169
- LOGGER.info(" group %s: %s (%d scenes)", gaddr.number, glabel, len(scenes))
170
-
171
- # --- Control gear / lights ---
172
- gears = await tpi.query_control_gear_dali_addresses(controller) or []
173
- LOGGER.info("Control gear: %d", len(gears))
174
- for addr in sorted(gears, key=lambda a: a.number):
175
- status_raw = await _raw_byte(
176
- tpi, controller, tpi.CMD["DALI_QUERY_CONTROL_GEAR_STATUS"], addr.ecg()
177
- )
178
- # Skip gear that does not answer status (absent / failed)
179
- if status_raw is None:
180
- LOGGER.warning(" ECG %s: no status — skipping", addr.number)
181
- continue
182
-
183
- dlabel = await tpi.query_dali_device_label(addr)
184
- serial = await tpi.query_dali_serial(addr)
185
- level = await tpi.dali_query_level(addr)
186
- min_level = await tpi.dali_query_min_level(addr)
187
- max_level = await tpi.dali_query_max_level(addr)
188
- last_scene = await tpi.dali_query_last_scene(addr)
189
- last_current = await tpi.dali_query_last_scene_is_current(addr)
190
- cg_types = await tpi.dali_query_cg_type(addr) or []
191
- groups = await tpi.query_group_membership_by_address(addr) or []
192
- group_nums = sorted({int(g.number) for g in groups})
193
-
194
- colour_features = {
195
- "supports_xy": False,
196
- "supports_tunable": False,
197
- "primary_count": 0,
198
- "rgbwaf_channels": 0,
199
- }
200
- colour = None
201
- colour_temp_limits = None
202
- if 8 in cg_types:
203
- features = await tpi.query_dali_colour_features(addr)
204
- if features:
205
- colour_features = {
206
- "supports_xy": bool(features.get("supports_xy", False)),
207
- "supports_tunable": bool(features.get("supports_tunable", False)),
208
- "primary_count": int(features.get("primary_count", 0)),
209
- "rgbwaf_channels": int(features.get("rgbwaf_channels", 0)),
210
- }
211
- colour = _colour_dict(await tpi.query_dali_colour(addr))
212
- if colour_features["supports_tunable"]:
213
- colour_temp_limits = await tpi.query_dali_colour_temp_limits(addr)
214
-
215
- scene_levels = _scene_levels(await tpi.query_scene_levels_by_address(addr))
216
- scene_colours = _scene_colours(await tpi.query_scene_colours_by_address(addr))
217
-
218
- light: dict[str, Any] = {
219
- "address": int(addr.number),
220
- "label": dlabel or f"Light {addr.number}",
221
- "serial": _hex_int(int(serial)) if serial else 0,
222
- "level": 0 if level is None else int(level),
223
- "min_level": 1 if min_level is None else int(min_level),
224
- "max_level": 254 if max_level is None else int(max_level),
225
- "last_scene": 0 if last_scene is None else int(last_scene),
226
- "last_scene_current": bool(last_current) if last_current is not None else False,
227
- "cg_types": [int(x) for x in cg_types],
228
- "colour": colour,
229
- "colour_features": colour_features,
230
- "groups": group_nums,
231
- "scene_levels": scene_levels,
232
- "status": status_raw,
233
- }
234
- if colour_temp_limits:
235
- light["colour_temp_limits"] = {
236
- "physical_warmest": int(colour_temp_limits["physical_warmest"]),
237
- "physical_coolest": int(colour_temp_limits["physical_coolest"]),
238
- "soft_warmest": int(colour_temp_limits["soft_warmest"]),
239
- "soft_coolest": int(colour_temp_limits["soft_coolest"]),
240
- "step_value": int(colour_temp_limits["step_value"]),
241
- }
242
- if any(c is not None for c in scene_colours):
243
- light["scene_colours"] = scene_colours
244
-
245
- world["lights"].append(light)
246
- LOGGER.info(
247
- " ECG %s: %s level=%s groups=%s cg=%s",
248
- addr.number,
249
- dlabel,
250
- light["level"],
251
- group_nums,
252
- cg_types,
253
- )
254
-
255
- # --- ECDs / instances ---
256
- ecds = await tpi.query_dali_addresses_with_instances(controller, 0) or []
257
- LOGGER.info("Devices with instances: %d", len(ecds))
258
- for ecd in sorted(ecds, key=lambda a: a.number):
259
- dlabel = await tpi.query_dali_device_label(ecd)
260
- serial = await tpi.query_dali_serial(ecd)
261
- instances = await tpi.query_instances_by_address(ecd) or []
262
- inst_list: list[dict[str, Any]] = []
263
- for inst in sorted(instances, key=lambda i: i.number):
264
- if inst.type is None:
265
- continue
266
- type_name = INSTANCE_TYPE_NAMES.get(inst.type)
267
- if type_name is None:
268
- LOGGER.warning(
269
- " ECD %s inst %s: unsupported type %s — skipped",
270
- ecd.number,
271
- inst.number,
272
- inst.type,
273
- )
274
- continue
275
- ilabel = await tpi.query_dali_instance_label(inst)
276
- entry: dict[str, Any] = {
277
- "number": int(inst.number),
278
- "type": type_name,
279
- "label": ilabel or f"Instance {inst.number}",
280
- "active": True,
281
- "error": False,
282
- }
283
- if inst.type == ZenInstanceType.OCCUPANCY_SENSOR:
284
- timers = await tpi.query_occupancy_instance_timers(inst)
285
- if timers:
286
- entry["timers"] = {
287
- "deadtime": int(timers["deadtime"]),
288
- "hold": int(timers["hold"]),
289
- "report": int(timers["report"]),
290
- "last_detect": int(timers["last_detect"]),
291
- }
292
- else:
293
- entry["timers"] = {
294
- "deadtime": 1,
295
- "hold": 60,
296
- "report": 20,
297
- "last_detect": 0,
298
- }
299
- inst_list.append(entry)
300
- LOGGER.info(
301
- " ECD %s.%s %s (%s)",
302
- ecd.number,
303
- inst.number,
304
- type_name,
305
- ilabel,
306
- )
307
- if not inst_list:
308
- continue
309
- world["devices"].append({
310
- "address": int(ecd.number),
311
- "label": dlabel or f"Device {ecd.number}",
312
- "serial": _hex_int(int(serial)) if serial else 0,
313
- "instances": inst_list,
314
- })
315
-
316
- # --- System variables (stop after consecutive unnamed IDs) ---
317
- give_up_after = 10
318
- failed = 0
319
- LOGGER.info("System variables…")
320
- for vid in range(Const.MAX_SYSVAR):
321
- name = await tpi.query_system_variable_name(controller, vid)
322
- if not name:
323
- failed += 1
324
- if failed >= give_up_after:
325
- break
326
- continue
327
- failed = 0
328
- value = await tpi.query_system_variable(controller, vid)
329
- world["system_variables"].append({
330
- "id": int(vid),
331
- "name": name,
332
- "value": 0 if value is None else int(value),
333
- })
334
- LOGGER.info(" sysvar %s: %s = %s", vid, name, value)
335
-
336
- return world
337
-
338
- class _HexInt(int):
339
- """YAML representable int that dumps as 0x…."""
340
-
341
- def _represent_hex_int(dumper: yaml.Dumper, data: _HexInt) -> Any:
342
- return dumper.represent_scalar("tag:yaml.org,2002:int", f"0x{int(data):X}")
343
-
344
- def _prepare_for_yaml(obj: Any) -> Any:
345
- """Convert serial hex strings back to ints tagged for hex dump where useful."""
346
- if isinstance(obj, dict):
347
- out = {}
348
- for key, value in obj.items():
349
- if key == "serial" and isinstance(value, str) and value.startswith("0x"):
350
- out[key] = _HexInt(int(value, 0))
351
- elif key == "event_mode" and isinstance(value, int):
352
- out[key] = _HexInt(value)
353
- elif key == "status" and isinstance(value, int):
354
- out[key] = _HexInt(value)
355
- else:
356
- out[key] = _prepare_for_yaml(value)
357
- return out
358
- if isinstance(obj, list):
359
- return [_prepare_for_yaml(x) for x in obj]
360
- return obj
361
-
362
- def write_yaml(path: Path, world: dict[str, Any]) -> None:
363
- class Dumper(yaml.SafeDumper):
364
- pass
365
-
366
- Dumper.add_representer(_HexInt, _represent_hex_int)
367
- payload = _prepare_for_yaml(world)
368
- header = (
369
- "# Auto-generated by zencontrol-python/examples/dump_simulator_config.py\n"
370
- "# Read-only snapshot of a live Zencontrol controller for zencontrol-simulator.\n"
371
- "# Control commands mutate in-memory state only; nothing is written back to disk.\n\n"
372
- )
373
- path.parent.mkdir(parents=True, exist_ok=True)
374
- with open(path, "w", encoding="utf-8") as fh:
375
- fh.write(header)
376
- yaml.dump(
377
- payload,
378
- fh,
379
- Dumper=Dumper,
380
- sort_keys=False,
381
- default_flow_style=False,
382
- allow_unicode=True,
383
- width=100,
384
- )
385
-
386
- def build_parser() -> argparse.ArgumentParser:
387
- example_dir = Path(__file__).resolve().parent
388
- default_config = example_dir / "config.yaml"
389
- default_out = example_dir.parents[1] / "zencontrol-simulator" / "config.from-live.yaml"
390
- parser = argparse.ArgumentParser(
391
- description="Dump a live Zencontrol controller to a zencontrol-simulator YAML world",
392
- )
393
- parser.add_argument(
394
- "-c",
395
- "--config",
396
- type=Path,
397
- default=default_config,
398
- help=f"zencontrol-python config.yaml (default: {default_config})",
399
- )
400
- parser.add_argument(
401
- "-o",
402
- "--output",
403
- type=Path,
404
- default=default_out,
405
- help=f"Output simulator config path (default: {default_out})",
406
- )
407
- parser.add_argument(
408
- "--controller",
409
- type=int,
410
- default=0,
411
- help="Index into zencontrol: list in config (default: 0)",
412
- )
413
- parser.add_argument(
414
- "-v",
415
- "--verbose",
416
- action="store_true",
417
- help="Debug logging",
418
- )
419
- return parser
420
-
421
- async def main(argv: list[str] | None = None) -> None:
422
- args = build_parser().parse_args(argv)
423
- logging.basicConfig(
424
- level=logging.DEBUG if args.verbose else logging.INFO,
425
- format="%(asctime)s %(levelname)s %(message)s",
426
- )
427
-
428
- if not args.config.is_file():
429
- LOGGER.error("Config not found: %s", args.config)
430
- sys.exit(1)
431
-
432
- with open(args.config, encoding="utf-8") as fh:
433
- config = yaml.safe_load(fh) or {}
434
- controllers = config.get("zencontrol") or []
435
- if not controllers:
436
- LOGGER.error("No zencontrol: entries in %s", args.config)
437
- sys.exit(1)
438
- if not 0 <= args.controller < len(controllers):
439
- LOGGER.error("Controller index %s out of range (have %d)", args.controller, len(controllers))
440
- sys.exit(1)
441
-
442
- entry = dict(controllers[args.controller])
443
- # ZenController / ZenProtocol do not need mqtt extras
444
- for key in ("system_variables", "buttons", "unicast_ip", "unicast_port", "filtering"):
445
- entry.pop(key, None)
446
-
447
- async with ZenProtocol(print_traffic=False) as tpi:
448
- # Coerce YAML types to what ZenController expects
449
- entry["id"] = str(entry.get("id", "1"))
450
- ctrl = ZenController(protocol=tpi, **entry)
451
- tpi.set_controllers([ctrl])
452
-
453
- ready = await tpi.query_controller_startup_complete(ctrl)
454
- if ready is False:
455
- LOGGER.warning("Controller reports startup incomplete — continuing anyway")
456
- dali = await tpi.query_is_dali_ready(ctrl)
457
- if dali is False:
458
- LOGGER.warning("DALI bus not ready — continuing anyway")
459
-
460
- world = await dump_controller(tpi, ctrl)
461
-
462
- write_yaml(args.output, world)
463
- LOGGER.info(
464
- "Wrote %s (%d lights, %d groups, %d devices, %d profiles, %d sysvars)",
465
- args.output,
466
- len(world["lights"]),
467
- len(world["groups"]),
468
- len(world["devices"]),
469
- len(world["profiles"]["items"]),
470
- len(world["system_variables"]),
471
- )
472
-
473
- if __name__ == "__main__":
474
- run_with_keyboard_interrupt(main)