python-broadlink 1.0.1__py3-none-any.whl → 1.0.3__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 +18 -19
- broadlink/alarm.py +1 -0
- broadlink/climate.py +10 -27
- broadlink/const.py +1 -0
- broadlink/cover.py +2 -1
- broadlink/device.py +128 -93
- broadlink/exceptions.py +16 -4
- broadlink/helpers.py +4 -3
- broadlink/hub.py +8 -10
- broadlink/light.py +31 -33
- broadlink/protocol.py +1 -0
- broadlink/remote.py +110 -63
- broadlink/sensor.py +2 -1
- broadlink/switch.py +32 -32
- {python_broadlink-1.0.1.dist-info → python_broadlink-1.0.3.dist-info}/METADATA +79 -14
- python_broadlink-1.0.3.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.3.dist-info}/WHEEL +0 -0
- {python_broadlink-1.0.1.dist-info → python_broadlink-1.0.3.dist-info}/licenses/LICENSE +0 -0
- {python_broadlink-1.0.1.dist-info → python_broadlink-1.0.3.dist-info}/top_level.txt +0 -0
broadlink/__init__.py
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
1
|
"""The python-broadlink library."""
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
3
4
|
from collections.abc import AsyncIterator
|
|
4
|
-
from typing import List, Optional, Tuple, Union
|
|
5
5
|
|
|
6
6
|
from . import exceptions as e
|
|
7
7
|
from .alarm import S1C
|
|
8
8
|
from .climate import hvac, hysen
|
|
9
9
|
from .const import DEFAULT_BCAST_ADDR, DEFAULT_PORT, DEFAULT_TIMEOUT
|
|
10
10
|
from .cover import dooya, dooya2, wser
|
|
11
|
-
from .device import Device,
|
|
11
|
+
from .device import Device, ping, scan, send_setup_packet
|
|
12
12
|
from .hub import s3
|
|
13
13
|
from .light import lb1, lb2
|
|
14
14
|
from .remote import rm, rm4, rm4mini, rm4pro, rm5plus, rmmini, rmminib, rmpro
|
|
@@ -223,8 +223,8 @@ SUPPORTED_TYPES = {
|
|
|
223
223
|
|
|
224
224
|
def gendevice(
|
|
225
225
|
dev_type: int,
|
|
226
|
-
host:
|
|
227
|
-
mac:
|
|
226
|
+
host: tuple[str, int],
|
|
227
|
+
mac: bytes | str,
|
|
228
228
|
name: str = "",
|
|
229
229
|
is_locked: bool = False,
|
|
230
230
|
) -> Device:
|
|
@@ -258,12 +258,15 @@ async def hello(
|
|
|
258
258
|
|
|
259
259
|
Useful if the device is locked.
|
|
260
260
|
"""
|
|
261
|
-
async
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
261
|
+
async with contextlib.aclosing(
|
|
262
|
+
xdiscover(
|
|
263
|
+
timeout=timeout,
|
|
264
|
+
discover_ip_address=ip_address,
|
|
265
|
+
discover_ip_port=port,
|
|
266
|
+
)
|
|
267
|
+
) as devices:
|
|
268
|
+
async for device in devices:
|
|
269
|
+
return device
|
|
267
270
|
raise e.NetworkTimeoutError(
|
|
268
271
|
-4000,
|
|
269
272
|
"Network timeout",
|
|
@@ -273,10 +276,10 @@ async def hello(
|
|
|
273
276
|
|
|
274
277
|
async def discover(
|
|
275
278
|
timeout: float = DEFAULT_TIMEOUT,
|
|
276
|
-
local_ip_address:
|
|
279
|
+
local_ip_address: str | None = None,
|
|
277
280
|
discover_ip_address: str = DEFAULT_BCAST_ADDR,
|
|
278
281
|
discover_ip_port: int = DEFAULT_PORT,
|
|
279
|
-
) ->
|
|
282
|
+
) -> list[Device]:
|
|
280
283
|
"""Discover devices connected to the local network."""
|
|
281
284
|
return [
|
|
282
285
|
device
|
|
@@ -288,7 +291,7 @@ async def discover(
|
|
|
288
291
|
|
|
289
292
|
async def xdiscover(
|
|
290
293
|
timeout: float = DEFAULT_TIMEOUT,
|
|
291
|
-
local_ip_address:
|
|
294
|
+
local_ip_address: str | None = None,
|
|
292
295
|
discover_ip_address: str = DEFAULT_BCAST_ADDR,
|
|
293
296
|
discover_ip_port: int = DEFAULT_PORT,
|
|
294
297
|
) -> AsyncIterator[Device]:
|
|
@@ -335,8 +338,4 @@ async def setup(
|
|
|
335
338
|
payload[0x20] = checksum & 0xFF # Checksum 1 position
|
|
336
339
|
payload[0x21] = checksum >> 8 # Checksum 2 position
|
|
337
340
|
|
|
338
|
-
|
|
339
|
-
try:
|
|
340
|
-
transport.sendto(payload, (ip_address, DEFAULT_PORT))
|
|
341
|
-
finally:
|
|
342
|
-
transport.close()
|
|
341
|
+
await send_setup_packet(bytes(payload), ip_address)
|
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,13 +54,14 @@ 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
|
-
def connection_made(self, transport) -> None:
|
|
56
|
-
|
|
59
|
+
def connection_made(self, transport: asyncio.BaseTransport) -> None:
|
|
60
|
+
"""Keep the transport; the endpoint sends through it."""
|
|
61
|
+
self.transport = transport # type: ignore[assignment]
|
|
57
62
|
|
|
58
63
|
def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None:
|
|
64
|
+
"""Queue every datagram for the request that is waiting."""
|
|
59
65
|
self.queue.put_nowait((data, addr))
|
|
60
66
|
|
|
61
67
|
def error_received(self, exc: Exception) -> None:
|
|
@@ -63,9 +69,8 @@ class _Protocol(asyncio.DatagramProtocol):
|
|
|
63
69
|
# the retry loop will time out and raise NetworkTimeoutError.
|
|
64
70
|
pass
|
|
65
71
|
|
|
66
|
-
def connection_lost(self, exc:
|
|
67
|
-
|
|
68
|
-
self.closed.set_result(None)
|
|
72
|
+
def connection_lost(self, exc: Exception | None) -> None:
|
|
73
|
+
"""Nothing to do; a waiting request is told through the queue."""
|
|
69
74
|
|
|
70
75
|
def drain(self) -> None:
|
|
71
76
|
"""Drop anything that arrived before the current request."""
|
|
@@ -74,8 +79,8 @@ class _Protocol(asyncio.DatagramProtocol):
|
|
|
74
79
|
|
|
75
80
|
|
|
76
81
|
async def _open_endpoint(
|
|
77
|
-
local_addr:
|
|
78
|
-
remote_addr:
|
|
82
|
+
local_addr: tuple[str, int] | None = None,
|
|
83
|
+
remote_addr: tuple[str, int] | None = None,
|
|
79
84
|
broadcast: bool = False,
|
|
80
85
|
) -> tuple[asyncio.DatagramTransport, _Protocol]:
|
|
81
86
|
"""Create a UDP endpoint. Tests replace this to fake the network."""
|
|
@@ -111,7 +116,7 @@ def _parse_hello(resp: bytes, host: tuple[str, int]) -> HelloResponse:
|
|
|
111
116
|
|
|
112
117
|
async def scan(
|
|
113
118
|
timeout: float = DEFAULT_TIMEOUT,
|
|
114
|
-
local_ip_address:
|
|
119
|
+
local_ip_address: str | None = None,
|
|
115
120
|
discover_ip_address: str = DEFAULT_BCAST_ADDR,
|
|
116
121
|
discover_ip_port: int = DEFAULT_PORT,
|
|
117
122
|
) -> AsyncIterator[HelloResponse]:
|
|
@@ -144,7 +149,7 @@ async def scan(
|
|
|
144
149
|
break
|
|
145
150
|
try:
|
|
146
151
|
resp, host = await asyncio.wait_for(protocol.queue.get(), remaining)
|
|
147
|
-
except
|
|
152
|
+
except TimeoutError:
|
|
148
153
|
break
|
|
149
154
|
if len(resp) < 0x80:
|
|
150
155
|
continue
|
|
@@ -158,6 +163,17 @@ async def scan(
|
|
|
158
163
|
transport.close()
|
|
159
164
|
|
|
160
165
|
|
|
166
|
+
async def send_setup_packet(
|
|
167
|
+
payload: bytes, ip_address: str, port: int = DEFAULT_PORT
|
|
168
|
+
) -> None:
|
|
169
|
+
"""Broadcast one Wi-Fi provisioning packet to a device in AP mode."""
|
|
170
|
+
transport, _ = await _open_endpoint(broadcast=True)
|
|
171
|
+
try:
|
|
172
|
+
transport.sendto(payload, (ip_address, port))
|
|
173
|
+
finally:
|
|
174
|
+
transport.close()
|
|
175
|
+
|
|
176
|
+
|
|
161
177
|
async def ping(ip_address: str, port: int = DEFAULT_PORT) -> None:
|
|
162
178
|
"""Send a ping packet to an address.
|
|
163
179
|
|
|
@@ -184,8 +200,8 @@ class Device:
|
|
|
184
200
|
|
|
185
201
|
def __init__(
|
|
186
202
|
self,
|
|
187
|
-
host:
|
|
188
|
-
mac:
|
|
203
|
+
host: tuple[str, int],
|
|
204
|
+
mac: bytes | str,
|
|
189
205
|
devtype: int,
|
|
190
206
|
timeout: float = DEFAULT_TIMEOUT,
|
|
191
207
|
name: str = "",
|
|
@@ -210,42 +226,33 @@ class Device:
|
|
|
210
226
|
self.aes = None
|
|
211
227
|
self.update_aes(bytes.fromhex(self.__INIT_KEY))
|
|
212
228
|
|
|
213
|
-
self._lock
|
|
214
|
-
self._transport:
|
|
215
|
-
self._protocol:
|
|
216
|
-
self._endpoint_addr:
|
|
217
|
-
self.
|
|
218
|
-
self._reauth_lock
|
|
229
|
+
self._lock = asyncio.Lock()
|
|
230
|
+
self._transport: asyncio.DatagramTransport | None = None
|
|
231
|
+
self._protocol: _Protocol | None = None
|
|
232
|
+
self._endpoint_addr: tuple[str, int] | None = None
|
|
233
|
+
self._recent: collections.deque[int] = collections.deque(maxlen=_RECENT_MAX)
|
|
234
|
+
self._reauth_lock = asyncio.Lock()
|
|
235
|
+
self._closes = 0 # Bumped by aclose(); guards an open racing a close.
|
|
219
236
|
self._auth_generation = 0
|
|
220
237
|
|
|
221
238
|
def __repr__(self) -> str:
|
|
222
239
|
"""Return a formal representation of the device."""
|
|
223
240
|
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,
|
|
241
|
+
f"{self.__class__.__module__}.{self.__class__.__qualname__}("
|
|
242
|
+
f"{self.host}, mac={self.mac!r}, devtype={self.devtype!r}, "
|
|
243
|
+
f"timeout={self.timeout!r}, name={self.name!r}, "
|
|
244
|
+
f"model={self.model!r}, manufacturer={self.manufacturer!r}, "
|
|
245
|
+
f"is_locked={self.is_locked!r})"
|
|
237
246
|
)
|
|
238
247
|
|
|
239
248
|
def __str__(self) -> str:
|
|
240
249
|
"""Return a readable representation of the device."""
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
":".join(format(x, "02X") for x in self.mac),
|
|
246
|
-
)
|
|
250
|
+
ident = " ".join(filter(None, [self.manufacturer, self.model, hex(self.devtype)]))
|
|
251
|
+
mac = ":".join(format(x, "02X") for x in self.mac)
|
|
252
|
+
name = self.name or "Unknown"
|
|
253
|
+
return f"{name} ({ident} / {self.host[0]}:{self.host[1]} / {mac})"
|
|
247
254
|
|
|
248
|
-
async def __aenter__(self) ->
|
|
255
|
+
async def __aenter__(self) -> Device:
|
|
249
256
|
return self
|
|
250
257
|
|
|
251
258
|
async def __aexit__(self, *exc) -> None:
|
|
@@ -272,39 +279,46 @@ class Device:
|
|
|
272
279
|
# ---------------------------------------------------------- session
|
|
273
280
|
|
|
274
281
|
async def auth(self) -> bool:
|
|
275
|
-
"""Authenticate to the device.
|
|
276
|
-
self.id = 0
|
|
277
|
-
self.update_aes(bytes.fromhex(self.__INIT_KEY))
|
|
282
|
+
"""Authenticate to the device.
|
|
278
283
|
|
|
284
|
+
The session reset, the exchange and the install of the new key all
|
|
285
|
+
happen while holding the request lock, so a request queued behind
|
|
286
|
+
the lock is never framed with the initial key or device id 0.
|
|
287
|
+
"""
|
|
279
288
|
packet = bytearray(0x50)
|
|
280
289
|
packet[0x04:0x14] = [0x31] * 16
|
|
281
290
|
packet[0x1E] = 0x01
|
|
282
291
|
packet[0x2D] = 0x01
|
|
283
|
-
packet[0x30:0x36] = "Test 1"
|
|
292
|
+
packet[0x30:0x36] = b"Test 1"
|
|
284
293
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
294
|
+
async with self._lock:
|
|
295
|
+
self.id = 0
|
|
296
|
+
self.update_aes(bytes.fromhex(self.__INIT_KEY))
|
|
297
|
+
response = await self._exchange(self._frame(0x65, bytes(packet)))
|
|
298
|
+
e.check_error(response[0x22:0x24])
|
|
299
|
+
payload = self.decrypt(response[0x38:])
|
|
300
|
+
self.id = int.from_bytes(payload[:0x4], "little")
|
|
301
|
+
self.update_aes(payload[0x04:0x14])
|
|
302
|
+
self._auth_generation += 1
|
|
303
|
+
_LOGGER.debug("%s: authenticated, session id %d", self.host[0], self.id)
|
|
292
304
|
return True
|
|
293
305
|
|
|
294
|
-
async def hello(self, local_ip_address=None) -> bool:
|
|
306
|
+
async def hello(self, local_ip_address: str | None = None) -> bool:
|
|
295
307
|
"""Send a hello message to the device.
|
|
296
308
|
|
|
297
309
|
Device information is checked before updating name and lock status.
|
|
298
310
|
"""
|
|
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
311
|
entry = None
|
|
306
|
-
async
|
|
307
|
-
|
|
312
|
+
async with contextlib.aclosing(
|
|
313
|
+
scan(
|
|
314
|
+
timeout=self.timeout,
|
|
315
|
+
local_ip_address=local_ip_address,
|
|
316
|
+
discover_ip_address=self.host[0],
|
|
317
|
+
discover_ip_port=self.host[1],
|
|
318
|
+
)
|
|
319
|
+
) as responses:
|
|
320
|
+
async for entry in responses: # noqa: B007 - first reply only
|
|
321
|
+
break
|
|
308
322
|
if entry is None:
|
|
309
323
|
raise e.NetworkTimeoutError(
|
|
310
324
|
-4000,
|
|
@@ -382,12 +396,14 @@ class Device:
|
|
|
382
396
|
A request in flight fails at once with ``ConnectionClosedError``
|
|
383
397
|
rather than waiting out its timeout.
|
|
384
398
|
"""
|
|
399
|
+
self._closes += 1
|
|
385
400
|
transport, protocol = self._transport, self._protocol
|
|
386
401
|
self._transport = None
|
|
387
402
|
self._protocol = None
|
|
388
403
|
self._endpoint_addr = None
|
|
389
404
|
if transport is not None:
|
|
390
405
|
transport.close()
|
|
406
|
+
_LOGGER.debug("%s: endpoint closed", self.host[0])
|
|
391
407
|
if protocol is not None:
|
|
392
408
|
protocol.queue.put_nowait(_CLOSED) # type: ignore[arg-type]
|
|
393
409
|
|
|
@@ -397,10 +413,17 @@ class Device:
|
|
|
397
413
|
# old address, so drop it.
|
|
398
414
|
await self.aclose()
|
|
399
415
|
if self._transport is None or self._transport.is_closing():
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
416
|
+
closes = self._closes
|
|
417
|
+
transport, protocol = await _open_endpoint(remote_addr=self.host)
|
|
418
|
+
if self._closes != closes:
|
|
419
|
+
# aclose() ran while the socket was being opened.
|
|
420
|
+
transport.close()
|
|
421
|
+
raise e.EndpointClosedError(
|
|
422
|
+
-4013, "Endpoint closed", "The device endpoint was closed"
|
|
423
|
+
)
|
|
424
|
+
self._transport, self._protocol = transport, protocol
|
|
403
425
|
self._endpoint_addr = self.host
|
|
426
|
+
_LOGGER.debug("%s: endpoint opened", self.host[0])
|
|
404
427
|
return self._transport, self._protocol # type: ignore[return-value]
|
|
405
428
|
|
|
406
429
|
def _frame(self, packet_type: int, payload: bytes) -> bytes:
|
|
@@ -450,8 +473,9 @@ class Device:
|
|
|
450
473
|
|
|
451
474
|
Replies carry the request's packet counter (offset 0x28), so a reply
|
|
452
475
|
is matched to the request by counter. A reply whose counter belongs
|
|
453
|
-
to
|
|
454
|
-
|
|
476
|
+
to any other recent request (a late answer, or the second answer to
|
|
477
|
+
a request that was resent) is dropped; one with a counter this
|
|
478
|
+
device has not used recently is accepted, for firmware that may not
|
|
455
479
|
echo it.
|
|
456
480
|
"""
|
|
457
481
|
transport, protocol = await self._endpoint()
|
|
@@ -460,9 +484,14 @@ class Device:
|
|
|
460
484
|
start = loop.time()
|
|
461
485
|
timeout = self.timeout
|
|
462
486
|
count = int.from_bytes(packet[0x28:0x2A], "little")
|
|
487
|
+
self._recent.append(count)
|
|
488
|
+
sends = 0
|
|
463
489
|
|
|
464
490
|
while True:
|
|
465
491
|
transport.sendto(packet)
|
|
492
|
+
sends += 1
|
|
493
|
+
if sends > 1:
|
|
494
|
+
_LOGGER.debug("%s: no reply, resending (%d)", self.host[0], sends)
|
|
466
495
|
resend_at = loop.time() + DEFAULT_RETRY_INTVL
|
|
467
496
|
while True:
|
|
468
497
|
now = loop.time()
|
|
@@ -471,54 +500,60 @@ class Device:
|
|
|
471
500
|
wait = min(resend_at, start + timeout) - now
|
|
472
501
|
try:
|
|
473
502
|
resp, _ = await asyncio.wait_for(protocol.queue.get(), max(wait, 0))
|
|
474
|
-
except
|
|
503
|
+
except TimeoutError:
|
|
475
504
|
if loop.time() - start >= timeout:
|
|
476
505
|
break
|
|
477
506
|
if loop.time() >= resend_at:
|
|
478
507
|
break # Resend.
|
|
479
508
|
continue
|
|
480
509
|
if resp is None:
|
|
481
|
-
raise e.
|
|
482
|
-
-4013, "
|
|
510
|
+
raise e.EndpointClosedError(
|
|
511
|
+
-4013, "Endpoint closed", "The device endpoint was closed"
|
|
483
512
|
)
|
|
484
513
|
resp = self._validate(resp)
|
|
485
514
|
reply_count = int.from_bytes(resp[0x28:0x2A], "little")
|
|
486
|
-
if reply_count == count or reply_count not in self.
|
|
515
|
+
if reply_count == count or reply_count not in self._recent:
|
|
487
516
|
return resp
|
|
488
|
-
|
|
517
|
+
_LOGGER.debug(
|
|
518
|
+
"%s: dropped a reply for an earlier request (counter 0x%04x)",
|
|
519
|
+
self.host[0],
|
|
520
|
+
reply_count,
|
|
521
|
+
)
|
|
489
522
|
if loop.time() - start >= timeout:
|
|
490
|
-
self.
|
|
523
|
+
_LOGGER.debug("%s: no reply within %ss", self.host[0], timeout)
|
|
491
524
|
raise e.NetworkTimeoutError(
|
|
492
525
|
-4000,
|
|
493
526
|
"Network timeout",
|
|
494
527
|
f"No response received within {timeout}s",
|
|
495
528
|
) from None
|
|
496
529
|
|
|
497
|
-
async def send_packet(
|
|
498
|
-
self, packet_type: int, payload: bytes, *, _reauth: bool = True
|
|
499
|
-
) -> bytes:
|
|
530
|
+
async def send_packet(self, packet_type: int, payload: bytes | bytearray) -> bytes:
|
|
500
531
|
"""Send a packet to the device and return the raw response frame.
|
|
501
532
|
|
|
502
533
|
If the device answers that the session key is no longer valid, the
|
|
503
534
|
session is re-authenticated once and the request is sent again.
|
|
504
535
|
Concurrent callers that hit the same expired key share one
|
|
505
|
-
re-authentication and each retry once.
|
|
536
|
+
re-authentication and each retry once. If that re-authentication
|
|
537
|
+
fails (for example the device has been locked in the app), the
|
|
538
|
+
original reply is returned unchanged, so the caller sees the same
|
|
539
|
+
error the original library raised and can run its own recovery.
|
|
506
540
|
"""
|
|
507
|
-
if self._lock is None:
|
|
508
|
-
self._lock = asyncio.Lock()
|
|
509
|
-
self._reauth_lock = asyncio.Lock()
|
|
510
|
-
generation = self._auth_generation
|
|
511
541
|
async with self._lock:
|
|
542
|
+
generation = self._auth_generation
|
|
512
543
|
resp = await self._exchange(self._frame(packet_type, bytes(payload)))
|
|
513
544
|
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
545
|
+
code = int.from_bytes(resp[0x22:0x24], "little", signed=True)
|
|
546
|
+
if code in _REAUTH_CODES:
|
|
547
|
+
_LOGGER.debug("%s: device answered %d, re-authenticating", self.host[0], code)
|
|
548
|
+
async with self._reauth_lock:
|
|
549
|
+
if self._auth_generation == generation:
|
|
550
|
+
try:
|
|
519
551
|
await self.auth()
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
552
|
+
except e.BroadlinkException as err:
|
|
553
|
+
_LOGGER.debug(
|
|
554
|
+
"%s: re-authentication failed: %s", self.host[0], err
|
|
555
|
+
)
|
|
556
|
+
return resp
|
|
557
|
+
async with self._lock:
|
|
558
|
+
resp = await self._exchange(self._frame(packet_type, bytes(payload)))
|
|
524
559
|
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
|
|
|
@@ -156,6 +168,6 @@ def exception(err_code: int) -> BroadlinkException:
|
|
|
156
168
|
|
|
157
169
|
def check_error(error: bytes) -> None:
|
|
158
170
|
"""Raise exception if an error occurred."""
|
|
159
|
-
error_code = struct.unpack("h", error)[0]
|
|
171
|
+
error_code = struct.unpack("<h", error)[0]
|
|
160
172
|
if error_code:
|
|
161
173
|
raise exception(error_code)
|