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
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Wire-level protocol implementation.
|
|
3
|
+
|
|
4
|
+
This module contains the lowest-level communication components:
|
|
5
|
+
- ZenClient, ZenListener - Raw UDP communication
|
|
6
|
+
- ZenEvent - Raw event data from wire
|
|
7
|
+
- Message framing and parsing
|
|
8
|
+
- Connection management
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from .command import ZenClient, Request, Response, ResponseType, RequestType, ClientConst
|
|
12
|
+
from .event import ZenListener, ZenEvent, EventConst
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"ZenClient",
|
|
16
|
+
"ZenListener",
|
|
17
|
+
"ZenEvent",
|
|
18
|
+
"Request",
|
|
19
|
+
"Response",
|
|
20
|
+
"ResponseType",
|
|
21
|
+
"RequestType",
|
|
22
|
+
"EventConst",
|
|
23
|
+
"ClientConst",
|
|
24
|
+
]
|
zencontrol/io/command.py
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ZenControl wire-level command client.
|
|
3
|
+
|
|
4
|
+
This module implements the command/request side of ZenControl TPI Advanced using asyncio.
|
|
5
|
+
It contains the ZenClient class for sending requests and receiving responses.
|
|
6
|
+
|
|
7
|
+
Terms:
|
|
8
|
+
- Request = A UDP packet sent by the Client to the controller
|
|
9
|
+
- Response = A response to a Request
|
|
10
|
+
- Client = A class which sends Requests and receives Responses
|
|
11
|
+
|
|
12
|
+
Example usage:
|
|
13
|
+
async def main():
|
|
14
|
+
client = await ZenClient.create(("192.0.2.10", 9000))
|
|
15
|
+
async with client:
|
|
16
|
+
req = Request(command=0x10, data=[0x01, 0xAA, 0x00, 0x00])
|
|
17
|
+
resp = await client.send_request(req)
|
|
18
|
+
if resp.resp_type == ResponseType.ANSWER:
|
|
19
|
+
print("Answer:", resp.data)
|
|
20
|
+
elif resp.resp_type == ResponseType.TIMEOUT:
|
|
21
|
+
print("Timed out after retries")
|
|
22
|
+
else:
|
|
23
|
+
print("Resp:", resp.resp_type.name, resp.data)
|
|
24
|
+
|
|
25
|
+
asyncio.run(main())
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
import asyncio
|
|
29
|
+
import logging
|
|
30
|
+
import time
|
|
31
|
+
from collections.abc import Awaitable, Callable
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
from enum import IntEnum
|
|
34
|
+
from typing import Dict, Optional, Self, Tuple
|
|
35
|
+
|
|
36
|
+
# Constants
|
|
37
|
+
class ClientConst:
|
|
38
|
+
"""Constants for the ZenClient"""
|
|
39
|
+
MAGIC = 0x04
|
|
40
|
+
DEFAULT_TIMEOUT = 1.5
|
|
41
|
+
MIN_TIMEOUT = 0.01
|
|
42
|
+
MAX_TIMEOUT = 10.0
|
|
43
|
+
# UDP datagram retries (lost packets / brief network blips); separate from QUEUE_FAILURE
|
|
44
|
+
DEFAULT_RETRIES = 1
|
|
45
|
+
# TPI ERROR payload: controller DALI command queue briefly full
|
|
46
|
+
QUEUE_FAILURE = 0xB3
|
|
47
|
+
QUEUE_FAILURE_RETRIES = 3
|
|
48
|
+
QUEUE_FAILURE_BASE_DELAY = 0.05 # doubles each attempt: 50/100/200ms
|
|
49
|
+
|
|
50
|
+
class RequestType(IntEnum):
|
|
51
|
+
"""Types of requests that can be sent"""
|
|
52
|
+
BASIC = 0x01
|
|
53
|
+
DYNAMIC = 0x02
|
|
54
|
+
DALI_COLOUR = 0x03
|
|
55
|
+
COMMAND = 0x04
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class Request:
|
|
59
|
+
"""Represents a request to be sent to the controller"""
|
|
60
|
+
command: int
|
|
61
|
+
data: bytes | list[int]
|
|
62
|
+
request_type: RequestType = RequestType.BASIC
|
|
63
|
+
seq: Optional[int] = None
|
|
64
|
+
raw_sent: Optional[bytes] = None
|
|
65
|
+
timestamp: float = field(default_factory=time.time)
|
|
66
|
+
|
|
67
|
+
def __post_init__(self):
|
|
68
|
+
self.timestamp = time.time()
|
|
69
|
+
# If data is a list, convert it to a bytes object
|
|
70
|
+
if isinstance(self.data, list):
|
|
71
|
+
self.data = bytes([d & 0xFF for d in self.data])
|
|
72
|
+
# Length of data
|
|
73
|
+
n = len(self.data)
|
|
74
|
+
# Validate request type
|
|
75
|
+
match self.request_type:
|
|
76
|
+
case RequestType.BASIC:
|
|
77
|
+
# Pad data to 4 bytes if it's less than 4 bytes
|
|
78
|
+
self.data = self.data + bytes([0x00] * (4 - n)) if n < 4 else self.data
|
|
79
|
+
if len(self.data) != 4:
|
|
80
|
+
raise ValueError("Request.data must be exactly 4 bytes when request type is BASIC")
|
|
81
|
+
case RequestType.DALI_COLOUR:
|
|
82
|
+
if n > 9:
|
|
83
|
+
raise ValueError("Request.data must be at most 9 bytes when request type is DALI_COLOUR")
|
|
84
|
+
case RequestType.DYNAMIC:
|
|
85
|
+
# Prepend data length to data
|
|
86
|
+
self.data = bytes([n]) + self.data
|
|
87
|
+
case RequestType.COMMAND:
|
|
88
|
+
# No padding for command type
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
def to_bytes(self, checksum: Callable[[bytes], int]) -> bytes:
|
|
92
|
+
"""Convert request to wire format"""
|
|
93
|
+
if self.seq is None:
|
|
94
|
+
raise ValueError("Request.seq must be set before calling to_bytes")
|
|
95
|
+
data = self.data if isinstance(self.data, bytes) else bytes([d & 0xFF for d in self.data])
|
|
96
|
+
req = bytes([ClientConst.MAGIC, self.seq & 0xFF, self.command & 0xFF]) + data
|
|
97
|
+
cs = bytes([checksum(req) & 0xFF])
|
|
98
|
+
self.raw_sent = req + cs
|
|
99
|
+
return req + cs
|
|
100
|
+
|
|
101
|
+
class ResponseType(IntEnum):
|
|
102
|
+
"""Types of responses from the controller"""
|
|
103
|
+
OK = 0xA0
|
|
104
|
+
ANSWER = 0xA1
|
|
105
|
+
NO_ANSWER = 0xA2
|
|
106
|
+
ERROR = 0xA3
|
|
107
|
+
TIMEOUT = 0xAE
|
|
108
|
+
INVALID = 0xAF
|
|
109
|
+
|
|
110
|
+
@dataclass()
|
|
111
|
+
class Response:
|
|
112
|
+
response_type: ResponseType
|
|
113
|
+
seq: Optional[int] = None
|
|
114
|
+
data: Optional[bytes] = None # empty for TIMEOUT and INVALID
|
|
115
|
+
raw_rcvd: Optional[bytes] = None
|
|
116
|
+
request: Optional[Request] = None
|
|
117
|
+
addr: Optional[Tuple[str, int]] = None
|
|
118
|
+
timestamp: float = field(default_factory=time.time)
|
|
119
|
+
|
|
120
|
+
# Protocol classes
|
|
121
|
+
class ZenRequestProtocol(asyncio.DatagramProtocol):
|
|
122
|
+
def __init__(
|
|
123
|
+
self,
|
|
124
|
+
response_handler: Callable[[bytes, Tuple[str, int]], Awaitable[None]],
|
|
125
|
+
logger: Optional[logging.Logger] = None,
|
|
126
|
+
on_transport_lost: Optional[Callable[[Optional[Exception]], None]] = None,
|
|
127
|
+
):
|
|
128
|
+
self.response_handler = response_handler
|
|
129
|
+
self.logger = logger or logging.getLogger(__name__)
|
|
130
|
+
self.on_transport_lost = on_transport_lost
|
|
131
|
+
self.transport: Optional[asyncio.transports.DatagramTransport] = None
|
|
132
|
+
|
|
133
|
+
def connection_made(self, transport):
|
|
134
|
+
self.transport = transport
|
|
135
|
+
|
|
136
|
+
def datagram_received(self, data, addr):
|
|
137
|
+
self._run_handler(self.response_handler(data, addr))
|
|
138
|
+
|
|
139
|
+
def _run_handler(self, coro: Awaitable[None]):
|
|
140
|
+
task = asyncio.ensure_future(coro)
|
|
141
|
+
task.add_done_callback(self._handler_done)
|
|
142
|
+
|
|
143
|
+
def _handler_done(self, task: asyncio.Task[None]):
|
|
144
|
+
if task.cancelled():
|
|
145
|
+
return
|
|
146
|
+
exc = task.exception()
|
|
147
|
+
if exc:
|
|
148
|
+
self.logger.error(f"Response handler failed: {exc}", exc_info=exc)
|
|
149
|
+
|
|
150
|
+
def error_received(self, exc):
|
|
151
|
+
self.logger.error(f"Request protocol error: {exc}")
|
|
152
|
+
if self.on_transport_lost:
|
|
153
|
+
self.on_transport_lost(exc)
|
|
154
|
+
|
|
155
|
+
def connection_lost(self, exc):
|
|
156
|
+
if exc:
|
|
157
|
+
self.logger.error(f"Request connection lost: {exc}")
|
|
158
|
+
else:
|
|
159
|
+
self.logger.info("Request connection closed")
|
|
160
|
+
if self.on_transport_lost:
|
|
161
|
+
self.on_transport_lost(exc)
|
|
162
|
+
|
|
163
|
+
class ZenClient:
|
|
164
|
+
"""
|
|
165
|
+
Request: [0x04, seq, command, address, data(3|7), checksum]
|
|
166
|
+
Response: [response_type, seq, data_len, data..., checksum]
|
|
167
|
+
- checksum = XOR of all preceding bytes
|
|
168
|
+
- seq is 1 byte (0..255), auto-incremented & reused for retries
|
|
169
|
+
- On any non-catastrophic parse problem, deliver ResponseType.INVALID instead of raising.
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
def __init__(self, server: Tuple[str, int], logger: Optional[logging.Logger] = None):
|
|
173
|
+
self.server = server
|
|
174
|
+
self.logger = logger or logging.getLogger(__name__)
|
|
175
|
+
self._transport: Optional[asyncio.transports.DatagramTransport] = None
|
|
176
|
+
self._protocol: Optional[ZenRequestProtocol] = None
|
|
177
|
+
self._pending: Dict[int, Tuple[asyncio.Future[Response], Request]] = {}
|
|
178
|
+
self._next_seq: int = 0
|
|
179
|
+
self._closed = False
|
|
180
|
+
self._stop_event = asyncio.Event()
|
|
181
|
+
self._lock = asyncio.Lock()
|
|
182
|
+
|
|
183
|
+
@classmethod
|
|
184
|
+
async def create(cls, server: Tuple[str, int], logger: Optional[logging.Logger] = None) -> Self:
|
|
185
|
+
self = cls(server, logger)
|
|
186
|
+
loop = asyncio.get_running_loop()
|
|
187
|
+
transport, protocol = await loop.create_datagram_endpoint(
|
|
188
|
+
lambda: ZenRequestProtocol(
|
|
189
|
+
self._receive_response,
|
|
190
|
+
self.logger,
|
|
191
|
+
on_transport_lost=self._mark_disconnected,
|
|
192
|
+
),
|
|
193
|
+
remote_addr=server, # Use connected UDP to maintain connection
|
|
194
|
+
)
|
|
195
|
+
self._transport = transport
|
|
196
|
+
self._protocol = protocol
|
|
197
|
+
self.logger.info(f"Connected to Zen server at {server[0]}:{server[1]}")
|
|
198
|
+
return self
|
|
199
|
+
|
|
200
|
+
def _mark_disconnected(self, exc: Optional[Exception] = None) -> None:
|
|
201
|
+
"""Mark the client dead after transport loss (must not await or take _lock)."""
|
|
202
|
+
if self._closed:
|
|
203
|
+
return
|
|
204
|
+
self._closed = True
|
|
205
|
+
transport = self._transport
|
|
206
|
+
self._transport = None
|
|
207
|
+
# Unblock waiters with TIMEOUT so callers use the normal recovery path
|
|
208
|
+
for fut, req in list(self._pending.values()):
|
|
209
|
+
if not fut.done():
|
|
210
|
+
fut.set_result(Response(ResponseType.TIMEOUT, request=req))
|
|
211
|
+
self._pending.clear()
|
|
212
|
+
if transport is not None and not transport.is_closing():
|
|
213
|
+
transport.close()
|
|
214
|
+
if exc:
|
|
215
|
+
self.logger.debug("ZenClient marked disconnected: %s", exc)
|
|
216
|
+
|
|
217
|
+
async def send_request(
|
|
218
|
+
self,
|
|
219
|
+
req: Request,
|
|
220
|
+
*,
|
|
221
|
+
timeout: Optional[float] = None,
|
|
222
|
+
retries: int = ClientConst.DEFAULT_RETRIES,
|
|
223
|
+
) -> Response:
|
|
224
|
+
if self._closed: raise RuntimeError("Client is closed")
|
|
225
|
+
if self._transport is None: raise RuntimeError("Transport is none?!")
|
|
226
|
+
|
|
227
|
+
if timeout is None: timeout = ClientConst.DEFAULT_TIMEOUT
|
|
228
|
+
timeout = max(ClientConst.MIN_TIMEOUT, min(timeout, ClientConst.MAX_TIMEOUT))
|
|
229
|
+
if retries < 0: retries = 0
|
|
230
|
+
|
|
231
|
+
async with self._lock:
|
|
232
|
+
# Create a future to await the response
|
|
233
|
+
loop = asyncio.get_running_loop()
|
|
234
|
+
fut: asyncio.Future[Response] = loop.create_future()
|
|
235
|
+
|
|
236
|
+
# Allocate a sequence number
|
|
237
|
+
req.seq = self._alloc_seq()
|
|
238
|
+
|
|
239
|
+
# Add the future and request to the pending requests
|
|
240
|
+
if req.seq in self._pending: raise RuntimeError(f"sequence {req.seq} already pending, which shouldn't be possible because we just allocated it")
|
|
241
|
+
self._pending[req.seq] = (fut, req)
|
|
242
|
+
|
|
243
|
+
# Send the request and wait for the future to complete
|
|
244
|
+
try:
|
|
245
|
+
wire = req.to_bytes(checksum=self._checksum)
|
|
246
|
+
for i in range(retries + 1):
|
|
247
|
+
if self._closed or self._transport is None:
|
|
248
|
+
return Response(ResponseType.TIMEOUT, request=req)
|
|
249
|
+
try:
|
|
250
|
+
req.timestamp = time.time() # Update timestamp when sending the request
|
|
251
|
+
self._transport.sendto(wire) # Connected socket doesn't need address
|
|
252
|
+
except Exception as e:
|
|
253
|
+
self.logger.debug(f"Send failed (attempt {i + 1}): {e}")
|
|
254
|
+
try:
|
|
255
|
+
resp: Response = await asyncio.wait_for(fut, timeout=timeout)
|
|
256
|
+
resp.request = req
|
|
257
|
+
return resp
|
|
258
|
+
except asyncio.TimeoutError:
|
|
259
|
+
if i == retries:
|
|
260
|
+
break
|
|
261
|
+
continue
|
|
262
|
+
# Retries exhausted
|
|
263
|
+
return Response(ResponseType.TIMEOUT, request=req)
|
|
264
|
+
finally:
|
|
265
|
+
# Delete the future and request from the pending requests
|
|
266
|
+
self._pending.pop(req.seq, None)
|
|
267
|
+
if not fut.done():
|
|
268
|
+
fut.cancel()
|
|
269
|
+
|
|
270
|
+
async def send_request_with_retries(
|
|
271
|
+
self,
|
|
272
|
+
req: Request,
|
|
273
|
+
*,
|
|
274
|
+
timeout: Optional[float] = None,
|
|
275
|
+
retries: int = ClientConst.DEFAULT_RETRIES,
|
|
276
|
+
queue_retries: int = ClientConst.QUEUE_FAILURE_RETRIES,
|
|
277
|
+
) -> Response:
|
|
278
|
+
"""Like send_request, but retries on TPI QUEUE_FAILURE with backoff."""
|
|
279
|
+
response: Response | None = None
|
|
280
|
+
for attempt in range(queue_retries + 1):
|
|
281
|
+
response = await self.send_request(req, timeout=timeout, retries=retries)
|
|
282
|
+
if (
|
|
283
|
+
response.response_type == ResponseType.ERROR
|
|
284
|
+
and response.data
|
|
285
|
+
and response.data[0] == ClientConst.QUEUE_FAILURE
|
|
286
|
+
and attempt < queue_retries
|
|
287
|
+
):
|
|
288
|
+
delay = ClientConst.QUEUE_FAILURE_BASE_DELAY * (2 ** attempt)
|
|
289
|
+
self.logger.debug(
|
|
290
|
+
"QUEUE_FAILURE from %s:%s, retry %d/%d in %.0fms",
|
|
291
|
+
self.server[0], self.server[1],
|
|
292
|
+
attempt + 1, queue_retries, delay * 1000,
|
|
293
|
+
)
|
|
294
|
+
await asyncio.sleep(delay)
|
|
295
|
+
continue
|
|
296
|
+
break
|
|
297
|
+
assert response is not None
|
|
298
|
+
return response
|
|
299
|
+
|
|
300
|
+
async def _receive_response(self, datagram: bytes, addr: Tuple[str, int]):
|
|
301
|
+
|
|
302
|
+
# Too short to be a valid packet
|
|
303
|
+
if len(datagram) < 4:
|
|
304
|
+
return
|
|
305
|
+
|
|
306
|
+
# Extract values
|
|
307
|
+
response_type_byte = datagram[0]
|
|
308
|
+
sequence_byte = datagram[1]
|
|
309
|
+
data_length_byte = datagram[2]
|
|
310
|
+
data_bytes = datagram[3:-1] # may be empty
|
|
311
|
+
checksum_byte = datagram[-1]
|
|
312
|
+
|
|
313
|
+
# Packet length mismatch
|
|
314
|
+
if len(datagram) != data_length_byte + 3 + 1: # data_len + 3 header + 1 checksum
|
|
315
|
+
return
|
|
316
|
+
|
|
317
|
+
# Checksum mismatch
|
|
318
|
+
if checksum_byte != self._checksum(datagram[:-1]):
|
|
319
|
+
return
|
|
320
|
+
|
|
321
|
+
# Unknown response type
|
|
322
|
+
if response_type_byte not in (ResponseType.OK, ResponseType.ANSWER, ResponseType.NO_ANSWER, ResponseType.ERROR):
|
|
323
|
+
return
|
|
324
|
+
|
|
325
|
+
# Valid response
|
|
326
|
+
response = Response(ResponseType(response_type_byte), seq=sequence_byte, data=data_bytes, raw_rcvd=datagram, addr=addr)
|
|
327
|
+
|
|
328
|
+
# Find the pending request
|
|
329
|
+
if response.seq is None:
|
|
330
|
+
return
|
|
331
|
+
pending = self._pending.get(response.seq)
|
|
332
|
+
if not pending:
|
|
333
|
+
return
|
|
334
|
+
future, request = pending
|
|
335
|
+
|
|
336
|
+
# The future has come
|
|
337
|
+
if not future.done():
|
|
338
|
+
response.request = request
|
|
339
|
+
future.set_result(response)
|
|
340
|
+
|
|
341
|
+
def _alloc_seq(self) -> int:
|
|
342
|
+
"""Allocate a unique sequence number"""
|
|
343
|
+
# Retry up to 256 times to find a free sequence number
|
|
344
|
+
for _ in range(256):
|
|
345
|
+
# By default, try the next sequence number
|
|
346
|
+
proposed_seq = self._next_seq
|
|
347
|
+
# Increment the sequence number
|
|
348
|
+
self._next_seq = (self._next_seq + 1) & 0xFF
|
|
349
|
+
# If the sequence number is not in use, return it
|
|
350
|
+
if proposed_seq not in self._pending:
|
|
351
|
+
return proposed_seq
|
|
352
|
+
raise RuntimeError("All 256 sequence numbers are in use, which is highly improbable")
|
|
353
|
+
|
|
354
|
+
def _checksum(self, buf: bytes) -> int:
|
|
355
|
+
acc = 0x00
|
|
356
|
+
for byte in buf:
|
|
357
|
+
acc ^= byte
|
|
358
|
+
return acc
|
|
359
|
+
|
|
360
|
+
async def __aenter__(self):
|
|
361
|
+
return self
|
|
362
|
+
|
|
363
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
364
|
+
await self.close()
|
|
365
|
+
|
|
366
|
+
def is_connected(self) -> bool:
|
|
367
|
+
"""Check if client has a usable datagram transport."""
|
|
368
|
+
return (
|
|
369
|
+
not self._closed
|
|
370
|
+
and self._transport is not None
|
|
371
|
+
and not self._transport.is_closing()
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
async def close(self):
|
|
375
|
+
"""Close the client"""
|
|
376
|
+
async with self._lock:
|
|
377
|
+
if self._closed and self._transport is None:
|
|
378
|
+
return
|
|
379
|
+
self._closed = True
|
|
380
|
+
for future, _request in self._pending.values():
|
|
381
|
+
if not future.done():
|
|
382
|
+
future.set_exception(RuntimeError("ZenClient closed"))
|
|
383
|
+
self._pending.clear()
|
|
384
|
+
transport = self._transport
|
|
385
|
+
self._transport = None
|
|
386
|
+
if transport is not None and not transport.is_closing():
|
|
387
|
+
transport.close()
|