python-broadlink 1.0.0__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 +342 -0
- broadlink/alarm.py +43 -0
- broadlink/climate.py +474 -0
- broadlink/const.py +5 -0
- broadlink/cover.py +182 -0
- broadlink/device.py +470 -0
- broadlink/exceptions.py +161 -0
- broadlink/helpers.py +43 -0
- broadlink/hub.py +98 -0
- broadlink/light.py +200 -0
- broadlink/protocol.py +50 -0
- broadlink/remote.py +521 -0
- broadlink/sensor.py +90 -0
- broadlink/switch.py +472 -0
- python_broadlink-1.0.0.dist-info/METADATA +360 -0
- python_broadlink-1.0.0.dist-info/RECORD +19 -0
- python_broadlink-1.0.0.dist-info/WHEEL +5 -0
- python_broadlink-1.0.0.dist-info/licenses/LICENSE +22 -0
- python_broadlink-1.0.0.dist-info/top_level.txt +1 -0
broadlink/device.py
ADDED
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
"""Support for Broadlink devices.
|
|
2
|
+
|
|
3
|
+
Transport layer. Every device method ends up in :meth:`Device.send_packet`,
|
|
4
|
+
which frames, encrypts and sends one request over UDP and waits for the one
|
|
5
|
+
reply. The protocol is strictly request and reply and the device never
|
|
6
|
+
speaks unprompted, so each device keeps a single datagram endpoint and an
|
|
7
|
+
``asyncio.Lock`` that serializes calls on it.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import random
|
|
14
|
+
import socket
|
|
15
|
+
from collections.abc import AsyncIterator
|
|
16
|
+
from typing import Optional, Tuple, Union
|
|
17
|
+
|
|
18
|
+
from cryptography.hazmat.backends import default_backend
|
|
19
|
+
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
20
|
+
|
|
21
|
+
from . import exceptions as e
|
|
22
|
+
from .const import (
|
|
23
|
+
DEFAULT_BCAST_ADDR,
|
|
24
|
+
DEFAULT_PORT,
|
|
25
|
+
DEFAULT_RETRY_INTVL,
|
|
26
|
+
DEFAULT_TIMEOUT,
|
|
27
|
+
)
|
|
28
|
+
from .protocol import Datetime
|
|
29
|
+
|
|
30
|
+
HelloResponse = Tuple[int, Tuple[str, int], bytes, str, bool]
|
|
31
|
+
|
|
32
|
+
# Device error codes that mean the session key is no longer accepted and a
|
|
33
|
+
# fresh auth() will fix it. -7: control key expired; -4012: control id error.
|
|
34
|
+
_REAUTH_CODES = {-7, -4012}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class _Protocol(asyncio.DatagramProtocol):
|
|
38
|
+
"""Datagram protocol that hands every received packet to a queue."""
|
|
39
|
+
|
|
40
|
+
def __init__(self) -> None:
|
|
41
|
+
self.queue: asyncio.Queue[tuple[bytes, tuple[str, int]]] = asyncio.Queue()
|
|
42
|
+
self.transport: Optional[asyncio.DatagramTransport] = None
|
|
43
|
+
self.closed = asyncio.get_running_loop().create_future()
|
|
44
|
+
|
|
45
|
+
def connection_made(self, transport) -> None: # type: ignore[override]
|
|
46
|
+
self.transport = transport
|
|
47
|
+
|
|
48
|
+
def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None:
|
|
49
|
+
self.queue.put_nowait((data, addr))
|
|
50
|
+
|
|
51
|
+
def error_received(self, exc: Exception) -> None:
|
|
52
|
+
# ICMP unreachable and the like. Surface it as a receive of nothing;
|
|
53
|
+
# the retry loop will time out and raise NetworkTimeoutError.
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
def connection_lost(self, exc: Optional[Exception]) -> None:
|
|
57
|
+
if not self.closed.done():
|
|
58
|
+
self.closed.set_result(None)
|
|
59
|
+
|
|
60
|
+
def drain(self) -> None:
|
|
61
|
+
"""Drop anything that arrived before the current request."""
|
|
62
|
+
while not self.queue.empty():
|
|
63
|
+
self.queue.get_nowait()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def _open_endpoint(
|
|
67
|
+
local_addr: Optional[tuple[str, int]] = None,
|
|
68
|
+
remote_addr: Optional[tuple[str, int]] = None,
|
|
69
|
+
broadcast: bool = False,
|
|
70
|
+
) -> tuple[asyncio.DatagramTransport, _Protocol]:
|
|
71
|
+
"""Create a UDP endpoint. Tests replace this to fake the network."""
|
|
72
|
+
loop = asyncio.get_running_loop()
|
|
73
|
+
transport, protocol = await loop.create_datagram_endpoint(
|
|
74
|
+
_Protocol,
|
|
75
|
+
local_addr=local_addr,
|
|
76
|
+
remote_addr=remote_addr,
|
|
77
|
+
family=socket.AF_INET,
|
|
78
|
+
allow_broadcast=broadcast,
|
|
79
|
+
)
|
|
80
|
+
return transport, protocol # type: ignore[return-value]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _hello_packet(local_ip_address: str, port: int) -> bytearray:
|
|
84
|
+
packet = bytearray(0x30)
|
|
85
|
+
packet[0x08:0x14] = Datetime.pack(Datetime.now())
|
|
86
|
+
packet[0x18:0x1C] = socket.inet_aton(local_ip_address)[::-1]
|
|
87
|
+
packet[0x1C:0x1E] = port.to_bytes(2, "little")
|
|
88
|
+
packet[0x26] = 6
|
|
89
|
+
checksum = sum(packet, 0xBEAF) & 0xFFFF
|
|
90
|
+
packet[0x20:0x22] = checksum.to_bytes(2, "little")
|
|
91
|
+
return packet
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _parse_hello(resp: bytes, host: tuple[str, int]) -> HelloResponse:
|
|
95
|
+
devtype = resp[0x34] | resp[0x35] << 8
|
|
96
|
+
mac = resp[0x3A:0x40][::-1]
|
|
97
|
+
name = resp[0x40:].split(b"\x00")[0].decode()
|
|
98
|
+
is_locked = bool(resp[0x7F])
|
|
99
|
+
return devtype, host, mac, name, is_locked
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
async def scan(
|
|
103
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
104
|
+
local_ip_address: Optional[str] = None,
|
|
105
|
+
discover_ip_address: str = DEFAULT_BCAST_ADDR,
|
|
106
|
+
discover_ip_port: int = DEFAULT_PORT,
|
|
107
|
+
) -> AsyncIterator[HelloResponse]:
|
|
108
|
+
"""Broadcast a hello message and yield responses as they arrive.
|
|
109
|
+
|
|
110
|
+
The hello is repeated every ``DEFAULT_RETRY_INTVL`` seconds until
|
|
111
|
+
``timeout`` elapses. Each device is yielded once.
|
|
112
|
+
"""
|
|
113
|
+
local_addr = (local_ip_address, 0) if local_ip_address else None
|
|
114
|
+
transport, protocol = await _open_endpoint(local_addr=local_addr, broadcast=True)
|
|
115
|
+
try:
|
|
116
|
+
if local_ip_address:
|
|
117
|
+
port = transport.get_extra_info("sockname")[1]
|
|
118
|
+
else:
|
|
119
|
+
local_ip_address = "0.0.0.0"
|
|
120
|
+
port = 0
|
|
121
|
+
packet = _hello_packet(local_ip_address, port)
|
|
122
|
+
|
|
123
|
+
loop = asyncio.get_running_loop()
|
|
124
|
+
start = loop.time()
|
|
125
|
+
discovered: set[tuple[tuple[str, int], bytes, int]] = set()
|
|
126
|
+
|
|
127
|
+
while (loop.time() - start) < timeout:
|
|
128
|
+
transport.sendto(packet, (discover_ip_address, discover_ip_port))
|
|
129
|
+
deadline = min(DEFAULT_RETRY_INTVL, timeout - (loop.time() - start))
|
|
130
|
+
slot_end = loop.time() + deadline
|
|
131
|
+
while True:
|
|
132
|
+
remaining = slot_end - loop.time()
|
|
133
|
+
if remaining <= 0:
|
|
134
|
+
break
|
|
135
|
+
try:
|
|
136
|
+
resp, host = await asyncio.wait_for(protocol.queue.get(), remaining)
|
|
137
|
+
except asyncio.TimeoutError:
|
|
138
|
+
break
|
|
139
|
+
if len(resp) < 0x80:
|
|
140
|
+
continue
|
|
141
|
+
entry = _parse_hello(resp, host)
|
|
142
|
+
key = (entry[1], entry[2], entry[0])
|
|
143
|
+
if key in discovered:
|
|
144
|
+
continue
|
|
145
|
+
discovered.add(key)
|
|
146
|
+
yield entry
|
|
147
|
+
finally:
|
|
148
|
+
transport.close()
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
async def ping(ip_address: str, port: int = DEFAULT_PORT) -> None:
|
|
152
|
+
"""Send a ping packet to an address.
|
|
153
|
+
|
|
154
|
+
This packet feeds the watchdog timer of firmwares >= v53.
|
|
155
|
+
Useful to prevent reboots when the cloud cannot be reached.
|
|
156
|
+
It must be sent every 2 minutes in such cases.
|
|
157
|
+
"""
|
|
158
|
+
transport, _ = await _open_endpoint(broadcast=True)
|
|
159
|
+
try:
|
|
160
|
+
packet = bytearray(0x30)
|
|
161
|
+
packet[0x26] = 1
|
|
162
|
+
transport.sendto(packet, (ip_address, port))
|
|
163
|
+
finally:
|
|
164
|
+
transport.close()
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class Device:
|
|
168
|
+
"""Controls a Broadlink device."""
|
|
169
|
+
|
|
170
|
+
TYPE = "Unknown"
|
|
171
|
+
|
|
172
|
+
__INIT_KEY = "097628343fe99e23765c1513accf8b02"
|
|
173
|
+
__INIT_VECT = "562e17996d093d28ddb3ba695a2e6f58"
|
|
174
|
+
|
|
175
|
+
def __init__(
|
|
176
|
+
self,
|
|
177
|
+
host: Tuple[str, int],
|
|
178
|
+
mac: Union[bytes, str],
|
|
179
|
+
devtype: int,
|
|
180
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
181
|
+
name: str = "",
|
|
182
|
+
model: str = "",
|
|
183
|
+
manufacturer: str = "",
|
|
184
|
+
is_locked: bool = False,
|
|
185
|
+
) -> None:
|
|
186
|
+
"""Initialize the controller."""
|
|
187
|
+
self.host = host
|
|
188
|
+
self.mac = bytes.fromhex(mac) if isinstance(mac, str) else mac
|
|
189
|
+
self.devtype = devtype
|
|
190
|
+
self.timeout = timeout
|
|
191
|
+
self.name = name
|
|
192
|
+
self.model = model
|
|
193
|
+
self.manufacturer = manufacturer
|
|
194
|
+
self.is_locked = is_locked
|
|
195
|
+
self.count = random.randint(0x8000, 0xFFFF)
|
|
196
|
+
self.iv = bytes.fromhex(self.__INIT_VECT)
|
|
197
|
+
self.id = 0
|
|
198
|
+
self.type = self.TYPE # For backwards compatibility.
|
|
199
|
+
|
|
200
|
+
self.aes = None
|
|
201
|
+
self.update_aes(bytes.fromhex(self.__INIT_KEY))
|
|
202
|
+
|
|
203
|
+
self._lock: Optional[asyncio.Lock] = None
|
|
204
|
+
self._transport: Optional[asyncio.DatagramTransport] = None
|
|
205
|
+
self._protocol: Optional[_Protocol] = None
|
|
206
|
+
self._reauth_ok = True
|
|
207
|
+
|
|
208
|
+
def __repr__(self) -> str:
|
|
209
|
+
"""Return a formal representation of the device."""
|
|
210
|
+
return (
|
|
211
|
+
"%s.%s(%s, mac=%r, devtype=%r, timeout=%r, name=%r, "
|
|
212
|
+
"model=%r, manufacturer=%r, is_locked=%r)"
|
|
213
|
+
) % (
|
|
214
|
+
self.__class__.__module__,
|
|
215
|
+
self.__class__.__qualname__,
|
|
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,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
def __str__(self) -> str:
|
|
227
|
+
"""Return a readable representation of the device."""
|
|
228
|
+
return "%s (%s / %s:%s / %s)" % (
|
|
229
|
+
self.name or "Unknown",
|
|
230
|
+
" ".join(filter(None, [self.manufacturer, self.model, hex(self.devtype)])),
|
|
231
|
+
*self.host,
|
|
232
|
+
":".join(format(x, "02X") for x in self.mac),
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
async def __aenter__(self) -> "Device":
|
|
236
|
+
return self
|
|
237
|
+
|
|
238
|
+
async def __aexit__(self, *exc) -> None:
|
|
239
|
+
await self.aclose()
|
|
240
|
+
|
|
241
|
+
# ------------------------------------------------------------ crypto
|
|
242
|
+
|
|
243
|
+
def update_aes(self, key: bytes) -> None:
|
|
244
|
+
"""Update AES."""
|
|
245
|
+
self.aes = Cipher(
|
|
246
|
+
algorithms.AES(bytes(key)), modes.CBC(self.iv), backend=default_backend()
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
def encrypt(self, payload: bytes) -> bytes:
|
|
250
|
+
"""Encrypt the payload."""
|
|
251
|
+
encryptor = self.aes.encryptor()
|
|
252
|
+
return encryptor.update(bytes(payload)) + encryptor.finalize()
|
|
253
|
+
|
|
254
|
+
def decrypt(self, payload: bytes) -> bytes:
|
|
255
|
+
"""Decrypt the payload."""
|
|
256
|
+
decryptor = self.aes.decryptor()
|
|
257
|
+
return decryptor.update(bytes(payload)) + decryptor.finalize()
|
|
258
|
+
|
|
259
|
+
# ---------------------------------------------------------- session
|
|
260
|
+
|
|
261
|
+
async def auth(self) -> bool:
|
|
262
|
+
"""Authenticate to the device."""
|
|
263
|
+
self.id = 0
|
|
264
|
+
self.update_aes(bytes.fromhex(self.__INIT_KEY))
|
|
265
|
+
|
|
266
|
+
packet = bytearray(0x50)
|
|
267
|
+
packet[0x04:0x14] = [0x31] * 16
|
|
268
|
+
packet[0x1E] = 0x01
|
|
269
|
+
packet[0x2D] = 0x01
|
|
270
|
+
packet[0x30:0x36] = "Test 1".encode()
|
|
271
|
+
|
|
272
|
+
response = await self.send_packet(0x65, packet, _reauth=False)
|
|
273
|
+
e.check_error(response[0x22:0x24])
|
|
274
|
+
payload = self.decrypt(response[0x38:])
|
|
275
|
+
|
|
276
|
+
self.id = int.from_bytes(payload[:0x4], "little")
|
|
277
|
+
self.update_aes(payload[0x04:0x14])
|
|
278
|
+
return True
|
|
279
|
+
|
|
280
|
+
async def hello(self, local_ip_address=None) -> bool:
|
|
281
|
+
"""Send a hello message to the device.
|
|
282
|
+
|
|
283
|
+
Device information is checked before updating name and lock status.
|
|
284
|
+
"""
|
|
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
|
+
entry = None
|
|
292
|
+
async for entry in responses:
|
|
293
|
+
break
|
|
294
|
+
if entry is None:
|
|
295
|
+
raise e.NetworkTimeoutError(
|
|
296
|
+
-4000,
|
|
297
|
+
"Network timeout",
|
|
298
|
+
f"No response received within {self.timeout}s",
|
|
299
|
+
)
|
|
300
|
+
devtype, _, mac, name, is_locked = entry
|
|
301
|
+
|
|
302
|
+
if mac != self.mac:
|
|
303
|
+
raise e.DataValidationError(
|
|
304
|
+
-2040,
|
|
305
|
+
"Device information is not intact",
|
|
306
|
+
"The MAC address is different",
|
|
307
|
+
f"Expected {self.mac} and received {mac}",
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
if devtype != self.devtype:
|
|
311
|
+
raise e.DataValidationError(
|
|
312
|
+
-2040,
|
|
313
|
+
"Device information is not intact",
|
|
314
|
+
"The product ID is different",
|
|
315
|
+
f"Expected {self.devtype} and received {devtype}",
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
self.name = name
|
|
319
|
+
self.is_locked = is_locked
|
|
320
|
+
return True
|
|
321
|
+
|
|
322
|
+
async def ping(self) -> None:
|
|
323
|
+
"""Ping the device.
|
|
324
|
+
|
|
325
|
+
This packet feeds the watchdog timer of firmwares >= v53.
|
|
326
|
+
Useful to prevent reboots when the cloud cannot be reached.
|
|
327
|
+
It must be sent every 2 minutes in such cases.
|
|
328
|
+
"""
|
|
329
|
+
await ping(self.host[0], port=self.host[1])
|
|
330
|
+
|
|
331
|
+
async def get_fwversion(self) -> int:
|
|
332
|
+
"""Get firmware version."""
|
|
333
|
+
packet = bytearray([0x68])
|
|
334
|
+
response = await self.send_packet(0x6A, packet)
|
|
335
|
+
e.check_error(response[0x22:0x24])
|
|
336
|
+
payload = self.decrypt(response[0x38:])
|
|
337
|
+
return payload[0x4] | payload[0x5] << 8
|
|
338
|
+
|
|
339
|
+
async def set_name(self, name: str) -> None:
|
|
340
|
+
"""Set device name."""
|
|
341
|
+
packet = bytearray(4)
|
|
342
|
+
packet += name.encode("utf-8")
|
|
343
|
+
packet += bytearray(0x50 - len(packet))
|
|
344
|
+
packet[0x43] = self.is_locked
|
|
345
|
+
response = await self.send_packet(0x6A, packet)
|
|
346
|
+
e.check_error(response[0x22:0x24])
|
|
347
|
+
self.name = name
|
|
348
|
+
|
|
349
|
+
async def set_lock(self, state: bool) -> None:
|
|
350
|
+
"""Lock/unlock the device."""
|
|
351
|
+
packet = bytearray(4)
|
|
352
|
+
packet += self.name.encode("utf-8")
|
|
353
|
+
packet += bytearray(0x50 - len(packet))
|
|
354
|
+
packet[0x43] = bool(state)
|
|
355
|
+
response = await self.send_packet(0x6A, packet)
|
|
356
|
+
e.check_error(response[0x22:0x24])
|
|
357
|
+
self.is_locked = bool(state)
|
|
358
|
+
|
|
359
|
+
def get_type(self) -> str:
|
|
360
|
+
"""Return device type."""
|
|
361
|
+
return self.type
|
|
362
|
+
|
|
363
|
+
# -------------------------------------------------------- transport
|
|
364
|
+
|
|
365
|
+
async def aclose(self) -> None:
|
|
366
|
+
"""Close the device's endpoint. It is reopened on the next call."""
|
|
367
|
+
if self._transport is not None:
|
|
368
|
+
self._transport.close()
|
|
369
|
+
self._transport = None
|
|
370
|
+
self._protocol = None
|
|
371
|
+
|
|
372
|
+
async def _endpoint(self) -> tuple[asyncio.DatagramTransport, _Protocol]:
|
|
373
|
+
if self._transport is None or self._transport.is_closing():
|
|
374
|
+
self._transport, self._protocol = await _open_endpoint(
|
|
375
|
+
remote_addr=self.host
|
|
376
|
+
)
|
|
377
|
+
return self._transport, self._protocol # type: ignore[return-value]
|
|
378
|
+
|
|
379
|
+
def _frame(self, packet_type: int, payload: bytes) -> bytes:
|
|
380
|
+
"""Build the wire frame for one request (advances the counter)."""
|
|
381
|
+
self.count = ((self.count + 1) | 0x8000) & 0xFFFF
|
|
382
|
+
packet = bytearray(0x38)
|
|
383
|
+
packet[0x00:0x08] = bytes.fromhex("5aa5aa555aa5aa55")
|
|
384
|
+
packet[0x24:0x26] = self.devtype.to_bytes(2, "little")
|
|
385
|
+
packet[0x26:0x28] = packet_type.to_bytes(2, "little")
|
|
386
|
+
packet[0x28:0x2A] = self.count.to_bytes(2, "little")
|
|
387
|
+
packet[0x2A:0x30] = self.mac[::-1]
|
|
388
|
+
packet[0x30:0x34] = self.id.to_bytes(4, "little")
|
|
389
|
+
|
|
390
|
+
p_checksum = sum(payload, 0xBEAF) & 0xFFFF
|
|
391
|
+
packet[0x34:0x36] = p_checksum.to_bytes(2, "little")
|
|
392
|
+
|
|
393
|
+
padding = (16 - len(payload)) % 16
|
|
394
|
+
payload = self.encrypt(payload + bytes(padding))
|
|
395
|
+
packet.extend(payload)
|
|
396
|
+
|
|
397
|
+
checksum = sum(packet, 0xBEAF) & 0xFFFF
|
|
398
|
+
packet[0x20:0x22] = checksum.to_bytes(2, "little")
|
|
399
|
+
return bytes(packet)
|
|
400
|
+
|
|
401
|
+
@staticmethod
|
|
402
|
+
def _validate(resp: bytes) -> bytes:
|
|
403
|
+
if len(resp) < 0x30:
|
|
404
|
+
raise e.DataValidationError(
|
|
405
|
+
-4007,
|
|
406
|
+
"Received data packet length error",
|
|
407
|
+
f"Expected at least 48 bytes and received {len(resp)}",
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
nom_checksum = int.from_bytes(resp[0x20:0x22], "little")
|
|
411
|
+
real_checksum = sum(resp, 0xBEAF) - sum(resp[0x20:0x22]) & 0xFFFF
|
|
412
|
+
|
|
413
|
+
if nom_checksum != real_checksum:
|
|
414
|
+
raise e.DataValidationError(
|
|
415
|
+
-4008,
|
|
416
|
+
"Received data packet check error",
|
|
417
|
+
f"Expected a checksum of {nom_checksum} and received {real_checksum}",
|
|
418
|
+
)
|
|
419
|
+
return resp
|
|
420
|
+
|
|
421
|
+
async def _exchange(self, packet: bytes) -> bytes:
|
|
422
|
+
"""Send one frame and wait for one reply, resending on silence."""
|
|
423
|
+
transport, protocol = await self._endpoint()
|
|
424
|
+
protocol.drain()
|
|
425
|
+
loop = asyncio.get_running_loop()
|
|
426
|
+
start = loop.time()
|
|
427
|
+
timeout = self.timeout
|
|
428
|
+
|
|
429
|
+
while True:
|
|
430
|
+
transport.sendto(packet)
|
|
431
|
+
time_left = timeout - (loop.time() - start)
|
|
432
|
+
wait = min(DEFAULT_RETRY_INTVL, time_left)
|
|
433
|
+
try:
|
|
434
|
+
resp, _ = await asyncio.wait_for(protocol.queue.get(), max(wait, 0))
|
|
435
|
+
except asyncio.TimeoutError:
|
|
436
|
+
if (loop.time() - start) >= timeout:
|
|
437
|
+
raise e.NetworkTimeoutError(
|
|
438
|
+
-4000,
|
|
439
|
+
"Network timeout",
|
|
440
|
+
f"No response received within {timeout}s",
|
|
441
|
+
) from None
|
|
442
|
+
continue
|
|
443
|
+
return self._validate(resp)
|
|
444
|
+
|
|
445
|
+
async def send_packet(
|
|
446
|
+
self, packet_type: int, payload: bytes, *, _reauth: bool = True
|
|
447
|
+
) -> bytes:
|
|
448
|
+
"""Send a packet to the device and return the raw response frame.
|
|
449
|
+
|
|
450
|
+
If the device answers that the session key is no longer valid, the
|
|
451
|
+
session is re-authenticated once and the request is sent again.
|
|
452
|
+
"""
|
|
453
|
+
if self._lock is None:
|
|
454
|
+
self._lock = asyncio.Lock()
|
|
455
|
+
async with self._lock:
|
|
456
|
+
resp = await self._exchange(self._frame(packet_type, bytes(payload)))
|
|
457
|
+
|
|
458
|
+
if _reauth and self._reauth_ok:
|
|
459
|
+
code = int.from_bytes(resp[0x22:0x24], "little", signed=True)
|
|
460
|
+
if code in _REAUTH_CODES:
|
|
461
|
+
self._reauth_ok = False
|
|
462
|
+
try:
|
|
463
|
+
await self.auth()
|
|
464
|
+
async with self._lock:
|
|
465
|
+
resp = await self._exchange(
|
|
466
|
+
self._frame(packet_type, bytes(payload))
|
|
467
|
+
)
|
|
468
|
+
finally:
|
|
469
|
+
self._reauth_ok = True
|
|
470
|
+
return resp
|
broadlink/exceptions.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Exceptions for Broadlink devices."""
|
|
2
|
+
import collections
|
|
3
|
+
import struct
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class BroadlinkException(Exception):
|
|
7
|
+
"""Base class common to all Broadlink exceptions."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, *args, **kwargs):
|
|
10
|
+
"""Initialize the exception."""
|
|
11
|
+
super().__init__(*args, **kwargs)
|
|
12
|
+
if len(args) >= 2:
|
|
13
|
+
self.errno = args[0]
|
|
14
|
+
self.strerror = ": ".join(str(arg) for arg in args[1:])
|
|
15
|
+
elif len(args) == 1:
|
|
16
|
+
self.errno = None
|
|
17
|
+
self.strerror = str(args[0])
|
|
18
|
+
else:
|
|
19
|
+
self.errno = None
|
|
20
|
+
self.strerror = ""
|
|
21
|
+
|
|
22
|
+
def __str__(self):
|
|
23
|
+
"""Return str(self)."""
|
|
24
|
+
if self.errno is not None:
|
|
25
|
+
return "[Errno %s] %s" % (self.errno, self.strerror)
|
|
26
|
+
return self.strerror
|
|
27
|
+
|
|
28
|
+
def __eq__(self, other):
|
|
29
|
+
"""Return self==value."""
|
|
30
|
+
# pylint: disable=unidiomatic-typecheck
|
|
31
|
+
return type(self) == type(other) and self.args == other.args
|
|
32
|
+
|
|
33
|
+
def __hash__(self):
|
|
34
|
+
"""Return hash(self)."""
|
|
35
|
+
return hash((type(self), self.args))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class MultipleErrors(BroadlinkException):
|
|
39
|
+
"""Multiple errors."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, *args, **kwargs):
|
|
42
|
+
"""Initialize the exception."""
|
|
43
|
+
errors = args[0][:] if args else []
|
|
44
|
+
counter = collections.Counter(errors)
|
|
45
|
+
strerror = "Multiple errors occurred: %s" % counter
|
|
46
|
+
super().__init__(strerror, **kwargs)
|
|
47
|
+
self.errors = errors
|
|
48
|
+
|
|
49
|
+
def __repr__(self):
|
|
50
|
+
"""Return repr(self)."""
|
|
51
|
+
return "MultipleErrors(%r)" % self.errors
|
|
52
|
+
|
|
53
|
+
def __str__(self):
|
|
54
|
+
"""Return str(self)."""
|
|
55
|
+
return self.strerror
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class AuthenticationError(BroadlinkException):
|
|
59
|
+
"""Authentication error."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class AuthorizationError(BroadlinkException):
|
|
63
|
+
"""Authorization error."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class CommandNotSupportedError(BroadlinkException):
|
|
67
|
+
"""Command not supported error."""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ConnectionClosedError(BroadlinkException):
|
|
71
|
+
"""Connection closed error."""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class StructureAbnormalError(BroadlinkException):
|
|
75
|
+
"""Structure abnormal error."""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class DeviceOfflineError(BroadlinkException):
|
|
79
|
+
"""Device offline error."""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class ReadError(BroadlinkException):
|
|
83
|
+
"""Read error."""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class SendError(BroadlinkException):
|
|
87
|
+
"""Send error."""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class SSIDNotFoundError(BroadlinkException):
|
|
91
|
+
"""SSID not found error."""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class StorageError(BroadlinkException):
|
|
95
|
+
"""Storage error."""
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class CaptureInProgressError(BroadlinkException):
|
|
99
|
+
"""A capture window is already open on this device.
|
|
100
|
+
|
|
101
|
+
A universal remote has one receiver, so only one ``capture`` or
|
|
102
|
+
``capture_rf`` window can be open at a time. Close the running one
|
|
103
|
+
before opening another.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class WriteError(BroadlinkException):
|
|
108
|
+
"""Write error."""
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class NetworkTimeoutError(BroadlinkException):
|
|
112
|
+
"""Network timeout error."""
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class DataValidationError(BroadlinkException):
|
|
116
|
+
"""Data validation error."""
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class UnknownError(BroadlinkException):
|
|
120
|
+
"""Unknown error."""
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
BROADLINK_EXCEPTIONS = {
|
|
124
|
+
# Firmware-related errors are generated by the device.
|
|
125
|
+
-1: (AuthenticationError, "Authentication failed"),
|
|
126
|
+
-2: (ConnectionClosedError, "You have been logged out"),
|
|
127
|
+
-3: (DeviceOfflineError, "The device is offline"),
|
|
128
|
+
-4: (CommandNotSupportedError, "Command not supported"),
|
|
129
|
+
-5: (StorageError, "The device storage is full"),
|
|
130
|
+
-6: (StructureAbnormalError, "Structure is abnormal"),
|
|
131
|
+
-7: (AuthorizationError, "Control key is expired"),
|
|
132
|
+
-8: (SendError, "Send error"),
|
|
133
|
+
-9: (WriteError, "Write error"),
|
|
134
|
+
-10: (ReadError, "Read error"),
|
|
135
|
+
-11: (SSIDNotFoundError, "SSID could not be found in AP configuration"),
|
|
136
|
+
# SDK related errors are generated by this module.
|
|
137
|
+
-2040: (DataValidationError, "Device information is not intact"),
|
|
138
|
+
-4000: (NetworkTimeoutError, "Network timeout"),
|
|
139
|
+
-4007: (DataValidationError, "Received data packet length error"),
|
|
140
|
+
-4008: (DataValidationError, "Received data packet check error"),
|
|
141
|
+
-4009: (DataValidationError, "Received data packet information type error"),
|
|
142
|
+
-4010: (DataValidationError, "Received encrypted data packet length error"),
|
|
143
|
+
-4011: (DataValidationError, "Received encrypted data packet check error"),
|
|
144
|
+
-4012: (AuthorizationError, "Device control ID error"),
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def exception(err_code: int) -> BroadlinkException:
|
|
149
|
+
"""Return exception corresponding to an error code."""
|
|
150
|
+
try:
|
|
151
|
+
exc, msg = BROADLINK_EXCEPTIONS[err_code]
|
|
152
|
+
return exc(err_code, msg)
|
|
153
|
+
except KeyError:
|
|
154
|
+
return UnknownError(err_code, "Unknown error")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def check_error(error: bytes) -> None:
|
|
158
|
+
"""Raise exception if an error occurred."""
|
|
159
|
+
error_code = struct.unpack("h", error)[0]
|
|
160
|
+
if error_code:
|
|
161
|
+
raise exception(error_code)
|
broadlink/helpers.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Helper functions and classes."""
|
|
2
|
+
from typing import Dict, List, Sequence
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class CRC16:
|
|
6
|
+
"""Helps with CRC-16 calculation.
|
|
7
|
+
|
|
8
|
+
CRC tables are cached for performance.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
_cache: Dict[int, List[int]] = {}
|
|
12
|
+
|
|
13
|
+
@classmethod
|
|
14
|
+
def get_table(cls, polynomial: int) -> List[int]:
|
|
15
|
+
"""Return the CRC-16 table for a polynomial."""
|
|
16
|
+
try:
|
|
17
|
+
crc_table = cls._cache[polynomial]
|
|
18
|
+
except KeyError:
|
|
19
|
+
crc_table = []
|
|
20
|
+
for dividend in range(0, 256):
|
|
21
|
+
remainder = dividend
|
|
22
|
+
for _ in range(0, 8):
|
|
23
|
+
if remainder & 1:
|
|
24
|
+
remainder = remainder >> 1 ^ polynomial
|
|
25
|
+
else:
|
|
26
|
+
remainder = remainder >> 1
|
|
27
|
+
crc_table.append(remainder)
|
|
28
|
+
cls._cache[polynomial] = crc_table
|
|
29
|
+
return crc_table
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def calculate(
|
|
33
|
+
cls,
|
|
34
|
+
sequence: Sequence[int],
|
|
35
|
+
polynomial: int = 0xA001, # CRC-16-ANSI.
|
|
36
|
+
init_value: int = 0xFFFF,
|
|
37
|
+
) -> int:
|
|
38
|
+
"""Calculate the CRC-16 of a sequence of integers."""
|
|
39
|
+
crc_table = cls.get_table(polynomial)
|
|
40
|
+
crc = init_value
|
|
41
|
+
for item in sequence:
|
|
42
|
+
crc = crc >> 8 ^ crc_table[(crc ^ item) & 0xFF]
|
|
43
|
+
return crc
|