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