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.
@@ -9,7 +9,7 @@ if str(ROOT) not in sys.path:
9
9
 
10
10
  import yaml
11
11
 
12
- from zencontrol.api.models import ZenAddress, ZenController
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
- ZenButton,
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 ZenClient, ZenListener, ZenEvent, Request, Response, ResponseType, RequestType
53
-
54
- # Shared types and exceptions
55
- from .api.types import ZenAddressType, ZenInstanceType, ZenColourType, ZenEventCode, ZenEventMask, ZenEventMode
56
- from .exceptions import ZenError, ZenTimeoutError, ZenResponseError, ZenConnectionError, ZenConfigurationError
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.4"
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
 
@@ -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 ZenController, ZenAddress, ZenInstance, ZenColour, ZenProfile, DiscoveredController
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 ZenAddressType, ZenInstanceType, ZenColourType, ZenEventMask, ZenEventMode
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 ZenAddressType, ZenInstanceType, ZenColourType, Const
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
- """Represents a ZenControl controller
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: ZenController
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: ZenController) -> Self:
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, w, a, f]:
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}")
@@ -1,29 +1,57 @@
1
- from collections.abc import Callable, Awaitable, Coroutine
2
1
  import asyncio
3
- import socket
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 Any, Self, Literal, overload
10
- from enum import Enum
11
- from colorama import Fore, Back, Style
12
- from dataclasses import dataclass, field
13
-
14
- from ..io import ZenClient, ZenListener, ZenEvent, Request, Response, ResponseType, RequestType, EventConst, ClientConst
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
- ZenProfile,
28
+ ZenController,
29
+ ZenInstance,
23
30
  )
24
- from .types import ZenAddressType, ZenInstanceType, ZenColourType, ZenEventCode, ZenEventMask, ZenEventMode, ZenErrorCode, Const
25
- from ..exceptions import ZenError, ZenTimeoutError
26
- from ..utils import local_ip_for_remote
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[ZenController]):
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(f"No answer with code: {response_data}")
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 + f" CACHE HIT"
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: ZenController, target: int
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: 1,
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 = ZenEventMask.all_events()) -> bool | None:
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 = ZenEventMask.all_events()) -> bool | None:
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 = ZenEventMode(enabled=True, filtering=False, unicast=False, multicast=True)) -> bool:
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: ZenController) -> list[ZenAddress]:
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: ZenController, start_address: int=0) -> list[ZenAddress]: # TODO: automate iteration over start_address=0, start_address=60, etc.
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: ZenController) -> list[ZenAddress]:
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
@@ -6,6 +6,7 @@ This module defines all custom exceptions used throughout the library.
6
6
 
7
7
  from typing import Any
8
8
 
9
+
9
10
  class ZenError(Exception):
10
11
  """Base exception for Zen protocol errors"""
11
12
  pass
@@ -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
- ZenButton,
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
  ]