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

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