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.
- examples/__init__.py +0 -0
- examples/mqtt_bridge.py +1142 -0
- zencontrol/__init__.py +108 -0
- zencontrol/api/__init__.py +30 -0
- zencontrol/api/models.py +273 -0
- zencontrol/api/protocol.py +1700 -0
- zencontrol/api/types.py +203 -0
- zencontrol/exceptions.py +30 -0
- zencontrol/interface/__init__.py +33 -0
- zencontrol/interface/interface.py +1579 -0
- zencontrol/io/__init__.py +24 -0
- zencontrol/io/command.py +387 -0
- zencontrol/io/event.py +316 -0
- zencontrol/py.typed +0 -0
- zencontrol/utils.py +67 -0
- zencontrol_python-0.1.0.dist-info/METADATA +79 -0
- zencontrol_python-0.1.0.dist-info/RECORD +21 -0
- zencontrol_python-0.1.0.dist-info/WHEEL +5 -0
- zencontrol_python-0.1.0.dist-info/entry_points.txt +2 -0
- zencontrol_python-0.1.0.dist-info/licenses/LICENSE +504 -0
- zencontrol_python-0.1.0.dist-info/top_level.txt +2 -0
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,,
|