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 +3 -1
- zencontrol/api/protocol.py +7 -3
- zencontrol/api/types.py +4 -0
- zencontrol/interface/__init__.py +2 -0
- zencontrol/interface/interface.py +306 -24
- {zencontrol_python-0.1.4.dist-info → zencontrol_python-0.1.6.dist-info}/METADATA +4 -2
- {zencontrol_python-0.1.4.dist-info → zencontrol_python-0.1.6.dist-info}/RECORD +11 -12
- examples/dump_simulator_config.py +0 -474
- {zencontrol_python-0.1.4.dist-info → zencontrol_python-0.1.6.dist-info}/WHEEL +0 -0
- {zencontrol_python-0.1.4.dist-info → zencontrol_python-0.1.6.dist-info}/entry_points.txt +0 -0
- {zencontrol_python-0.1.4.dist-info → zencontrol_python-0.1.6.dist-info}/licenses/LICENSE +0 -0
- {zencontrol_python-0.1.4.dist-info → zencontrol_python-0.1.6.dist-info}/top_level.txt +0 -0
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.
|
|
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
|
|
zencontrol/api/protocol.py
CHANGED
|
@@ -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:
|
|
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
|
zencontrol/interface/__init__.py
CHANGED
|
@@ -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) ->
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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.
|
|
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.
|
|
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
|
|
372
|
-
|
|
373
|
-
self.
|
|
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
|
-
|
|
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
|
|
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
|
|
1148
|
-
(colour_type == ZenColourType.RGBWAF and self.features
|
|
1149
|
-
(colour_type == ZenColourType.RGBWAF and self.features
|
|
1150
|
-
(colour_type == ZenColourType.RGBWAF and self.features
|
|
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.
|
|
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=
|
|
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=
|
|
26
|
-
zencontrol/api/types.py,sha256=
|
|
27
|
-
zencontrol/interface/__init__.py,sha256=
|
|
28
|
-
zencontrol/interface/interface.py,sha256=
|
|
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.
|
|
33
|
-
zencontrol_python-0.1.
|
|
34
|
-
zencontrol_python-0.1.
|
|
35
|
-
zencontrol_python-0.1.
|
|
36
|
-
zencontrol_python-0.1.
|
|
37
|
-
zencontrol_python-0.1.
|
|
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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|