python-broadlink 1.0.1__py3-none-any.whl → 1.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
broadlink/__init__.py 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 List, Optional, Tuple, Union
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: Tuple[str, int],
227
- mac: Union[bytes, str],
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 for device in xdiscover(
262
- timeout=timeout,
263
- discover_ip_address=ip_address,
264
- discover_ip_port=port,
265
- ):
266
- return device
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: Optional[str] = None,
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
- ) -> List[Device]:
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: Optional[str] = None,
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
@@ -1,4 +1,5 @@
1
1
  """Support for alarm kits."""
2
+
2
3
  from . import exceptions as e
3
4
  from .device import Device
4
5
 
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 typing import List, Sequence
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: List[dict], weekend: List[dict]) -> None:
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
@@ -1,4 +1,5 @@
1
1
  """Constants."""
2
+
2
3
  DEFAULT_BCAST_ADDR = "255.255.255.255"
3
4
  DEFAULT_PORT = 80
4
5
  DEFAULT_RETRY_INTVL = 1
broadlink/cover.py CHANGED
@@ -1,6 +1,7 @@
1
1
  """Support for covers."""
2
+
2
3
  import asyncio
3
- from typing import Sequence
4
+ from collections.abc import Sequence
4
5
 
5
6
  from . import exceptions as e
6
7
  from .device import Device
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
- HelloResponse = Tuple[int, Tuple[str, int], bytes, str, bool]
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 timed-out request counters to remember, so that a reply to one
39
- # of them arriving late is recognised and dropped instead of being taken as
40
- # the answer to a later request.
41
- _ABANDONED_MAX = 32
41
+ # How many recently used request counters to remember. A reply carrying one
42
+ # of them (other than the current request's) is a late or duplicate answer
43
+ # to an earlier request and is dropped rather than taken as the answer to
44
+ # the current one. 64 covers a burst of resends comfortably and ages out
45
+ # long before the 16-bit counter wraps.
46
+ _RECENT_MAX = 64
42
47
 
43
48
  _CLOSED = (None, None)
44
49
  """Sentinel put on the receive queue when the endpoint is closed."""
@@ -49,8 +54,7 @@ class _Protocol(asyncio.DatagramProtocol):
49
54
 
50
55
  def __init__(self) -> None:
51
56
  self.queue: asyncio.Queue[tuple[bytes, tuple[str, int]]] = asyncio.Queue()
52
- self.transport: Optional[asyncio.DatagramTransport] = None
53
- self.closed = asyncio.get_running_loop().create_future()
57
+ self.transport: asyncio.DatagramTransport | None = None
54
58
 
55
59
  def connection_made(self, transport) -> None: # type: ignore[override]
56
60
  self.transport = transport
@@ -63,9 +67,8 @@ class _Protocol(asyncio.DatagramProtocol):
63
67
  # the retry loop will time out and raise NetworkTimeoutError.
64
68
  pass
65
69
 
66
- def connection_lost(self, exc: Optional[Exception]) -> None:
67
- if not self.closed.done():
68
- self.closed.set_result(None)
70
+ def connection_lost(self, exc: Exception | None) -> None:
71
+ pass
69
72
 
70
73
  def drain(self) -> None:
71
74
  """Drop anything that arrived before the current request."""
@@ -74,8 +77,8 @@ class _Protocol(asyncio.DatagramProtocol):
74
77
 
75
78
 
76
79
  async def _open_endpoint(
77
- local_addr: Optional[tuple[str, int]] = None,
78
- remote_addr: Optional[tuple[str, int]] = None,
80
+ local_addr: tuple[str, int] | None = None,
81
+ remote_addr: tuple[str, int] | None = None,
79
82
  broadcast: bool = False,
80
83
  ) -> tuple[asyncio.DatagramTransport, _Protocol]:
81
84
  """Create a UDP endpoint. Tests replace this to fake the network."""
@@ -111,7 +114,7 @@ def _parse_hello(resp: bytes, host: tuple[str, int]) -> HelloResponse:
111
114
 
112
115
  async def scan(
113
116
  timeout: float = DEFAULT_TIMEOUT,
114
- local_ip_address: Optional[str] = None,
117
+ local_ip_address: str | None = None,
115
118
  discover_ip_address: str = DEFAULT_BCAST_ADDR,
116
119
  discover_ip_port: int = DEFAULT_PORT,
117
120
  ) -> AsyncIterator[HelloResponse]:
@@ -144,7 +147,7 @@ async def scan(
144
147
  break
145
148
  try:
146
149
  resp, host = await asyncio.wait_for(protocol.queue.get(), remaining)
147
- except asyncio.TimeoutError:
150
+ except TimeoutError:
148
151
  break
149
152
  if len(resp) < 0x80:
150
153
  continue
@@ -184,8 +187,8 @@ class Device:
184
187
 
185
188
  def __init__(
186
189
  self,
187
- host: Tuple[str, int],
188
- mac: Union[bytes, str],
190
+ host: tuple[str, int],
191
+ mac: bytes | str,
189
192
  devtype: int,
190
193
  timeout: float = DEFAULT_TIMEOUT,
191
194
  name: str = "",
@@ -210,42 +213,32 @@ class Device:
210
213
  self.aes = None
211
214
  self.update_aes(bytes.fromhex(self.__INIT_KEY))
212
215
 
213
- self._lock: Optional[asyncio.Lock] = None
214
- self._transport: Optional[asyncio.DatagramTransport] = None
215
- self._protocol: Optional[_Protocol] = None
216
- self._endpoint_addr: Optional[Tuple[str, int]] = None
217
- self._abandoned: collections.deque[int] = collections.deque(maxlen=_ABANDONED_MAX)
218
- self._reauth_lock: Optional[asyncio.Lock] = None
216
+ self._lock: asyncio.Lock | None = None
217
+ self._transport: asyncio.DatagramTransport | None = None
218
+ self._protocol: _Protocol | None = None
219
+ self._endpoint_addr: tuple[str, int] | None = None
220
+ self._recent: collections.deque[int] = collections.deque(maxlen=_RECENT_MAX)
221
+ self._reauth_lock: asyncio.Lock | None = None
219
222
  self._auth_generation = 0
220
223
 
221
224
  def __repr__(self) -> str:
222
225
  """Return a formal representation of the device."""
223
226
  return (
224
- "%s.%s(%s, mac=%r, devtype=%r, timeout=%r, name=%r, "
225
- "model=%r, manufacturer=%r, is_locked=%r)"
226
- ) % (
227
- self.__class__.__module__,
228
- self.__class__.__qualname__,
229
- self.host,
230
- self.mac,
231
- self.devtype,
232
- self.timeout,
233
- self.name,
234
- self.model,
235
- self.manufacturer,
236
- self.is_locked,
227
+ f"{self.__class__.__module__}.{self.__class__.__qualname__}("
228
+ f"{self.host}, mac={self.mac!r}, devtype={self.devtype!r}, "
229
+ f"timeout={self.timeout!r}, name={self.name!r}, "
230
+ f"model={self.model!r}, manufacturer={self.manufacturer!r}, "
231
+ f"is_locked={self.is_locked!r})"
237
232
  )
238
233
 
239
234
  def __str__(self) -> str:
240
235
  """Return a readable representation of the device."""
241
- return "%s (%s / %s:%s / %s)" % (
242
- self.name or "Unknown",
243
- " ".join(filter(None, [self.manufacturer, self.model, hex(self.devtype)])),
244
- *self.host,
245
- ":".join(format(x, "02X") for x in self.mac),
246
- )
236
+ ident = " ".join(filter(None, [self.manufacturer, self.model, hex(self.devtype)]))
237
+ mac = ":".join(format(x, "02X") for x in self.mac)
238
+ name = self.name or "Unknown"
239
+ return f"{name} ({ident} / {self.host[0]}:{self.host[1]} / {mac})"
247
240
 
248
- async def __aenter__(self) -> "Device":
241
+ async def __aenter__(self) -> Device:
249
242
  return self
250
243
 
251
244
  async def __aexit__(self, *exc) -> None:
@@ -272,23 +265,31 @@ class Device:
272
265
  # ---------------------------------------------------------- session
273
266
 
274
267
  async def auth(self) -> bool:
275
- """Authenticate to the device."""
276
- self.id = 0
277
- self.update_aes(bytes.fromhex(self.__INIT_KEY))
268
+ """Authenticate to the device.
278
269
 
270
+ The session reset, the exchange and the install of the new key all
271
+ happen while holding the request lock, so a request queued behind
272
+ the lock is never framed with the initial key or device id 0.
273
+ """
279
274
  packet = bytearray(0x50)
280
275
  packet[0x04:0x14] = [0x31] * 16
281
276
  packet[0x1E] = 0x01
282
277
  packet[0x2D] = 0x01
283
- packet[0x30:0x36] = "Test 1".encode()
284
-
285
- response = await self.send_packet(0x65, packet, _reauth=False)
286
- e.check_error(response[0x22:0x24])
287
- payload = self.decrypt(response[0x38:])
278
+ packet[0x30:0x36] = b"Test 1"
288
279
 
289
- self.id = int.from_bytes(payload[:0x4], "little")
290
- self.update_aes(payload[0x04:0x14])
291
- self._auth_generation += 1
280
+ if self._lock is None:
281
+ self._lock = asyncio.Lock()
282
+ self._reauth_lock = asyncio.Lock()
283
+ async with self._lock:
284
+ self.id = 0
285
+ self.update_aes(bytes.fromhex(self.__INIT_KEY))
286
+ response = await self._exchange(self._frame(0x65, bytes(packet)))
287
+ e.check_error(response[0x22:0x24])
288
+ payload = self.decrypt(response[0x38:])
289
+ self.id = int.from_bytes(payload[:0x4], "little")
290
+ self.update_aes(payload[0x04:0x14])
291
+ self._auth_generation += 1
292
+ _LOGGER.debug("%s: authenticated, session id %d", self.host[0], self.id)
292
293
  return True
293
294
 
294
295
  async def hello(self, local_ip_address=None) -> bool:
@@ -296,15 +297,17 @@ class Device:
296
297
 
297
298
  Device information is checked before updating name and lock status.
298
299
  """
299
- responses = scan(
300
- timeout=self.timeout,
301
- local_ip_address=local_ip_address,
302
- discover_ip_address=self.host[0],
303
- discover_ip_port=self.host[1],
304
- )
305
300
  entry = None
306
- async for entry in responses:
307
- break
301
+ async with contextlib.aclosing(
302
+ scan(
303
+ timeout=self.timeout,
304
+ local_ip_address=local_ip_address,
305
+ discover_ip_address=self.host[0],
306
+ discover_ip_port=self.host[1],
307
+ )
308
+ ) as responses:
309
+ async for entry in responses: # noqa: B007 - first reply only
310
+ break
308
311
  if entry is None:
309
312
  raise e.NetworkTimeoutError(
310
313
  -4000,
@@ -388,6 +391,7 @@ class Device:
388
391
  self._endpoint_addr = None
389
392
  if transport is not None:
390
393
  transport.close()
394
+ _LOGGER.debug("%s: endpoint closed", self.host[0])
391
395
  if protocol is not None:
392
396
  protocol.queue.put_nowait(_CLOSED) # type: ignore[arg-type]
393
397
 
@@ -397,10 +401,9 @@ class Device:
397
401
  # old address, so drop it.
398
402
  await self.aclose()
399
403
  if self._transport is None or self._transport.is_closing():
400
- self._transport, self._protocol = await _open_endpoint(
401
- remote_addr=self.host
402
- )
404
+ self._transport, self._protocol = await _open_endpoint(remote_addr=self.host)
403
405
  self._endpoint_addr = self.host
406
+ _LOGGER.debug("%s: endpoint opened", self.host[0])
404
407
  return self._transport, self._protocol # type: ignore[return-value]
405
408
 
406
409
  def _frame(self, packet_type: int, payload: bytes) -> bytes:
@@ -450,8 +453,9 @@ class Device:
450
453
 
451
454
  Replies carry the request's packet counter (offset 0x28), so a reply
452
455
  is matched to the request by counter. A reply whose counter belongs
453
- to a request that already timed out is dropped; one with a counter
454
- this device has never sent is accepted, for firmware that may not
456
+ to any other recent request (a late answer, or the second answer to
457
+ a request that was resent) is dropped; one with a counter this
458
+ device has not used recently is accepted, for firmware that may not
455
459
  echo it.
456
460
  """
457
461
  transport, protocol = await self._endpoint()
@@ -460,9 +464,14 @@ class Device:
460
464
  start = loop.time()
461
465
  timeout = self.timeout
462
466
  count = int.from_bytes(packet[0x28:0x2A], "little")
467
+ self._recent.append(count)
468
+ sends = 0
463
469
 
464
470
  while True:
465
471
  transport.sendto(packet)
472
+ sends += 1
473
+ if sends > 1:
474
+ _LOGGER.debug("%s: no reply, resending (%d)", self.host[0], sends)
466
475
  resend_at = loop.time() + DEFAULT_RETRY_INTVL
467
476
  while True:
468
477
  now = loop.time()
@@ -471,32 +480,34 @@ class Device:
471
480
  wait = min(resend_at, start + timeout) - now
472
481
  try:
473
482
  resp, _ = await asyncio.wait_for(protocol.queue.get(), max(wait, 0))
474
- except asyncio.TimeoutError:
483
+ except TimeoutError:
475
484
  if loop.time() - start >= timeout:
476
485
  break
477
486
  if loop.time() >= resend_at:
478
487
  break # Resend.
479
488
  continue
480
489
  if resp is None:
481
- raise e.ConnectionClosedError(
482
- -4013, "Connection closed", "The device endpoint was closed"
490
+ raise e.EndpointClosedError(
491
+ -4013, "Endpoint closed", "The device endpoint was closed"
483
492
  )
484
493
  resp = self._validate(resp)
485
494
  reply_count = int.from_bytes(resp[0x28:0x2A], "little")
486
- if reply_count == count or reply_count not in self._abandoned:
495
+ if reply_count == count or reply_count not in self._recent:
487
496
  return resp
488
- # A late answer to a request we gave up on: keep waiting.
497
+ _LOGGER.debug(
498
+ "%s: dropped a reply for an earlier request (counter 0x%04x)",
499
+ self.host[0],
500
+ reply_count,
501
+ )
489
502
  if loop.time() - start >= timeout:
490
- self._abandoned.append(count)
503
+ _LOGGER.debug("%s: no reply within %ss", self.host[0], timeout)
491
504
  raise e.NetworkTimeoutError(
492
505
  -4000,
493
506
  "Network timeout",
494
507
  f"No response received within {timeout}s",
495
508
  ) from None
496
509
 
497
- async def send_packet(
498
- self, packet_type: int, payload: bytes, *, _reauth: bool = True
499
- ) -> bytes:
510
+ async def send_packet(self, packet_type: int, payload: bytes) -> bytes:
500
511
  """Send a packet to the device and return the raw response frame.
501
512
 
502
513
  If the device answers that the session key is no longer valid, the
@@ -511,14 +522,12 @@ class Device:
511
522
  async with self._lock:
512
523
  resp = await self._exchange(self._frame(packet_type, bytes(payload)))
513
524
 
514
- if _reauth:
515
- code = int.from_bytes(resp[0x22:0x24], "little", signed=True)
516
- if code in _REAUTH_CODES:
517
- async with self._reauth_lock: # type: ignore[union-attr]
518
- if self._auth_generation == generation:
519
- await self.auth()
520
- async with self._lock:
521
- resp = await self._exchange(
522
- self._frame(packet_type, bytes(payload))
523
- )
525
+ code = int.from_bytes(resp[0x22:0x24], "little", signed=True)
526
+ if code in _REAUTH_CODES:
527
+ _LOGGER.debug("%s: device answered %d, re-authenticating", self.host[0], code)
528
+ async with self._reauth_lock: # type: ignore[union-attr]
529
+ if self._auth_generation == generation:
530
+ await self.auth()
531
+ async with self._lock:
532
+ resp = await self._exchange(self._frame(packet_type, bytes(payload)))
524
533
  return resp
broadlink/exceptions.py CHANGED
@@ -1,4 +1,5 @@
1
1
  """Exceptions for Broadlink devices."""
2
+
2
3
  import collections
3
4
  import struct
4
5
 
@@ -22,7 +23,7 @@ class BroadlinkException(Exception):
22
23
  def __str__(self):
23
24
  """Return str(self)."""
24
25
  if self.errno is not None:
25
- return "[Errno %s] %s" % (self.errno, self.strerror)
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: %s" % counter
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(%r)" % self.errors
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
- from typing import Dict, List, Sequence
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: Dict[int, List[int]] = {}
12
+ _cache: dict[int, list[int]] = {}
12
13
 
13
14
  @classmethod
14
- def get_table(cls, polynomial: int) -> List[int]:
15
+ def get_table(cls, polynomial: int) -> list[int]:
15
16
  """Return the CRC-16 table for a polynomial."""
16
17
  try:
17
18
  crc_table = cls._cache[polynomial]
broadlink/hub.py CHANGED
@@ -1,7 +1,7 @@
1
1
  """Support for hubs."""
2
+
2
3
  import json
3
4
  import struct
4
- from typing import Optional
5
5
 
6
6
  from . import exceptions as e
7
7
  from .device import Device
@@ -43,7 +43,7 @@ class s3(Device):
43
43
 
44
44
  return sub_devices
45
45
 
46
- async def get_state(self, did: Optional[str] = None) -> dict:
46
+ async def get_state(self, did: str | None = None) -> dict:
47
47
  """Return the power state of the device."""
48
48
  state = {}
49
49
  if did is not None:
@@ -56,10 +56,10 @@ class s3(Device):
56
56
 
57
57
  async def set_state(
58
58
  self,
59
- did: Optional[str] = None,
60
- pwr1: Optional[bool] = None,
61
- pwr2: Optional[bool] = None,
62
- pwr3: Optional[bool] = None,
59
+ did: str | None = None,
60
+ pwr1: bool | None = None,
61
+ pwr2: bool | None = None,
62
+ pwr3: bool | None = None,
63
63
  ) -> dict:
64
64
  """Set the power state of the device."""
65
65
  state = {}
@@ -82,9 +82,7 @@ class s3(Device):
82
82
  # flag: 1 for reading, 2 for writing.
83
83
  packet = bytearray(12)
84
84
  data = json.dumps(state, separators=(",", ":")).encode()
85
- struct.pack_into(
86
- "<HHHBBI", packet, 0, 0xA5A5, 0x5A5A, 0, flag, 0x0B, len(data)
87
- )
85
+ struct.pack_into("<HHHBBI", packet, 0, 0xA5A5, 0x5A5A, 0, flag, 0x0B, len(data))
88
86
  packet.extend(data)
89
87
  checksum = sum(packet, 0xBEAF) & 0xFFFF
90
88
  packet[0x04:0x06] = checksum.to_bytes(2, "little")
@@ -94,5 +92,5 @@ class s3(Device):
94
92
  """Decode a JSON packet."""
95
93
  payload = self.decrypt(response[0x38:])
96
94
  js_len = struct.unpack_from("<I", payload, 0x08)[0]
97
- state = json.loads(payload[0x0C:0x0C+js_len])
95
+ state = json.loads(payload[0x0C : 0x0C + js_len])
98
96
  return state