zencontrol-python 0.1.4__py3-none-any.whl → 0.1.7__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.
- examples/live/level_change_v2.py +1 -1
- examples/mqtt_bridge.py +7 -1
- zencontrol/__init__.py +35 -13
- zencontrol/api/__init__.py +15 -2
- zencontrol/api/models.py +27 -12
- zencontrol/api/protocol.py +77 -48
- zencontrol/api/types.py +6 -2
- zencontrol/exceptions.py +1 -0
- zencontrol/interface/__init__.py +5 -3
- zencontrol/interface/interface.py +379 -90
- zencontrol/io/__init__.py +9 -2
- zencontrol/io/command.py +20 -16
- zencontrol/io/event.py +24 -22
- zencontrol/utils.py +2 -2
- {zencontrol_python-0.1.4.dist-info → zencontrol_python-0.1.7.dist-info}/METADATA +10 -5
- zencontrol_python-0.1.7.dist-info/RECORD +36 -0
- examples/dump_simulator_config.py +0 -474
- zencontrol_python-0.1.4.dist-info/RECORD +0 -37
- {zencontrol_python-0.1.4.dist-info → zencontrol_python-0.1.7.dist-info}/WHEEL +0 -0
- {zencontrol_python-0.1.4.dist-info → zencontrol_python-0.1.7.dist-info}/entry_points.txt +0 -0
- {zencontrol_python-0.1.4.dist-info → zencontrol_python-0.1.7.dist-info}/licenses/LICENSE +0 -0
- {zencontrol_python-0.1.4.dist-info → zencontrol_python-0.1.7.dist-info}/top_level.txt +0 -0
examples/live/level_change_v2.py
CHANGED
|
@@ -9,7 +9,7 @@ if str(ROOT) not in sys.path:
|
|
|
9
9
|
|
|
10
10
|
import yaml
|
|
11
11
|
|
|
12
|
-
from zencontrol
|
|
12
|
+
from zencontrol import ZenAddress, ZenController
|
|
13
13
|
from zencontrol.api.protocol import ZenProtocol
|
|
14
14
|
from zencontrol.api.types import ZenAddressType, ZenEventCode
|
|
15
15
|
from zencontrol.utils import run_with_keyboard_interrupt
|
examples/mqtt_bridge.py
CHANGED
|
@@ -10,12 +10,18 @@ import zencontrol
|
|
|
10
10
|
from zencontrol import ZenController, ZenProtocol, ZenClient, ZenColour, ZenColourType, ZenProfile, ZenLight, ZenGroup, ZenButton, ZenMotionSensor, ZenSystemVariable, ZenTimeoutError, ZenAddressType
|
|
11
11
|
from zencontrol.api.types import Const as ApiConst
|
|
12
12
|
import aiomqtt
|
|
13
|
-
from colorama import Fore, Back, Style
|
|
14
13
|
import logging
|
|
15
14
|
from logging.handlers import RotatingFileHandler
|
|
16
15
|
import math
|
|
17
16
|
import pickle
|
|
18
17
|
import traceback
|
|
18
|
+
try:
|
|
19
|
+
from colorama import Fore, Style
|
|
20
|
+
except ImportError:
|
|
21
|
+
class _NoColor:
|
|
22
|
+
def __getattr__(self, _name: str) -> str:
|
|
23
|
+
return ""
|
|
24
|
+
Fore = Style = _NoColor() # type: ignore[assignment]
|
|
19
25
|
|
|
20
26
|
class RateLimiter:
|
|
21
27
|
"""Rate limiter to control concurrent coroutine execution"""
|
zencontrol/__init__.py
CHANGED
|
@@ -33,32 +33,53 @@ Example usage:
|
|
|
33
33
|
"""
|
|
34
34
|
|
|
35
35
|
# High-level interface (recommended for most users)
|
|
36
|
+
# API-level models (used by zen_api)
|
|
37
|
+
from .api.models import DiscoveredController, ZenAddress, ZenColour, ZenInstance
|
|
38
|
+
from .api.protocol import ZenProtocol
|
|
39
|
+
|
|
40
|
+
# Shared types and exceptions
|
|
41
|
+
from .api.types import (
|
|
42
|
+
ZenAddressType,
|
|
43
|
+
ZenColourType,
|
|
44
|
+
ZenEventCode,
|
|
45
|
+
ZenEventMask,
|
|
46
|
+
ZenEventMode,
|
|
47
|
+
ZenInstanceType,
|
|
48
|
+
)
|
|
49
|
+
from .exceptions import (
|
|
50
|
+
ZenConfigurationError,
|
|
51
|
+
ZenConnectionError,
|
|
52
|
+
ZenError,
|
|
53
|
+
ZenResponseError,
|
|
54
|
+
ZenTimeoutError,
|
|
55
|
+
)
|
|
36
56
|
from .interface import (
|
|
57
|
+
ZenAbsoluteInput,
|
|
58
|
+
ZenButton,
|
|
37
59
|
ZenControl,
|
|
38
60
|
ZenController,
|
|
39
|
-
ZenProfile,
|
|
40
|
-
ZenLight,
|
|
41
61
|
ZenGroup,
|
|
42
|
-
|
|
62
|
+
ZenLight,
|
|
43
63
|
ZenMotionSensor,
|
|
64
|
+
ZenProfile,
|
|
44
65
|
ZenSystemVariable,
|
|
45
66
|
)
|
|
46
67
|
|
|
47
|
-
# API-level models (used by zen_api)
|
|
48
|
-
from .api.models import ZenAddress, ZenInstance, ZenColour, DiscoveredController
|
|
49
|
-
from .api.protocol import ZenProtocol
|
|
50
|
-
|
|
51
68
|
# Low-level models (used by zen_io)
|
|
52
|
-
from .io import
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
69
|
+
from .io import (
|
|
70
|
+
Request,
|
|
71
|
+
RequestType,
|
|
72
|
+
Response,
|
|
73
|
+
ResponseType,
|
|
74
|
+
ZenClient,
|
|
75
|
+
ZenEvent,
|
|
76
|
+
ZenListener,
|
|
77
|
+
)
|
|
57
78
|
|
|
58
79
|
# Utilities
|
|
59
80
|
from .utils import run_with_keyboard_interrupt
|
|
60
81
|
|
|
61
|
-
__version__ = "0.1.
|
|
82
|
+
__version__ = "0.1.7"
|
|
62
83
|
__author__ = "Simon Wright"
|
|
63
84
|
|
|
64
85
|
# Public API - these are the main classes users should import
|
|
@@ -72,6 +93,7 @@ __all__ = [
|
|
|
72
93
|
"ZenLight",
|
|
73
94
|
"ZenGroup",
|
|
74
95
|
"ZenButton",
|
|
96
|
+
"ZenAbsoluteInput",
|
|
75
97
|
"ZenMotionSensor",
|
|
76
98
|
"ZenSystemVariable",
|
|
77
99
|
|
zencontrol/api/__init__.py
CHANGED
|
@@ -8,9 +8,22 @@ This module contains models and types that belong to the API layer:
|
|
|
8
8
|
- Types and enums used by the API layer
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
|
-
from .models import
|
|
11
|
+
from .models import (
|
|
12
|
+
DiscoveredController,
|
|
13
|
+
ZenAddress,
|
|
14
|
+
ZenColour,
|
|
15
|
+
ZenController,
|
|
16
|
+
ZenInstance,
|
|
17
|
+
ZenProfile,
|
|
18
|
+
)
|
|
12
19
|
from .protocol import ZenProtocol
|
|
13
|
-
from .types import
|
|
20
|
+
from .types import (
|
|
21
|
+
ZenAddressType,
|
|
22
|
+
ZenColourType,
|
|
23
|
+
ZenEventMask,
|
|
24
|
+
ZenEventMode,
|
|
25
|
+
ZenInstanceType,
|
|
26
|
+
)
|
|
14
27
|
|
|
15
28
|
__all__ = [
|
|
16
29
|
# API-level models
|
zencontrol/api/models.py
CHANGED
|
@@ -12,10 +12,16 @@ import socket
|
|
|
12
12
|
import struct
|
|
13
13
|
import time
|
|
14
14
|
from dataclasses import dataclass, field
|
|
15
|
-
from typing import Any, Self
|
|
15
|
+
from typing import TYPE_CHECKING, Any, Self
|
|
16
16
|
|
|
17
17
|
from ..io import ZenClient
|
|
18
|
-
from .types import
|
|
18
|
+
from .types import Const, ZenAddressType, ZenColourType, ZenInstanceType
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
# Addresses are only ever built from registered controllers, which are
|
|
22
|
+
# interface-layer objects. Type-only import; at runtime the interface layer
|
|
23
|
+
# imports this module, so importing it here would be circular.
|
|
24
|
+
from ..interface.interface import ZenController as ZenInterfaceController
|
|
19
25
|
|
|
20
26
|
|
|
21
27
|
DEFAULT_CONTROLLER_PORT = 5108
|
|
@@ -33,8 +39,13 @@ class DiscoveredController:
|
|
|
33
39
|
|
|
34
40
|
@dataclass
|
|
35
41
|
class ZenController:
|
|
36
|
-
"""
|
|
37
|
-
|
|
42
|
+
"""Base class holding a controller's config and transport state.
|
|
43
|
+
|
|
44
|
+
``zencontrol.ZenController`` (the interface layer) subclasses this and adds
|
|
45
|
+
entity state; that subclass is what ``ZenControl.add_controller()`` returns
|
|
46
|
+
and what registered controllers always are. This base exists so the API
|
|
47
|
+
layer can talk about controllers without importing the interface layer.
|
|
48
|
+
|
|
38
49
|
The 'host' field can be any resolvable hostname or IP address.
|
|
39
50
|
The 'ip' property will resolve the hostname to an IP address and cache it.
|
|
40
51
|
"""
|
|
@@ -55,7 +66,7 @@ class ZenController:
|
|
|
55
66
|
client: ZenClient | None = None
|
|
56
67
|
_ip: str | None = field(init=False, repr=False, default=None)
|
|
57
68
|
|
|
58
|
-
def __post_init__(self):
|
|
69
|
+
def __post_init__(self) -> None:
|
|
59
70
|
self._update_mac_bytes(self.mac)
|
|
60
71
|
|
|
61
72
|
def __setattr__(self, name: str, value: object) -> None:
|
|
@@ -104,14 +115,14 @@ class ZenController:
|
|
|
104
115
|
@dataclass(slots=True)
|
|
105
116
|
class ZenAddress:
|
|
106
117
|
"""Represents a DALI address"""
|
|
107
|
-
controller:
|
|
118
|
+
controller: ZenInterfaceController
|
|
108
119
|
type: ZenAddressType
|
|
109
120
|
number: int
|
|
110
121
|
label: str | None = field(default=None, init=False)
|
|
111
122
|
serial: str | None = field(default=None, init=False)
|
|
112
123
|
|
|
113
124
|
@classmethod
|
|
114
|
-
def broadcast(cls, controller:
|
|
125
|
+
def broadcast(cls, controller: ZenInterfaceController) -> Self:
|
|
115
126
|
return cls(controller=controller, type=ZenAddressType.BROADCAST, number=255)
|
|
116
127
|
|
|
117
128
|
def ecg(self) -> int:
|
|
@@ -152,7 +163,7 @@ class ZenAddress:
|
|
|
152
163
|
"""Return a stable HA-friendly identifier for this address."""
|
|
153
164
|
return f"{self.type.name.casefold()}{self.number}"
|
|
154
165
|
|
|
155
|
-
def __post_init__(self):
|
|
166
|
+
def __post_init__(self) -> None:
|
|
156
167
|
match self.type:
|
|
157
168
|
case ZenAddressType.BROADCAST:
|
|
158
169
|
if self.number != 255:
|
|
@@ -176,7 +187,7 @@ class ZenInstance:
|
|
|
176
187
|
number: int
|
|
177
188
|
active: bool | None = None
|
|
178
189
|
error: bool | None = None
|
|
179
|
-
def __post_init__(self):
|
|
190
|
+
def __post_init__(self) -> None:
|
|
180
191
|
if not 0 <= self.number < Const.MAX_INSTANCE:
|
|
181
192
|
raise ValueError(f"Instance number must be between 0 and {Const.MAX_INSTANCE-1}, received {self.number}")
|
|
182
193
|
|
|
@@ -202,7 +213,11 @@ class ZenColour:
|
|
|
202
213
|
@classmethod
|
|
203
214
|
def from_bytes(cls, data: bytes) -> Self | None:
|
|
204
215
|
match list(data):
|
|
205
|
-
case [ZenColourType.RGBWAF.value, r, g, b,
|
|
216
|
+
case [ZenColourType.RGBWAF.value, r, g, b, *rest] if len(rest) <= 3:
|
|
217
|
+
# COLOUR_CHANGED_EVENT from a fixture with fewer than six channels
|
|
218
|
+
# carries only channels + 1 bytes, so an RGB fixture sends
|
|
219
|
+
# [0x80, R, G, B]. Channels the fixture does not have stay None.
|
|
220
|
+
w, a, f = (list(rest) + [None, None, None])[:3]
|
|
206
221
|
return cls(type=ZenColourType.RGBWAF, r=r, g=g, b=b, w=w, a=a, f=f)
|
|
207
222
|
case [ZenColourType.TC.value, hi, lo] | [ZenColourType.TC.value, hi, lo, *_]:
|
|
208
223
|
if len(data) not in (3, 7):
|
|
@@ -215,7 +230,7 @@ class ZenColour:
|
|
|
215
230
|
case _:
|
|
216
231
|
return None
|
|
217
232
|
|
|
218
|
-
def __post_init__(self):
|
|
233
|
+
def __post_init__(self) -> None:
|
|
219
234
|
match self.type:
|
|
220
235
|
case ZenColourType.TC:
|
|
221
236
|
kelvin = self.kelvin
|
|
@@ -310,6 +325,6 @@ class ZenProfile:
|
|
|
310
325
|
address: ZenAddress
|
|
311
326
|
profile: int
|
|
312
327
|
|
|
313
|
-
def __post_init__(self):
|
|
328
|
+
def __post_init__(self) -> None:
|
|
314
329
|
if not (0 <= self.profile <= 255):
|
|
315
330
|
raise ValueError(f"Profile must be 0-255, got {self.profile}")
|
zencontrol/api/protocol.py
CHANGED
|
@@ -1,29 +1,57 @@
|
|
|
1
|
-
from collections.abc import Callable, Awaitable, Coroutine
|
|
2
1
|
import asyncio
|
|
3
|
-
import
|
|
2
|
+
import logging
|
|
4
3
|
import struct
|
|
5
4
|
import time
|
|
6
|
-
import logging
|
|
7
5
|
import traceback
|
|
6
|
+
from collections.abc import Awaitable, Callable, Coroutine
|
|
8
7
|
from datetime import datetime as dt
|
|
9
|
-
from typing import
|
|
10
|
-
|
|
11
|
-
from
|
|
12
|
-
from
|
|
13
|
-
|
|
14
|
-
|
|
8
|
+
from typing import TYPE_CHECKING, Any, Literal, Self, overload
|
|
9
|
+
|
|
10
|
+
from ..exceptions import ZenTimeoutError
|
|
11
|
+
from ..io import (
|
|
12
|
+
ClientConst,
|
|
13
|
+
EventConst,
|
|
14
|
+
Request,
|
|
15
|
+
RequestType,
|
|
16
|
+
Response,
|
|
17
|
+
ResponseType,
|
|
18
|
+
ZenClient,
|
|
19
|
+
ZenEvent,
|
|
20
|
+
ZenListener,
|
|
21
|
+
)
|
|
22
|
+
from ..utils import local_ip_for_remote
|
|
15
23
|
from .models import (
|
|
16
24
|
DEFAULT_CONTROLLER_PORT,
|
|
17
25
|
DiscoveredController,
|
|
18
|
-
ZenController,
|
|
19
26
|
ZenAddress,
|
|
20
|
-
ZenInstance,
|
|
21
27
|
ZenColour,
|
|
22
|
-
|
|
28
|
+
ZenController,
|
|
29
|
+
ZenInstance,
|
|
23
30
|
)
|
|
24
|
-
from .types import
|
|
25
|
-
|
|
26
|
-
|
|
31
|
+
from .types import (
|
|
32
|
+
Const,
|
|
33
|
+
ZenAddressType,
|
|
34
|
+
ZenErrorCode,
|
|
35
|
+
ZenEventCode,
|
|
36
|
+
ZenEventMask,
|
|
37
|
+
ZenEventMode,
|
|
38
|
+
ZenInstanceType,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# Magic import to avoid dependency on colorama
|
|
42
|
+
try:
|
|
43
|
+
from colorama import Fore, Style
|
|
44
|
+
except ImportError:
|
|
45
|
+
class _NoColor:
|
|
46
|
+
def __getattr__(self, _name: str) -> str:
|
|
47
|
+
return ""
|
|
48
|
+
Fore = Style = _NoColor() # type: ignore[assignment]
|
|
49
|
+
|
|
50
|
+
if TYPE_CHECKING:
|
|
51
|
+
# Registered controllers are always interface-layer objects, so anything
|
|
52
|
+
# that hands out ZenAddress instances is typed with that class. Type-only
|
|
53
|
+
# import; importing the interface layer at runtime would be circular.
|
|
54
|
+
from ..interface.interface import ZenController as ZenInterfaceController
|
|
27
55
|
|
|
28
56
|
|
|
29
57
|
def _mac_bytes_to_str(mac: bytes) -> str:
|
|
@@ -57,6 +85,7 @@ class ZenCallbacks:
|
|
|
57
85
|
self.light_change: Callable[..., Awaitable[None]] | None = None
|
|
58
86
|
self.button_press: Callable[..., Awaitable[None]] | None = None
|
|
59
87
|
self.button_long_press: Callable[..., Awaitable[None]] | None = None
|
|
88
|
+
self.absolute_input_change: Callable[..., Awaitable[None]] | None = None
|
|
60
89
|
self.motion_event: Callable[..., Awaitable[None]] | None = None
|
|
61
90
|
self.system_variable_change: Callable[..., Awaitable[None]] | None = None
|
|
62
91
|
self.controller_discovered: Callable[[DiscoveredController], Awaitable[None]] | None = None
|
|
@@ -75,6 +104,7 @@ class EntityRegistry:
|
|
|
75
104
|
self.lights: dict[str, Any] = {}
|
|
76
105
|
self.groups: dict[str, Any] = {}
|
|
77
106
|
self.buttons: dict[str, Any] = {}
|
|
107
|
+
self.absolute_inputs: dict[str, Any] = {}
|
|
78
108
|
self.motion_sensors: dict[str, Any] = {}
|
|
79
109
|
self.system_variables: dict[str, Any] = {}
|
|
80
110
|
|
|
@@ -84,6 +114,7 @@ class EntityRegistry:
|
|
|
84
114
|
self.lights.clear()
|
|
85
115
|
self.groups.clear()
|
|
86
116
|
self.buttons.clear()
|
|
117
|
+
self.absolute_inputs.clear()
|
|
87
118
|
self.motion_sensors.clear()
|
|
88
119
|
self.system_variables.clear()
|
|
89
120
|
|
|
@@ -96,6 +127,7 @@ class EntityRegistry:
|
|
|
96
127
|
self.lights,
|
|
97
128
|
self.groups,
|
|
98
129
|
self.buttons,
|
|
130
|
+
self.absolute_inputs,
|
|
99
131
|
self.motion_sensors,
|
|
100
132
|
self.system_variables,
|
|
101
133
|
):
|
|
@@ -214,7 +246,7 @@ class ZenProtocol:
|
|
|
214
246
|
unicast: bool = False,
|
|
215
247
|
listen_ip: str | None = None,
|
|
216
248
|
listen_port: int | None = None,
|
|
217
|
-
cache: dict[bytes, dict[str, Any]] | None = None):
|
|
249
|
+
cache: dict[bytes, dict[str, Any]] | None = None) -> None:
|
|
218
250
|
self.logger = logger or logging.getLogger('null')
|
|
219
251
|
if logger is None:
|
|
220
252
|
self.logger.addHandler(logging.NullHandler())
|
|
@@ -232,7 +264,7 @@ class ZenProtocol:
|
|
|
232
264
|
self.local_ip = self.listen_ip
|
|
233
265
|
# Setup event monitoring using ZenListener
|
|
234
266
|
self.event_listener: ZenListener | None = None
|
|
235
|
-
self.event_task = None
|
|
267
|
+
self.event_task: asyncio.Task[None] | None = None
|
|
236
268
|
# Prevents double on_disconnect if stop races with listener death
|
|
237
269
|
self._disconnect_notified = False
|
|
238
270
|
|
|
@@ -250,7 +282,7 @@ class ZenProtocol:
|
|
|
250
282
|
self.disconnect_callback: Callable[[], Awaitable[None]] | None = None
|
|
251
283
|
|
|
252
284
|
# Controllers will be assigned later
|
|
253
|
-
self.controllers = []
|
|
285
|
+
self.controllers: list[ZenInterfaceController] = []
|
|
254
286
|
# Controllers identified via multicast but not yet registered
|
|
255
287
|
self.identified_controllers: list[DiscoveredController] = []
|
|
256
288
|
self._discovery_pending_macs: set[str] = set()
|
|
@@ -263,11 +295,11 @@ class ZenProtocol:
|
|
|
263
295
|
# Fire-and-forget work (delayed events, refresh timers, motion holds)
|
|
264
296
|
self._bg_tasks: set[asyncio.Task[Any]] = set()
|
|
265
297
|
|
|
266
|
-
async def __aenter__(self):
|
|
298
|
+
async def __aenter__(self) -> Self:
|
|
267
299
|
"""Async context manager entry"""
|
|
268
300
|
return self
|
|
269
301
|
|
|
270
|
-
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
302
|
+
async def __aexit__(self, exc_type: object, exc_val: object, exc_tb: object) -> None:
|
|
271
303
|
"""Async context manager exit"""
|
|
272
304
|
await self.aclose()
|
|
273
305
|
|
|
@@ -302,7 +334,7 @@ class ZenProtocol:
|
|
|
302
334
|
await asyncio.gather(*tasks, return_exceptions=True)
|
|
303
335
|
self._bg_tasks.clear()
|
|
304
336
|
|
|
305
|
-
async def aclose(self):
|
|
337
|
+
async def aclose(self) -> None:
|
|
306
338
|
"""Stop monitoring, cancel background tasks, and close UDP clients."""
|
|
307
339
|
await self.stop_event_monitoring()
|
|
308
340
|
await self._cancel_background_tasks()
|
|
@@ -317,7 +349,7 @@ class ZenProtocol:
|
|
|
317
349
|
pass
|
|
318
350
|
self.clear_entity_cache()
|
|
319
351
|
|
|
320
|
-
def set_controllers(self, controllers: list[
|
|
352
|
+
def set_controllers(self, controllers: list[ZenInterfaceController]) -> None:
|
|
321
353
|
self.controllers = controllers # Used to match events to controllers, and include controller objects in callbacks
|
|
322
354
|
# Drop identified entries that are now registered
|
|
323
355
|
for ctrl in controllers:
|
|
@@ -434,8 +466,6 @@ class ZenProtocol:
|
|
|
434
466
|
) -> (bytes | str | list[int] | int | bool) | None:
|
|
435
467
|
request: Request = Request(command=command, data=[address] + (data or []), request_type=RequestType.BASIC)
|
|
436
468
|
response_data, response_code = await self._send_packet(controller, request, cacheable=cacheable)
|
|
437
|
-
if response_data is None and response_code is None:
|
|
438
|
-
return None
|
|
439
469
|
match response_code:
|
|
440
470
|
case 0xA0: # OK
|
|
441
471
|
match return_type:
|
|
@@ -523,7 +553,7 @@ class ZenProtocol:
|
|
|
523
553
|
pass # Answer is in data bytes
|
|
524
554
|
case 0xA2: # NO_ANSWER
|
|
525
555
|
if response_data is not None and len(response_data) > 0:
|
|
526
|
-
self.logger.error(
|
|
556
|
+
self.logger.error("No answer with code: %r", response_data)
|
|
527
557
|
return None
|
|
528
558
|
case 0xA3: # ERROR
|
|
529
559
|
self._log_response_error(response_data)
|
|
@@ -561,7 +591,7 @@ class ZenProtocol:
|
|
|
561
591
|
if self.print_traffic:
|
|
562
592
|
cached_packet = bytes([cached_response_type & 0xFF, len(cached_data) & 0xFF]) + cached_data
|
|
563
593
|
print(Fore.MAGENTA + f"FOUND: [----, {' '.join(f'0x{b:02X}' for b in cache_key)}, ----] "
|
|
564
|
-
+ Fore.RED + Style.DIM +
|
|
594
|
+
+ Fore.RED + Style.DIM + " CACHE HIT"
|
|
565
595
|
+ Style.BRIGHT + Fore.CYAN + f" [{' '.join(f'0x{b:02X}' for b in cached_packet)}, ----]"
|
|
566
596
|
+ Style.RESET_ALL)
|
|
567
597
|
return cached_data, cached_response_type
|
|
@@ -657,7 +687,7 @@ class ZenProtocol:
|
|
|
657
687
|
profile_change_callback: Callable[..., Awaitable[None]] | None = None,
|
|
658
688
|
system_variable_change_callback: Callable[..., Awaitable[None]] | None = None,
|
|
659
689
|
disconnect_callback: Callable[[], Awaitable[None]] | None = None,
|
|
660
|
-
):
|
|
690
|
+
) -> None:
|
|
661
691
|
self.button_press_callback = button_press_callback
|
|
662
692
|
self.button_hold_callback = button_hold_callback
|
|
663
693
|
self.absolute_input_callback = absolute_input_callback
|
|
@@ -670,7 +700,7 @@ class ZenProtocol:
|
|
|
670
700
|
self.system_variable_change_callback = system_variable_change_callback
|
|
671
701
|
self.disconnect_callback = disconnect_callback
|
|
672
702
|
|
|
673
|
-
async def start_event_monitoring(self):
|
|
703
|
+
async def start_event_monitoring(self) -> None:
|
|
674
704
|
if self.event_task and not self.event_task.done():
|
|
675
705
|
# Event monitoring already running
|
|
676
706
|
return
|
|
@@ -710,7 +740,7 @@ class ZenProtocol:
|
|
|
710
740
|
except Exception as err:
|
|
711
741
|
self.logger.error(f"disconnect_callback error: {err}")
|
|
712
742
|
|
|
713
|
-
async def _async_event_listener(self):
|
|
743
|
+
async def _async_event_listener(self) -> None:
|
|
714
744
|
"""Async event listener using ZenListener"""
|
|
715
745
|
# True unless stop_event_monitoring cancelled us intentionally
|
|
716
746
|
unexpected = True
|
|
@@ -746,7 +776,7 @@ class ZenProtocol:
|
|
|
746
776
|
if unexpected:
|
|
747
777
|
await self.notify_disconnect()
|
|
748
778
|
|
|
749
|
-
def get_controller_by_ip_mac(self, ip: str | None = None, mac: bytes | None = None):
|
|
779
|
+
def get_controller_by_ip_mac(self, ip: str | None = None, mac: bytes | None = None) -> ZenInterfaceController | None:
|
|
750
780
|
"""Find a controller by IP address or MAC address"""
|
|
751
781
|
if ip is not None:
|
|
752
782
|
for ctrl in self.controllers:
|
|
@@ -759,7 +789,7 @@ class ZenProtocol:
|
|
|
759
789
|
return None
|
|
760
790
|
|
|
761
791
|
def _ecd_address_from_target(
|
|
762
|
-
self, controller:
|
|
792
|
+
self, controller: ZenInterfaceController, target: int
|
|
763
793
|
) -> ZenAddress | None:
|
|
764
794
|
"""Map an event target byte to an ECD ZenAddress, or None if out of range."""
|
|
765
795
|
number = target - 64
|
|
@@ -849,7 +879,7 @@ class ZenProtocol:
|
|
|
849
879
|
pass
|
|
850
880
|
temp.client = None
|
|
851
881
|
|
|
852
|
-
async def _process_zen_event(self, event: ZenEvent):
|
|
882
|
+
async def _process_zen_event(self, event: ZenEvent) -> None:
|
|
853
883
|
"""Process received ZenEvent from ZenListener"""
|
|
854
884
|
typecast = "unicast" if self.unicast else "multicast"
|
|
855
885
|
|
|
@@ -860,7 +890,6 @@ class ZenProtocol:
|
|
|
860
890
|
return
|
|
861
891
|
|
|
862
892
|
ip_address = event.ip_address
|
|
863
|
-
ip_port = event.ip_port
|
|
864
893
|
target = event.target
|
|
865
894
|
payload = event.payload
|
|
866
895
|
event_code = event.event_code
|
|
@@ -873,7 +902,7 @@ class ZenProtocol:
|
|
|
873
902
|
min_payload = {
|
|
874
903
|
ZenEventCode.BUTTON_PRESS: 1,
|
|
875
904
|
ZenEventCode.BUTTON_HOLD: 1,
|
|
876
|
-
ZenEventCode.ABSOLUTE_INPUT:
|
|
905
|
+
ZenEventCode.ABSOLUTE_INPUT: 3,
|
|
877
906
|
ZenEventCode.LEVEL_CHANGE: 1,
|
|
878
907
|
ZenEventCode.LEVEL_CHANGE_V2: 2,
|
|
879
908
|
ZenEventCode.GROUP_LEVEL_CHANGE: 1,
|
|
@@ -886,9 +915,6 @@ class ZenProtocol:
|
|
|
886
915
|
if len(payload) < min_payload:
|
|
887
916
|
self.logger.warning(f"Event {event_enum.name} payload too short: {len(payload)} < {min_payload}")
|
|
888
917
|
return
|
|
889
|
-
# Get event name from ZenEventCode, with fallback to unknown, underscores replaced with spaces, and first letter capitalized
|
|
890
|
-
event_name = event_enum.name.replace("_", " ").title()
|
|
891
|
-
|
|
892
918
|
# Print the raw packet
|
|
893
919
|
# if self.print_traffic:
|
|
894
920
|
# print(Fore.MAGENTA + f"{typecast.upper()} {ip_address}:" +
|
|
@@ -921,10 +947,10 @@ class ZenProtocol:
|
|
|
921
947
|
await self.button_hold_callback(instance=instance, payload=payload)
|
|
922
948
|
|
|
923
949
|
case ZenEventCode.ABSOLUTE_INPUT:
|
|
950
|
+
value = (payload[1] << 8) | payload[2]
|
|
924
951
|
if self.print_traffic:
|
|
925
952
|
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)}]" +
|
|
953
|
+
Fore.CYAN + f" Absolute {target-64}.{payload[0]} = {value}" +
|
|
928
954
|
Style.RESET_ALL)
|
|
929
955
|
if self.absolute_input_callback:
|
|
930
956
|
address = self._ecd_address_from_target(controller, target)
|
|
@@ -1050,7 +1076,7 @@ class ZenProtocol:
|
|
|
1050
1076
|
pass
|
|
1051
1077
|
|
|
1052
1078
|
|
|
1053
|
-
async def stop_event_monitoring(self):
|
|
1079
|
+
async def stop_event_monitoring(self) -> None:
|
|
1054
1080
|
"""Stop listening for events"""
|
|
1055
1081
|
if self.event_task:
|
|
1056
1082
|
task = self.event_task
|
|
@@ -1110,8 +1136,9 @@ class ZenProtocol:
|
|
|
1110
1136
|
return None
|
|
1111
1137
|
return ZenEventMode.from_byte(response[0]).enabled
|
|
1112
1138
|
|
|
1113
|
-
async def dali_add_tpi_event_filter(self, address: ZenAddress|ZenInstance, filter: ZenEventMask =
|
|
1139
|
+
async def dali_add_tpi_event_filter(self, address: ZenAddress|ZenInstance, filter: ZenEventMask | None = None) -> bool | None:
|
|
1114
1140
|
"""Stop specific events from an address/instance from being sent. Events in mask will be muted. Returns true if filter was added successfully."""
|
|
1141
|
+
if filter is None: filter = ZenEventMask.all_events()
|
|
1115
1142
|
instance_number = 0xFF
|
|
1116
1143
|
if isinstance(address, ZenInstance):
|
|
1117
1144
|
instance: ZenInstance = address
|
|
@@ -1123,8 +1150,9 @@ class ZenProtocol:
|
|
|
1123
1150
|
[instance_number, filter.upper(), filter.lower()],
|
|
1124
1151
|
return_type='bool')
|
|
1125
1152
|
|
|
1126
|
-
async def dali_clear_tpi_event_filter(self, address: ZenAddress|ZenInstance, unfilter: ZenEventMask =
|
|
1153
|
+
async def dali_clear_tpi_event_filter(self, address: ZenAddress|ZenInstance, unfilter: ZenEventMask | None = None) -> bool | None:
|
|
1127
1154
|
"""Allow specific events from an address/instance to be sent again. Events in mask will be unmuted. Returns true if filter was cleared successfully."""
|
|
1155
|
+
if unfilter is None: unfilter = ZenEventMask.all_events()
|
|
1128
1156
|
instance_number = 0xFF
|
|
1129
1157
|
if isinstance(address, ZenInstance):
|
|
1130
1158
|
instance: ZenInstance = address
|
|
@@ -1181,8 +1209,9 @@ class ZenProtocol:
|
|
|
1181
1209
|
|
|
1182
1210
|
return results
|
|
1183
1211
|
|
|
1184
|
-
async def tpi_event_emit(self, controller: ZenController, mode: ZenEventMode
|
|
1212
|
+
async def tpi_event_emit(self, controller: ZenController, mode: ZenEventMode | None = None) -> bool:
|
|
1185
1213
|
"""Enable or disable TPI Event emission. Returns True if successful, else False."""
|
|
1214
|
+
if mode is None: mode = ZenEventMode(enabled=True, filtering=False, unicast=False, multicast=True)
|
|
1186
1215
|
mask = mode.bitmask()
|
|
1187
1216
|
# response = await self._send_basic(controller, self.CMD["ENABLE_TPI_EVENT_EMIT"], 0x00) # disable first to clear any existing state... I think this is a bug?
|
|
1188
1217
|
response = await self._send_basic(controller, self.CMD["ENABLE_TPI_EVENT_EMIT"], mask)
|
|
@@ -1191,7 +1220,7 @@ class ZenProtocol:
|
|
|
1191
1220
|
return True
|
|
1192
1221
|
return False
|
|
1193
1222
|
|
|
1194
|
-
async def set_tpi_event_unicast_address(self, controller: ZenController, ipaddr: str | None = None, port: int | None = None):
|
|
1223
|
+
async def set_tpi_event_unicast_address(self, controller: ZenController, ipaddr: str | None = None, port: int | None = None) -> bytes | None:
|
|
1195
1224
|
"""Configure TPI Events for Unicast mode with IP and port as defined in the ZenController instance."""
|
|
1196
1225
|
data = [0,0,0,0,0,0]
|
|
1197
1226
|
if port is not None:
|
|
@@ -1211,7 +1240,7 @@ class ZenProtocol:
|
|
|
1211
1240
|
raise ValueError
|
|
1212
1241
|
data[2:6] = ip_bytes
|
|
1213
1242
|
except ValueError:
|
|
1214
|
-
raise ValueError("Invalid IP address format")
|
|
1243
|
+
raise ValueError("Invalid IP address format") from None
|
|
1215
1244
|
|
|
1216
1245
|
return await self._send_dynamic(controller, self.CMD["SET_TPI_EVENT_UNICAST_ADDRESS"], data)
|
|
1217
1246
|
|
|
@@ -1240,7 +1269,7 @@ class ZenProtocol:
|
|
|
1240
1269
|
}
|
|
1241
1270
|
return None
|
|
1242
1271
|
|
|
1243
|
-
async def query_group_numbers(self, controller:
|
|
1272
|
+
async def query_group_numbers(self, controller: ZenInterfaceController) -> list[ZenAddress]:
|
|
1244
1273
|
"""Query a controller for groups."""
|
|
1245
1274
|
groups = await self._send_basic(controller, self.CMD["QUERY_GROUP_NUMBERS"], return_type='list')
|
|
1246
1275
|
zen_groups = []
|
|
@@ -1439,7 +1468,7 @@ class ZenProtocol:
|
|
|
1439
1468
|
return zen_groups
|
|
1440
1469
|
return []
|
|
1441
1470
|
|
|
1442
|
-
async def query_dali_addresses_with_instances(self, controller:
|
|
1471
|
+
async def query_dali_addresses_with_instances(self, controller: ZenInterfaceController, start_address: int=0) -> list[ZenAddress]: # TODO: automate iteration over start_address=0, start_address=60, etc.
|
|
1443
1472
|
"""Query for DALI addresses that have instances associated with them.
|
|
1444
1473
|
|
|
1445
1474
|
Due to payload restrictions, this needs to be called multiple times with different
|
|
@@ -1505,7 +1534,7 @@ class ZenProtocol:
|
|
|
1505
1534
|
return f"{response[0]}.{response[1]}.{response[2]}"
|
|
1506
1535
|
return None
|
|
1507
1536
|
|
|
1508
|
-
async def query_control_gear_dali_addresses(self, controller:
|
|
1537
|
+
async def query_control_gear_dali_addresses(self, controller: ZenInterfaceController) -> list[ZenAddress]:
|
|
1509
1538
|
"""Query which DALI control gear addresses are present in the database. Returns a list of ZenAddress instances."""
|
|
1510
1539
|
response = await self._send_basic(controller, self.CMD["QUERY_CONTROL_GEAR_DALI_ADDRESSES"])
|
|
1511
1540
|
if response and len(response) == 8: # 8 data bytes representing addresses 0-63
|
zencontrol/api/types.py
CHANGED
|
@@ -7,9 +7,9 @@ This module contains types and enums that belong to the API layer:
|
|
|
7
7
|
- Constants used by the API layer
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
|
+
from dataclasses import dataclass
|
|
10
11
|
from enum import Enum
|
|
11
12
|
from typing import Self
|
|
12
|
-
from dataclasses import dataclass
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
class ZenAddressType(Enum):
|
|
@@ -105,7 +105,7 @@ class ZenEventMask:
|
|
|
105
105
|
level_change_v2: bool = False
|
|
106
106
|
|
|
107
107
|
@classmethod
|
|
108
|
-
def all_events(cls):
|
|
108
|
+
def all_events(cls) -> Self:
|
|
109
109
|
# Exclude deprecated level_change / group_level_change — use level_change_v2 (spec V2.001.121+)
|
|
110
110
|
return cls(
|
|
111
111
|
button_press = True,
|
|
@@ -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/exceptions.py
CHANGED
zencontrol/interface/__init__.py
CHANGED
|
@@ -8,13 +8,14 @@ This module contains models that belong to the zen_interface layer:
|
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
10
|
from .interface import (
|
|
11
|
+
ZenAbsoluteInput,
|
|
12
|
+
ZenButton,
|
|
11
13
|
ZenControl,
|
|
12
14
|
ZenController,
|
|
13
|
-
ZenProfile,
|
|
14
|
-
ZenLight,
|
|
15
15
|
ZenGroup,
|
|
16
|
-
|
|
16
|
+
ZenLight,
|
|
17
17
|
ZenMotionSensor,
|
|
18
|
+
ZenProfile,
|
|
18
19
|
ZenSystemVariable,
|
|
19
20
|
)
|
|
20
21
|
|
|
@@ -28,6 +29,7 @@ __all__ = [
|
|
|
28
29
|
"ZenLight",
|
|
29
30
|
"ZenGroup",
|
|
30
31
|
"ZenButton",
|
|
32
|
+
"ZenAbsoluteInput",
|
|
31
33
|
"ZenMotionSensor",
|
|
32
34
|
"ZenSystemVariable",
|
|
33
35
|
]
|