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

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