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.
@@ -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
+ ]
@@ -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()