zencontrol-python 0.1.0__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/io/event.py ADDED
@@ -0,0 +1,316 @@
1
+ """
2
+ ZenControl wire-level event listener.
3
+
4
+ This module implements the event/listener side of ZenControl TPI Advanced using asyncio.
5
+ It contains the ZenListener class for receiving multicast or unicast event packets.
6
+
7
+ Terms:
8
+ - Event = A multicast or unicast packet sent by a controller
9
+ - Listener = A class which receives Events
10
+
11
+ Example usage:
12
+ async def listen_for_events():
13
+ listener = await ZenListener.create(unicast=False) # Multicast mode
14
+ async with listener:
15
+ async for event in listener.events():
16
+ print(f"Event: {event.event_code}, Target: {event.target}, Payload: {event.payload.hex()}")
17
+
18
+ asyncio.run(listen_for_events())
19
+ """
20
+
21
+ import asyncio
22
+ import socket
23
+ import struct
24
+ import logging
25
+ import time
26
+ from collections.abc import Awaitable, Callable
27
+ from dataclasses import dataclass, field
28
+ from typing import Optional, Tuple, AsyncGenerator
29
+
30
+ # Event classes
31
+ @dataclass
32
+ class ZenEvent:
33
+ """Represents a Zen TPI event"""
34
+ raw_data: bytes
35
+ event_code: int
36
+ target: int
37
+ payload: bytes
38
+ mac_address: bytes
39
+ ip_address: str
40
+ ip_port: int
41
+ timestamp: float = field(default_factory=time.time)
42
+
43
+ # Constants
44
+ class EventConst:
45
+ """Constants for event handling"""
46
+ MULTICAST_GROUP = "239.255.90.67"
47
+ MULTICAST_PORT = 6969
48
+ DEFAULT_MAX_QUEUE_SIZE = 1000
49
+ DROP_LOG_INTERVAL = 5.0 # seconds between queue-full warnings
50
+
51
+ class ZenEventProtocol(asyncio.DatagramProtocol):
52
+ def __init__(
53
+ self,
54
+ event_handler: Callable[[bytes, Tuple[str, int]], Awaitable[None]],
55
+ logger: Optional[logging.Logger] = None,
56
+ ):
57
+ self.event_handler = event_handler
58
+ self.logger = logger or logging.getLogger(__name__)
59
+ self.transport = None
60
+ def connection_made(self, transport):
61
+ self.transport = transport
62
+ def datagram_received(self, data, addr):
63
+ self._run_handler(self.event_handler(data, addr))
64
+
65
+ def _run_handler(self, coro: Awaitable[None]):
66
+ task = asyncio.ensure_future(coro)
67
+ task.add_done_callback(self._handler_done)
68
+
69
+ def _handler_done(self, task: asyncio.Task[None]):
70
+ if task.cancelled():
71
+ return
72
+ exc = task.exception()
73
+ if exc:
74
+ self.logger.error(f"Event handler failed: {exc}", exc_info=exc)
75
+ def error_received(self, exc):
76
+ self.logger.error(f"Event protocol error: {exc}")
77
+ def connection_lost(self, exc):
78
+ if exc:
79
+ self.logger.error(f"Event connection lost: {exc}")
80
+ else:
81
+ self.logger.info("Event connection closed")
82
+
83
+ class ZenListener:
84
+ def __init__(
85
+ self,
86
+ unicast: bool = False,
87
+ listen_ip: str = "0.0.0.0",
88
+ listen_port: int = 0,
89
+ logger: Optional[logging.Logger] = None,
90
+ max_queue_size: int = EventConst.DEFAULT_MAX_QUEUE_SIZE,
91
+ ):
92
+ self.unicast = unicast
93
+ self.listen_ip = listen_ip
94
+ self.listen_port = listen_port
95
+ self.logger = logger or logging.getLogger(__name__)
96
+ self.max_queue_size = max(1, max_queue_size)
97
+
98
+ # Modern asyncio components
99
+ self.transport: Optional[asyncio.DatagramTransport] = None
100
+ self.protocol: Optional[ZenEventProtocol] = None
101
+ self._stop_event = asyncio.Event()
102
+
103
+ # Bounded queue: drop-oldest under backpressure so the UDP path never stalls
104
+ self._event_queue: asyncio.Queue[ZenEvent] = asyncio.Queue(maxsize=self.max_queue_size)
105
+ self.dropped_events = 0
106
+ self._last_drop_log = 0.0
107
+
108
+ @classmethod
109
+ async def create(
110
+ cls,
111
+ unicast: bool = False,
112
+ listen_ip: str = "0.0.0.0",
113
+ listen_port: int = 0,
114
+ logger: Optional[logging.Logger] = None,
115
+ max_queue_size: int = EventConst.DEFAULT_MAX_QUEUE_SIZE,
116
+ ) -> "ZenListener":
117
+ """Create and start a ZenListener instance"""
118
+ self = cls(unicast, listen_ip, listen_port, logger, max_queue_size=max_queue_size)
119
+ await self._create_datagram_endpoint()
120
+ self.logger.info(f"Started event listener in {'unicast' if self.unicast else 'multicast'} mode")
121
+ return self
122
+
123
+ async def start_listening(self):
124
+ if self.transport and not self.transport.is_closing():
125
+ self.logger.warning("Event listener already running")
126
+ return
127
+
128
+ self._stop_event.clear()
129
+ await self._create_datagram_endpoint()
130
+ self.logger.info(f"Started event listener in {'unicast' if self.unicast else 'multicast'} mode")
131
+
132
+ async def stop_listening(self):
133
+ if self.transport and not self.transport.is_closing():
134
+ self._stop_event.set()
135
+ self.transport.close()
136
+ self.transport = None
137
+ self.protocol = None
138
+
139
+ # Clear any remaining events in queue
140
+ while not self._event_queue.empty():
141
+ try:
142
+ self._event_queue.get_nowait()
143
+ self._event_queue.task_done()
144
+ except asyncio.QueueEmpty:
145
+ break
146
+
147
+ self.logger.info("Stopped event listener")
148
+
149
+ async def close(self):
150
+ """Close the listener (alias for stop_listening for async context manager compatibility)"""
151
+ await self.stop_listening()
152
+
153
+ def is_listening(self) -> bool:
154
+ """Check if the listener is active and ready"""
155
+ return self.transport is not None and not self.transport.is_closing()
156
+
157
+ async def _create_datagram_endpoint(self):
158
+ loop = asyncio.get_running_loop()
159
+
160
+ if self.unicast:
161
+ # Unicast mode
162
+ self.transport, _ = await loop.create_datagram_endpoint(
163
+ lambda: ZenEventProtocol(self._receive_event, self.logger),
164
+ local_addr=(self.listen_ip, self.listen_port),
165
+ reuse_port=True
166
+ )
167
+ sockname = self.transport.get_extra_info('sockname')
168
+ if sockname:
169
+ self.listen_port = sockname[1]
170
+ self.logger.info(f"Listening for unicast events on {self.listen_ip}:{self.listen_port}")
171
+ else:
172
+ # Multicast mode
173
+ try:
174
+ self.transport, _ = await loop.create_datagram_endpoint(
175
+ lambda: ZenEventProtocol(self._receive_event, self.logger),
176
+ local_addr=('0.0.0.0', EventConst.MULTICAST_PORT),
177
+ reuse_port=True
178
+ )
179
+ self.logger.info(f"Listening for multicast events on {EventConst.MULTICAST_GROUP}:{EventConst.MULTICAST_PORT}")
180
+
181
+ # Join the multicast group
182
+ sock = self.transport.get_extra_info('socket')
183
+ if sock:
184
+ # Set socket options for multicast
185
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
186
+ group = socket.inet_aton(EventConst.MULTICAST_GROUP)
187
+ mreq = struct.pack('=4sI', group, socket.INADDR_ANY)
188
+ sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
189
+ except Exception as e:
190
+ self.logger.critical(f"Failed to create multicast endpoint: {e}")
191
+ raise
192
+
193
+ async def _receive_event(self, data: bytes, addr: Tuple[str, int]):
194
+ """Process received event data"""
195
+ typecast = "unicast" if self.unicast else "multicast"
196
+
197
+ # Drop packet if it doesn't match the expected structure
198
+ if len(data) < 13 or data[0:2] != bytes([0x5a, 0x43]):
199
+ self.logger.debug(f"Received {typecast} invalid packet: {addr} - {', '.join(f'0x{b:02x}' for b in data)}")
200
+ return
201
+
202
+ # Extract values
203
+ mac_address = data[2:8]
204
+ target = int.from_bytes(data[8:10], byteorder='big')
205
+ event_code = data[10]
206
+ payload_len = data[11]
207
+ payload = data[12:-1]
208
+ received_checksum = data[-1]
209
+
210
+ # Verify checksum
211
+ calculated_checksum = self._checksum(data[:-1])
212
+ if received_checksum != calculated_checksum:
213
+ self.logger.error(f"{typecast.capitalize()} packet has invalid checksum: {calculated_checksum} != {received_checksum}")
214
+ return
215
+
216
+ # Verify data length
217
+ if len(payload) != payload_len:
218
+ self.logger.error(f"{typecast.capitalize()} packet has invalid payload length: {len(payload)} != {payload_len}")
219
+ return
220
+
221
+ # Create event object and put in queue
222
+ event = ZenEvent(
223
+ raw_data=data,
224
+ mac_address=mac_address,
225
+ target=target,
226
+ event_code=event_code,
227
+ payload=payload,
228
+ ip_address=addr[0],
229
+ ip_port=addr[1],
230
+ timestamp=time.time()
231
+ )
232
+
233
+ if event_code != 7 and event_code != 8: # 7=system varaiable, 8=colour changed
234
+ self.logger.debug(f"Received {typecast} from {addr[0]}:{addr[1]}: target {target} event {event_code} payload [{', '.join(f'0x{b:02x}' for b in payload)}]")
235
+
236
+ self._enqueue_event(event)
237
+
238
+ def _enqueue_event(self, event: ZenEvent) -> None:
239
+ """Enqueue event; if full, drop the oldest and keep the newest."""
240
+ try:
241
+ self._event_queue.put_nowait(event)
242
+ return
243
+ except asyncio.QueueFull:
244
+ pass
245
+
246
+ try:
247
+ self._event_queue.get_nowait()
248
+ self._event_queue.task_done()
249
+ except asyncio.QueueEmpty:
250
+ pass
251
+
252
+ try:
253
+ self._event_queue.put_nowait(event)
254
+ except asyncio.QueueFull:
255
+ pass
256
+
257
+ self.dropped_events += 1
258
+ now = time.time()
259
+ if now - self._last_drop_log >= EventConst.DROP_LOG_INTERVAL:
260
+ self._last_drop_log = now
261
+ self.logger.warning(
262
+ "Event queue full (max=%d); dropped %d event(s) total",
263
+ self.max_queue_size,
264
+ self.dropped_events,
265
+ )
266
+
267
+ async def events(self, timeout: Optional[float] = None) -> AsyncGenerator[ZenEvent, None]:
268
+ """Async generator yielding events as they arrive"""
269
+ while not self._stop_event.is_set():
270
+ try:
271
+ event = await asyncio.wait_for(
272
+ self._event_queue.get(),
273
+ timeout=timeout
274
+ )
275
+ yield event
276
+ self._event_queue.task_done()
277
+ except asyncio.TimeoutError:
278
+ if timeout is not None:
279
+ break
280
+ continue
281
+
282
+ async def get_event(self, timeout: Optional[float] = None) -> Optional[ZenEvent]:
283
+ """Get next event from queue"""
284
+ try:
285
+ event = await asyncio.wait_for(self._event_queue.get(), timeout=timeout)
286
+ self._event_queue.task_done()
287
+ return event
288
+ except asyncio.TimeoutError:
289
+ return None
290
+
291
+ async def get_events(self, count: int, timeout: Optional[float] = None) -> list[ZenEvent]:
292
+ """Get multiple events from queue"""
293
+ events = []
294
+ for _ in range(count):
295
+ event = await self.get_event(timeout)
296
+ if event is None:
297
+ break
298
+ events.append(event)
299
+ return events
300
+
301
+ async def __aenter__(self):
302
+ """Async context manager entry"""
303
+ # If not already started, start listening
304
+ if not self.transport or self.transport.is_closing():
305
+ await self.start_listening()
306
+ return self
307
+
308
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
309
+ """Async context manager exit"""
310
+ await self.stop_listening()
311
+
312
+ def _checksum(self, buf: bytes) -> int:
313
+ acc = 0
314
+ for b in buf:
315
+ acc ^= b
316
+ return acc & 0xFF
zencontrol/py.typed ADDED
File without changes
zencontrol/utils.py ADDED
@@ -0,0 +1,67 @@
1
+ """
2
+ Utility functions for the ZenControl library
3
+ """
4
+ import asyncio
5
+ import signal
6
+ import socket
7
+ import sys
8
+ from typing import Callable, Any
9
+
10
+
11
+ def local_ip_for_remote(remote_host: str) -> str:
12
+ """Return the local IPv4 address used to reach ``remote_host``.
13
+
14
+ Uses a UDP ``connect`` (no packets sent) so the OS picks the correct
15
+ interface on multi-homed hosts, Docker, and macOS — unlike
16
+ ``socket.gethostname()``.
17
+ """
18
+ try:
19
+ remote_ip = socket.gethostbyname(remote_host)
20
+ except OSError:
21
+ remote_ip = remote_host
22
+
23
+ try:
24
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
25
+ sock.connect((remote_ip, 1))
26
+ return sock.getsockname()[0]
27
+ except OSError:
28
+ try:
29
+ return socket.gethostbyname(socket.gethostname())
30
+ except OSError:
31
+ return "127.0.0.1"
32
+
33
+
34
+ def run_with_keyboard_interrupt(main_func: Callable[[], Any]) -> None:
35
+ """
36
+ Run an async main function with graceful KeyboardInterrupt handling.
37
+
38
+ This function wraps asyncio.run() to catch KeyboardInterrupt (Ctrl+C) and
39
+ provide a clean shutdown experience.
40
+
41
+ Args:
42
+ main_func: The async main function to run
43
+ """
44
+ try:
45
+ asyncio.run(main_func())
46
+ except KeyboardInterrupt:
47
+ print("\nšŸ›‘ Interrupted by user (Ctrl+C)")
48
+ print("Shutting down gracefully...")
49
+ sys.exit(0)
50
+ except Exception as e:
51
+ print(f"āŒ Error: {e}")
52
+ sys.exit(1)
53
+
54
+
55
+ def setup_signal_handlers() -> None:
56
+ """
57
+ Set up signal handlers for graceful shutdown.
58
+
59
+ This function sets up handlers for SIGINT (Ctrl+C) and SIGTERM to ensure
60
+ clean shutdown of async operations.
61
+ """
62
+ def signal_handler(signum, frame):
63
+ print(f"\nšŸ›‘ Received signal {signum}, shutting down gracefully...")
64
+ sys.exit(0)
65
+
66
+ signal.signal(signal.SIGINT, signal_handler)
67
+ signal.signal(signal.SIGTERM, signal_handler)
@@ -0,0 +1,79 @@
1
+ Metadata-Version: 2.4
2
+ Name: zencontrol-python
3
+ Version: 0.1.0
4
+ Summary: Python implementation of the Zencontrol TPI Advanced protocol for DALI lighting controllers.
5
+ Author: Simon Wright
6
+ License-Expression: LGPL-2.1-only
7
+ Project-URL: Homepage, https://github.com/sjwright/zencontrol-python
8
+ Project-URL: Repository, https://github.com/sjwright/zencontrol-python
9
+ Project-URL: Issues, https://github.com/sjwright/zencontrol-python/issues
10
+ Keywords: dali,lighting,control,zencontrol,home-automation
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Home Automation
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.11
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Requires-Dist: colorama>=0.4.6
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest>=6.0; extra == "dev"
27
+ Requires-Dist: pytest-asyncio>=0.18.0; extra == "dev"
28
+ Requires-Dist: black>=22.0; extra == "dev"
29
+ Requires-Dist: flake8>=4.0; extra == "dev"
30
+ Requires-Dist: mypy>=0.950; extra == "dev"
31
+ Requires-Dist: build>=1.0; extra == "dev"
32
+ Provides-Extra: mqtt
33
+ Requires-Dist: aiomqtt>=2.1.0; extra == "mqtt"
34
+ Requires-Dist: PyYAML>=6.0; extra == "mqtt"
35
+ Dynamic: license-file
36
+
37
+ # zencontrol-python
38
+
39
+ This is an implementation of the **Zencontrol TPI Advanced** protocol, written in Python. This library has been written with three levels of abstraction:
40
+
41
+ - zencontrol.io: Implementation of the raw TPI Advanced UDP packet specification;
42
+ - zencontrol.api: Implementation of most TPI Advanced API commands and events;
43
+ - zencontrol.interface: An opinionated abstraction layer suitable for integration into smart building control software. It provides methods, objects, and callbacks for managing lights, groups, profiles, buttons, motion sensors, and system variables. This code is still undergoing significant refinement.
44
+
45
+ Built on top of this is an example MQTT bridge for Home Assistant. See [examples/mqtt_bridge.md](examples/mqtt_bridge.md).
46
+
47
+ ## Requirements
48
+
49
+ * Python 3.11 (or later)
50
+ * Controller firmware 2.2.11 (or later)
51
+
52
+ ## Install
53
+
54
+ Refer to zencontrol-tpi project for now.
55
+ For integrators, the library is also published on PyPI.
56
+
57
+ ## Limitations
58
+
59
+ Implemented but untested:
60
+
61
+ * Dealing with multiple controllers (I only have one controller)
62
+ * RGB+ and XY colour commands (I don't have any compatible lights)
63
+ * Numerical (absolute) instances (I don't have any such ECDs)
64
+ * Event filtering (I haven't tested it)
65
+
66
+ Not implemented:
67
+
68
+ * 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)
69
+ * Any commands described in the documentation as "legacy" (they aren't useful)
70
+ ## TPI Advanced wishlist
71
+
72
+ * 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.)_
73
+ * Command to list active system variables _(As a workaround, you can query every number for its label. This assumes no system variables of interest are unlabelled.)_
74
+ * Command to read an ambient light sensor's lux value. _(As a workaround, you can target a light sensor to a system variable. Not elegant but it works.)_
75
+ * Event notification for ambient light sensor lux values. _(Same workaround as above.)_
76
+
77
+ ## Links
78
+
79
+ * [About TPI Advanced](https://support.zencontrol.com/hc/en-us/articles/360000337175-What-is-the-Third-Party-Interface-TPI)
@@ -0,0 +1,21 @@
1
+ examples/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ examples/mqtt_bridge.py,sha256=IXgZJ6yNDKUHRL_u9PcFCV2wiibiY9RIBYzpid45OVs,50668
3
+ zencontrol/__init__.py,sha256=51vS8pYDLePpZsXte9SlrKG1xm_PUFP-c0mJagFYLyA,2790
4
+ zencontrol/exceptions.py,sha256=PKlMHWfivA3QEZOEqfm5lmt3VdNV2LtIA_Ognm4w1O8,578
5
+ zencontrol/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ zencontrol/utils.py,sha256=jxi6sV4sugTjKMKRS_WKxNUMpK56TOXaoGhKv5pbeh0,1955
7
+ zencontrol/api/__init__.py,sha256=ORN0VQ0ahYBSo4HsMRNTcFlu8-RMxibUh9Ta2jEeE8U,825
8
+ zencontrol/api/models.py,sha256=fFZU6QoSd168ddhX_ofJyKGeDBYs4Na4ixzfQoixyQw,11218
9
+ zencontrol/api/protocol.py,sha256=cnww4iyZbi0UJ93SJugyoQUj05R34SZ-MnnR0hYAfTE,94904
10
+ zencontrol/api/types.py,sha256=FtD74rwTxhOpqHl2p6DaZdgIvdwrAQrc3A4x0OMGg94,6665
11
+ zencontrol/interface/__init__.py,sha256=gw_-LSlEwUL0vrOYTxTBwPHZmuNsNqXRqVFbz1ZBQuA,662
12
+ zencontrol/interface/interface.py,sha256=iwP40cOwGblj2uwvJbtlwuYYYh_52wrztQnRXLe85Bo,73086
13
+ zencontrol/io/__init__.py,sha256=cBfOKr0BqOI8E2LicAwcDzLhdlz7KHHrtcn-iUzRmcU,570
14
+ zencontrol/io/command.py,sha256=Zknc0xv71ezniC-RmCsdCGcuya2u0aWVCObtEsfKJQE,14827
15
+ zencontrol/io/event.py,sha256=CT9pP6vHRnMFvJuCy8T9cwXkz5AsuHSbHzdmEvRegQE,11692
16
+ zencontrol_python-0.1.0.dist-info/licenses/LICENSE,sha256=NLFVE3E6u6qdN0zN9mzIRM69zZaCv78QgUOh5Mp4ckk,26508
17
+ zencontrol_python-0.1.0.dist-info/METADATA,sha256=50dOXDVxcrRLSbNkXM7XQ_-LF7N1LpsWO8xBHVntHyc,3773
18
+ zencontrol_python-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
19
+ zencontrol_python-0.1.0.dist-info/entry_points.txt,sha256=bowyQt57_veMMZzF7LvDwDajG2HcY4-99qYHJ_hasw4,62
20
+ zencontrol_python-0.1.0.dist-info/top_level.txt,sha256=cKZS8nB2cZlXavIXB_0tzE16PfGGw6WZDqNzemZKZ4w,20
21
+ zencontrol_python-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ zencontrol-mqtt = examples.mqtt_bridge:main