python-broadlink 1.0.0__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 +157 -94
- 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 +178 -75
- broadlink/sensor.py +2 -1
- broadlink/switch.py +32 -32
- {python_broadlink-1.0.0.dist-info → python_broadlink-1.0.2.dist-info}/METADATA +75 -15
- python_broadlink-1.0.2.dist-info/RECORD +19 -0
- python_broadlink-1.0.0.dist-info/RECORD +0 -19
- {python_broadlink-1.0.0.dist-info → python_broadlink-1.0.2.dist-info}/WHEEL +0 -0
- {python_broadlink-1.0.0.dist-info → python_broadlink-1.0.2.dist-info}/licenses/LICENSE +0 -0
- {python_broadlink-1.0.0.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
|
@@ -10,10 +10,12 @@ speaks unprompted, so each device keeps a single datagram endpoint and an
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
12
|
import asyncio
|
|
13
|
+
import collections
|
|
14
|
+
import contextlib
|
|
15
|
+
import logging
|
|
13
16
|
import random
|
|
14
17
|
import socket
|
|
15
18
|
from collections.abc import AsyncIterator
|
|
16
|
-
from typing import Optional, Tuple, Union
|
|
17
19
|
|
|
18
20
|
from cryptography.hazmat.backends import default_backend
|
|
19
21
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
@@ -27,11 +29,24 @@ from .const import (
|
|
|
27
29
|
)
|
|
28
30
|
from .protocol import Datetime
|
|
29
31
|
|
|
30
|
-
|
|
32
|
+
_LOGGER = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
HelloResponse = tuple[int, tuple[str, int], bytes, str, bool]
|
|
31
35
|
|
|
32
36
|
# Device error codes that mean the session key is no longer accepted and a
|
|
33
|
-
# fresh auth() will fix it. -
|
|
34
|
-
|
|
37
|
+
# fresh auth() will fix it. -2: logged out; -7: control key expired;
|
|
38
|
+
# -4012: control id error.
|
|
39
|
+
_REAUTH_CODES = {-2, -7, -4012}
|
|
40
|
+
|
|
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
|
|
47
|
+
|
|
48
|
+
_CLOSED = (None, None)
|
|
49
|
+
"""Sentinel put on the receive queue when the endpoint is closed."""
|
|
35
50
|
|
|
36
51
|
|
|
37
52
|
class _Protocol(asyncio.DatagramProtocol):
|
|
@@ -39,8 +54,7 @@ class _Protocol(asyncio.DatagramProtocol):
|
|
|
39
54
|
|
|
40
55
|
def __init__(self) -> None:
|
|
41
56
|
self.queue: asyncio.Queue[tuple[bytes, tuple[str, int]]] = asyncio.Queue()
|
|
42
|
-
self.transport:
|
|
43
|
-
self.closed = asyncio.get_running_loop().create_future()
|
|
57
|
+
self.transport: asyncio.DatagramTransport | None = None
|
|
44
58
|
|
|
45
59
|
def connection_made(self, transport) -> None: # type: ignore[override]
|
|
46
60
|
self.transport = transport
|
|
@@ -53,9 +67,8 @@ class _Protocol(asyncio.DatagramProtocol):
|
|
|
53
67
|
# the retry loop will time out and raise NetworkTimeoutError.
|
|
54
68
|
pass
|
|
55
69
|
|
|
56
|
-
def connection_lost(self, exc:
|
|
57
|
-
|
|
58
|
-
self.closed.set_result(None)
|
|
70
|
+
def connection_lost(self, exc: Exception | None) -> None:
|
|
71
|
+
pass
|
|
59
72
|
|
|
60
73
|
def drain(self) -> None:
|
|
61
74
|
"""Drop anything that arrived before the current request."""
|
|
@@ -64,8 +77,8 @@ class _Protocol(asyncio.DatagramProtocol):
|
|
|
64
77
|
|
|
65
78
|
|
|
66
79
|
async def _open_endpoint(
|
|
67
|
-
local_addr:
|
|
68
|
-
remote_addr:
|
|
80
|
+
local_addr: tuple[str, int] | None = None,
|
|
81
|
+
remote_addr: tuple[str, int] | None = None,
|
|
69
82
|
broadcast: bool = False,
|
|
70
83
|
) -> tuple[asyncio.DatagramTransport, _Protocol]:
|
|
71
84
|
"""Create a UDP endpoint. Tests replace this to fake the network."""
|
|
@@ -101,7 +114,7 @@ def _parse_hello(resp: bytes, host: tuple[str, int]) -> HelloResponse:
|
|
|
101
114
|
|
|
102
115
|
async def scan(
|
|
103
116
|
timeout: float = DEFAULT_TIMEOUT,
|
|
104
|
-
local_ip_address:
|
|
117
|
+
local_ip_address: str | None = None,
|
|
105
118
|
discover_ip_address: str = DEFAULT_BCAST_ADDR,
|
|
106
119
|
discover_ip_port: int = DEFAULT_PORT,
|
|
107
120
|
) -> AsyncIterator[HelloResponse]:
|
|
@@ -134,7 +147,7 @@ async def scan(
|
|
|
134
147
|
break
|
|
135
148
|
try:
|
|
136
149
|
resp, host = await asyncio.wait_for(protocol.queue.get(), remaining)
|
|
137
|
-
except
|
|
150
|
+
except TimeoutError:
|
|
138
151
|
break
|
|
139
152
|
if len(resp) < 0x80:
|
|
140
153
|
continue
|
|
@@ -174,8 +187,8 @@ class Device:
|
|
|
174
187
|
|
|
175
188
|
def __init__(
|
|
176
189
|
self,
|
|
177
|
-
host:
|
|
178
|
-
mac:
|
|
190
|
+
host: tuple[str, int],
|
|
191
|
+
mac: bytes | str,
|
|
179
192
|
devtype: int,
|
|
180
193
|
timeout: float = DEFAULT_TIMEOUT,
|
|
181
194
|
name: str = "",
|
|
@@ -200,39 +213,32 @@ class Device:
|
|
|
200
213
|
self.aes = None
|
|
201
214
|
self.update_aes(bytes.fromhex(self.__INIT_KEY))
|
|
202
215
|
|
|
203
|
-
self._lock:
|
|
204
|
-
self._transport:
|
|
205
|
-
self._protocol:
|
|
206
|
-
self.
|
|
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
|
|
222
|
+
self._auth_generation = 0
|
|
207
223
|
|
|
208
224
|
def __repr__(self) -> str:
|
|
209
225
|
"""Return a formal representation of the device."""
|
|
210
226
|
return (
|
|
211
|
-
"
|
|
212
|
-
"
|
|
213
|
-
|
|
214
|
-
self.
|
|
215
|
-
self.
|
|
216
|
-
self.host,
|
|
217
|
-
self.mac,
|
|
218
|
-
self.devtype,
|
|
219
|
-
self.timeout,
|
|
220
|
-
self.name,
|
|
221
|
-
self.model,
|
|
222
|
-
self.manufacturer,
|
|
223
|
-
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})"
|
|
224
232
|
)
|
|
225
233
|
|
|
226
234
|
def __str__(self) -> str:
|
|
227
235
|
"""Return a readable representation of the device."""
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
":".join(format(x, "02X") for x in self.mac),
|
|
233
|
-
)
|
|
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})"
|
|
234
240
|
|
|
235
|
-
async def __aenter__(self) ->
|
|
241
|
+
async def __aenter__(self) -> Device:
|
|
236
242
|
return self
|
|
237
243
|
|
|
238
244
|
async def __aexit__(self, *exc) -> None:
|
|
@@ -259,22 +265,31 @@ class Device:
|
|
|
259
265
|
# ---------------------------------------------------------- session
|
|
260
266
|
|
|
261
267
|
async def auth(self) -> bool:
|
|
262
|
-
"""Authenticate to the device.
|
|
263
|
-
self.id = 0
|
|
264
|
-
self.update_aes(bytes.fromhex(self.__INIT_KEY))
|
|
268
|
+
"""Authenticate to the device.
|
|
265
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
|
+
"""
|
|
266
274
|
packet = bytearray(0x50)
|
|
267
275
|
packet[0x04:0x14] = [0x31] * 16
|
|
268
276
|
packet[0x1E] = 0x01
|
|
269
277
|
packet[0x2D] = 0x01
|
|
270
|
-
packet[0x30:0x36] = "Test 1"
|
|
271
|
-
|
|
272
|
-
response = await self.send_packet(0x65, packet, _reauth=False)
|
|
273
|
-
e.check_error(response[0x22:0x24])
|
|
274
|
-
payload = self.decrypt(response[0x38:])
|
|
278
|
+
packet[0x30:0x36] = b"Test 1"
|
|
275
279
|
|
|
276
|
-
self.
|
|
277
|
-
|
|
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)
|
|
278
293
|
return True
|
|
279
294
|
|
|
280
295
|
async def hello(self, local_ip_address=None) -> bool:
|
|
@@ -282,15 +297,17 @@ class Device:
|
|
|
282
297
|
|
|
283
298
|
Device information is checked before updating name and lock status.
|
|
284
299
|
"""
|
|
285
|
-
responses = scan(
|
|
286
|
-
timeout=self.timeout,
|
|
287
|
-
local_ip_address=local_ip_address,
|
|
288
|
-
discover_ip_address=self.host[0],
|
|
289
|
-
discover_ip_port=self.host[1],
|
|
290
|
-
)
|
|
291
300
|
entry = None
|
|
292
|
-
async
|
|
293
|
-
|
|
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
|
|
294
311
|
if entry is None:
|
|
295
312
|
raise e.NetworkTimeoutError(
|
|
296
313
|
-4000,
|
|
@@ -363,17 +380,30 @@ class Device:
|
|
|
363
380
|
# -------------------------------------------------------- transport
|
|
364
381
|
|
|
365
382
|
async def aclose(self) -> None:
|
|
366
|
-
"""Close the device's endpoint. It is reopened on the next call.
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
383
|
+
"""Close the device's endpoint. It is reopened on the next call.
|
|
384
|
+
|
|
385
|
+
A request in flight fails at once with ``ConnectionClosedError``
|
|
386
|
+
rather than waiting out its timeout.
|
|
387
|
+
"""
|
|
388
|
+
transport, protocol = self._transport, self._protocol
|
|
389
|
+
self._transport = None
|
|
390
|
+
self._protocol = None
|
|
391
|
+
self._endpoint_addr = None
|
|
392
|
+
if transport is not None:
|
|
393
|
+
transport.close()
|
|
394
|
+
_LOGGER.debug("%s: endpoint closed", self.host[0])
|
|
395
|
+
if protocol is not None:
|
|
396
|
+
protocol.queue.put_nowait(_CLOSED) # type: ignore[arg-type]
|
|
371
397
|
|
|
372
398
|
async def _endpoint(self) -> tuple[asyncio.DatagramTransport, _Protocol]:
|
|
399
|
+
if self._transport is not None and self._endpoint_addr != self.host:
|
|
400
|
+
# The caller changed host; the connected socket points at the
|
|
401
|
+
# old address, so drop it.
|
|
402
|
+
await self.aclose()
|
|
373
403
|
if self._transport is None or self._transport.is_closing():
|
|
374
|
-
self._transport, self._protocol = await _open_endpoint(
|
|
375
|
-
|
|
376
|
-
)
|
|
404
|
+
self._transport, self._protocol = await _open_endpoint(remote_addr=self.host)
|
|
405
|
+
self._endpoint_addr = self.host
|
|
406
|
+
_LOGGER.debug("%s: endpoint opened", self.host[0])
|
|
377
407
|
return self._transport, self._protocol # type: ignore[return-value]
|
|
378
408
|
|
|
379
409
|
def _frame(self, packet_type: int, payload: bytes) -> bytes:
|
|
@@ -419,52 +449,85 @@ class Device:
|
|
|
419
449
|
return resp
|
|
420
450
|
|
|
421
451
|
async def _exchange(self, packet: bytes) -> bytes:
|
|
422
|
-
"""Send one frame and wait for
|
|
452
|
+
"""Send one frame and wait for its reply, resending on silence.
|
|
453
|
+
|
|
454
|
+
Replies carry the request's packet counter (offset 0x28), so a reply
|
|
455
|
+
is matched to the request by counter. A reply whose counter belongs
|
|
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
|
|
459
|
+
echo it.
|
|
460
|
+
"""
|
|
423
461
|
transport, protocol = await self._endpoint()
|
|
424
462
|
protocol.drain()
|
|
425
463
|
loop = asyncio.get_running_loop()
|
|
426
464
|
start = loop.time()
|
|
427
465
|
timeout = self.timeout
|
|
466
|
+
count = int.from_bytes(packet[0x28:0x2A], "little")
|
|
467
|
+
self._recent.append(count)
|
|
468
|
+
sends = 0
|
|
428
469
|
|
|
429
470
|
while True:
|
|
430
471
|
transport.sendto(packet)
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
)
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
472
|
+
sends += 1
|
|
473
|
+
if sends > 1:
|
|
474
|
+
_LOGGER.debug("%s: no reply, resending (%d)", self.host[0], sends)
|
|
475
|
+
resend_at = loop.time() + DEFAULT_RETRY_INTVL
|
|
476
|
+
while True:
|
|
477
|
+
now = loop.time()
|
|
478
|
+
if now - start >= timeout:
|
|
479
|
+
break
|
|
480
|
+
wait = min(resend_at, start + timeout) - now
|
|
481
|
+
try:
|
|
482
|
+
resp, _ = await asyncio.wait_for(protocol.queue.get(), max(wait, 0))
|
|
483
|
+
except TimeoutError:
|
|
484
|
+
if loop.time() - start >= timeout:
|
|
485
|
+
break
|
|
486
|
+
if loop.time() >= resend_at:
|
|
487
|
+
break # Resend.
|
|
488
|
+
continue
|
|
489
|
+
if resp is None:
|
|
490
|
+
raise e.EndpointClosedError(
|
|
491
|
+
-4013, "Endpoint closed", "The device endpoint was closed"
|
|
492
|
+
)
|
|
493
|
+
resp = self._validate(resp)
|
|
494
|
+
reply_count = int.from_bytes(resp[0x28:0x2A], "little")
|
|
495
|
+
if reply_count == count or reply_count not in self._recent:
|
|
496
|
+
return resp
|
|
497
|
+
_LOGGER.debug(
|
|
498
|
+
"%s: dropped a reply for an earlier request (counter 0x%04x)",
|
|
499
|
+
self.host[0],
|
|
500
|
+
reply_count,
|
|
501
|
+
)
|
|
502
|
+
if loop.time() - start >= timeout:
|
|
503
|
+
_LOGGER.debug("%s: no reply within %ss", self.host[0], timeout)
|
|
504
|
+
raise e.NetworkTimeoutError(
|
|
505
|
+
-4000,
|
|
506
|
+
"Network timeout",
|
|
507
|
+
f"No response received within {timeout}s",
|
|
508
|
+
) from None
|
|
509
|
+
|
|
510
|
+
async def send_packet(self, packet_type: int, payload: bytes) -> bytes:
|
|
448
511
|
"""Send a packet to the device and return the raw response frame.
|
|
449
512
|
|
|
450
513
|
If the device answers that the session key is no longer valid, the
|
|
451
514
|
session is re-authenticated once and the request is sent again.
|
|
515
|
+
Concurrent callers that hit the same expired key share one
|
|
516
|
+
re-authentication and each retry once.
|
|
452
517
|
"""
|
|
453
518
|
if self._lock is None:
|
|
454
519
|
self._lock = asyncio.Lock()
|
|
520
|
+
self._reauth_lock = asyncio.Lock()
|
|
521
|
+
generation = self._auth_generation
|
|
455
522
|
async with self._lock:
|
|
456
523
|
resp = await self._exchange(self._frame(packet_type, bytes(payload)))
|
|
457
524
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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:
|
|
463
530
|
await self.auth()
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
self._frame(packet_type, bytes(payload))
|
|
467
|
-
)
|
|
468
|
-
finally:
|
|
469
|
-
self._reauth_ok = True
|
|
531
|
+
async with self._lock:
|
|
532
|
+
resp = await self._exchange(self._frame(packet_type, bytes(payload)))
|
|
470
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]
|