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/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
@@ -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