python-broadlink 1.0.1__py3-none-any.whl → 1.0.2__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.
- broadlink/__init__.py +17 -12
- broadlink/alarm.py +1 -0
- broadlink/climate.py +10 -27
- broadlink/const.py +1 -0
- broadlink/cover.py +2 -1
- broadlink/device.py +95 -86
- broadlink/exceptions.py +15 -3
- broadlink/helpers.py +4 -3
- broadlink/hub.py +8 -10
- broadlink/light.py +31 -33
- broadlink/protocol.py +1 -0
- broadlink/remote.py +101 -59
- broadlink/sensor.py +2 -1
- broadlink/switch.py +32 -32
- {python_broadlink-1.0.1.dist-info → python_broadlink-1.0.2.dist-info}/METADATA +65 -12
- python_broadlink-1.0.2.dist-info/RECORD +19 -0
- python_broadlink-1.0.1.dist-info/RECORD +0 -19
- {python_broadlink-1.0.1.dist-info → python_broadlink-1.0.2.dist-info}/WHEEL +0 -0
- {python_broadlink-1.0.1.dist-info → python_broadlink-1.0.2.dist-info}/licenses/LICENSE +0 -0
- {python_broadlink-1.0.1.dist-info → python_broadlink-1.0.2.dist-info}/top_level.txt +0 -0
broadlink/__init__.py
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""The python-broadlink library."""
|
|
3
|
+
|
|
4
|
+
import contextlib
|
|
3
5
|
from collections.abc import AsyncIterator
|
|
4
|
-
from typing import
|
|
6
|
+
from typing import Optional, Union
|
|
5
7
|
|
|
6
8
|
from . import exceptions as e
|
|
7
9
|
from .alarm import S1C
|
|
@@ -223,8 +225,8 @@ SUPPORTED_TYPES = {
|
|
|
223
225
|
|
|
224
226
|
def gendevice(
|
|
225
227
|
dev_type: int,
|
|
226
|
-
host:
|
|
227
|
-
mac:
|
|
228
|
+
host: tuple[str, int],
|
|
229
|
+
mac: bytes | str,
|
|
228
230
|
name: str = "",
|
|
229
231
|
is_locked: bool = False,
|
|
230
232
|
) -> Device:
|
|
@@ -258,12 +260,15 @@ async def hello(
|
|
|
258
260
|
|
|
259
261
|
Useful if the device is locked.
|
|
260
262
|
"""
|
|
261
|
-
async
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
263
|
+
async with contextlib.aclosing(
|
|
264
|
+
xdiscover(
|
|
265
|
+
timeout=timeout,
|
|
266
|
+
discover_ip_address=ip_address,
|
|
267
|
+
discover_ip_port=port,
|
|
268
|
+
)
|
|
269
|
+
) as devices:
|
|
270
|
+
async for device in devices:
|
|
271
|
+
return device
|
|
267
272
|
raise e.NetworkTimeoutError(
|
|
268
273
|
-4000,
|
|
269
274
|
"Network timeout",
|
|
@@ -273,10 +278,10 @@ async def hello(
|
|
|
273
278
|
|
|
274
279
|
async def discover(
|
|
275
280
|
timeout: float = DEFAULT_TIMEOUT,
|
|
276
|
-
local_ip_address:
|
|
281
|
+
local_ip_address: str | None = None,
|
|
277
282
|
discover_ip_address: str = DEFAULT_BCAST_ADDR,
|
|
278
283
|
discover_ip_port: int = DEFAULT_PORT,
|
|
279
|
-
) ->
|
|
284
|
+
) -> list[Device]:
|
|
280
285
|
"""Discover devices connected to the local network."""
|
|
281
286
|
return [
|
|
282
287
|
device
|
|
@@ -288,7 +293,7 @@ async def discover(
|
|
|
288
293
|
|
|
289
294
|
async def xdiscover(
|
|
290
295
|
timeout: float = DEFAULT_TIMEOUT,
|
|
291
|
-
local_ip_address:
|
|
296
|
+
local_ip_address: str | None = None,
|
|
292
297
|
discover_ip_address: str = DEFAULT_BCAST_ADDR,
|
|
293
298
|
discover_ip_port: int = DEFAULT_PORT,
|
|
294
299
|
) -> AsyncIterator[Device]:
|
broadlink/alarm.py
CHANGED
broadlink/climate.py
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"""Support for climate control."""
|
|
2
|
+
|
|
2
3
|
import enum
|
|
3
4
|
import struct
|
|
4
|
-
from
|
|
5
|
+
from collections.abc import Sequence
|
|
5
6
|
|
|
6
7
|
from . import exceptions as e
|
|
7
8
|
from .device import Device
|
|
@@ -33,7 +34,7 @@ class hysen(Device):
|
|
|
33
34
|
payload = self.decrypt(response[0x38:])
|
|
34
35
|
|
|
35
36
|
p_len = int.from_bytes(payload[:0x02], "little")
|
|
36
|
-
nom_crc = int.from_bytes(payload[p_len:p_len+2], "little")
|
|
37
|
+
nom_crc = int.from_bytes(payload[p_len : p_len + 2], "little")
|
|
37
38
|
real_crc = CRC16.calculate(payload[0x02:p_len])
|
|
38
39
|
|
|
39
40
|
if nom_crc != real_crc:
|
|
@@ -83,9 +84,7 @@ class hysen(Device):
|
|
|
83
84
|
data["dif"] = payload[10]
|
|
84
85
|
data["svh"] = payload[11]
|
|
85
86
|
data["svl"] = payload[12]
|
|
86
|
-
data["room_temp_adj"] = (
|
|
87
|
-
int.from_bytes(payload[13:15], "big", signed=True) / 10.0
|
|
88
|
-
)
|
|
87
|
+
data["room_temp_adj"] = int.from_bytes(payload[13:15], "big", signed=True) / 10.0
|
|
89
88
|
data["fre"] = payload[15]
|
|
90
89
|
data["poweron"] = payload[16]
|
|
91
90
|
data["unknown"] = payload[17]
|
|
@@ -127,9 +126,7 @@ class hysen(Device):
|
|
|
127
126
|
# E.g. loop_mode = 0 ("12345,67") means Saturday and Sunday (weekend schedule)
|
|
128
127
|
# loop_mode = 2 ("1234567") means every day, including Saturday and Sunday (weekday schedule)
|
|
129
128
|
# The sensor command is currently experimental
|
|
130
|
-
async def set_mode(
|
|
131
|
-
self, auto_mode: int, loop_mode: int, sensor: int = 0
|
|
132
|
-
) -> None:
|
|
129
|
+
async def set_mode(self, auto_mode: int, loop_mode: int, sensor: int = 0) -> None:
|
|
133
130
|
"""Set the mode of the device."""
|
|
134
131
|
mode_byte = ((loop_mode + 1) << 4) + auto_mode
|
|
135
132
|
await self.send_request([0x01, 0x06, 0x00, 0x02, mode_byte, sensor])
|
|
@@ -210,19 +207,7 @@ class hysen(Device):
|
|
|
210
207
|
async def set_time(self, hour: int, minute: int, second: int, day: int) -> None:
|
|
211
208
|
"""Set the time."""
|
|
212
209
|
await self.send_request(
|
|
213
|
-
[
|
|
214
|
-
0x01,
|
|
215
|
-
0x10,
|
|
216
|
-
0x00,
|
|
217
|
-
0x08,
|
|
218
|
-
0x00,
|
|
219
|
-
0x02,
|
|
220
|
-
0x04,
|
|
221
|
-
hour,
|
|
222
|
-
minute,
|
|
223
|
-
second,
|
|
224
|
-
day
|
|
225
|
-
]
|
|
210
|
+
[0x01, 0x10, 0x00, 0x08, 0x00, 0x02, 0x04, hour, minute, second, day]
|
|
226
211
|
)
|
|
227
212
|
|
|
228
213
|
# Set timer schedule
|
|
@@ -231,7 +216,7 @@ class hysen(Device):
|
|
|
231
216
|
# {'start_hour':17, 'start_minute':30, 'temp': 22 }
|
|
232
217
|
# Each one specifies the thermostat temp that will become effective at start_hour:start_minute
|
|
233
218
|
# weekend is similar but only has 2 (e.g. switch on in morning and off in afternoon)
|
|
234
|
-
async def set_schedule(self, weekday:
|
|
219
|
+
async def set_schedule(self, weekday: list[dict], weekend: list[dict]) -> None:
|
|
235
220
|
"""Set timer schedule."""
|
|
236
221
|
request = [0x01, 0x10, 0x00, 0x0A, 0x00, 0x0C, 0x18]
|
|
237
222
|
|
|
@@ -317,9 +302,7 @@ class hvac(Device):
|
|
|
317
302
|
"""Encode data for transport."""
|
|
318
303
|
packet = bytearray(10)
|
|
319
304
|
p_len = 10 + len(data)
|
|
320
|
-
struct.pack_into(
|
|
321
|
-
"<HHHHH", packet, 0, p_len, 0x00BB, 0x8006, 0, len(data)
|
|
322
|
-
)
|
|
305
|
+
struct.pack_into("<HHHHH", packet, 0, p_len, 0x00BB, 0x8006, 0, len(data))
|
|
323
306
|
packet += data
|
|
324
307
|
crc = CRC16.calculate(packet[0x02:], polynomial=0x9BE4)
|
|
325
308
|
packet += crc.to_bytes(2, "little")
|
|
@@ -330,7 +313,7 @@ class hvac(Device):
|
|
|
330
313
|
# payload[0x2:0x8] == bytes([0xbb, 0x00, 0x07, 0x00, 0x00, 0x00])
|
|
331
314
|
payload = self.decrypt(response[0x38:])
|
|
332
315
|
p_len = int.from_bytes(payload[:0x02], "little")
|
|
333
|
-
nom_crc = int.from_bytes(payload[p_len:p_len+2], "little")
|
|
316
|
+
nom_crc = int.from_bytes(payload[p_len : p_len + 2], "little")
|
|
334
317
|
real_crc = CRC16.calculate(payload[0x02:p_len], polynomial=0x9BE4)
|
|
335
318
|
|
|
336
319
|
if nom_crc != real_crc:
|
|
@@ -341,7 +324,7 @@ class hvac(Device):
|
|
|
341
324
|
)
|
|
342
325
|
|
|
343
326
|
d_len = int.from_bytes(payload[0x08:0x0A], "little")
|
|
344
|
-
return payload[0x0A:0x0A+d_len]
|
|
327
|
+
return payload[0x0A : 0x0A + d_len]
|
|
345
328
|
|
|
346
329
|
async def _send(self, command: int, data: bytes = b"") -> bytes:
|
|
347
330
|
"""Send a command to the unit."""
|
broadlink/const.py
CHANGED
broadlink/cover.py
CHANGED
broadlink/device.py
CHANGED
|
@@ -11,10 +11,11 @@ from __future__ import annotations
|
|
|
11
11
|
|
|
12
12
|
import asyncio
|
|
13
13
|
import collections
|
|
14
|
+
import contextlib
|
|
15
|
+
import logging
|
|
14
16
|
import random
|
|
15
17
|
import socket
|
|
16
18
|
from collections.abc import AsyncIterator
|
|
17
|
-
from typing import Optional, Tuple, Union
|
|
18
19
|
|
|
19
20
|
from cryptography.hazmat.backends import default_backend
|
|
20
21
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
@@ -28,17 +29,21 @@ from .const import (
|
|
|
28
29
|
)
|
|
29
30
|
from .protocol import Datetime
|
|
30
31
|
|
|
31
|
-
|
|
32
|
+
_LOGGER = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
HelloResponse = tuple[int, tuple[str, int], bytes, str, bool]
|
|
32
35
|
|
|
33
36
|
# Device error codes that mean the session key is no longer accepted and a
|
|
34
37
|
# fresh auth() will fix it. -2: logged out; -7: control key expired;
|
|
35
38
|
# -4012: control id error.
|
|
36
39
|
_REAUTH_CODES = {-2, -7, -4012}
|
|
37
40
|
|
|
38
|
-
# How many
|
|
39
|
-
# of them
|
|
40
|
-
# the answer to
|
|
41
|
-
|
|
41
|
+
# How many recently used request counters to remember. A reply carrying one
|
|
42
|
+
# of them (other than the current request's) is a late or duplicate answer
|
|
43
|
+
# to an earlier request and is dropped rather than taken as the answer to
|
|
44
|
+
# the current one. 64 covers a burst of resends comfortably and ages out
|
|
45
|
+
# long before the 16-bit counter wraps.
|
|
46
|
+
_RECENT_MAX = 64
|
|
42
47
|
|
|
43
48
|
_CLOSED = (None, None)
|
|
44
49
|
"""Sentinel put on the receive queue when the endpoint is closed."""
|
|
@@ -49,8 +54,7 @@ class _Protocol(asyncio.DatagramProtocol):
|
|
|
49
54
|
|
|
50
55
|
def __init__(self) -> None:
|
|
51
56
|
self.queue: asyncio.Queue[tuple[bytes, tuple[str, int]]] = asyncio.Queue()
|
|
52
|
-
self.transport:
|
|
53
|
-
self.closed = asyncio.get_running_loop().create_future()
|
|
57
|
+
self.transport: asyncio.DatagramTransport | None = None
|
|
54
58
|
|
|
55
59
|
def connection_made(self, transport) -> None: # type: ignore[override]
|
|
56
60
|
self.transport = transport
|
|
@@ -63,9 +67,8 @@ class _Protocol(asyncio.DatagramProtocol):
|
|
|
63
67
|
# the retry loop will time out and raise NetworkTimeoutError.
|
|
64
68
|
pass
|
|
65
69
|
|
|
66
|
-
def connection_lost(self, exc:
|
|
67
|
-
|
|
68
|
-
self.closed.set_result(None)
|
|
70
|
+
def connection_lost(self, exc: Exception | None) -> None:
|
|
71
|
+
pass
|
|
69
72
|
|
|
70
73
|
def drain(self) -> None:
|
|
71
74
|
"""Drop anything that arrived before the current request."""
|
|
@@ -74,8 +77,8 @@ class _Protocol(asyncio.DatagramProtocol):
|
|
|
74
77
|
|
|
75
78
|
|
|
76
79
|
async def _open_endpoint(
|
|
77
|
-
local_addr:
|
|
78
|
-
remote_addr:
|
|
80
|
+
local_addr: tuple[str, int] | None = None,
|
|
81
|
+
remote_addr: tuple[str, int] | None = None,
|
|
79
82
|
broadcast: bool = False,
|
|
80
83
|
) -> tuple[asyncio.DatagramTransport, _Protocol]:
|
|
81
84
|
"""Create a UDP endpoint. Tests replace this to fake the network."""
|
|
@@ -111,7 +114,7 @@ def _parse_hello(resp: bytes, host: tuple[str, int]) -> HelloResponse:
|
|
|
111
114
|
|
|
112
115
|
async def scan(
|
|
113
116
|
timeout: float = DEFAULT_TIMEOUT,
|
|
114
|
-
local_ip_address:
|
|
117
|
+
local_ip_address: str | None = None,
|
|
115
118
|
discover_ip_address: str = DEFAULT_BCAST_ADDR,
|
|
116
119
|
discover_ip_port: int = DEFAULT_PORT,
|
|
117
120
|
) -> AsyncIterator[HelloResponse]:
|
|
@@ -144,7 +147,7 @@ async def scan(
|
|
|
144
147
|
break
|
|
145
148
|
try:
|
|
146
149
|
resp, host = await asyncio.wait_for(protocol.queue.get(), remaining)
|
|
147
|
-
except
|
|
150
|
+
except TimeoutError:
|
|
148
151
|
break
|
|
149
152
|
if len(resp) < 0x80:
|
|
150
153
|
continue
|
|
@@ -184,8 +187,8 @@ class Device:
|
|
|
184
187
|
|
|
185
188
|
def __init__(
|
|
186
189
|
self,
|
|
187
|
-
host:
|
|
188
|
-
mac:
|
|
190
|
+
host: tuple[str, int],
|
|
191
|
+
mac: bytes | str,
|
|
189
192
|
devtype: int,
|
|
190
193
|
timeout: float = DEFAULT_TIMEOUT,
|
|
191
194
|
name: str = "",
|
|
@@ -210,42 +213,32 @@ class Device:
|
|
|
210
213
|
self.aes = None
|
|
211
214
|
self.update_aes(bytes.fromhex(self.__INIT_KEY))
|
|
212
215
|
|
|
213
|
-
self._lock:
|
|
214
|
-
self._transport:
|
|
215
|
-
self._protocol:
|
|
216
|
-
self._endpoint_addr:
|
|
217
|
-
self.
|
|
218
|
-
self._reauth_lock:
|
|
216
|
+
self._lock: asyncio.Lock | None = None
|
|
217
|
+
self._transport: asyncio.DatagramTransport | None = None
|
|
218
|
+
self._protocol: _Protocol | None = None
|
|
219
|
+
self._endpoint_addr: tuple[str, int] | None = None
|
|
220
|
+
self._recent: collections.deque[int] = collections.deque(maxlen=_RECENT_MAX)
|
|
221
|
+
self._reauth_lock: asyncio.Lock | None = None
|
|
219
222
|
self._auth_generation = 0
|
|
220
223
|
|
|
221
224
|
def __repr__(self) -> str:
|
|
222
225
|
"""Return a formal representation of the device."""
|
|
223
226
|
return (
|
|
224
|
-
"
|
|
225
|
-
"
|
|
226
|
-
|
|
227
|
-
self.
|
|
228
|
-
self.
|
|
229
|
-
self.host,
|
|
230
|
-
self.mac,
|
|
231
|
-
self.devtype,
|
|
232
|
-
self.timeout,
|
|
233
|
-
self.name,
|
|
234
|
-
self.model,
|
|
235
|
-
self.manufacturer,
|
|
236
|
-
self.is_locked,
|
|
227
|
+
f"{self.__class__.__module__}.{self.__class__.__qualname__}("
|
|
228
|
+
f"{self.host}, mac={self.mac!r}, devtype={self.devtype!r}, "
|
|
229
|
+
f"timeout={self.timeout!r}, name={self.name!r}, "
|
|
230
|
+
f"model={self.model!r}, manufacturer={self.manufacturer!r}, "
|
|
231
|
+
f"is_locked={self.is_locked!r})"
|
|
237
232
|
)
|
|
238
233
|
|
|
239
234
|
def __str__(self) -> str:
|
|
240
235
|
"""Return a readable representation of the device."""
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
":".join(format(x, "02X") for x in self.mac),
|
|
246
|
-
)
|
|
236
|
+
ident = " ".join(filter(None, [self.manufacturer, self.model, hex(self.devtype)]))
|
|
237
|
+
mac = ":".join(format(x, "02X") for x in self.mac)
|
|
238
|
+
name = self.name or "Unknown"
|
|
239
|
+
return f"{name} ({ident} / {self.host[0]}:{self.host[1]} / {mac})"
|
|
247
240
|
|
|
248
|
-
async def __aenter__(self) ->
|
|
241
|
+
async def __aenter__(self) -> Device:
|
|
249
242
|
return self
|
|
250
243
|
|
|
251
244
|
async def __aexit__(self, *exc) -> None:
|
|
@@ -272,23 +265,31 @@ class Device:
|
|
|
272
265
|
# ---------------------------------------------------------- session
|
|
273
266
|
|
|
274
267
|
async def auth(self) -> bool:
|
|
275
|
-
"""Authenticate to the device.
|
|
276
|
-
self.id = 0
|
|
277
|
-
self.update_aes(bytes.fromhex(self.__INIT_KEY))
|
|
268
|
+
"""Authenticate to the device.
|
|
278
269
|
|
|
270
|
+
The session reset, the exchange and the install of the new key all
|
|
271
|
+
happen while holding the request lock, so a request queued behind
|
|
272
|
+
the lock is never framed with the initial key or device id 0.
|
|
273
|
+
"""
|
|
279
274
|
packet = bytearray(0x50)
|
|
280
275
|
packet[0x04:0x14] = [0x31] * 16
|
|
281
276
|
packet[0x1E] = 0x01
|
|
282
277
|
packet[0x2D] = 0x01
|
|
283
|
-
packet[0x30:0x36] = "Test 1"
|
|
284
|
-
|
|
285
|
-
response = await self.send_packet(0x65, packet, _reauth=False)
|
|
286
|
-
e.check_error(response[0x22:0x24])
|
|
287
|
-
payload = self.decrypt(response[0x38:])
|
|
278
|
+
packet[0x30:0x36] = b"Test 1"
|
|
288
279
|
|
|
289
|
-
self.
|
|
290
|
-
|
|
291
|
-
|
|
280
|
+
if self._lock is None:
|
|
281
|
+
self._lock = asyncio.Lock()
|
|
282
|
+
self._reauth_lock = asyncio.Lock()
|
|
283
|
+
async with self._lock:
|
|
284
|
+
self.id = 0
|
|
285
|
+
self.update_aes(bytes.fromhex(self.__INIT_KEY))
|
|
286
|
+
response = await self._exchange(self._frame(0x65, bytes(packet)))
|
|
287
|
+
e.check_error(response[0x22:0x24])
|
|
288
|
+
payload = self.decrypt(response[0x38:])
|
|
289
|
+
self.id = int.from_bytes(payload[:0x4], "little")
|
|
290
|
+
self.update_aes(payload[0x04:0x14])
|
|
291
|
+
self._auth_generation += 1
|
|
292
|
+
_LOGGER.debug("%s: authenticated, session id %d", self.host[0], self.id)
|
|
292
293
|
return True
|
|
293
294
|
|
|
294
295
|
async def hello(self, local_ip_address=None) -> bool:
|
|
@@ -296,15 +297,17 @@ class Device:
|
|
|
296
297
|
|
|
297
298
|
Device information is checked before updating name and lock status.
|
|
298
299
|
"""
|
|
299
|
-
responses = scan(
|
|
300
|
-
timeout=self.timeout,
|
|
301
|
-
local_ip_address=local_ip_address,
|
|
302
|
-
discover_ip_address=self.host[0],
|
|
303
|
-
discover_ip_port=self.host[1],
|
|
304
|
-
)
|
|
305
300
|
entry = None
|
|
306
|
-
async
|
|
307
|
-
|
|
301
|
+
async with contextlib.aclosing(
|
|
302
|
+
scan(
|
|
303
|
+
timeout=self.timeout,
|
|
304
|
+
local_ip_address=local_ip_address,
|
|
305
|
+
discover_ip_address=self.host[0],
|
|
306
|
+
discover_ip_port=self.host[1],
|
|
307
|
+
)
|
|
308
|
+
) as responses:
|
|
309
|
+
async for entry in responses: # noqa: B007 - first reply only
|
|
310
|
+
break
|
|
308
311
|
if entry is None:
|
|
309
312
|
raise e.NetworkTimeoutError(
|
|
310
313
|
-4000,
|
|
@@ -388,6 +391,7 @@ class Device:
|
|
|
388
391
|
self._endpoint_addr = None
|
|
389
392
|
if transport is not None:
|
|
390
393
|
transport.close()
|
|
394
|
+
_LOGGER.debug("%s: endpoint closed", self.host[0])
|
|
391
395
|
if protocol is not None:
|
|
392
396
|
protocol.queue.put_nowait(_CLOSED) # type: ignore[arg-type]
|
|
393
397
|
|
|
@@ -397,10 +401,9 @@ class Device:
|
|
|
397
401
|
# old address, so drop it.
|
|
398
402
|
await self.aclose()
|
|
399
403
|
if self._transport is None or self._transport.is_closing():
|
|
400
|
-
self._transport, self._protocol = await _open_endpoint(
|
|
401
|
-
remote_addr=self.host
|
|
402
|
-
)
|
|
404
|
+
self._transport, self._protocol = await _open_endpoint(remote_addr=self.host)
|
|
403
405
|
self._endpoint_addr = self.host
|
|
406
|
+
_LOGGER.debug("%s: endpoint opened", self.host[0])
|
|
404
407
|
return self._transport, self._protocol # type: ignore[return-value]
|
|
405
408
|
|
|
406
409
|
def _frame(self, packet_type: int, payload: bytes) -> bytes:
|
|
@@ -450,8 +453,9 @@ class Device:
|
|
|
450
453
|
|
|
451
454
|
Replies carry the request's packet counter (offset 0x28), so a reply
|
|
452
455
|
is matched to the request by counter. A reply whose counter belongs
|
|
453
|
-
to
|
|
454
|
-
|
|
456
|
+
to any other recent request (a late answer, or the second answer to
|
|
457
|
+
a request that was resent) is dropped; one with a counter this
|
|
458
|
+
device has not used recently is accepted, for firmware that may not
|
|
455
459
|
echo it.
|
|
456
460
|
"""
|
|
457
461
|
transport, protocol = await self._endpoint()
|
|
@@ -460,9 +464,14 @@ class Device:
|
|
|
460
464
|
start = loop.time()
|
|
461
465
|
timeout = self.timeout
|
|
462
466
|
count = int.from_bytes(packet[0x28:0x2A], "little")
|
|
467
|
+
self._recent.append(count)
|
|
468
|
+
sends = 0
|
|
463
469
|
|
|
464
470
|
while True:
|
|
465
471
|
transport.sendto(packet)
|
|
472
|
+
sends += 1
|
|
473
|
+
if sends > 1:
|
|
474
|
+
_LOGGER.debug("%s: no reply, resending (%d)", self.host[0], sends)
|
|
466
475
|
resend_at = loop.time() + DEFAULT_RETRY_INTVL
|
|
467
476
|
while True:
|
|
468
477
|
now = loop.time()
|
|
@@ -471,32 +480,34 @@ class Device:
|
|
|
471
480
|
wait = min(resend_at, start + timeout) - now
|
|
472
481
|
try:
|
|
473
482
|
resp, _ = await asyncio.wait_for(protocol.queue.get(), max(wait, 0))
|
|
474
|
-
except
|
|
483
|
+
except TimeoutError:
|
|
475
484
|
if loop.time() - start >= timeout:
|
|
476
485
|
break
|
|
477
486
|
if loop.time() >= resend_at:
|
|
478
487
|
break # Resend.
|
|
479
488
|
continue
|
|
480
489
|
if resp is None:
|
|
481
|
-
raise e.
|
|
482
|
-
-4013, "
|
|
490
|
+
raise e.EndpointClosedError(
|
|
491
|
+
-4013, "Endpoint closed", "The device endpoint was closed"
|
|
483
492
|
)
|
|
484
493
|
resp = self._validate(resp)
|
|
485
494
|
reply_count = int.from_bytes(resp[0x28:0x2A], "little")
|
|
486
|
-
if reply_count == count or reply_count not in self.
|
|
495
|
+
if reply_count == count or reply_count not in self._recent:
|
|
487
496
|
return resp
|
|
488
|
-
|
|
497
|
+
_LOGGER.debug(
|
|
498
|
+
"%s: dropped a reply for an earlier request (counter 0x%04x)",
|
|
499
|
+
self.host[0],
|
|
500
|
+
reply_count,
|
|
501
|
+
)
|
|
489
502
|
if loop.time() - start >= timeout:
|
|
490
|
-
self.
|
|
503
|
+
_LOGGER.debug("%s: no reply within %ss", self.host[0], timeout)
|
|
491
504
|
raise e.NetworkTimeoutError(
|
|
492
505
|
-4000,
|
|
493
506
|
"Network timeout",
|
|
494
507
|
f"No response received within {timeout}s",
|
|
495
508
|
) from None
|
|
496
509
|
|
|
497
|
-
async def send_packet(
|
|
498
|
-
self, packet_type: int, payload: bytes, *, _reauth: bool = True
|
|
499
|
-
) -> bytes:
|
|
510
|
+
async def send_packet(self, packet_type: int, payload: bytes) -> bytes:
|
|
500
511
|
"""Send a packet to the device and return the raw response frame.
|
|
501
512
|
|
|
502
513
|
If the device answers that the session key is no longer valid, the
|
|
@@ -511,14 +522,12 @@ class Device:
|
|
|
511
522
|
async with self._lock:
|
|
512
523
|
resp = await self._exchange(self._frame(packet_type, bytes(payload)))
|
|
513
524
|
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
self._frame(packet_type, bytes(payload))
|
|
523
|
-
)
|
|
525
|
+
code = int.from_bytes(resp[0x22:0x24], "little", signed=True)
|
|
526
|
+
if code in _REAUTH_CODES:
|
|
527
|
+
_LOGGER.debug("%s: device answered %d, re-authenticating", self.host[0], code)
|
|
528
|
+
async with self._reauth_lock: # type: ignore[union-attr]
|
|
529
|
+
if self._auth_generation == generation:
|
|
530
|
+
await self.auth()
|
|
531
|
+
async with self._lock:
|
|
532
|
+
resp = await self._exchange(self._frame(packet_type, bytes(payload)))
|
|
524
533
|
return resp
|
broadlink/exceptions.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
"""Exceptions for Broadlink devices."""
|
|
2
|
+
|
|
2
3
|
import collections
|
|
3
4
|
import struct
|
|
4
5
|
|
|
@@ -22,7 +23,7 @@ class BroadlinkException(Exception):
|
|
|
22
23
|
def __str__(self):
|
|
23
24
|
"""Return str(self)."""
|
|
24
25
|
if self.errno is not None:
|
|
25
|
-
return "[Errno
|
|
26
|
+
return f"[Errno {self.errno}] {self.strerror}"
|
|
26
27
|
return self.strerror
|
|
27
28
|
|
|
28
29
|
def __eq__(self, other):
|
|
@@ -42,13 +43,13 @@ class MultipleErrors(BroadlinkException):
|
|
|
42
43
|
"""Initialize the exception."""
|
|
43
44
|
errors = args[0][:] if args else []
|
|
44
45
|
counter = collections.Counter(errors)
|
|
45
|
-
strerror = "Multiple errors occurred:
|
|
46
|
+
strerror = f"Multiple errors occurred: {counter}"
|
|
46
47
|
super().__init__(strerror, **kwargs)
|
|
47
48
|
self.errors = errors
|
|
48
49
|
|
|
49
50
|
def __repr__(self):
|
|
50
51
|
"""Return repr(self)."""
|
|
51
|
-
return "MultipleErrors(
|
|
52
|
+
return f"MultipleErrors({self.errors!r})"
|
|
52
53
|
|
|
53
54
|
def __str__(self):
|
|
54
55
|
"""Return str(self)."""
|
|
@@ -71,6 +72,16 @@ class ConnectionClosedError(BroadlinkException):
|
|
|
71
72
|
"""Connection closed error."""
|
|
72
73
|
|
|
73
74
|
|
|
75
|
+
class EndpointClosedError(ConnectionClosedError):
|
|
76
|
+
"""The library's own endpoint was closed while a request was in flight.
|
|
77
|
+
|
|
78
|
+
Raised locally by ``Device.aclose()``, not by the device. It is a
|
|
79
|
+
subclass of ``ConnectionClosedError`` so existing handlers still catch
|
|
80
|
+
it, and a distinct class so a caller that closed the device on purpose
|
|
81
|
+
can tell it apart from the device's "logged out" (-2) answer.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
|
|
74
85
|
class StructureAbnormalError(BroadlinkException):
|
|
75
86
|
"""Structure abnormal error."""
|
|
76
87
|
|
|
@@ -142,6 +153,7 @@ BROADLINK_EXCEPTIONS = {
|
|
|
142
153
|
-4010: (DataValidationError, "Received encrypted data packet length error"),
|
|
143
154
|
-4011: (DataValidationError, "Received encrypted data packet check error"),
|
|
144
155
|
-4012: (AuthorizationError, "Device control ID error"),
|
|
156
|
+
-4013: (EndpointClosedError, "Endpoint closed"),
|
|
145
157
|
}
|
|
146
158
|
|
|
147
159
|
|
broadlink/helpers.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Helper functions and classes."""
|
|
2
|
-
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
3
4
|
|
|
4
5
|
|
|
5
6
|
class CRC16:
|
|
@@ -8,10 +9,10 @@ class CRC16:
|
|
|
8
9
|
CRC tables are cached for performance.
|
|
9
10
|
"""
|
|
10
11
|
|
|
11
|
-
_cache:
|
|
12
|
+
_cache: dict[int, list[int]] = {}
|
|
12
13
|
|
|
13
14
|
@classmethod
|
|
14
|
-
def get_table(cls, polynomial: int) ->
|
|
15
|
+
def get_table(cls, polynomial: int) -> list[int]:
|
|
15
16
|
"""Return the CRC-16 table for a polynomial."""
|
|
16
17
|
try:
|
|
17
18
|
crc_table = cls._cache[polynomial]
|
broadlink/hub.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Support for hubs."""
|
|
2
|
+
|
|
2
3
|
import json
|
|
3
4
|
import struct
|
|
4
|
-
from typing import Optional
|
|
5
5
|
|
|
6
6
|
from . import exceptions as e
|
|
7
7
|
from .device import Device
|
|
@@ -43,7 +43,7 @@ class s3(Device):
|
|
|
43
43
|
|
|
44
44
|
return sub_devices
|
|
45
45
|
|
|
46
|
-
async def get_state(self, did:
|
|
46
|
+
async def get_state(self, did: str | None = None) -> dict:
|
|
47
47
|
"""Return the power state of the device."""
|
|
48
48
|
state = {}
|
|
49
49
|
if did is not None:
|
|
@@ -56,10 +56,10 @@ class s3(Device):
|
|
|
56
56
|
|
|
57
57
|
async def set_state(
|
|
58
58
|
self,
|
|
59
|
-
did:
|
|
60
|
-
pwr1:
|
|
61
|
-
pwr2:
|
|
62
|
-
pwr3:
|
|
59
|
+
did: str | None = None,
|
|
60
|
+
pwr1: bool | None = None,
|
|
61
|
+
pwr2: bool | None = None,
|
|
62
|
+
pwr3: bool | None = None,
|
|
63
63
|
) -> dict:
|
|
64
64
|
"""Set the power state of the device."""
|
|
65
65
|
state = {}
|
|
@@ -82,9 +82,7 @@ class s3(Device):
|
|
|
82
82
|
# flag: 1 for reading, 2 for writing.
|
|
83
83
|
packet = bytearray(12)
|
|
84
84
|
data = json.dumps(state, separators=(",", ":")).encode()
|
|
85
|
-
struct.pack_into(
|
|
86
|
-
"<HHHBBI", packet, 0, 0xA5A5, 0x5A5A, 0, flag, 0x0B, len(data)
|
|
87
|
-
)
|
|
85
|
+
struct.pack_into("<HHHBBI", packet, 0, 0xA5A5, 0x5A5A, 0, flag, 0x0B, len(data))
|
|
88
86
|
packet.extend(data)
|
|
89
87
|
checksum = sum(packet, 0xBEAF) & 0xFFFF
|
|
90
88
|
packet[0x04:0x06] = checksum.to_bytes(2, "little")
|
|
@@ -94,5 +92,5 @@ class s3(Device):
|
|
|
94
92
|
"""Decode a JSON packet."""
|
|
95
93
|
payload = self.decrypt(response[0x38:])
|
|
96
94
|
js_len = struct.unpack_from("<I", payload, 0x08)[0]
|
|
97
|
-
state = json.loads(payload[0x0C:0x0C+js_len])
|
|
95
|
+
state = json.loads(payload[0x0C : 0x0C + js_len])
|
|
98
96
|
return state
|