pysilverline 0.2.1__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.
- pysilverline/__init__.py +30 -0
- pysilverline/client.py +484 -0
- pysilverline/const.py +75 -0
- pysilverline/discovery.py +204 -0
- pysilverline/exceptions.py +30 -0
- pysilverline/models.py +95 -0
- pysilverline/protocol.py +224 -0
- pysilverline/py.typed +0 -0
- pysilverline-0.2.1.dist-info/METADATA +82 -0
- pysilverline-0.2.1.dist-info/RECORD +12 -0
- pysilverline-0.2.1.dist-info/WHEEL +4 -0
- pysilverline-0.2.1.dist-info/licenses/LICENSE +21 -0
pysilverline/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Async client for Poolex Silverline / Tuya v3.3 pool heat pumps."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from . import const
|
|
6
|
+
from .client import SilverlineClient
|
|
7
|
+
from .discovery import DiscoveryInfo, discover, discover_once
|
|
8
|
+
from .exceptions import (
|
|
9
|
+
CannotConnect,
|
|
10
|
+
InvalidAuth,
|
|
11
|
+
ProtocolError,
|
|
12
|
+
SilverlineError,
|
|
13
|
+
)
|
|
14
|
+
from .models import DeviceInfo, DeviceState
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"CannotConnect",
|
|
18
|
+
"DeviceInfo",
|
|
19
|
+
"DeviceState",
|
|
20
|
+
"DiscoveryInfo",
|
|
21
|
+
"InvalidAuth",
|
|
22
|
+
"ProtocolError",
|
|
23
|
+
"SilverlineClient",
|
|
24
|
+
"SilverlineError",
|
|
25
|
+
"const",
|
|
26
|
+
"discover",
|
|
27
|
+
"discover_once",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
__version__ = "0.2.1"
|
pysilverline/client.py
ADDED
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
"""High-level async client for a Poolex Silverline / Tuya v3.3 heat pump."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from . import const
|
|
12
|
+
from .exceptions import (
|
|
13
|
+
CannotConnect,
|
|
14
|
+
IncompleteFrame,
|
|
15
|
+
InvalidAuth,
|
|
16
|
+
ProtocolError,
|
|
17
|
+
SilverlineError,
|
|
18
|
+
)
|
|
19
|
+
from .models import DeviceInfo, DeviceState
|
|
20
|
+
from .protocol import Frame, FrameCodec, is_invalid_auth_retcode
|
|
21
|
+
|
|
22
|
+
_LOGGER = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
_DEFAULT_REQUEST_TIMEOUT: float = 10.0
|
|
25
|
+
_HEARTBEAT_INTERVAL: float = 10.0
|
|
26
|
+
_RECONNECT_BACKOFF: tuple[float, ...] = (1.0, 2.0, 4.0, 8.0, 16.0, 30.0, 60.0)
|
|
27
|
+
_READ_CHUNK: int = 4096
|
|
28
|
+
# Hard cap on the inbound buffer when no complete frame has decoded yet. A
|
|
29
|
+
# legitimate frame is < 64 KiB (see protocol._MAX_FRAME_SIZE); 256 KiB gives
|
|
30
|
+
# us comfortable slack but still bounds memory growth from a hostile peer
|
|
31
|
+
# that dribbles bytes after claiming an oversize header.
|
|
32
|
+
_MAX_READ_BUFFER: int = 256 * 1024
|
|
33
|
+
|
|
34
|
+
PushListener = Callable[[DeviceState], None]
|
|
35
|
+
ConnectionListener = Callable[[bool], None]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class SilverlineClient:
|
|
39
|
+
"""Async client for one Tuya v3.3 device.
|
|
40
|
+
|
|
41
|
+
Lifecycle: ``connect()`` opens a persistent socket and starts a background
|
|
42
|
+
reader. ``get_status`` / ``set_dp`` / ``set_multiple`` issue commands.
|
|
43
|
+
Spontaneous DP pushes from the device are forwarded to listeners
|
|
44
|
+
registered via ``add_listener``. ``disconnect()`` shuts everything down.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
host: str,
|
|
50
|
+
device_id: str,
|
|
51
|
+
local_key: str,
|
|
52
|
+
*,
|
|
53
|
+
port: int = const.DEFAULT_PORT,
|
|
54
|
+
request_timeout: float = _DEFAULT_REQUEST_TIMEOUT,
|
|
55
|
+
) -> None:
|
|
56
|
+
self.host = host
|
|
57
|
+
self.port = port
|
|
58
|
+
self.device_id = device_id
|
|
59
|
+
self._codec = FrameCodec(local_key)
|
|
60
|
+
self._timeout = request_timeout
|
|
61
|
+
|
|
62
|
+
self._reader: asyncio.StreamReader | None = None
|
|
63
|
+
self._writer: asyncio.StreamWriter | None = None
|
|
64
|
+
self._reader_task: asyncio.Task[None] | None = None
|
|
65
|
+
self._heartbeat_task: asyncio.Task[None] | None = None
|
|
66
|
+
self._reconnect_task: asyncio.Task[None] | None = None
|
|
67
|
+
self._send_lock = asyncio.Lock()
|
|
68
|
+
|
|
69
|
+
self._pending: dict[int, asyncio.Future[Frame]] = {}
|
|
70
|
+
self._listeners: list[PushListener] = []
|
|
71
|
+
self._connection_listeners: list[ConnectionListener] = []
|
|
72
|
+
self._state = DeviceState()
|
|
73
|
+
self._closing = False
|
|
74
|
+
self._connection_lost_handled = False
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def connected(self) -> bool:
|
|
78
|
+
return self._writer is not None and not self._writer.is_closing()
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def state(self) -> DeviceState:
|
|
82
|
+
return self._state
|
|
83
|
+
|
|
84
|
+
async def connect(self) -> None:
|
|
85
|
+
"""Open the TCP connection and start the background reader."""
|
|
86
|
+
if self.connected:
|
|
87
|
+
return
|
|
88
|
+
self._closing = False
|
|
89
|
+
self._connection_lost_handled = False
|
|
90
|
+
try:
|
|
91
|
+
self._reader, self._writer = await asyncio.wait_for(
|
|
92
|
+
asyncio.open_connection(self.host, self.port),
|
|
93
|
+
timeout=self._timeout,
|
|
94
|
+
)
|
|
95
|
+
except (OSError, asyncio.TimeoutError) as err:
|
|
96
|
+
raise CannotConnect(f"connect {self.host}:{self.port}: {err}") from err
|
|
97
|
+
|
|
98
|
+
self._reader_task = asyncio.create_task(
|
|
99
|
+
self._read_loop(), name=f"silverline-read-{self.host}"
|
|
100
|
+
)
|
|
101
|
+
self._heartbeat_task = asyncio.create_task(
|
|
102
|
+
self._heartbeat_loop(), name=f"silverline-hb-{self.host}"
|
|
103
|
+
)
|
|
104
|
+
self._notify_connection(True)
|
|
105
|
+
|
|
106
|
+
async def disconnect(self) -> None:
|
|
107
|
+
"""Close the connection and stop background tasks.
|
|
108
|
+
|
|
109
|
+
Cancels any in-flight reconnect task too — once ``disconnect`` is
|
|
110
|
+
called, the client stays down until the caller invokes ``connect``
|
|
111
|
+
again explicitly.
|
|
112
|
+
"""
|
|
113
|
+
self._closing = True
|
|
114
|
+
for task in (
|
|
115
|
+
self._heartbeat_task,
|
|
116
|
+
self._reader_task,
|
|
117
|
+
self._reconnect_task,
|
|
118
|
+
):
|
|
119
|
+
if task and not task.done():
|
|
120
|
+
task.cancel()
|
|
121
|
+
try:
|
|
122
|
+
await task
|
|
123
|
+
except (asyncio.CancelledError, Exception): # noqa: BLE001
|
|
124
|
+
pass
|
|
125
|
+
self._heartbeat_task = None
|
|
126
|
+
self._reader_task = None
|
|
127
|
+
self._reconnect_task = None
|
|
128
|
+
|
|
129
|
+
for fut in self._pending.values():
|
|
130
|
+
if not fut.done():
|
|
131
|
+
fut.set_exception(CannotConnect("client disconnecting"))
|
|
132
|
+
self._pending.clear()
|
|
133
|
+
|
|
134
|
+
if self._writer is not None:
|
|
135
|
+
try:
|
|
136
|
+
self._writer.close()
|
|
137
|
+
await self._writer.wait_closed()
|
|
138
|
+
except OSError:
|
|
139
|
+
pass
|
|
140
|
+
self._reader = None
|
|
141
|
+
self._writer = None
|
|
142
|
+
|
|
143
|
+
def add_listener(self, callback: PushListener) -> Callable[[], None]:
|
|
144
|
+
"""Register a synchronous callback for push DP updates.
|
|
145
|
+
|
|
146
|
+
Returns an unsubscribe function.
|
|
147
|
+
"""
|
|
148
|
+
self._listeners.append(callback)
|
|
149
|
+
|
|
150
|
+
def _unsubscribe() -> None:
|
|
151
|
+
try:
|
|
152
|
+
self._listeners.remove(callback)
|
|
153
|
+
except ValueError:
|
|
154
|
+
pass
|
|
155
|
+
|
|
156
|
+
return _unsubscribe
|
|
157
|
+
|
|
158
|
+
def add_connection_listener(
|
|
159
|
+
self, callback: ConnectionListener
|
|
160
|
+
) -> Callable[[], None]:
|
|
161
|
+
"""Register a synchronous callback for connection state changes.
|
|
162
|
+
|
|
163
|
+
Invoked with ``True`` after a (re)connection succeeds and ``False``
|
|
164
|
+
when the socket drops unexpectedly. Returns an unsubscribe function.
|
|
165
|
+
"""
|
|
166
|
+
self._connection_listeners.append(callback)
|
|
167
|
+
|
|
168
|
+
def _unsubscribe() -> None:
|
|
169
|
+
try:
|
|
170
|
+
self._connection_listeners.remove(callback)
|
|
171
|
+
except ValueError:
|
|
172
|
+
pass
|
|
173
|
+
|
|
174
|
+
return _unsubscribe
|
|
175
|
+
|
|
176
|
+
def _notify_connection(self, connected: bool) -> None:
|
|
177
|
+
for listener in list(self._connection_listeners):
|
|
178
|
+
try:
|
|
179
|
+
listener(connected)
|
|
180
|
+
except Exception: # noqa: BLE001
|
|
181
|
+
_LOGGER.exception("connection listener raised")
|
|
182
|
+
|
|
183
|
+
async def get_status(self) -> DeviceState:
|
|
184
|
+
"""Issue a DP_QUERY and return the resulting DeviceState."""
|
|
185
|
+
body = {
|
|
186
|
+
"gwId": self.device_id,
|
|
187
|
+
"devId": self.device_id,
|
|
188
|
+
"uid": "",
|
|
189
|
+
"t": int(time.time()),
|
|
190
|
+
}
|
|
191
|
+
frame = await self._request(const.CMD_DP_QUERY, body)
|
|
192
|
+
retcode, ciphertext = self._codec.split_response_payload(
|
|
193
|
+
frame.cmd, frame.payload
|
|
194
|
+
)
|
|
195
|
+
if is_invalid_auth_retcode(retcode):
|
|
196
|
+
raise InvalidAuth(f"DP_QUERY rejected retcode={retcode}")
|
|
197
|
+
decoded = self._codec.decrypt_body(ciphertext)
|
|
198
|
+
dps = decoded.get("dps", {}) if isinstance(decoded, dict) else {}
|
|
199
|
+
if not isinstance(dps, dict):
|
|
200
|
+
raise ProtocolError(f"unexpected dps payload: {decoded!r}")
|
|
201
|
+
# Merge rather than replace: some Tuya firmware variants only
|
|
202
|
+
# ship certain DPs in spontaneous STATUS pushes, not in
|
|
203
|
+
# DP_QUERY responses. If we replaced wholesale, those push-only
|
|
204
|
+
# DPs would flicker to None on every 30s poll. The push path
|
|
205
|
+
# already merges (_dispatch in this module); the poll path
|
|
206
|
+
# has to behave symmetrically.
|
|
207
|
+
self._state = self._state.merge(dps)
|
|
208
|
+
return self._state
|
|
209
|
+
|
|
210
|
+
async def set_dp(self, dp_id: int, value: bool | int | str) -> None:
|
|
211
|
+
"""Convenience wrapper around set_multiple for a single DP."""
|
|
212
|
+
await self.set_multiple({dp_id: value})
|
|
213
|
+
|
|
214
|
+
async def set_multiple(self, values: dict[int, bool | int | str]) -> None:
|
|
215
|
+
"""Send one CONTROL command updating multiple DPs atomically."""
|
|
216
|
+
if not values:
|
|
217
|
+
return
|
|
218
|
+
dps = {str(k): v for k, v in values.items()}
|
|
219
|
+
body = {
|
|
220
|
+
"devId": self.device_id,
|
|
221
|
+
"gwId": self.device_id,
|
|
222
|
+
"uid": "",
|
|
223
|
+
"t": int(time.time()),
|
|
224
|
+
"dps": dps,
|
|
225
|
+
}
|
|
226
|
+
frame = await self._request(const.CMD_CONTROL, body)
|
|
227
|
+
retcode, _ = self._codec.split_response_payload(frame.cmd, frame.payload)
|
|
228
|
+
if is_invalid_auth_retcode(retcode):
|
|
229
|
+
raise InvalidAuth(f"device rejected CONTROL retcode={retcode}")
|
|
230
|
+
if retcode not in (None, 0):
|
|
231
|
+
raise SilverlineError(f"CONTROL failed retcode=0x{retcode:08x}")
|
|
232
|
+
# The device usually echoes the new state via a push frame within
|
|
233
|
+
# ~200ms; merge optimistically so callers see the updated DPs even if
|
|
234
|
+
# they query before the push arrives.
|
|
235
|
+
self._state = self._state.merge(dps)
|
|
236
|
+
|
|
237
|
+
async def get_device_info(self) -> DeviceInfo:
|
|
238
|
+
"""The Tuya local protocol does not expose firmware/model strings;
|
|
239
|
+
we return the device_id so callers can build a DeviceInfo block."""
|
|
240
|
+
return DeviceInfo(device_id=self.device_id)
|
|
241
|
+
|
|
242
|
+
async def _request(self, cmd: int, body: dict[str, Any]) -> Frame:
|
|
243
|
+
if not self.connected:
|
|
244
|
+
raise CannotConnect("not connected")
|
|
245
|
+
loop = asyncio.get_running_loop()
|
|
246
|
+
future: asyncio.Future[Frame] = loop.create_future()
|
|
247
|
+
|
|
248
|
+
async with self._send_lock:
|
|
249
|
+
wire = self._codec.encode(cmd, body)
|
|
250
|
+
# The seq the codec assigned occupies bytes 4..8 of the frame.
|
|
251
|
+
frame_seq = int.from_bytes(wire[4:8], "big")
|
|
252
|
+
self._pending[frame_seq] = future
|
|
253
|
+
try:
|
|
254
|
+
writer = self._writer
|
|
255
|
+
if writer is None:
|
|
256
|
+
raise CannotConnect("not connected")
|
|
257
|
+
writer.write(wire)
|
|
258
|
+
await writer.drain()
|
|
259
|
+
except (OSError, ConnectionError) as err:
|
|
260
|
+
self._pending.pop(frame_seq, None)
|
|
261
|
+
raise CannotConnect(f"send: {err}") from err
|
|
262
|
+
|
|
263
|
+
try:
|
|
264
|
+
return await asyncio.wait_for(future, timeout=self._timeout)
|
|
265
|
+
except asyncio.TimeoutError as err:
|
|
266
|
+
self._pending.pop(frame_seq, None)
|
|
267
|
+
raise CannotConnect(f"timeout waiting for cmd 0x{cmd:02x}") from err
|
|
268
|
+
|
|
269
|
+
def _close_writer(self) -> None:
|
|
270
|
+
"""Close the underlying writer, swallowing OS errors.
|
|
271
|
+
|
|
272
|
+
Used from the read loop when we decide to bail out (oversize
|
|
273
|
+
buffer, malformed frame); the disconnect path in the ``finally``
|
|
274
|
+
block of ``_read_loop`` then notifies listeners and schedules a
|
|
275
|
+
reconnect.
|
|
276
|
+
"""
|
|
277
|
+
writer = self._writer
|
|
278
|
+
if writer is None:
|
|
279
|
+
return
|
|
280
|
+
try:
|
|
281
|
+
writer.close()
|
|
282
|
+
except OSError:
|
|
283
|
+
pass
|
|
284
|
+
|
|
285
|
+
async def _read_loop(self) -> None:
|
|
286
|
+
buf = bytearray()
|
|
287
|
+
reader = self._reader
|
|
288
|
+
if reader is None:
|
|
289
|
+
return
|
|
290
|
+
try:
|
|
291
|
+
while not self._closing:
|
|
292
|
+
try:
|
|
293
|
+
chunk = await reader.read(_READ_CHUNK)
|
|
294
|
+
except (OSError, ConnectionError) as err:
|
|
295
|
+
_LOGGER.debug("read error: %s", err)
|
|
296
|
+
break
|
|
297
|
+
if not chunk:
|
|
298
|
+
_LOGGER.debug("connection closed by peer")
|
|
299
|
+
break
|
|
300
|
+
buf.extend(chunk)
|
|
301
|
+
if len(buf) > _MAX_READ_BUFFER:
|
|
302
|
+
_LOGGER.warning(
|
|
303
|
+
"read buffer exceeded %d bytes without a complete frame; "
|
|
304
|
+
"closing connection",
|
|
305
|
+
_MAX_READ_BUFFER,
|
|
306
|
+
)
|
|
307
|
+
self._close_writer()
|
|
308
|
+
break
|
|
309
|
+
drop_connection = False
|
|
310
|
+
while len(buf) >= 24:
|
|
311
|
+
try:
|
|
312
|
+
frame, remainder = self._codec.decode(bytes(buf))
|
|
313
|
+
except IncompleteFrame:
|
|
314
|
+
# Normal case under TCP fragmentation: the wire
|
|
315
|
+
# delivered the header but not yet the full body,
|
|
316
|
+
# or vice versa. Stop draining and wait for the
|
|
317
|
+
# next read to fill the gap.
|
|
318
|
+
break
|
|
319
|
+
except ProtocolError as err:
|
|
320
|
+
# Bad prefix / suffix / CRC / oversize means we
|
|
321
|
+
# are desynchronized (or talking to something
|
|
322
|
+
# hostile). There is no safe recovery from
|
|
323
|
+
# mid-stream garbage, so drop the connection and
|
|
324
|
+
# let the reconnect path re-establish a fresh
|
|
325
|
+
# session.
|
|
326
|
+
_LOGGER.warning(
|
|
327
|
+
"dropping connection on malformed frame: %s", err
|
|
328
|
+
)
|
|
329
|
+
buf.clear()
|
|
330
|
+
drop_connection = True
|
|
331
|
+
break
|
|
332
|
+
buf = bytearray(remainder)
|
|
333
|
+
self._dispatch(frame)
|
|
334
|
+
if drop_connection:
|
|
335
|
+
self._close_writer()
|
|
336
|
+
break
|
|
337
|
+
except asyncio.CancelledError:
|
|
338
|
+
raise
|
|
339
|
+
except Exception: # noqa: BLE001
|
|
340
|
+
_LOGGER.exception("read loop crashed")
|
|
341
|
+
finally:
|
|
342
|
+
for fut in self._pending.values():
|
|
343
|
+
if not fut.done():
|
|
344
|
+
fut.set_exception(CannotConnect("connection lost"))
|
|
345
|
+
self._pending.clear()
|
|
346
|
+
self._on_connection_dropped()
|
|
347
|
+
|
|
348
|
+
def _dispatch(self, frame: Frame) -> None:
|
|
349
|
+
if frame.seq in self._pending:
|
|
350
|
+
fut = self._pending.pop(frame.seq)
|
|
351
|
+
if not fut.done():
|
|
352
|
+
fut.set_result(frame)
|
|
353
|
+
return
|
|
354
|
+
|
|
355
|
+
if frame.cmd in (const.CMD_STATUS, const.CMD_DP_REFRESH):
|
|
356
|
+
ciphertext = self._codec.split_request_payload(frame.payload)
|
|
357
|
+
try:
|
|
358
|
+
decoded = self._codec.decrypt_body(ciphertext)
|
|
359
|
+
except InvalidAuth:
|
|
360
|
+
_LOGGER.debug("ignoring undecryptable push frame")
|
|
361
|
+
return
|
|
362
|
+
dps = decoded.get("dps", {}) if isinstance(decoded, dict) else {}
|
|
363
|
+
if not isinstance(dps, dict) or not dps:
|
|
364
|
+
return
|
|
365
|
+
self._state = self._state.merge(dps)
|
|
366
|
+
for listener in list(self._listeners):
|
|
367
|
+
try:
|
|
368
|
+
listener(self._state)
|
|
369
|
+
except Exception: # noqa: BLE001
|
|
370
|
+
_LOGGER.exception("push listener raised")
|
|
371
|
+
|
|
372
|
+
async def _heartbeat_loop(self) -> None:
|
|
373
|
+
try:
|
|
374
|
+
while not self._closing and self.connected:
|
|
375
|
+
await asyncio.sleep(_HEARTBEAT_INTERVAL)
|
|
376
|
+
if self._closing or not self.connected:
|
|
377
|
+
return
|
|
378
|
+
try:
|
|
379
|
+
await self._send_heartbeat()
|
|
380
|
+
except CannotConnect as err:
|
|
381
|
+
_LOGGER.debug("heartbeat failed: %s", err)
|
|
382
|
+
self._on_connection_dropped()
|
|
383
|
+
return
|
|
384
|
+
except asyncio.CancelledError:
|
|
385
|
+
raise
|
|
386
|
+
|
|
387
|
+
async def _send_heartbeat(self) -> None:
|
|
388
|
+
async with self._send_lock:
|
|
389
|
+
writer = self._writer
|
|
390
|
+
if writer is None:
|
|
391
|
+
return
|
|
392
|
+
wire = self._codec.encode(const.CMD_HEART_BEAT, {})
|
|
393
|
+
try:
|
|
394
|
+
writer.write(wire)
|
|
395
|
+
await writer.drain()
|
|
396
|
+
except (OSError, ConnectionError) as err:
|
|
397
|
+
raise CannotConnect(f"heartbeat write: {err}") from err
|
|
398
|
+
|
|
399
|
+
def _on_connection_dropped(self) -> None:
|
|
400
|
+
"""Called from inside the read/heartbeat tasks when the socket dies.
|
|
401
|
+
|
|
402
|
+
Idempotent: a single drop triggers exactly one ``False`` listener
|
|
403
|
+
callback and one reconnect task even though both background loops
|
|
404
|
+
will eventually call this on their way out.
|
|
405
|
+
"""
|
|
406
|
+
if self._closing or self._connection_lost_handled:
|
|
407
|
+
return
|
|
408
|
+
self._connection_lost_handled = True
|
|
409
|
+
_LOGGER.warning("connection to %s lost; scheduling reconnect", self.host)
|
|
410
|
+
self._notify_connection(False)
|
|
411
|
+
# Schedule the reconnect from a fresh task so we don't block whichever
|
|
412
|
+
# background loop just fell over.
|
|
413
|
+
if self._reconnect_task is None or self._reconnect_task.done():
|
|
414
|
+
self._reconnect_task = asyncio.create_task(
|
|
415
|
+
self._reconnect_loop(),
|
|
416
|
+
name=f"silverline-reconnect-{self.host}",
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
async def _reconnect_loop(self) -> None:
|
|
420
|
+
"""Walk the backoff schedule trying to reopen the socket.
|
|
421
|
+
|
|
422
|
+
The body runs inside a ``try/finally`` that clears
|
|
423
|
+
``self._reconnect_task`` on exit. Without that, a peer that drops
|
|
424
|
+
the freshly reconnected socket *before this coroutine returns*
|
|
425
|
+
would have its ``_on_connection_dropped`` signal suppressed —
|
|
426
|
+
that callback bails when ``self._reconnect_task`` is still
|
|
427
|
+
running, leaving the client dead with no scheduled retry.
|
|
428
|
+
"""
|
|
429
|
+
try:
|
|
430
|
+
# Close the dead writer so the next connect() succeeds cleanly.
|
|
431
|
+
if self._writer is not None:
|
|
432
|
+
try:
|
|
433
|
+
self._writer.close()
|
|
434
|
+
await self._writer.wait_closed()
|
|
435
|
+
except OSError:
|
|
436
|
+
pass
|
|
437
|
+
self._reader = None
|
|
438
|
+
self._writer = None
|
|
439
|
+
# Reap the dead reader/heartbeat tasks before kicking new ones.
|
|
440
|
+
for task_attr in ("_reader_task", "_heartbeat_task"):
|
|
441
|
+
task: asyncio.Task[None] | None = getattr(self, task_attr)
|
|
442
|
+
if task and not task.done():
|
|
443
|
+
task.cancel()
|
|
444
|
+
try:
|
|
445
|
+
await task
|
|
446
|
+
except (asyncio.CancelledError, Exception): # noqa: BLE001
|
|
447
|
+
pass
|
|
448
|
+
setattr(self, task_attr, None)
|
|
449
|
+
|
|
450
|
+
for delay in _RECONNECT_BACKOFF:
|
|
451
|
+
if self._closing:
|
|
452
|
+
return
|
|
453
|
+
await asyncio.sleep(delay)
|
|
454
|
+
if self._closing:
|
|
455
|
+
return
|
|
456
|
+
try:
|
|
457
|
+
await self.connect()
|
|
458
|
+
except CannotConnect as err:
|
|
459
|
+
_LOGGER.debug("reconnect attempt failed: %s", err)
|
|
460
|
+
continue
|
|
461
|
+
# connect() notifies True; refresh state so listeners see
|
|
462
|
+
# fresh DPs. If the brand-new socket already died (the peer
|
|
463
|
+
# closed it mid-reconnect, and our own reader fired
|
|
464
|
+
# _on_connection_dropped while we were still the current
|
|
465
|
+
# reconnect task — so the schedule check below was a no-op),
|
|
466
|
+
# roll over to the next backoff iteration instead of
|
|
467
|
+
# returning to a dead connection.
|
|
468
|
+
try:
|
|
469
|
+
await self.get_status()
|
|
470
|
+
except (CannotConnect, InvalidAuth) as err:
|
|
471
|
+
_LOGGER.debug("post-reconnect refresh failed: %s", err)
|
|
472
|
+
if not self.connected:
|
|
473
|
+
continue
|
|
474
|
+
return
|
|
475
|
+
_LOGGER.error(
|
|
476
|
+
"exhausted reconnect backoff to %s; giving up until next connect()",
|
|
477
|
+
self.host,
|
|
478
|
+
)
|
|
479
|
+
finally:
|
|
480
|
+
# Clearing this here is what makes back-to-back drops keep
|
|
481
|
+
# triggering reconnects: any drop signal that arrives after
|
|
482
|
+
# this point sees no active reconnect task and schedules a
|
|
483
|
+
# fresh one via _on_connection_dropped.
|
|
484
|
+
self._reconnect_task = None
|
pysilverline/const.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Constants for the Tuya v3.3 protocol and Poolex Silverline DPs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Final
|
|
6
|
+
|
|
7
|
+
DEFAULT_PORT: Final = 6668
|
|
8
|
+
DISCOVERY_PORT_PLAIN: Final = 6666
|
|
9
|
+
DISCOVERY_PORT_ENCRYPTED: Final = 6667
|
|
10
|
+
|
|
11
|
+
PROTOCOL_VERSION: Final = b"3.3"
|
|
12
|
+
PROTOCOL_33_HEADER: Final = PROTOCOL_VERSION + b"\x00" * 12 # 15 bytes
|
|
13
|
+
FRAME_PREFIX: Final = 0x000055AA
|
|
14
|
+
FRAME_SUFFIX: Final = 0x0000AA55
|
|
15
|
+
|
|
16
|
+
CMD_CONTROL: Final = 0x07
|
|
17
|
+
CMD_STATUS: Final = 0x08
|
|
18
|
+
CMD_HEART_BEAT: Final = 0x09
|
|
19
|
+
CMD_DP_QUERY: Final = 0x0A
|
|
20
|
+
CMD_DP_REFRESH: Final = 0x12
|
|
21
|
+
|
|
22
|
+
CMDS_WITHOUT_HEADER: Final = frozenset({CMD_DP_QUERY})
|
|
23
|
+
|
|
24
|
+
DP_POWER: Final = 1
|
|
25
|
+
DP_TEMP_SET: Final = 2
|
|
26
|
+
DP_TEMP_CURRENT: Final = 3
|
|
27
|
+
DP_MODE: Final = 4
|
|
28
|
+
DP_FAULT: Final = 13
|
|
29
|
+
DP_EXHAUST_TEMP: Final = 101
|
|
30
|
+
DP_RETURN_TEMP: Final = 102
|
|
31
|
+
DP_COIL_TEMP: Final = 103
|
|
32
|
+
DP_AMBIENT_TEMP: Final = 104
|
|
33
|
+
DP_INLET_TEMP: Final = 105
|
|
34
|
+
DP_OUTLET_TEMP: Final = 106
|
|
35
|
+
DP_TARGET_FREQUENCY: Final = 107
|
|
36
|
+
DP_ACTUAL_FREQUENCY: Final = 108
|
|
37
|
+
DP_EEV_STEPS: Final = 109
|
|
38
|
+
DP_FAN_SPEED: Final = 110
|
|
39
|
+
DP_WATER_PUMP: Final = 111
|
|
40
|
+
|
|
41
|
+
MODE_HEAT: Final = "Heat"
|
|
42
|
+
MODE_COOL: Final = "Cool"
|
|
43
|
+
MODE_AUTO: Final = "Auto"
|
|
44
|
+
MODE_BOOST_HEAT: Final = "BoostHeat"
|
|
45
|
+
MODE_BOOST_COOL: Final = "BoostCool"
|
|
46
|
+
MODE_SILENT_HEAT: Final = "SilentHeat"
|
|
47
|
+
MODE_SILENT_COOL: Final = "SilentCool"
|
|
48
|
+
|
|
49
|
+
ALL_MODES: Final = frozenset(
|
|
50
|
+
{
|
|
51
|
+
MODE_HEAT,
|
|
52
|
+
MODE_COOL,
|
|
53
|
+
MODE_AUTO,
|
|
54
|
+
MODE_BOOST_HEAT,
|
|
55
|
+
MODE_BOOST_COOL,
|
|
56
|
+
MODE_SILENT_HEAT,
|
|
57
|
+
MODE_SILENT_COOL,
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
TEMP_MIN: Final = 8
|
|
62
|
+
TEMP_MAX: Final = 40
|
|
63
|
+
|
|
64
|
+
FAULT_BIT_NAMES: Final = {
|
|
65
|
+
0: "E03",
|
|
66
|
+
1: "E04",
|
|
67
|
+
2: "E05",
|
|
68
|
+
3: "E06",
|
|
69
|
+
4: "E09",
|
|
70
|
+
5: "E10",
|
|
71
|
+
6: "P3",
|
|
72
|
+
7: "P4",
|
|
73
|
+
8: "P1",
|
|
74
|
+
9: "P7",
|
|
75
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""UDP broadcast discovery for Tuya v3.3 devices.
|
|
2
|
+
|
|
3
|
+
Every Tuya device that's running the standard Tuya local stack
|
|
4
|
+
(WBR3 / Realtek-based modules in particular) broadcasts a small JSON
|
|
5
|
+
blob announcing its presence every ~10-30 seconds, encrypted with a
|
|
6
|
+
*static* AES-128-ECB key shared across the entire Tuya ecosystem:
|
|
7
|
+
|
|
8
|
+
UDP_DISCOVERY_KEY = MD5(b"yGAdlopoPVldABfn")
|
|
9
|
+
|
|
10
|
+
That key is published in tinytuya and tuya-local; it's the same on
|
|
11
|
+
every Tuya device, regardless of cloud account.
|
|
12
|
+
|
|
13
|
+
Frame format on the wire (verified live against a Poolex PC-SLP090N
|
|
14
|
+
on 2026-05-22):
|
|
15
|
+
|
|
16
|
+
[prefix 0x000055AA][seq][cmd=0x13][size][payload][crc32][suffix 0x0000AA55]
|
|
17
|
+
payload = [4 zero bytes (retcode)][AES-128-ECB ciphertext]
|
|
18
|
+
|
|
19
|
+
Note that this is subtly different from the TCP push frames — the UDP
|
|
20
|
+
payload has NO inner ``3.3`` header between the retcode and the
|
|
21
|
+
ciphertext.
|
|
22
|
+
|
|
23
|
+
Decoded JSON fields seen in the wild:
|
|
24
|
+
|
|
25
|
+
{
|
|
26
|
+
"ip": "10.2.1.98",
|
|
27
|
+
"gwId": "bf90769136c9ac3653oqwj",
|
|
28
|
+
"active": 2,
|
|
29
|
+
"ablility": 0, # sic — Tuya typo, sometimes "ablilty"
|
|
30
|
+
"encrypt": true,
|
|
31
|
+
"productKey": "3bhylhz5zhogklel",
|
|
32
|
+
"version": "3.3"
|
|
33
|
+
}
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import asyncio
|
|
39
|
+
import binascii
|
|
40
|
+
import hashlib
|
|
41
|
+
import json
|
|
42
|
+
import logging
|
|
43
|
+
import struct
|
|
44
|
+
from collections.abc import AsyncIterator
|
|
45
|
+
from dataclasses import dataclass
|
|
46
|
+
from typing import Any
|
|
47
|
+
|
|
48
|
+
from . import const
|
|
49
|
+
from .protocol import aes_decrypt
|
|
50
|
+
|
|
51
|
+
_LOGGER = logging.getLogger(__name__)
|
|
52
|
+
|
|
53
|
+
CMD_UDP: int = 0x13
|
|
54
|
+
UDP_DISCOVERY_KEY: bytes = hashlib.md5(b"yGAdlopoPVldABfn").digest()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(slots=True, kw_only=True, frozen=True)
|
|
58
|
+
class DiscoveryInfo:
|
|
59
|
+
"""One device announcement parsed from a UDP broadcast."""
|
|
60
|
+
|
|
61
|
+
device_id: str
|
|
62
|
+
ip: str
|
|
63
|
+
version: str = "3.3"
|
|
64
|
+
product_key: str | None = None
|
|
65
|
+
encrypt: bool = True
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _decode_broadcast(data: bytes, *, encrypted: bool) -> DiscoveryInfo | None:
|
|
69
|
+
"""Parse a single UDP datagram. Returns None on any malformation —
|
|
70
|
+
discovery must tolerate any garbage that lands on the listening port."""
|
|
71
|
+
if len(data) < 24:
|
|
72
|
+
return None
|
|
73
|
+
try:
|
|
74
|
+
prefix, _seq, _cmd, size = struct.unpack(">IIII", data[:16])
|
|
75
|
+
except struct.error:
|
|
76
|
+
return None
|
|
77
|
+
if prefix != const.FRAME_PREFIX:
|
|
78
|
+
return None
|
|
79
|
+
total = 16 + size
|
|
80
|
+
if len(data) < total:
|
|
81
|
+
return None
|
|
82
|
+
# Last 8 bytes are crc32 + suffix; validate CRC so we don't decrypt junk.
|
|
83
|
+
expected_crc, suffix = struct.unpack(">II", data[total - 8 : total])
|
|
84
|
+
if suffix != const.FRAME_SUFFIX:
|
|
85
|
+
return None
|
|
86
|
+
if expected_crc != binascii.crc32(data[: total - 8]) & 0xFFFFFFFF:
|
|
87
|
+
return None
|
|
88
|
+
payload = data[16 : total - 8]
|
|
89
|
+
if len(payload) < 4:
|
|
90
|
+
return None
|
|
91
|
+
# UDP discovery payloads always carry a 4-byte zero retcode; the rest
|
|
92
|
+
# is either plaintext JSON (port 6666) or AES-128-ECB ciphertext (port 6667).
|
|
93
|
+
body = payload[4:]
|
|
94
|
+
if encrypted:
|
|
95
|
+
if len(body) == 0 or len(body) % 16 != 0:
|
|
96
|
+
return None
|
|
97
|
+
try:
|
|
98
|
+
plaintext = aes_decrypt(body, UDP_DISCOVERY_KEY)
|
|
99
|
+
except Exception: # noqa: BLE001 — codec raises ProtocolError/ValueError/InvalidAuth
|
|
100
|
+
return None
|
|
101
|
+
else:
|
|
102
|
+
plaintext = body
|
|
103
|
+
try:
|
|
104
|
+
parsed: Any = json.loads(plaintext)
|
|
105
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
106
|
+
return None
|
|
107
|
+
if not isinstance(parsed, dict):
|
|
108
|
+
return None
|
|
109
|
+
gw_id = parsed.get("gwId")
|
|
110
|
+
ip = parsed.get("ip")
|
|
111
|
+
if not isinstance(gw_id, str) or not isinstance(ip, str):
|
|
112
|
+
return None
|
|
113
|
+
return DiscoveryInfo(
|
|
114
|
+
device_id=gw_id,
|
|
115
|
+
ip=ip,
|
|
116
|
+
version=str(parsed.get("version", "3.3")),
|
|
117
|
+
product_key=parsed.get("productKey") if isinstance(parsed.get("productKey"), str) else None,
|
|
118
|
+
encrypt=bool(parsed.get("encrypt", encrypted)),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class _DiscoveryProtocol(asyncio.DatagramProtocol):
|
|
123
|
+
"""Pushes parsed DiscoveryInfo events onto an asyncio.Queue."""
|
|
124
|
+
|
|
125
|
+
def __init__(
|
|
126
|
+
self, queue: asyncio.Queue[DiscoveryInfo], *, encrypted: bool
|
|
127
|
+
) -> None:
|
|
128
|
+
self._queue = queue
|
|
129
|
+
self._encrypted = encrypted
|
|
130
|
+
|
|
131
|
+
def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None:
|
|
132
|
+
info = _decode_broadcast(data, encrypted=self._encrypted)
|
|
133
|
+
if info is None:
|
|
134
|
+
return
|
|
135
|
+
try:
|
|
136
|
+
self._queue.put_nowait(info)
|
|
137
|
+
except asyncio.QueueFull:
|
|
138
|
+
_LOGGER.debug("discovery queue full; dropping %s", info.device_id)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
async def _bind_listeners(
|
|
142
|
+
queue: asyncio.Queue[DiscoveryInfo],
|
|
143
|
+
) -> tuple[asyncio.DatagramTransport, asyncio.DatagramTransport]:
|
|
144
|
+
"""Bind to both Tuya discovery ports. Returns the two transports
|
|
145
|
+
so callers can close them when done."""
|
|
146
|
+
loop = asyncio.get_running_loop()
|
|
147
|
+
t_plain, _ = await loop.create_datagram_endpoint(
|
|
148
|
+
lambda: _DiscoveryProtocol(queue, encrypted=False),
|
|
149
|
+
local_addr=("0.0.0.0", const.DISCOVERY_PORT_PLAIN),
|
|
150
|
+
allow_broadcast=True,
|
|
151
|
+
reuse_port=True,
|
|
152
|
+
)
|
|
153
|
+
t_enc, _ = await loop.create_datagram_endpoint(
|
|
154
|
+
lambda: _DiscoveryProtocol(queue, encrypted=True),
|
|
155
|
+
local_addr=("0.0.0.0", const.DISCOVERY_PORT_ENCRYPTED),
|
|
156
|
+
allow_broadcast=True,
|
|
157
|
+
reuse_port=True,
|
|
158
|
+
)
|
|
159
|
+
return t_plain, t_enc
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
async def discover_once(timeout: float = 15.0) -> list[DiscoveryInfo]:
|
|
163
|
+
"""Listen for UDP broadcasts for ``timeout`` seconds.
|
|
164
|
+
|
|
165
|
+
Returns the set of unique devices seen (deduplicated by ``device_id``).
|
|
166
|
+
Returns an empty list if no devices announce in the window. Never
|
|
167
|
+
raises on garbage input.
|
|
168
|
+
"""
|
|
169
|
+
queue: asyncio.Queue[DiscoveryInfo] = asyncio.Queue()
|
|
170
|
+
t_plain, t_enc = await _bind_listeners(queue)
|
|
171
|
+
seen: dict[str, DiscoveryInfo] = {}
|
|
172
|
+
loop = asyncio.get_running_loop()
|
|
173
|
+
deadline = loop.time() + timeout
|
|
174
|
+
try:
|
|
175
|
+
while True:
|
|
176
|
+
remaining = deadline - loop.time()
|
|
177
|
+
if remaining <= 0:
|
|
178
|
+
break
|
|
179
|
+
try:
|
|
180
|
+
info = await asyncio.wait_for(queue.get(), timeout=remaining)
|
|
181
|
+
except asyncio.TimeoutError:
|
|
182
|
+
break
|
|
183
|
+
seen[info.device_id] = info
|
|
184
|
+
finally:
|
|
185
|
+
t_plain.close()
|
|
186
|
+
t_enc.close()
|
|
187
|
+
return list(seen.values())
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
async def discover() -> AsyncIterator[DiscoveryInfo]:
|
|
191
|
+
"""Listen indefinitely for UDP broadcasts.
|
|
192
|
+
|
|
193
|
+
Yields every parsed announcement (no deduplication — callers that
|
|
194
|
+
care can track ``device_id``s themselves). Cancel the task to stop.
|
|
195
|
+
"""
|
|
196
|
+
queue: asyncio.Queue[DiscoveryInfo] = asyncio.Queue()
|
|
197
|
+
t_plain, t_enc = await _bind_listeners(queue)
|
|
198
|
+
try:
|
|
199
|
+
while True:
|
|
200
|
+
info = await queue.get()
|
|
201
|
+
yield info
|
|
202
|
+
finally:
|
|
203
|
+
t_plain.close()
|
|
204
|
+
t_enc.close()
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Exceptions raised by pysilverline."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SilverlineError(Exception):
|
|
7
|
+
"""Base error."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CannotConnect(SilverlineError):
|
|
11
|
+
"""Network or transport-level failure."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class InvalidAuth(SilverlineError):
|
|
15
|
+
"""The local_key was rejected by the device."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ProtocolError(SilverlineError):
|
|
19
|
+
"""The frame was malformed or out of spec."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class IncompleteFrame(SilverlineError):
|
|
23
|
+
"""Not malformed — just not all bytes have arrived yet.
|
|
24
|
+
|
|
25
|
+
Distinct from ProtocolError so callers can tell the difference
|
|
26
|
+
between "drop the connection, we're desynchronized" (ProtocolError)
|
|
27
|
+
and "wait for the next chunk and try again" (IncompleteFrame).
|
|
28
|
+
TCP is free to split any frame across read boundaries, so this
|
|
29
|
+
is the normal case, not an error condition.
|
|
30
|
+
"""
|
pysilverline/models.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Typed data models for device state."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from . import const
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(slots=True, kw_only=True, frozen=True)
|
|
12
|
+
class DeviceInfo:
|
|
13
|
+
"""Static device identity. Tuya v3.3 does not return a model string;
|
|
14
|
+
fields are populated from the config entry plus what we infer."""
|
|
15
|
+
|
|
16
|
+
device_id: str
|
|
17
|
+
firmware: str | None = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(slots=True, kw_only=True, frozen=True)
|
|
21
|
+
class DeviceState:
|
|
22
|
+
"""Snapshot of all known DPs at a point in time. Missing DPs are None."""
|
|
23
|
+
|
|
24
|
+
power: bool | None = None
|
|
25
|
+
temp_set: int | None = None
|
|
26
|
+
temp_current: int | None = None
|
|
27
|
+
mode: str | None = None
|
|
28
|
+
fault: int | None = None
|
|
29
|
+
exhaust_temp: int | None = None
|
|
30
|
+
return_temp: int | None = None
|
|
31
|
+
coil_temp: int | None = None
|
|
32
|
+
ambient_temp: int | None = None
|
|
33
|
+
inlet_temp: int | None = None
|
|
34
|
+
outlet_temp: int | None = None
|
|
35
|
+
target_frequency: int | None = None
|
|
36
|
+
actual_frequency: int | None = None
|
|
37
|
+
eev_steps: int | None = None
|
|
38
|
+
fan_speed: int | None = None
|
|
39
|
+
water_pump: bool | None = None
|
|
40
|
+
raw: dict[str, Any] = field(default_factory=dict)
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def from_dps(cls, dps: dict[str, Any]) -> DeviceState:
|
|
44
|
+
"""Build a DeviceState from a Tuya `dps` mapping (string keys).
|
|
45
|
+
|
|
46
|
+
Coerces each DP through a type filter rather than trusting the
|
|
47
|
+
wire payload — a malformed frame or a firmware that ships a
|
|
48
|
+
string where we expect an int would otherwise propagate into
|
|
49
|
+
entity arithmetic (e.g. `d.temp_set - d.temp_current`) and
|
|
50
|
+
break consumers in surprising ways. The defensive choice for a
|
|
51
|
+
DP whose value does not match its declared type is to expose
|
|
52
|
+
it as None and keep the raw dict intact for diagnostics.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def _bool(dp: int) -> bool | None:
|
|
56
|
+
value = dps.get(str(dp))
|
|
57
|
+
return value if isinstance(value, bool) else None
|
|
58
|
+
|
|
59
|
+
def _int(dp: int) -> int | None:
|
|
60
|
+
value = dps.get(str(dp))
|
|
61
|
+
# bool is a subclass of int in Python; reject it explicitly
|
|
62
|
+
# so a power-style DP doesn't accidentally satisfy an int DP.
|
|
63
|
+
if isinstance(value, bool):
|
|
64
|
+
return None
|
|
65
|
+
return value if isinstance(value, int) else None
|
|
66
|
+
|
|
67
|
+
def _str(dp: int) -> str | None:
|
|
68
|
+
value = dps.get(str(dp))
|
|
69
|
+
return value if isinstance(value, str) else None
|
|
70
|
+
|
|
71
|
+
return cls(
|
|
72
|
+
power=_bool(const.DP_POWER),
|
|
73
|
+
temp_set=_int(const.DP_TEMP_SET),
|
|
74
|
+
temp_current=_int(const.DP_TEMP_CURRENT),
|
|
75
|
+
mode=_str(const.DP_MODE),
|
|
76
|
+
fault=_int(const.DP_FAULT),
|
|
77
|
+
exhaust_temp=_int(const.DP_EXHAUST_TEMP),
|
|
78
|
+
return_temp=_int(const.DP_RETURN_TEMP),
|
|
79
|
+
coil_temp=_int(const.DP_COIL_TEMP),
|
|
80
|
+
ambient_temp=_int(const.DP_AMBIENT_TEMP),
|
|
81
|
+
inlet_temp=_int(const.DP_INLET_TEMP),
|
|
82
|
+
outlet_temp=_int(const.DP_OUTLET_TEMP),
|
|
83
|
+
target_frequency=_int(const.DP_TARGET_FREQUENCY),
|
|
84
|
+
actual_frequency=_int(const.DP_ACTUAL_FREQUENCY),
|
|
85
|
+
eev_steps=_int(const.DP_EEV_STEPS),
|
|
86
|
+
fan_speed=_int(const.DP_FAN_SPEED),
|
|
87
|
+
water_pump=_bool(const.DP_WATER_PUMP),
|
|
88
|
+
raw=dict(dps),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
def merge(self, dps: dict[str, Any]) -> DeviceState:
|
|
92
|
+
"""Return a new state with `dps` overlaid onto the current `raw` dict."""
|
|
93
|
+
|
|
94
|
+
merged = {**self.raw, **dps}
|
|
95
|
+
return DeviceState.from_dps(merged)
|
pysilverline/protocol.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Tuya local protocol v3.3 frame codec.
|
|
2
|
+
|
|
3
|
+
A frame on the wire is:
|
|
4
|
+
|
|
5
|
+
[prefix:4][seq:4][cmd:4][size:4][payload:N][crc32:4][suffix:4]
|
|
6
|
+
|
|
7
|
+
with `size = N + 8`. CRC32 is computed over everything before the CRC bytes.
|
|
8
|
+
All multi-byte integers are big-endian.
|
|
9
|
+
|
|
10
|
+
Payloads for outbound CONTROL/REFRESH commands are prefixed with 15 bytes of
|
|
11
|
+
v3.3 header (b"3.3" + 12 nulls) before AES encryption; DP_QUERY (0x0a) is
|
|
12
|
+
encrypted directly. Inbound payloads start with a 4-byte return code on most
|
|
13
|
+
command echoes, optionally followed by the v3.3 header and the AES-encrypted
|
|
14
|
+
JSON body.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import binascii
|
|
20
|
+
import itertools
|
|
21
|
+
import json
|
|
22
|
+
import struct
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
27
|
+
|
|
28
|
+
from . import const
|
|
29
|
+
from .exceptions import IncompleteFrame, InvalidAuth, ProtocolError
|
|
30
|
+
|
|
31
|
+
_HEADER_FMT = ">IIII" # prefix, seq, cmd, size
|
|
32
|
+
_HEADER_SIZE = struct.calcsize(_HEADER_FMT)
|
|
33
|
+
_FOOTER_FMT = ">II" # crc32, suffix
|
|
34
|
+
_FOOTER_SIZE = struct.calcsize(_FOOTER_FMT)
|
|
35
|
+
_BLOCK_SIZE = 16 # AES-128 block size
|
|
36
|
+
# Upper bound on the wire-claimed `size` field. Real Tuya frames from a heat
|
|
37
|
+
# pump are well under 1 KiB; capping at 64 KiB prevents a hostile LAN peer
|
|
38
|
+
# from claiming a 4 GiB frame to exhaust memory while we wait for bytes.
|
|
39
|
+
_MAX_FRAME_SIZE = 64 * 1024
|
|
40
|
+
|
|
41
|
+
_RETCODE_INVALID_KEY = {0x00000FFF, 0xFFFFFFFF}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _pkcs7_pad(data: bytes) -> bytes:
|
|
45
|
+
pad_len = _BLOCK_SIZE - (len(data) % _BLOCK_SIZE)
|
|
46
|
+
return data + bytes([pad_len]) * pad_len
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _pkcs7_unpad(data: bytes) -> bytes:
|
|
50
|
+
if not data or len(data) % _BLOCK_SIZE != 0:
|
|
51
|
+
raise ProtocolError("ciphertext length not a multiple of block size")
|
|
52
|
+
pad_len = data[-1]
|
|
53
|
+
if pad_len < 1 or pad_len > _BLOCK_SIZE:
|
|
54
|
+
raise ProtocolError("invalid PKCS#7 padding")
|
|
55
|
+
if data[-pad_len:] != bytes([pad_len]) * pad_len:
|
|
56
|
+
raise ProtocolError("corrupt PKCS#7 padding")
|
|
57
|
+
return data[:-pad_len]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _make_cipher(key: bytes) -> Cipher[modes.ECB]:
|
|
61
|
+
if len(key) != 16:
|
|
62
|
+
raise ValueError(f"local_key must be 16 bytes, got {len(key)}")
|
|
63
|
+
return Cipher(algorithms.AES(key), modes.ECB())
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def aes_encrypt(plaintext: bytes, key: bytes) -> bytes:
|
|
67
|
+
"""AES-128-ECB encrypt with PKCS#7 padding."""
|
|
68
|
+
encryptor = _make_cipher(key).encryptor()
|
|
69
|
+
return encryptor.update(_pkcs7_pad(plaintext)) + encryptor.finalize()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def aes_decrypt(ciphertext: bytes, key: bytes) -> bytes:
|
|
73
|
+
"""AES-128-ECB decrypt with PKCS#7 unpadding."""
|
|
74
|
+
decryptor = _make_cipher(key).decryptor()
|
|
75
|
+
raw = decryptor.update(ciphertext) + decryptor.finalize()
|
|
76
|
+
return _pkcs7_unpad(raw)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(slots=True, kw_only=True)
|
|
80
|
+
class Frame:
|
|
81
|
+
"""A decoded Tuya wire frame.
|
|
82
|
+
|
|
83
|
+
``payload`` is the raw inner bytes; use ``FrameCodec.split_response_payload``
|
|
84
|
+
or ``FrameCodec.split_request_payload`` to peel the retcode / v3.3 header
|
|
85
|
+
before decryption, depending on direction.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
seq: int
|
|
89
|
+
cmd: int
|
|
90
|
+
payload: bytes
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class FrameCodec:
|
|
94
|
+
"""Encodes outbound frames and decodes inbound ones for one device.
|
|
95
|
+
|
|
96
|
+
Sequence numbers monotonically increase per outbound frame; the codec is
|
|
97
|
+
not thread-safe, callers should serialize use within their own locks.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
def __init__(self, local_key: str) -> None:
|
|
101
|
+
self._key = local_key.encode("utf-8")
|
|
102
|
+
if len(self._key) != 16:
|
|
103
|
+
raise ValueError("local_key must be 16 ASCII characters")
|
|
104
|
+
self._seq = itertools.count(1)
|
|
105
|
+
|
|
106
|
+
def next_seq(self) -> int:
|
|
107
|
+
return next(self._seq)
|
|
108
|
+
|
|
109
|
+
def encode(self, cmd: int, body: dict[str, Any]) -> bytes:
|
|
110
|
+
"""Build a complete frame for `cmd` with JSON-serialized `body`."""
|
|
111
|
+
|
|
112
|
+
plaintext = json.dumps(body, separators=(",", ":")).encode("utf-8")
|
|
113
|
+
ciphertext = aes_encrypt(plaintext, self._key)
|
|
114
|
+
if cmd not in const.CMDS_WITHOUT_HEADER:
|
|
115
|
+
payload = const.PROTOCOL_33_HEADER + ciphertext
|
|
116
|
+
else:
|
|
117
|
+
payload = ciphertext
|
|
118
|
+
|
|
119
|
+
seq = self.next_seq()
|
|
120
|
+
size = len(payload) + _FOOTER_SIZE
|
|
121
|
+
header = struct.pack(_HEADER_FMT, const.FRAME_PREFIX, seq, cmd, size)
|
|
122
|
+
body_bytes = header + payload
|
|
123
|
+
crc = binascii.crc32(body_bytes) & 0xFFFFFFFF
|
|
124
|
+
return body_bytes + struct.pack(_FOOTER_FMT, crc, const.FRAME_SUFFIX)
|
|
125
|
+
|
|
126
|
+
def decode(self, data: bytes) -> tuple[Frame, bytes]:
|
|
127
|
+
"""Decode the first complete frame from `data`.
|
|
128
|
+
|
|
129
|
+
Returns the decoded frame (with its raw inner payload — the v3.3
|
|
130
|
+
header and any retcode prefix are NOT stripped here, since that
|
|
131
|
+
depends on whether the frame is a request or a response) and the
|
|
132
|
+
unconsumed remainder of the buffer.
|
|
133
|
+
|
|
134
|
+
Raises ``IncompleteFrame`` when more bytes are needed before a
|
|
135
|
+
frame can be decoded — caller should accumulate more bytes and
|
|
136
|
+
retry. Raises ``ProtocolError`` only when the bytes that have
|
|
137
|
+
arrived violate the spec (bad prefix/suffix/size/CRC), in which
|
|
138
|
+
case the connection is desynchronized and must be dropped.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
if len(data) < _HEADER_SIZE + _FOOTER_SIZE:
|
|
142
|
+
raise IncompleteFrame("header not yet complete")
|
|
143
|
+
prefix, seq, cmd, size = struct.unpack(_HEADER_FMT, data[:_HEADER_SIZE])
|
|
144
|
+
# Validate the prefix BEFORE the size cap: a peer that sends
|
|
145
|
+
# garbage shaped vaguely like a Tuya frame might also produce a
|
|
146
|
+
# plausibly-sized but bogus size field, and we want the more
|
|
147
|
+
# specific "bad prefix" diagnostic in the logs.
|
|
148
|
+
if prefix != const.FRAME_PREFIX:
|
|
149
|
+
raise ProtocolError(f"bad prefix 0x{prefix:08x}")
|
|
150
|
+
if size > _MAX_FRAME_SIZE:
|
|
151
|
+
raise ProtocolError(f"frame too large: {size}")
|
|
152
|
+
total = _HEADER_SIZE + size
|
|
153
|
+
if len(data) < total:
|
|
154
|
+
raise IncompleteFrame(f"need {total - len(data)} more bytes")
|
|
155
|
+
|
|
156
|
+
payload_end = total - _FOOTER_SIZE
|
|
157
|
+
payload = data[_HEADER_SIZE:payload_end]
|
|
158
|
+
crc, suffix = struct.unpack(_FOOTER_FMT, data[payload_end:total])
|
|
159
|
+
if suffix != const.FRAME_SUFFIX:
|
|
160
|
+
raise ProtocolError(f"bad suffix 0x{suffix:08x}")
|
|
161
|
+
if crc != binascii.crc32(data[:payload_end]) & 0xFFFFFFFF:
|
|
162
|
+
raise ProtocolError("CRC mismatch")
|
|
163
|
+
|
|
164
|
+
return Frame(seq=seq, cmd=cmd, payload=payload), data[total:]
|
|
165
|
+
|
|
166
|
+
@staticmethod
|
|
167
|
+
def split_response_payload(cmd: int, payload: bytes) -> tuple[int | None, bytes]:
|
|
168
|
+
"""Peel a 4-byte retcode and a v3.3 header off a response payload.
|
|
169
|
+
|
|
170
|
+
Use this on frames received in response to commands we sent
|
|
171
|
+
(CONTROL/DP_QUERY/DP_REFRESH). Spontaneous pushes (CMD_STATUS,
|
|
172
|
+
CMD_HEART_BEAT) carry no retcode, so callers should pass them
|
|
173
|
+
directly to ``decrypt_body``.
|
|
174
|
+
"""
|
|
175
|
+
retcode: int | None = None
|
|
176
|
+
body = payload
|
|
177
|
+
if cmd in (const.CMD_CONTROL, const.CMD_DP_QUERY, const.CMD_DP_REFRESH):
|
|
178
|
+
if len(body) >= 4:
|
|
179
|
+
retcode = struct.unpack(">I", body[:4])[0]
|
|
180
|
+
body = body[4:]
|
|
181
|
+
if body.startswith(const.PROTOCOL_VERSION):
|
|
182
|
+
body = body[len(const.PROTOCOL_33_HEADER):]
|
|
183
|
+
return retcode, body
|
|
184
|
+
|
|
185
|
+
@staticmethod
|
|
186
|
+
def split_request_payload(payload: bytes) -> bytes:
|
|
187
|
+
"""Strip the optional v3.3 header from a push frame payload.
|
|
188
|
+
|
|
189
|
+
Real WBR3 firmwares send spontaneous ``CMD_STATUS`` pushes shaped
|
|
190
|
+
as ``[4-byte zero retcode][v3.3 header][ciphertext]``, even though
|
|
191
|
+
the Tuya protocol notes describe pushes as headerless. We peel
|
|
192
|
+
either shape so push DPs decrypt correctly.
|
|
193
|
+
"""
|
|
194
|
+
if payload.startswith(const.PROTOCOL_VERSION):
|
|
195
|
+
return payload[len(const.PROTOCOL_33_HEADER):]
|
|
196
|
+
if len(payload) >= 4 and payload[4:].startswith(const.PROTOCOL_VERSION):
|
|
197
|
+
return payload[4 + len(const.PROTOCOL_33_HEADER):]
|
|
198
|
+
return payload
|
|
199
|
+
|
|
200
|
+
def decrypt_body(self, body: bytes) -> dict[str, Any]:
|
|
201
|
+
"""Decrypt a payload body and parse it as JSON.
|
|
202
|
+
|
|
203
|
+
Empty bodies return an empty dict; a non-JSON or auth-rejected
|
|
204
|
+
ciphertext raises InvalidAuth so the caller can trigger a reauth flow.
|
|
205
|
+
"""
|
|
206
|
+
|
|
207
|
+
if not body:
|
|
208
|
+
return {}
|
|
209
|
+
try:
|
|
210
|
+
plaintext = aes_decrypt(body, self._key)
|
|
211
|
+
except (ProtocolError, ValueError) as err:
|
|
212
|
+
raise InvalidAuth("decryption failed — local_key likely wrong") from err
|
|
213
|
+
try:
|
|
214
|
+
parsed = json.loads(plaintext)
|
|
215
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as err:
|
|
216
|
+
raise InvalidAuth("decrypted payload is not JSON") from err
|
|
217
|
+
if not isinstance(parsed, dict):
|
|
218
|
+
raise InvalidAuth(f"decrypted payload is not a JSON object: {type(parsed).__name__}")
|
|
219
|
+
return parsed
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def is_invalid_auth_retcode(retcode: int | None) -> bool:
|
|
223
|
+
"""Some firmwares signal a wrong local_key with these return codes."""
|
|
224
|
+
return retcode is not None and retcode in _RETCODE_INVALID_KEY
|
pysilverline/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pysilverline
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: Async client for Poolex Silverline / Tuya v3.3 pool heat pumps.
|
|
5
|
+
Project-URL: Homepage, https://github.com/christianreiss/ha-silverline
|
|
6
|
+
Project-URL: Issues, https://github.com/christianreiss/ha-silverline/issues
|
|
7
|
+
Author-email: Christian Reiss <email@christian-reiss.de>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: heatpump,home-assistant,poolex,silverline,tuya
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Topic :: Home Automation
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: >=3.13
|
|
18
|
+
Requires-Dist: cryptography>=41.0
|
|
19
|
+
Provides-Extra: test
|
|
20
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'test'
|
|
21
|
+
Requires-Dist: pytest>=8; extra == 'test'
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# pysilverline
|
|
25
|
+
|
|
26
|
+
Async client for **Poolex Silverline / Tuya v3.3** pool heat pumps. Speaks the
|
|
27
|
+
local Tuya protocol (TCP/6668, AES-128-ECB) directly — no cloud, no Smart Life
|
|
28
|
+
account at runtime.
|
|
29
|
+
|
|
30
|
+
This package is the I/O layer underneath the
|
|
31
|
+
[`poolex_silverline`](https://github.com/christianreiss/ha-silverline) Home
|
|
32
|
+
Assistant integration but works standalone too.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install pysilverline
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Use
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
import asyncio
|
|
44
|
+
from pysilverline import SilverlineClient
|
|
45
|
+
|
|
46
|
+
async def main():
|
|
47
|
+
client = SilverlineClient(
|
|
48
|
+
host="10.0.0.50",
|
|
49
|
+
device_id="bf1234567890abcdefghij",
|
|
50
|
+
local_key="0123456789abcdef",
|
|
51
|
+
)
|
|
52
|
+
await client.connect()
|
|
53
|
+
state = await client.get_status()
|
|
54
|
+
print(state)
|
|
55
|
+
await client.set_dp(2, 28) # set target temp to 28 °C
|
|
56
|
+
await client.set_dp(4, "BoostHeat")
|
|
57
|
+
await client.disconnect()
|
|
58
|
+
|
|
59
|
+
asyncio.run(main())
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Listen for spontaneous push updates from the device:
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
def on_update(state):
|
|
66
|
+
print("push:", state.mode, state.temp_current)
|
|
67
|
+
|
|
68
|
+
unsub = client.add_listener(on_update)
|
|
69
|
+
# ... later
|
|
70
|
+
unsub()
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Compatible devices
|
|
74
|
+
|
|
75
|
+
The Tuya schema is shared across the Poolex Silverline FI family and several
|
|
76
|
+
OEM siblings: Poolex JetLine Selection FI, Steinbach Silent Mini, Brustec BR
|
|
77
|
+
series, Phalén Calidi XP. DPs 1, 2, 3, 4, 13 are confirmed across the family;
|
|
78
|
+
DPs 101–111 are firmware-dependent.
|
|
79
|
+
|
|
80
|
+
## License
|
|
81
|
+
|
|
82
|
+
MIT
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
pysilverline/__init__.py,sha256=kkEFBWeO_XpLWZiIuXtkoBQU2K8Pd6EX2t9dNFwYIzs,628
|
|
2
|
+
pysilverline/client.py,sha256=uQ1Dbdze4h3bFQU1fOoZJ_oSMwFUVs92_fs0mIAPQeE,19235
|
|
3
|
+
pysilverline/const.py,sha256=p_lD2_ZJrpWlLazArVO8GW1tCrwz9YCBmexWm8ni-XY,1665
|
|
4
|
+
pysilverline/discovery.py,sha256=bzqjjipM2jIthfjQJPaenRHdKZvsItwVK8qOz-UNRIY,6758
|
|
5
|
+
pysilverline/exceptions.py,sha256=ahmODhZEr8s08ZggmY4Gx0ImlAfe5G2v3I1OyfvO5T0,835
|
|
6
|
+
pysilverline/models.py,sha256=5JGBw_e5XMjIaQUsDSiSev0fGaNk55g_N1myR454PC0,3539
|
|
7
|
+
pysilverline/protocol.py,sha256=L2wSV9y2a5iwvZc5gx2DHribasdLF8VTckUS44LJors,8934
|
|
8
|
+
pysilverline/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
pysilverline-0.2.1.dist-info/METADATA,sha256=UvZAawiUl8Nz_ZNyRlT4KodenoJbIhOb9VKOi13osqw,2318
|
|
10
|
+
pysilverline-0.2.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
11
|
+
pysilverline-0.2.1.dist-info/licenses/LICENSE,sha256=_TcJXhABY2mEfjP6dLgAghPRnVus7cj7Y_RzJg17xXo,1072
|
|
12
|
+
pysilverline-0.2.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Christian Reiss
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|