zencontrol-python 0.1.6__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 +34 -14
- zencontrol/api/__init__.py +15 -2
- zencontrol/api/models.py +27 -12
- zencontrol/api/protocol.py +70 -45
- zencontrol/api/types.py +2 -2
- zencontrol/exceptions.py +1 -0
- zencontrol/interface/__init__.py +4 -4
- zencontrol/interface/interface.py +78 -71
- 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.6.dist-info → zencontrol_python-0.1.7.dist-info}/METADATA +7 -4
- zencontrol_python-0.1.7.dist-info/RECORD +36 -0
- zencontrol_python-0.1.6.dist-info/RECORD +0 -36
- {zencontrol_python-0.1.6.dist-info → zencontrol_python-0.1.7.dist-info}/WHEEL +0 -0
- {zencontrol_python-0.1.6.dist-info → zencontrol_python-0.1.7.dist-info}/entry_points.txt +0 -0
- {zencontrol_python-0.1.6.dist-info → zencontrol_python-0.1.7.dist-info}/licenses/LICENSE +0 -0
- {zencontrol_python-0.1.6.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,33 +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
|
-
|
|
43
|
-
ZenAbsoluteInput,
|
|
62
|
+
ZenLight,
|
|
44
63
|
ZenMotionSensor,
|
|
64
|
+
ZenProfile,
|
|
45
65
|
ZenSystemVariable,
|
|
46
66
|
)
|
|
47
67
|
|
|
48
|
-
# API-level models (used by zen_api)
|
|
49
|
-
from .api.models import ZenAddress, ZenInstance, ZenColour, DiscoveredController
|
|
50
|
-
from .api.protocol import ZenProtocol
|
|
51
|
-
|
|
52
68
|
# Low-level models (used by zen_io)
|
|
53
|
-
from .io import
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
69
|
+
from .io import (
|
|
70
|
+
Request,
|
|
71
|
+
RequestType,
|
|
72
|
+
Response,
|
|
73
|
+
ResponseType,
|
|
74
|
+
ZenClient,
|
|
75
|
+
ZenEvent,
|
|
76
|
+
ZenListener,
|
|
77
|
+
)
|
|
58
78
|
|
|
59
79
|
# Utilities
|
|
60
80
|
from .utils import run_with_keyboard_interrupt
|
|
61
81
|
|
|
62
|
-
__version__ = "0.1.
|
|
82
|
+
__version__ = "0.1.7"
|
|
63
83
|
__author__ = "Simon Wright"
|
|
64
84
|
|
|
65
85
|
# Public API - these are the main classes users should import
|
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:
|
|
@@ -218,7 +246,7 @@ class ZenProtocol:
|
|
|
218
246
|
unicast: bool = False,
|
|
219
247
|
listen_ip: str | None = None,
|
|
220
248
|
listen_port: int | None = None,
|
|
221
|
-
cache: dict[bytes, dict[str, Any]] | None = None):
|
|
249
|
+
cache: dict[bytes, dict[str, Any]] | None = None) -> None:
|
|
222
250
|
self.logger = logger or logging.getLogger('null')
|
|
223
251
|
if logger is None:
|
|
224
252
|
self.logger.addHandler(logging.NullHandler())
|
|
@@ -236,7 +264,7 @@ class ZenProtocol:
|
|
|
236
264
|
self.local_ip = self.listen_ip
|
|
237
265
|
# Setup event monitoring using ZenListener
|
|
238
266
|
self.event_listener: ZenListener | None = None
|
|
239
|
-
self.event_task = None
|
|
267
|
+
self.event_task: asyncio.Task[None] | None = None
|
|
240
268
|
# Prevents double on_disconnect if stop races with listener death
|
|
241
269
|
self._disconnect_notified = False
|
|
242
270
|
|
|
@@ -254,7 +282,7 @@ class ZenProtocol:
|
|
|
254
282
|
self.disconnect_callback: Callable[[], Awaitable[None]] | None = None
|
|
255
283
|
|
|
256
284
|
# Controllers will be assigned later
|
|
257
|
-
self.controllers = []
|
|
285
|
+
self.controllers: list[ZenInterfaceController] = []
|
|
258
286
|
# Controllers identified via multicast but not yet registered
|
|
259
287
|
self.identified_controllers: list[DiscoveredController] = []
|
|
260
288
|
self._discovery_pending_macs: set[str] = set()
|
|
@@ -267,11 +295,11 @@ class ZenProtocol:
|
|
|
267
295
|
# Fire-and-forget work (delayed events, refresh timers, motion holds)
|
|
268
296
|
self._bg_tasks: set[asyncio.Task[Any]] = set()
|
|
269
297
|
|
|
270
|
-
async def __aenter__(self):
|
|
298
|
+
async def __aenter__(self) -> Self:
|
|
271
299
|
"""Async context manager entry"""
|
|
272
300
|
return self
|
|
273
301
|
|
|
274
|
-
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:
|
|
275
303
|
"""Async context manager exit"""
|
|
276
304
|
await self.aclose()
|
|
277
305
|
|
|
@@ -306,7 +334,7 @@ class ZenProtocol:
|
|
|
306
334
|
await asyncio.gather(*tasks, return_exceptions=True)
|
|
307
335
|
self._bg_tasks.clear()
|
|
308
336
|
|
|
309
|
-
async def aclose(self):
|
|
337
|
+
async def aclose(self) -> None:
|
|
310
338
|
"""Stop monitoring, cancel background tasks, and close UDP clients."""
|
|
311
339
|
await self.stop_event_monitoring()
|
|
312
340
|
await self._cancel_background_tasks()
|
|
@@ -321,7 +349,7 @@ class ZenProtocol:
|
|
|
321
349
|
pass
|
|
322
350
|
self.clear_entity_cache()
|
|
323
351
|
|
|
324
|
-
def set_controllers(self, controllers: list[
|
|
352
|
+
def set_controllers(self, controllers: list[ZenInterfaceController]) -> None:
|
|
325
353
|
self.controllers = controllers # Used to match events to controllers, and include controller objects in callbacks
|
|
326
354
|
# Drop identified entries that are now registered
|
|
327
355
|
for ctrl in controllers:
|
|
@@ -438,8 +466,6 @@ class ZenProtocol:
|
|
|
438
466
|
) -> (bytes | str | list[int] | int | bool) | None:
|
|
439
467
|
request: Request = Request(command=command, data=[address] + (data or []), request_type=RequestType.BASIC)
|
|
440
468
|
response_data, response_code = await self._send_packet(controller, request, cacheable=cacheable)
|
|
441
|
-
if response_data is None and response_code is None:
|
|
442
|
-
return None
|
|
443
469
|
match response_code:
|
|
444
470
|
case 0xA0: # OK
|
|
445
471
|
match return_type:
|
|
@@ -527,7 +553,7 @@ class ZenProtocol:
|
|
|
527
553
|
pass # Answer is in data bytes
|
|
528
554
|
case 0xA2: # NO_ANSWER
|
|
529
555
|
if response_data is not None and len(response_data) > 0:
|
|
530
|
-
self.logger.error(
|
|
556
|
+
self.logger.error("No answer with code: %r", response_data)
|
|
531
557
|
return None
|
|
532
558
|
case 0xA3: # ERROR
|
|
533
559
|
self._log_response_error(response_data)
|
|
@@ -565,7 +591,7 @@ class ZenProtocol:
|
|
|
565
591
|
if self.print_traffic:
|
|
566
592
|
cached_packet = bytes([cached_response_type & 0xFF, len(cached_data) & 0xFF]) + cached_data
|
|
567
593
|
print(Fore.MAGENTA + f"FOUND: [----, {' '.join(f'0x{b:02X}' for b in cache_key)}, ----] "
|
|
568
|
-
+ Fore.RED + Style.DIM +
|
|
594
|
+
+ Fore.RED + Style.DIM + " CACHE HIT"
|
|
569
595
|
+ Style.BRIGHT + Fore.CYAN + f" [{' '.join(f'0x{b:02X}' for b in cached_packet)}, ----]"
|
|
570
596
|
+ Style.RESET_ALL)
|
|
571
597
|
return cached_data, cached_response_type
|
|
@@ -661,7 +687,7 @@ class ZenProtocol:
|
|
|
661
687
|
profile_change_callback: Callable[..., Awaitable[None]] | None = None,
|
|
662
688
|
system_variable_change_callback: Callable[..., Awaitable[None]] | None = None,
|
|
663
689
|
disconnect_callback: Callable[[], Awaitable[None]] | None = None,
|
|
664
|
-
):
|
|
690
|
+
) -> None:
|
|
665
691
|
self.button_press_callback = button_press_callback
|
|
666
692
|
self.button_hold_callback = button_hold_callback
|
|
667
693
|
self.absolute_input_callback = absolute_input_callback
|
|
@@ -674,7 +700,7 @@ class ZenProtocol:
|
|
|
674
700
|
self.system_variable_change_callback = system_variable_change_callback
|
|
675
701
|
self.disconnect_callback = disconnect_callback
|
|
676
702
|
|
|
677
|
-
async def start_event_monitoring(self):
|
|
703
|
+
async def start_event_monitoring(self) -> None:
|
|
678
704
|
if self.event_task and not self.event_task.done():
|
|
679
705
|
# Event monitoring already running
|
|
680
706
|
return
|
|
@@ -714,7 +740,7 @@ class ZenProtocol:
|
|
|
714
740
|
except Exception as err:
|
|
715
741
|
self.logger.error(f"disconnect_callback error: {err}")
|
|
716
742
|
|
|
717
|
-
async def _async_event_listener(self):
|
|
743
|
+
async def _async_event_listener(self) -> None:
|
|
718
744
|
"""Async event listener using ZenListener"""
|
|
719
745
|
# True unless stop_event_monitoring cancelled us intentionally
|
|
720
746
|
unexpected = True
|
|
@@ -750,7 +776,7 @@ class ZenProtocol:
|
|
|
750
776
|
if unexpected:
|
|
751
777
|
await self.notify_disconnect()
|
|
752
778
|
|
|
753
|
-
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:
|
|
754
780
|
"""Find a controller by IP address or MAC address"""
|
|
755
781
|
if ip is not None:
|
|
756
782
|
for ctrl in self.controllers:
|
|
@@ -763,7 +789,7 @@ class ZenProtocol:
|
|
|
763
789
|
return None
|
|
764
790
|
|
|
765
791
|
def _ecd_address_from_target(
|
|
766
|
-
self, controller:
|
|
792
|
+
self, controller: ZenInterfaceController, target: int
|
|
767
793
|
) -> ZenAddress | None:
|
|
768
794
|
"""Map an event target byte to an ECD ZenAddress, or None if out of range."""
|
|
769
795
|
number = target - 64
|
|
@@ -853,7 +879,7 @@ class ZenProtocol:
|
|
|
853
879
|
pass
|
|
854
880
|
temp.client = None
|
|
855
881
|
|
|
856
|
-
async def _process_zen_event(self, event: ZenEvent):
|
|
882
|
+
async def _process_zen_event(self, event: ZenEvent) -> None:
|
|
857
883
|
"""Process received ZenEvent from ZenListener"""
|
|
858
884
|
typecast = "unicast" if self.unicast else "multicast"
|
|
859
885
|
|
|
@@ -864,7 +890,6 @@ class ZenProtocol:
|
|
|
864
890
|
return
|
|
865
891
|
|
|
866
892
|
ip_address = event.ip_address
|
|
867
|
-
ip_port = event.ip_port
|
|
868
893
|
target = event.target
|
|
869
894
|
payload = event.payload
|
|
870
895
|
event_code = event.event_code
|
|
@@ -890,9 +915,6 @@ class ZenProtocol:
|
|
|
890
915
|
if len(payload) < min_payload:
|
|
891
916
|
self.logger.warning(f"Event {event_enum.name} payload too short: {len(payload)} < {min_payload}")
|
|
892
917
|
return
|
|
893
|
-
# Get event name from ZenEventCode, with fallback to unknown, underscores replaced with spaces, and first letter capitalized
|
|
894
|
-
event_name = event_enum.name.replace("_", " ").title()
|
|
895
|
-
|
|
896
918
|
# Print the raw packet
|
|
897
919
|
# if self.print_traffic:
|
|
898
920
|
# print(Fore.MAGENTA + f"{typecast.upper()} {ip_address}:" +
|
|
@@ -1054,7 +1076,7 @@ class ZenProtocol:
|
|
|
1054
1076
|
pass
|
|
1055
1077
|
|
|
1056
1078
|
|
|
1057
|
-
async def stop_event_monitoring(self):
|
|
1079
|
+
async def stop_event_monitoring(self) -> None:
|
|
1058
1080
|
"""Stop listening for events"""
|
|
1059
1081
|
if self.event_task:
|
|
1060
1082
|
task = self.event_task
|
|
@@ -1114,8 +1136,9 @@ class ZenProtocol:
|
|
|
1114
1136
|
return None
|
|
1115
1137
|
return ZenEventMode.from_byte(response[0]).enabled
|
|
1116
1138
|
|
|
1117
|
-
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:
|
|
1118
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()
|
|
1119
1142
|
instance_number = 0xFF
|
|
1120
1143
|
if isinstance(address, ZenInstance):
|
|
1121
1144
|
instance: ZenInstance = address
|
|
@@ -1127,8 +1150,9 @@ class ZenProtocol:
|
|
|
1127
1150
|
[instance_number, filter.upper(), filter.lower()],
|
|
1128
1151
|
return_type='bool')
|
|
1129
1152
|
|
|
1130
|
-
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:
|
|
1131
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()
|
|
1132
1156
|
instance_number = 0xFF
|
|
1133
1157
|
if isinstance(address, ZenInstance):
|
|
1134
1158
|
instance: ZenInstance = address
|
|
@@ -1185,8 +1209,9 @@ class ZenProtocol:
|
|
|
1185
1209
|
|
|
1186
1210
|
return results
|
|
1187
1211
|
|
|
1188
|
-
async def tpi_event_emit(self, controller: ZenController, mode: ZenEventMode
|
|
1212
|
+
async def tpi_event_emit(self, controller: ZenController, mode: ZenEventMode | None = None) -> bool:
|
|
1189
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)
|
|
1190
1215
|
mask = mode.bitmask()
|
|
1191
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?
|
|
1192
1217
|
response = await self._send_basic(controller, self.CMD["ENABLE_TPI_EVENT_EMIT"], mask)
|
|
@@ -1195,7 +1220,7 @@ class ZenProtocol:
|
|
|
1195
1220
|
return True
|
|
1196
1221
|
return False
|
|
1197
1222
|
|
|
1198
|
-
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:
|
|
1199
1224
|
"""Configure TPI Events for Unicast mode with IP and port as defined in the ZenController instance."""
|
|
1200
1225
|
data = [0,0,0,0,0,0]
|
|
1201
1226
|
if port is not None:
|
|
@@ -1215,7 +1240,7 @@ class ZenProtocol:
|
|
|
1215
1240
|
raise ValueError
|
|
1216
1241
|
data[2:6] = ip_bytes
|
|
1217
1242
|
except ValueError:
|
|
1218
|
-
raise ValueError("Invalid IP address format")
|
|
1243
|
+
raise ValueError("Invalid IP address format") from None
|
|
1219
1244
|
|
|
1220
1245
|
return await self._send_dynamic(controller, self.CMD["SET_TPI_EVENT_UNICAST_ADDRESS"], data)
|
|
1221
1246
|
|
|
@@ -1244,7 +1269,7 @@ class ZenProtocol:
|
|
|
1244
1269
|
}
|
|
1245
1270
|
return None
|
|
1246
1271
|
|
|
1247
|
-
async def query_group_numbers(self, controller:
|
|
1272
|
+
async def query_group_numbers(self, controller: ZenInterfaceController) -> list[ZenAddress]:
|
|
1248
1273
|
"""Query a controller for groups."""
|
|
1249
1274
|
groups = await self._send_basic(controller, self.CMD["QUERY_GROUP_NUMBERS"], return_type='list')
|
|
1250
1275
|
zen_groups = []
|
|
@@ -1443,7 +1468,7 @@ class ZenProtocol:
|
|
|
1443
1468
|
return zen_groups
|
|
1444
1469
|
return []
|
|
1445
1470
|
|
|
1446
|
-
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.
|
|
1447
1472
|
"""Query for DALI addresses that have instances associated with them.
|
|
1448
1473
|
|
|
1449
1474
|
Due to payload restrictions, this needs to be called multiple times with different
|
|
@@ -1509,7 +1534,7 @@ class ZenProtocol:
|
|
|
1509
1534
|
return f"{response[0]}.{response[1]}.{response[2]}"
|
|
1510
1535
|
return None
|
|
1511
1536
|
|
|
1512
|
-
async def query_control_gear_dali_addresses(self, controller:
|
|
1537
|
+
async def query_control_gear_dali_addresses(self, controller: ZenInterfaceController) -> list[ZenAddress]:
|
|
1513
1538
|
"""Query which DALI control gear addresses are present in the database. Returns a list of ZenAddress instances."""
|
|
1514
1539
|
response = await self._send_basic(controller, self.CMD["QUERY_CONTROL_GEAR_DALI_ADDRESSES"])
|
|
1515
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,
|
zencontrol/exceptions.py
CHANGED
zencontrol/interface/__init__.py
CHANGED
|
@@ -8,14 +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
|
-
|
|
17
|
-
ZenAbsoluteInput,
|
|
16
|
+
ZenLight,
|
|
18
17
|
ZenMotionSensor,
|
|
18
|
+
ZenProfile,
|
|
19
19
|
ZenSystemVariable,
|
|
20
20
|
)
|
|
21
21
|
|