python-broadlink 1.0.2__tar.gz → 1.0.3__tar.gz

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.
Files changed (42) hide show
  1. {python_broadlink-1.0.2 → python_broadlink-1.0.3}/CHANGELOG.md +45 -0
  2. {python_broadlink-1.0.2/python_broadlink.egg-info → python_broadlink-1.0.3}/PKG-INFO +17 -5
  3. {python_broadlink-1.0.2 → python_broadlink-1.0.3}/README.md +16 -4
  4. {python_broadlink-1.0.2 → python_broadlink-1.0.3}/broadlink/__init__.py +2 -8
  5. {python_broadlink-1.0.2 → python_broadlink-1.0.3}/broadlink/device.py +44 -18
  6. {python_broadlink-1.0.2 → python_broadlink-1.0.3}/broadlink/exceptions.py +1 -1
  7. {python_broadlink-1.0.2 → python_broadlink-1.0.3}/broadlink/remote.py +11 -6
  8. {python_broadlink-1.0.2 → python_broadlink-1.0.3}/cli/broadlink_cli +8 -1
  9. {python_broadlink-1.0.2 → python_broadlink-1.0.3}/pyproject.toml +1 -1
  10. {python_broadlink-1.0.2 → python_broadlink-1.0.3/python_broadlink.egg-info}/PKG-INFO +17 -5
  11. {python_broadlink-1.0.2 → python_broadlink-1.0.3}/tests/test_capture.py +9 -3
  12. {python_broadlink-1.0.2 → python_broadlink-1.0.3}/tests/test_transport.py +110 -5
  13. {python_broadlink-1.0.2 → python_broadlink-1.0.3}/LICENSE +0 -0
  14. {python_broadlink-1.0.2 → python_broadlink-1.0.3}/MANIFEST.in +0 -0
  15. {python_broadlink-1.0.2 → python_broadlink-1.0.3}/TROUBLESHOOTING.md +0 -0
  16. {python_broadlink-1.0.2 → python_broadlink-1.0.3}/broadlink/alarm.py +0 -0
  17. {python_broadlink-1.0.2 → python_broadlink-1.0.3}/broadlink/climate.py +0 -0
  18. {python_broadlink-1.0.2 → python_broadlink-1.0.3}/broadlink/const.py +0 -0
  19. {python_broadlink-1.0.2 → python_broadlink-1.0.3}/broadlink/cover.py +0 -0
  20. {python_broadlink-1.0.2 → python_broadlink-1.0.3}/broadlink/helpers.py +0 -0
  21. {python_broadlink-1.0.2 → python_broadlink-1.0.3}/broadlink/hub.py +0 -0
  22. {python_broadlink-1.0.2 → python_broadlink-1.0.3}/broadlink/light.py +0 -0
  23. {python_broadlink-1.0.2 → python_broadlink-1.0.3}/broadlink/protocol.py +0 -0
  24. {python_broadlink-1.0.2 → python_broadlink-1.0.3}/broadlink/sensor.py +0 -0
  25. {python_broadlink-1.0.2 → python_broadlink-1.0.3}/broadlink/switch.py +0 -0
  26. {python_broadlink-1.0.2 → python_broadlink-1.0.3}/cli/README.md +0 -0
  27. {python_broadlink-1.0.2 → python_broadlink-1.0.3}/cli/broadlink_discovery +0 -0
  28. {python_broadlink-1.0.2 → python_broadlink-1.0.3}/protocol.md +0 -0
  29. {python_broadlink-1.0.2 → python_broadlink-1.0.3}/python_broadlink.egg-info/SOURCES.txt +0 -0
  30. {python_broadlink-1.0.2 → python_broadlink-1.0.3}/python_broadlink.egg-info/dependency_links.txt +0 -0
  31. {python_broadlink-1.0.2 → python_broadlink-1.0.3}/python_broadlink.egg-info/requires.txt +0 -0
  32. {python_broadlink-1.0.2 → python_broadlink-1.0.3}/python_broadlink.egg-info/top_level.txt +0 -0
  33. {python_broadlink-1.0.2 → python_broadlink-1.0.3}/setup.cfg +0 -0
  34. {python_broadlink-1.0.2 → python_broadlink-1.0.3}/tests/__init__.py +0 -0
  35. {python_broadlink-1.0.2 → python_broadlink-1.0.3}/tests/oracle/__init__.py +0 -0
  36. {python_broadlink-1.0.2 → python_broadlink-1.0.3}/tests/oracle/cases.py +0 -0
  37. {python_broadlink-1.0.2 → python_broadlink-1.0.3}/tests/oracle/fixtures.json +0 -0
  38. {python_broadlink-1.0.2 → python_broadlink-1.0.3}/tests/oracle/harness.py +0 -0
  39. {python_broadlink-1.0.2 → python_broadlink-1.0.3}/tests/oracle/record.py +0 -0
  40. {python_broadlink-1.0.2 → python_broadlink-1.0.3}/tests/test_helpers.py +0 -0
  41. {python_broadlink-1.0.2 → python_broadlink-1.0.3}/tests/test_oracle.py +0 -0
  42. {python_broadlink-1.0.2 → python_broadlink-1.0.3}/tests/test_remote.py +0 -0
@@ -3,6 +3,51 @@
3
3
  All notable changes to this project are recorded here. The format follows
4
4
  Keep a Changelog; versions follow Semantic Versioning.
5
5
 
6
+ ## 1.0.3 - 2026-09-06
7
+
8
+ Fixes from a third review, this one of 1.0.2. No change to the wire
9
+ format. One small API change: `pulses` on a captured signal is a tuple.
10
+
11
+ ### Fixed
12
+
13
+ - When the device answered that the session key had expired and the
14
+ re-authentication then failed (for example because the device had been
15
+ locked in the app), the call raised `AuthenticationError` from the
16
+ re-authentication instead of the error the device gave the request. The
17
+ original library never re-authenticated, so a program written against
18
+ it, Home Assistant's integration included, handles the request's own
19
+ error and never expected the other one. The failed re-authentication is
20
+ now logged and the request's original reply is returned, so the caller
21
+ sees the same `AuthorizationError` or `ConnectionClosedError` it always
22
+ did.
23
+ - The authentication generation was read before the request lock was
24
+ taken rather than under it, so a request queued behind an `auth()`
25
+ could observe a stale generation and skip a re-authentication it needed.
26
+ - `aclose()` racing an endpoint that was still being opened could leave
27
+ the new socket open and unreferenced. The open now notices the close
28
+ and fails with `EndpointClosedError`.
29
+ - After a new capture window gives the finalizer its turn, it re-checks
30
+ that no other window claimed the device in the meantime.
31
+ - `CapturedSignal` and `ParsedPacket` are frozen dataclasses, but they
32
+ held a list, so they could not be hashed or put in a set. `pulses` is
33
+ now a `tuple[int, ...]`.
34
+ - `check_error` unpacks the error code as little-endian explicitly
35
+ (`"<h"`), matching the rest of the code, instead of native order.
36
+ - The CLI closes the device it opens instead of leaving that to
37
+ `asyncio.run`, which warned under `python -X dev`.
38
+ - The locks are created in `__init__` rather than lazily in two places.
39
+
40
+ ### Changed
41
+
42
+ - `send_packet` accepts a `bytearray` payload as well as `bytes`.
43
+ - `setup()` sends its provisioning packet through a new
44
+ `send_setup_packet()` helper in `broadlink.device` instead of reaching
45
+ into a private function.
46
+ - README: the re-authentication contract and its worst case (one call can
47
+ wait out up to three timeouts), the A2 sensor and the Hysen HY02/HY03
48
+ in the device list, and the hello response's `mac` being `bytes` in the
49
+ list of differences from 0.19.0.
50
+
6
51
  ## 1.0.2 - 2026-09-05
7
52
 
8
53
  Fixes from a second, adversarial review of 1.0.1 and a re-test of the
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-broadlink
3
- Version: 1.0.2
3
+ Version: 1.0.3
4
4
  Summary: Python API for controlling Broadlink devices
5
5
  Author: DAB-LABS
6
6
  Author-email: Matthew Garrett <mjg59@srcf.ucam.org>
@@ -51,8 +51,9 @@ Every call that reaches a device is a coroutine and must be awaited. That
51
51
  is the main change from the original library's API: method names and
52
52
  arguments are the same, and so are return values, with the small
53
53
  exceptions listed in `CHANGELOG.md` (the IR tick constant, `pulses_to_data`
54
- returning `bytes`, the unused `Device.lock` attribute removed, and
55
- `timeout` parameters typed as floats).
54
+ returning `bytes`, the unused `Device.lock` attribute removed, `timeout`
55
+ parameters typed as floats, and the `mac` in a hello response typed as
56
+ `bytes`).
56
57
 
57
58
  ```python
58
59
  import asyncio
@@ -81,11 +82,11 @@ The following devices are supported:
81
82
  - **Switches**: MCB1, SC1, SCB1E, SCB2
82
83
  - **Outlets**: BG 800, BG 900
83
84
  - **Power strips**: MP1-1K3S2U, MP1-1K4S, MP2
84
- - **Environment sensors**: A1
85
+ - **Environment sensors**: A1, A2
85
86
  - **Alarm kits**: S1C, S2KIT
86
87
  - **Light bulbs**: LB1, LB26 R1, LB27 R1, SB800TD, LEDVANCE SMART+ WIFI CEILING TW 24W
87
88
  - **Curtain motors**: Dooya DT360E-45/20
88
- - **Thermostats**: Hysen HY02B05H
89
+ - **Thermostats**: Hysen HY02/HY03
89
90
  - **Hubs**: S3
90
91
 
91
92
  ## Timing
@@ -204,6 +205,17 @@ After discovering the device, call the `auth()` method to obtain the authenticat
204
205
  await device.auth()
205
206
  ```
206
207
 
208
+ The session key expires on the device after a while. When a request comes
209
+ back with an expired-key answer, the library authenticates again and
210
+ repeats the request once, so a long-running program does not need to
211
+ handle that itself. If the second authentication fails, for example
212
+ because the device was locked in the app in the meantime, the call raises
213
+ the error the device gave the first time, the same `AuthorizationError`
214
+ or `ConnectionClosedError` the original library raised, and it is up to
215
+ the caller to decide what to do. In the worst case one call can wait out
216
+ three timeouts (the request, the authentication, and the repeat), each
217
+ bounded by `device.timeout`.
218
+
207
219
  ### Closing
208
220
 
209
221
  Each device keeps one UDP socket open for its lifetime (the original
@@ -20,8 +20,9 @@ Every call that reaches a device is a coroutine and must be awaited. That
20
20
  is the main change from the original library's API: method names and
21
21
  arguments are the same, and so are return values, with the small
22
22
  exceptions listed in `CHANGELOG.md` (the IR tick constant, `pulses_to_data`
23
- returning `bytes`, the unused `Device.lock` attribute removed, and
24
- `timeout` parameters typed as floats).
23
+ returning `bytes`, the unused `Device.lock` attribute removed, `timeout`
24
+ parameters typed as floats, and the `mac` in a hello response typed as
25
+ `bytes`).
25
26
 
26
27
  ```python
27
28
  import asyncio
@@ -50,11 +51,11 @@ The following devices are supported:
50
51
  - **Switches**: MCB1, SC1, SCB1E, SCB2
51
52
  - **Outlets**: BG 800, BG 900
52
53
  - **Power strips**: MP1-1K3S2U, MP1-1K4S, MP2
53
- - **Environment sensors**: A1
54
+ - **Environment sensors**: A1, A2
54
55
  - **Alarm kits**: S1C, S2KIT
55
56
  - **Light bulbs**: LB1, LB26 R1, LB27 R1, SB800TD, LEDVANCE SMART+ WIFI CEILING TW 24W
56
57
  - **Curtain motors**: Dooya DT360E-45/20
57
- - **Thermostats**: Hysen HY02B05H
58
+ - **Thermostats**: Hysen HY02/HY03
58
59
  - **Hubs**: S3
59
60
 
60
61
  ## Timing
@@ -173,6 +174,17 @@ After discovering the device, call the `auth()` method to obtain the authenticat
173
174
  await device.auth()
174
175
  ```
175
176
 
177
+ The session key expires on the device after a while. When a request comes
178
+ back with an expired-key answer, the library authenticates again and
179
+ repeats the request once, so a long-running program does not need to
180
+ handle that itself. If the second authentication fails, for example
181
+ because the device was locked in the app in the meantime, the call raises
182
+ the error the device gave the first time, the same `AuthorizationError`
183
+ or `ConnectionClosedError` the original library raised, and it is up to
184
+ the caller to decide what to do. In the worst case one call can wait out
185
+ three timeouts (the request, the authentication, and the repeat), each
186
+ bounded by `device.timeout`.
187
+
176
188
  ### Closing
177
189
 
178
190
  Each device keeps one UDP socket open for its lifetime (the original
@@ -1,16 +1,14 @@
1
- #!/usr/bin/env python3
2
1
  """The python-broadlink library."""
3
2
 
4
3
  import contextlib
5
4
  from collections.abc import AsyncIterator
6
- from typing import Optional, Union
7
5
 
8
6
  from . import exceptions as e
9
7
  from .alarm import S1C
10
8
  from .climate import hvac, hysen
11
9
  from .const import DEFAULT_BCAST_ADDR, DEFAULT_PORT, DEFAULT_TIMEOUT
12
10
  from .cover import dooya, dooya2, wser
13
- from .device import Device, _open_endpoint, ping, scan
11
+ from .device import Device, ping, scan, send_setup_packet
14
12
  from .hub import s3
15
13
  from .light import lb1, lb2
16
14
  from .remote import rm, rm4, rm4mini, rm4pro, rm5plus, rmmini, rmminib, rmpro
@@ -340,8 +338,4 @@ async def setup(
340
338
  payload[0x20] = checksum & 0xFF # Checksum 1 position
341
339
  payload[0x21] = checksum >> 8 # Checksum 2 position
342
340
 
343
- transport, _ = await _open_endpoint(broadcast=True)
344
- try:
345
- transport.sendto(payload, (ip_address, DEFAULT_PORT))
346
- finally:
347
- transport.close()
341
+ await send_setup_packet(bytes(payload), ip_address)
@@ -56,10 +56,12 @@ class _Protocol(asyncio.DatagramProtocol):
56
56
  self.queue: asyncio.Queue[tuple[bytes, tuple[str, int]]] = asyncio.Queue()
57
57
  self.transport: asyncio.DatagramTransport | None = None
58
58
 
59
- def connection_made(self, transport) -> None: # type: ignore[override]
60
- 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]
61
62
 
62
63
  def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None:
64
+ """Queue every datagram for the request that is waiting."""
63
65
  self.queue.put_nowait((data, addr))
64
66
 
65
67
  def error_received(self, exc: Exception) -> None:
@@ -68,7 +70,7 @@ class _Protocol(asyncio.DatagramProtocol):
68
70
  pass
69
71
 
70
72
  def connection_lost(self, exc: Exception | None) -> None:
71
- pass
73
+ """Nothing to do; a waiting request is told through the queue."""
72
74
 
73
75
  def drain(self) -> None:
74
76
  """Drop anything that arrived before the current request."""
@@ -161,6 +163,17 @@ async def scan(
161
163
  transport.close()
162
164
 
163
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
+
164
177
  async def ping(ip_address: str, port: int = DEFAULT_PORT) -> None:
165
178
  """Send a ping packet to an address.
166
179
 
@@ -213,12 +226,13 @@ class Device:
213
226
  self.aes = None
214
227
  self.update_aes(bytes.fromhex(self.__INIT_KEY))
215
228
 
216
- self._lock: asyncio.Lock | None = None
229
+ self._lock = asyncio.Lock()
217
230
  self._transport: asyncio.DatagramTransport | None = None
218
231
  self._protocol: _Protocol | None = None
219
232
  self._endpoint_addr: tuple[str, int] | None = None
220
233
  self._recent: collections.deque[int] = collections.deque(maxlen=_RECENT_MAX)
221
- self._reauth_lock: asyncio.Lock | None = None
234
+ self._reauth_lock = asyncio.Lock()
235
+ self._closes = 0 # Bumped by aclose(); guards an open racing a close.
222
236
  self._auth_generation = 0
223
237
 
224
238
  def __repr__(self) -> str:
@@ -277,9 +291,6 @@ class Device:
277
291
  packet[0x2D] = 0x01
278
292
  packet[0x30:0x36] = b"Test 1"
279
293
 
280
- if self._lock is None:
281
- self._lock = asyncio.Lock()
282
- self._reauth_lock = asyncio.Lock()
283
294
  async with self._lock:
284
295
  self.id = 0
285
296
  self.update_aes(bytes.fromhex(self.__INIT_KEY))
@@ -292,7 +303,7 @@ class Device:
292
303
  _LOGGER.debug("%s: authenticated, session id %d", self.host[0], self.id)
293
304
  return True
294
305
 
295
- async def hello(self, local_ip_address=None) -> bool:
306
+ async def hello(self, local_ip_address: str | None = None) -> bool:
296
307
  """Send a hello message to the device.
297
308
 
298
309
  Device information is checked before updating name and lock status.
@@ -385,6 +396,7 @@ class Device:
385
396
  A request in flight fails at once with ``ConnectionClosedError``
386
397
  rather than waiting out its timeout.
387
398
  """
399
+ self._closes += 1
388
400
  transport, protocol = self._transport, self._protocol
389
401
  self._transport = None
390
402
  self._protocol = None
@@ -401,7 +413,15 @@ class Device:
401
413
  # old address, so drop it.
402
414
  await self.aclose()
403
415
  if self._transport is None or self._transport.is_closing():
404
- self._transport, self._protocol = await _open_endpoint(remote_addr=self.host)
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
405
425
  self._endpoint_addr = self.host
406
426
  _LOGGER.debug("%s: endpoint opened", self.host[0])
407
427
  return self._transport, self._protocol # type: ignore[return-value]
@@ -507,27 +527,33 @@ class Device:
507
527
  f"No response received within {timeout}s",
508
528
  ) from None
509
529
 
510
- async def send_packet(self, packet_type: int, payload: bytes) -> bytes:
530
+ async def send_packet(self, packet_type: int, payload: bytes | bytearray) -> bytes:
511
531
  """Send a packet to the device and return the raw response frame.
512
532
 
513
533
  If the device answers that the session key is no longer valid, the
514
534
  session is re-authenticated once and the request is sent again.
515
535
  Concurrent callers that hit the same expired key share one
516
- 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.
517
540
  """
518
- if self._lock is None:
519
- self._lock = asyncio.Lock()
520
- self._reauth_lock = asyncio.Lock()
521
- generation = self._auth_generation
522
541
  async with self._lock:
542
+ generation = self._auth_generation
523
543
  resp = await self._exchange(self._frame(packet_type, bytes(payload)))
524
544
 
525
545
  code = int.from_bytes(resp[0x22:0x24], "little", signed=True)
526
546
  if code in _REAUTH_CODES:
527
547
  _LOGGER.debug("%s: device answered %d, re-authenticating", self.host[0], code)
528
- async with self._reauth_lock: # type: ignore[union-attr]
548
+ async with self._reauth_lock:
529
549
  if self._auth_generation == generation:
530
- await self.auth()
550
+ try:
551
+ await self.auth()
552
+ except e.BroadlinkException as err:
553
+ _LOGGER.debug(
554
+ "%s: re-authentication failed: %s", self.host[0], err
555
+ )
556
+ return resp
531
557
  async with self._lock:
532
558
  resp = await self._exchange(self._frame(packet_type, bytes(payload)))
533
559
  return resp
@@ -168,6 +168,6 @@ def exception(err_code: int) -> BroadlinkException:
168
168
 
169
169
  def check_error(error: bytes) -> None:
170
170
  """Raise exception if an error occurred."""
171
- error_code = struct.unpack("h", error)[0]
171
+ error_code = struct.unpack("<h", error)[0]
172
172
  if error_code:
173
173
  raise exception(error_code)
@@ -8,6 +8,7 @@ import time
8
8
  import weakref
9
9
  from collections.abc import AsyncIterator, Awaitable, Callable
10
10
  from dataclasses import dataclass, field
11
+ from typing import Self
11
12
 
12
13
  from . import exceptions as e
13
14
  from .device import Device
@@ -53,10 +54,11 @@ class SignalKind(enum.IntEnum):
53
54
 
54
55
  @property
55
56
  def is_rf(self) -> bool:
57
+ """True for the radio bands."""
56
58
  return self is not SignalKind.IR
57
59
 
58
60
  @classmethod
59
- def classify(cls, type_byte: int) -> "SignalKind":
61
+ def classify(cls, type_byte: int) -> Self:
60
62
  """Map a packet's raw first byte to a kind, tolerantly.
61
63
 
62
64
  The RF learn path returns bytes in the 0xB_ (433 MHz) and 0xD_
@@ -139,7 +141,7 @@ class ParsedPacket:
139
141
 
140
142
  kind: SignalKind
141
143
  repeat: int
142
- pulses: list[int]
144
+ pulses: tuple[int, ...]
143
145
  type_byte: int
144
146
 
145
147
 
@@ -152,7 +154,7 @@ def parse_packet(data: bytes, tick: float = TICK) -> ParsedPacket:
152
154
  if len(data) < 4:
153
155
  raise ValueError("Malformed data.")
154
156
  kind = SignalKind.classify(data[0x00])
155
- return ParsedPacket(kind, data[0x01], data_to_pulses(data, tick), data[0x00])
157
+ return ParsedPacket(kind, data[0x01], tuple(data_to_pulses(data, tick)), data[0x00])
156
158
 
157
159
 
158
160
  @dataclass(frozen=True)
@@ -170,7 +172,7 @@ class CapturedSignal:
170
172
 
171
173
  packet: bytes
172
174
  kind: SignalKind
173
- pulses: list[int] = field(repr=False)
175
+ pulses: tuple[int, ...] = field(repr=False)
174
176
  repeat: int = 0
175
177
  frequency_mhz: float | None = None
176
178
  type_byte: int | None = None
@@ -183,7 +185,7 @@ class CapturedSignal:
183
185
  frequency_mhz: float | None = None,
184
186
  *,
185
187
  kind: SignalKind | None = None,
186
- ) -> "CapturedSignal":
188
+ ) -> Self:
187
189
  """Build a signal from a device-returned packet.
188
190
 
189
191
  ``kind`` overrides the band read from the packet's type byte. A
@@ -200,7 +202,7 @@ class CapturedSignal:
200
202
  return cls(
201
203
  bytes(packet),
202
204
  kind,
203
- data_to_pulses(packet),
205
+ tuple(data_to_pulses(packet)),
204
206
  packet[0x01],
205
207
  frequency_mhz,
206
208
  type_byte,
@@ -259,6 +261,9 @@ class rmmini(Device):
259
261
  raise e.CaptureInProgressError(
260
262
  "A capture window is already open; close it with aclose() first"
261
263
  )
264
+ if self._window is not prev:
265
+ # Another claimant got in during the two turns above.
266
+ raise e.CaptureInProgressError("A capture window is already open")
262
267
  self._window = new
263
268
 
264
269
  async def _send(self, command: int, data: bytes = b"") -> bytes:
@@ -4,7 +4,7 @@ import asyncio
4
4
  import base64
5
5
  import sys
6
6
  import time
7
- from contextlib import aclosing
7
+ from contextlib import AsyncExitStack, aclosing
8
8
  from typing import List
9
9
 
10
10
  import broadlink
@@ -96,6 +96,12 @@ args = parser.parse_args()
96
96
 
97
97
 
98
98
  async def main():
99
+ async with AsyncExitStack() as stack:
100
+ await run_commands(stack)
101
+
102
+
103
+ async def run_commands(stack: AsyncExitStack):
104
+ """Run the requested commands; the device is closed with the stack."""
99
105
  dev = None
100
106
 
101
107
  if args.device:
@@ -110,6 +116,7 @@ async def main():
110
116
 
111
117
  if args.host or args.device:
112
118
  dev = broadlink.gendevice(devtype, (host, DEFAULT_PORT), mac)
119
+ await stack.enter_async_context(dev)
113
120
  await dev.auth()
114
121
 
115
122
  if args.joinwifi:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-broadlink"
7
- version = "1.0.2"
7
+ version = "1.0.3"
8
8
  description = "Python API for controlling Broadlink devices"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-broadlink
3
- Version: 1.0.2
3
+ Version: 1.0.3
4
4
  Summary: Python API for controlling Broadlink devices
5
5
  Author: DAB-LABS
6
6
  Author-email: Matthew Garrett <mjg59@srcf.ucam.org>
@@ -51,8 +51,9 @@ Every call that reaches a device is a coroutine and must be awaited. That
51
51
  is the main change from the original library's API: method names and
52
52
  arguments are the same, and so are return values, with the small
53
53
  exceptions listed in `CHANGELOG.md` (the IR tick constant, `pulses_to_data`
54
- returning `bytes`, the unused `Device.lock` attribute removed, and
55
- `timeout` parameters typed as floats).
54
+ returning `bytes`, the unused `Device.lock` attribute removed, `timeout`
55
+ parameters typed as floats, and the `mac` in a hello response typed as
56
+ `bytes`).
56
57
 
57
58
  ```python
58
59
  import asyncio
@@ -81,11 +82,11 @@ The following devices are supported:
81
82
  - **Switches**: MCB1, SC1, SCB1E, SCB2
82
83
  - **Outlets**: BG 800, BG 900
83
84
  - **Power strips**: MP1-1K3S2U, MP1-1K4S, MP2
84
- - **Environment sensors**: A1
85
+ - **Environment sensors**: A1, A2
85
86
  - **Alarm kits**: S1C, S2KIT
86
87
  - **Light bulbs**: LB1, LB26 R1, LB27 R1, SB800TD, LEDVANCE SMART+ WIFI CEILING TW 24W
87
88
  - **Curtain motors**: Dooya DT360E-45/20
88
- - **Thermostats**: Hysen HY02B05H
89
+ - **Thermostats**: Hysen HY02/HY03
89
90
  - **Hubs**: S3
90
91
 
91
92
  ## Timing
@@ -204,6 +205,17 @@ After discovering the device, call the `auth()` method to obtain the authenticat
204
205
  await device.auth()
205
206
  ```
206
207
 
208
+ The session key expires on the device after a while. When a request comes
209
+ back with an expired-key answer, the library authenticates again and
210
+ repeats the request once, so a long-running program does not need to
211
+ handle that itself. If the second authentication fails, for example
212
+ because the device was locked in the app in the meantime, the call raises
213
+ the error the device gave the first time, the same `AuthorizationError`
214
+ or `ConnectionClosedError` the original library raised, and it is up to
215
+ the caller to decide what to do. In the worst case one call can wait out
216
+ three timeouts (the request, the authentication, and the repeat), each
217
+ bounded by `device.timeout`.
218
+
207
219
  ### Closing
208
220
 
209
221
  Each device keeps one UDP socket open for its lifetime (the original
@@ -168,7 +168,7 @@ def test_capture_yields_first_signal_and_closes(cls_name, devtype):
168
168
  assert isinstance(sig, CapturedSignal)
169
169
  assert sig.packet == IR
170
170
  assert sig.kind is SignalKind.IR
171
- assert sig.pulses == data_to_pulses(IR)
171
+ assert sig.pulses == tuple(data_to_pulses(IR))
172
172
  assert sig.frequency_mhz is None
173
173
  assert fake.commands[0][0] == CMD_LEARN
174
174
  assert fake.count(CMD_LEARN) == 1
@@ -661,7 +661,7 @@ def test_parse_packet_round_trip():
661
661
  parsed = parse_packet(packet)
662
662
  assert parsed.kind is kind
663
663
  assert parsed.repeat == 1
664
- assert parsed.pulses == data_to_pulses(packet)
664
+ assert parsed.pulses == tuple(data_to_pulses(packet))
665
665
  for a, b in zip(pulses, parsed.pulses, strict=True):
666
666
  assert abs(a - b) <= 16
667
667
 
@@ -712,10 +712,16 @@ def test_signal_kind_flags():
712
712
  assert SignalKind(0x26) is SignalKind.IR
713
713
 
714
714
 
715
+ def test_captured_signal_is_hashable():
716
+ a = CapturedSignal.from_packet(RF, 433.92)
717
+ assert isinstance(hash(a), int) # a frozen value type belongs in a set
718
+ assert len({parse_packet(RF), parse_packet(RF)}) == 1
719
+
720
+
715
721
  def test_captured_signal_from_packet():
716
722
  sig = CapturedSignal.from_packet(RF, 433.92)
717
723
  assert sig.kind is SignalKind.RF_433
718
724
  assert sig.repeat == 0
719
- assert sig.pulses == data_to_pulses(RF)
725
+ assert sig.pulses == tuple(data_to_pulses(RF))
720
726
  assert sig.frequency_mhz == 433.92
721
727
  assert sig.captured_at > 0
@@ -75,7 +75,6 @@ class FakeNet:
75
75
  def net(monkeypatch):
76
76
  fake = FakeNet()
77
77
  monkeypatch.setattr(device_module, "_open_endpoint", fake)
78
- monkeypatch.setattr(broadlink, "_open_endpoint", fake)
79
78
  # Keep the retry loop from waiting on real time.
80
79
  monkeypatch.setattr(device_module, "DEFAULT_RETRY_INTVL", 0.005)
81
80
  return fake
@@ -386,6 +385,30 @@ def test_aclose_fails_inflight_request_fast(net):
386
385
  assert run(go()) < 1.0
387
386
 
388
387
 
388
+ def test_aclose_during_endpoint_open_does_not_leak(net, monkeypatch):
389
+ """aclose() landing while create_datagram_endpoint is still running must
390
+ not leave the freshly opened socket behind."""
391
+ dev = fixed_device()
392
+ slow = net
393
+
394
+ async def slow_open(**kwargs):
395
+ await asyncio.sleep(0.02)
396
+ return await slow(**kwargs)
397
+
398
+ monkeypatch.setattr(device_module, "_open_endpoint", slow_open)
399
+
400
+ async def go():
401
+ task = asyncio.get_running_loop().create_task(dev.send_packet(0x6A, b""))
402
+ await asyncio.sleep(0.005) # inside the slow open
403
+ await dev.aclose()
404
+ with pytest.raises(e.EndpointClosedError):
405
+ await task
406
+
407
+ run(go())
408
+ assert dev._transport is None
409
+ assert all(ep.closed for ep in net.endpoints)
410
+
411
+
389
412
  def test_host_change_reopens_endpoint(net):
390
413
  dev = fixed_device()
391
414
 
@@ -475,18 +498,100 @@ def test_expired_session_is_reauthenticated_once(net):
475
498
  assert dev.decrypt(resp[0x38:])[0] == 9
476
499
 
477
500
 
478
- def test_reauth_is_not_attempted_twice(net):
501
+ def test_failed_reauth_returns_the_original_reply(net):
502
+ """When the library's own re-authentication fails, the caller must see
503
+ the reply the device gave to its request, exactly as 0.19.0 would have
504
+ shown it, so the caller's own recovery (Home Assistant's reauth flow)
505
+ still runs."""
479
506
  dev = fixed_device()
480
507
 
481
508
  async def go():
482
509
  await dev._endpoint()
483
510
  ep = net.endpoints[-1]
484
511
  expired = (make_response(dev, b"", error=0xFFF9), HOST)
485
- ep.replies = [expired, expired] # request fails, auth fails
486
- return await dev.send_packet(0x6A, b"")
512
+ ep.replies = [expired, expired] # request fails -7, auth fails -7
513
+ resp = await dev.send_packet(0x6A, b"")
514
+ return ep, resp
487
515
 
516
+ ep, resp = run(go())
517
+ assert int.from_bytes(resp[0x22:0x24], "little", signed=True) == -7
518
+ types = [int.from_bytes(f[0x26:0x28], "little") for f, _ in ep.sent]
519
+ assert types == [0x6A, 0x65] # one auth attempt, no blind retry
488
520
  with pytest.raises(e.AuthorizationError):
489
- run(go())
521
+ e.check_error(resp[0x22:0x24])
522
+
523
+
524
+ def test_locked_device_surfaces_as_the_original_error(net):
525
+ """Device locked in the app: request answered -7, auth answered -1. The
526
+ caller gets the -7 frame back (its check_error raises
527
+ AuthorizationError), and its own auth() call then sees the -1."""
528
+ dev = fixed_device()
529
+
530
+ async def go():
531
+ await dev._endpoint()
532
+ ep = net.endpoints[-1]
533
+
534
+ def sendto(data, addr=None):
535
+ ep.sent.append((bytes(data), addr or ep.remote_addr))
536
+ ptype = int.from_bytes(data[0x26:0x28], "little")
537
+ error = 0xFFFF if ptype == 0x65 else 0xFFF9 # -1 to auth, -7 to requests
538
+ ep.protocol.queue.put_nowait((make_response(dev, b"", error=error), HOST))
539
+
540
+ ep.sendto = sendto
541
+ resp = await dev.send_packet(0x6A, b"")
542
+ code = int.from_bytes(resp[0x22:0x24], "little", signed=True)
543
+ with pytest.raises(e.AuthenticationError):
544
+ await dev.auth()
545
+ return ep, code
546
+
547
+ ep, code = run(go())
548
+ assert code == -7
549
+ types = [int.from_bytes(f[0x26:0x28], "little") for f, _ in ep.sent]
550
+ assert types == [0x6A, 0x65, 0x65]
551
+
552
+
553
+ def test_request_queued_behind_an_auth_still_reauths_if_needed(net):
554
+ """The auth generation is read under the lock, so a request that was
555
+ queued while another caller's auth() ran, and still gets -7, performs
556
+ its own re-authentication instead of assuming the earlier one covers
557
+ it."""
558
+ dev = fixed_device()
559
+ dev.id = 5
560
+ session_key = bytes.fromhex("00112233445566778899aabbccddeeff")
561
+ fresh = fixed_device()
562
+ auth_reply = make_response(fresh, (0x42).to_bytes(4, "little") + session_key)
563
+ renewed = fixed_device()
564
+ renewed.update_aes(session_key)
565
+
566
+ async def go():
567
+ await dev._endpoint()
568
+ ep = net.endpoints[-1]
569
+ auths = {"n": 0}
570
+ loop = asyncio.get_running_loop()
571
+
572
+ def sendto(data, addr=None):
573
+ ep.sent.append((bytes(data), addr or ep.remote_addr))
574
+ ptype = int.from_bytes(data[0x26:0x28], "little")
575
+ if ptype == 0x65:
576
+ auths["n"] += 1
577
+ reply = auth_reply
578
+ elif auths["n"] < 2:
579
+ reply = make_response(dev, b"", error=0xFFF9) # still -7 after auth #1
580
+ else:
581
+ reply = make_response(renewed, bytes([9]) + bytes(15))
582
+ loop.call_later(0.002, ep.protocol.queue.put_nowait, (reply, ep.remote_addr))
583
+
584
+ ep.sendto = sendto
585
+ first_auth = loop.create_task(dev.auth())
586
+ await asyncio.sleep(0) # let auth() take the lock first
587
+ resp = await dev.send_packet(0x6A, b"")
588
+ await first_auth
589
+ return ep, resp
590
+
591
+ ep, resp = run(go())
592
+ types = [int.from_bytes(f[0x26:0x28], "little") for f, _ in ep.sent]
593
+ assert types == [0x65, 0x6A, 0x65, 0x6A]
594
+ assert dev.decrypt(resp[0x38:])[0] == 9
490
595
 
491
596
 
492
597
  def test_concurrent_callers_share_one_reauth(net):