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.
@@ -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)
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.