python-broadlink 1.0.2__tar.gz → 1.0.4__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 (43) hide show
  1. {python_broadlink-1.0.2 → python_broadlink-1.0.4}/CHANGELOG.md +98 -0
  2. {python_broadlink-1.0.2/python_broadlink.egg-info → python_broadlink-1.0.4}/PKG-INFO +33 -9
  3. {python_broadlink-1.0.2 → python_broadlink-1.0.4}/README.md +32 -8
  4. {python_broadlink-1.0.2 → python_broadlink-1.0.4}/broadlink/__init__.py +8 -14
  5. {python_broadlink-1.0.2 → python_broadlink-1.0.4}/broadlink/device.py +129 -29
  6. {python_broadlink-1.0.2 → python_broadlink-1.0.4}/broadlink/exceptions.py +1 -1
  7. {python_broadlink-1.0.2 → python_broadlink-1.0.4}/broadlink/remote.py +21 -15
  8. {python_broadlink-1.0.2 → python_broadlink-1.0.4}/broadlink/sensor.py +27 -27
  9. {python_broadlink-1.0.2 → python_broadlink-1.0.4}/cli/broadlink_cli +8 -1
  10. {python_broadlink-1.0.2 → python_broadlink-1.0.4}/pyproject.toml +1 -1
  11. {python_broadlink-1.0.2 → python_broadlink-1.0.4/python_broadlink.egg-info}/PKG-INFO +33 -9
  12. {python_broadlink-1.0.2 → python_broadlink-1.0.4}/python_broadlink.egg-info/SOURCES.txt +1 -0
  13. {python_broadlink-1.0.2 → python_broadlink-1.0.4}/tests/oracle/fixtures.json +1 -1
  14. {python_broadlink-1.0.2 → python_broadlink-1.0.4}/tests/oracle/harness.py +7 -0
  15. {python_broadlink-1.0.2 → python_broadlink-1.0.4}/tests/test_capture.py +9 -3
  16. python_broadlink-1.0.4/tests/test_loopback.py +110 -0
  17. {python_broadlink-1.0.2 → python_broadlink-1.0.4}/tests/test_transport.py +252 -13
  18. {python_broadlink-1.0.2 → python_broadlink-1.0.4}/LICENSE +0 -0
  19. {python_broadlink-1.0.2 → python_broadlink-1.0.4}/MANIFEST.in +0 -0
  20. {python_broadlink-1.0.2 → python_broadlink-1.0.4}/TROUBLESHOOTING.md +0 -0
  21. {python_broadlink-1.0.2 → python_broadlink-1.0.4}/broadlink/alarm.py +0 -0
  22. {python_broadlink-1.0.2 → python_broadlink-1.0.4}/broadlink/climate.py +0 -0
  23. {python_broadlink-1.0.2 → python_broadlink-1.0.4}/broadlink/const.py +0 -0
  24. {python_broadlink-1.0.2 → python_broadlink-1.0.4}/broadlink/cover.py +0 -0
  25. {python_broadlink-1.0.2 → python_broadlink-1.0.4}/broadlink/helpers.py +0 -0
  26. {python_broadlink-1.0.2 → python_broadlink-1.0.4}/broadlink/hub.py +0 -0
  27. {python_broadlink-1.0.2 → python_broadlink-1.0.4}/broadlink/light.py +0 -0
  28. {python_broadlink-1.0.2 → python_broadlink-1.0.4}/broadlink/protocol.py +0 -0
  29. {python_broadlink-1.0.2 → python_broadlink-1.0.4}/broadlink/switch.py +0 -0
  30. {python_broadlink-1.0.2 → python_broadlink-1.0.4}/cli/README.md +0 -0
  31. {python_broadlink-1.0.2 → python_broadlink-1.0.4}/cli/broadlink_discovery +0 -0
  32. {python_broadlink-1.0.2 → python_broadlink-1.0.4}/protocol.md +0 -0
  33. {python_broadlink-1.0.2 → python_broadlink-1.0.4}/python_broadlink.egg-info/dependency_links.txt +0 -0
  34. {python_broadlink-1.0.2 → python_broadlink-1.0.4}/python_broadlink.egg-info/requires.txt +0 -0
  35. {python_broadlink-1.0.2 → python_broadlink-1.0.4}/python_broadlink.egg-info/top_level.txt +0 -0
  36. {python_broadlink-1.0.2 → python_broadlink-1.0.4}/setup.cfg +0 -0
  37. {python_broadlink-1.0.2 → python_broadlink-1.0.4}/tests/__init__.py +0 -0
  38. {python_broadlink-1.0.2 → python_broadlink-1.0.4}/tests/oracle/__init__.py +0 -0
  39. {python_broadlink-1.0.2 → python_broadlink-1.0.4}/tests/oracle/cases.py +0 -0
  40. {python_broadlink-1.0.2 → python_broadlink-1.0.4}/tests/oracle/record.py +0 -0
  41. {python_broadlink-1.0.2 → python_broadlink-1.0.4}/tests/test_helpers.py +0 -0
  42. {python_broadlink-1.0.2 → python_broadlink-1.0.4}/tests/test_oracle.py +0 -0
  43. {python_broadlink-1.0.2 → python_broadlink-1.0.4}/tests/test_remote.py +0 -0
@@ -3,6 +3,104 @@
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.4 - 2026-09-06
7
+
8
+ Fixes from a fourth review, of 1.0.3, which drove the real socket path the
9
+ test suite fakes and found two behaviours the original library had and
10
+ this one had lost. One device fix carried from upstream.
11
+
12
+ ### Fixed
13
+
14
+ - A connected socket that went bad (interface bounce, host address change,
15
+ container network restart) was never replaced: the request waited out
16
+ its timeout and every later request did the same until `aclose()`. The
17
+ original opened a socket per call, so it healed on the next one. Now a
18
+ send failure the socket reports (no route, address gone) fails the
19
+ waiting request at once with that `OSError`, and a request that fails
20
+ for a network reason (that, or a timeout) drops the socket so the next
21
+ call opens a fresh one. A transport asyncio closes from its side also
22
+ wakes the waiting request instead of leaving it to time out. An ICMP
23
+ "port unreachable" (a host that is up with nothing listening, or a
24
+ device mid-reboot) is logged and treated as silence, since the
25
+ original's unconnected socket never saw those, so the timeout decides
26
+ as before.
27
+ - `discover()`, `hello()`, `ping()` and `setup()` passed hostnames straight
28
+ to `sendto`, which resolved them with a blocking call on the event loop
29
+ and swallowed the failure: a name that did not resolve made `hello()`
30
+ wait out its timeout and `ping()` return without sending. The
31
+ destination is now resolved once, off the loop, and `socket.gaierror`
32
+ propagates as it did from the original's socket. A send failure in
33
+ `ping()` and `setup()` is raised too.
34
+ - The A2 air quality sensor's request frame was two bytes short and
35
+ declared the wrong length, and real units answered every read with
36
+ error -5. The frame now follows the SP4/LB1 layout, which is byte for
37
+ byte the packet upstream pull request #826 tested on an A2. That is the
38
+ one oracle case re-recorded on purpose; the fix is carried on the
39
+ strength of that report, not of hardware we have.
40
+ - `xdiscover()` closes the `scan()` generator it wraps, so the discovery
41
+ socket is closed when the caller stops iterating rather than by the
42
+ finalizer a few turns later (1.0.2 claimed this and only `Device.hello()`
43
+ did it).
44
+ - Two identical captures compare equal: `CapturedSignal.captured_at` no
45
+ longer takes part in equality or hashing.
46
+ - Async generator functions are annotated `AsyncGenerator`, which has the
47
+ `aclose()` the library and the README call; `AsyncIterator` does not.
48
+ - The `TICK` docstring tells the same story as the README: 8192/269 from
49
+ protocol.md's measured conversion, not a 32768 Hz clock.
50
+
51
+ ### Added
52
+
53
+ - A loopback test module that drives the real datagram endpoint, including
54
+ the socket-error path, since every other transport test fakes it.
55
+ - README: which errors `discover()` and `hello()` raise, that a failed
56
+ request drops its socket, and that `CaptureInProgressError` can come
57
+ from the `capture()` call or from the first iteration.
58
+
59
+ ## 1.0.3 - 2026-09-06
60
+
61
+ Fixes from a third review, this one of 1.0.2. No change to the wire
62
+ format. One small API change: `pulses` on a captured signal is a tuple.
63
+
64
+ ### Fixed
65
+
66
+ - When the device answered that the session key had expired and the
67
+ re-authentication then failed (for example because the device had been
68
+ locked in the app), the call raised `AuthenticationError` from the
69
+ re-authentication instead of the error the device gave the request. The
70
+ original library never re-authenticated, so a program written against
71
+ it, Home Assistant's integration included, handles the request's own
72
+ error and never expected the other one. The failed re-authentication is
73
+ now logged and the request's original reply is returned, so the caller
74
+ sees the same `AuthorizationError` or `ConnectionClosedError` it always
75
+ did.
76
+ - The authentication generation was read before the request lock was
77
+ taken rather than under it, so a request queued behind an `auth()`
78
+ could observe a stale generation and skip a re-authentication it needed.
79
+ - `aclose()` racing an endpoint that was still being opened could leave
80
+ the new socket open and unreferenced. The open now notices the close
81
+ and fails with `EndpointClosedError`.
82
+ - After a new capture window gives the finalizer its turn, it re-checks
83
+ that no other window claimed the device in the meantime.
84
+ - `CapturedSignal` and `ParsedPacket` are frozen dataclasses, but they
85
+ held a list, so they could not be hashed or put in a set. `pulses` is
86
+ now a `tuple[int, ...]`.
87
+ - `check_error` unpacks the error code as little-endian explicitly
88
+ (`"<h"`), matching the rest of the code, instead of native order.
89
+ - The CLI closes the device it opens instead of leaving that to
90
+ `asyncio.run`, which warned under `python -X dev`.
91
+ - The locks are created in `__init__` rather than lazily in two places.
92
+
93
+ ### Changed
94
+
95
+ - `send_packet` accepts a `bytearray` payload as well as `bytes`.
96
+ - `setup()` sends its provisioning packet through a new
97
+ `send_setup_packet()` helper in `broadlink.device` instead of reaching
98
+ into a private function.
99
+ - README: the re-authentication contract and its worst case (one call can
100
+ wait out up to three timeouts), the A2 sensor and the Hysen HY02/HY03
101
+ in the device list, and the hello response's `mac` being `bytes` in the
102
+ list of differences from 0.19.0.
103
+
6
104
  ## 1.0.2 - 2026-09-05
7
105
 
8
106
  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.4
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
@@ -192,6 +193,11 @@ If the device is locked, it may not be discoverable with broadcast. In such case
192
193
  device = await broadlink.hello("192.168.0.16")
193
194
  ```
194
195
 
196
+ `discover()` and `hello()` raise `NetworkTimeoutError` when nothing answers
197
+ within the timeout, `socket.gaierror` when a hostname does not resolve, and
198
+ `OSError` when the socket cannot be opened or the send fails (no route, for
199
+ example), the same errors the original library raised from its socket.
200
+
195
201
  If you are a perfomance freak, use `broadlink.xdiscover()` to create devices instantly:
196
202
  ```python3
197
203
  async for device in broadlink.xdiscover():
@@ -204,6 +210,17 @@ After discovering the device, call the `auth()` method to obtain the authenticat
204
210
  await device.auth()
205
211
  ```
206
212
 
213
+ The session key expires on the device after a while. When a request comes
214
+ back with an expired-key answer, the library authenticates again and
215
+ repeats the request once, so a long-running program does not need to
216
+ handle that itself. If the second authentication fails, for example
217
+ because the device was locked in the app in the meantime, the call raises
218
+ the error the device gave the first time, the same `AuthorizationError`
219
+ or `ConnectionClosedError` the original library raised, and it is up to
220
+ the caller to decide what to do. In the worst case one call can wait out
221
+ three timeouts (the request, the authentication, and the repeat), each
222
+ bounded by `device.timeout`.
223
+
207
224
  ### Closing
208
225
 
209
226
  Each device keeps one UDP socket open for its lifetime (the original
@@ -221,9 +238,14 @@ await device.aclose()
221
238
 
222
239
  The socket reopens by itself on the next call, so closing is cheap and
223
240
  safe to do at any time. A request that is in flight when `aclose()` runs
224
- fails with `EndpointClosedError`. An integration that creates devices
225
- should close them when it unloads; a device that is never closed holds
226
- its socket until it is garbage collected.
241
+ fails with `EndpointClosedError`. A request that fails for a network
242
+ reason (a timeout, or an `OSError` from the socket such as "network is
243
+ unreachable" after an interface change, raised at once) also drops the socket, so the
244
+ next call starts fresh rather than reusing one that has gone bad, which
245
+ is how the original library behaved by opening a socket per call. An
246
+ integration that creates devices should close them when it unloads; a
247
+ device that is never closed holds its socket until it is garbage
248
+ collected.
227
249
 
228
250
  The next steps depend on the type of device you want to control.
229
251
 
@@ -299,7 +321,9 @@ By default the window closes after the first signal. Pass
299
321
  because the device holds only one code per learning session. A universal
300
322
  remote has a single receiver, so only one capture window can be open on a
301
323
  device at a time: opening a second one raises `CaptureInProgressError`
302
- while the first is still held. Always close a window you leave early
324
+ while the first is still held, either from the `capture()` call itself or
325
+ from the new window's first iteration, depending on what the first window
326
+ was doing at that moment. Always close a window you leave early
303
327
  (`aclosing` above does it), otherwise it stays open until Python collects
304
328
  the generator.
305
329
 
@@ -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
@@ -161,6 +162,11 @@ If the device is locked, it may not be discoverable with broadcast. In such case
161
162
  device = await broadlink.hello("192.168.0.16")
162
163
  ```
163
164
 
165
+ `discover()` and `hello()` raise `NetworkTimeoutError` when nothing answers
166
+ within the timeout, `socket.gaierror` when a hostname does not resolve, and
167
+ `OSError` when the socket cannot be opened or the send fails (no route, for
168
+ example), the same errors the original library raised from its socket.
169
+
164
170
  If you are a perfomance freak, use `broadlink.xdiscover()` to create devices instantly:
165
171
  ```python3
166
172
  async for device in broadlink.xdiscover():
@@ -173,6 +179,17 @@ After discovering the device, call the `auth()` method to obtain the authenticat
173
179
  await device.auth()
174
180
  ```
175
181
 
182
+ The session key expires on the device after a while. When a request comes
183
+ back with an expired-key answer, the library authenticates again and
184
+ repeats the request once, so a long-running program does not need to
185
+ handle that itself. If the second authentication fails, for example
186
+ because the device was locked in the app in the meantime, the call raises
187
+ the error the device gave the first time, the same `AuthorizationError`
188
+ or `ConnectionClosedError` the original library raised, and it is up to
189
+ the caller to decide what to do. In the worst case one call can wait out
190
+ three timeouts (the request, the authentication, and the repeat), each
191
+ bounded by `device.timeout`.
192
+
176
193
  ### Closing
177
194
 
178
195
  Each device keeps one UDP socket open for its lifetime (the original
@@ -190,9 +207,14 @@ await device.aclose()
190
207
 
191
208
  The socket reopens by itself on the next call, so closing is cheap and
192
209
  safe to do at any time. A request that is in flight when `aclose()` runs
193
- fails with `EndpointClosedError`. An integration that creates devices
194
- should close them when it unloads; a device that is never closed holds
195
- its socket until it is garbage collected.
210
+ fails with `EndpointClosedError`. A request that fails for a network
211
+ reason (a timeout, or an `OSError` from the socket such as "network is
212
+ unreachable" after an interface change, raised at once) also drops the socket, so the
213
+ next call starts fresh rather than reusing one that has gone bad, which
214
+ is how the original library behaved by opening a socket per call. An
215
+ integration that creates devices should close them when it unloads; a
216
+ device that is never closed holds its socket until it is garbage
217
+ collected.
196
218
 
197
219
  The next steps depend on the type of device you want to control.
198
220
 
@@ -268,7 +290,9 @@ By default the window closes after the first signal. Pass
268
290
  because the device holds only one code per learning session. A universal
269
291
  remote has a single receiver, so only one capture window can be open on a
270
292
  device at a time: opening a second one raises `CaptureInProgressError`
271
- while the first is still held. Always close a window you leave early
293
+ while the first is still held, either from the `capture()` call itself or
294
+ from the new window's first iteration, depending on what the first window
295
+ was doing at that moment. Always close a window you leave early
272
296
  (`aclosing` above does it), otherwise it stays open until Python collects
273
297
  the generator.
274
298
 
@@ -1,16 +1,14 @@
1
- #!/usr/bin/env python3
2
1
  """The python-broadlink library."""
3
2
 
4
3
  import contextlib
5
- from collections.abc import AsyncIterator
6
- from typing import Optional, Union
4
+ from collections.abc import AsyncGenerator
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
@@ -296,15 +294,15 @@ async def xdiscover(
296
294
  local_ip_address: str | None = None,
297
295
  discover_ip_address: str = DEFAULT_BCAST_ADDR,
298
296
  discover_ip_port: int = DEFAULT_PORT,
299
- ) -> AsyncIterator[Device]:
297
+ ) -> AsyncGenerator[Device]:
300
298
  """Discover devices connected to the local network.
301
299
 
302
300
  Yields each device as soon as it answers.
303
301
  """
304
- async for resp in scan(
305
- timeout, local_ip_address, discover_ip_address, discover_ip_port
306
- ):
307
- yield gendevice(*resp)
302
+ responses = scan(timeout, local_ip_address, discover_ip_address, discover_ip_port)
303
+ async with contextlib.aclosing(responses):
304
+ async for resp in responses:
305
+ yield gendevice(*resp)
308
306
 
309
307
 
310
308
  # Setup a new Broadlink device via AP Mode. Review the README to see how to enter AP Mode.
@@ -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)
@@ -15,7 +15,7 @@ import contextlib
15
15
  import logging
16
16
  import random
17
17
  import socket
18
- from collections.abc import AsyncIterator
18
+ from collections.abc import AsyncGenerator
19
19
 
20
20
  from cryptography.hazmat.backends import default_backend
21
21
  from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
@@ -48,33 +48,55 @@ _RECENT_MAX = 64
48
48
  _CLOSED = (None, None)
49
49
  """Sentinel put on the receive queue when the endpoint is closed."""
50
50
 
51
+ _QueueItem = tuple[bytes | Exception | None, tuple[str, int] | None]
52
+ """What the receive queue carries: a datagram with its source address, an
53
+ error the socket reported (address ``None``), or ``_CLOSED``."""
54
+
51
55
 
52
56
  class _Protocol(asyncio.DatagramProtocol):
53
- """Datagram protocol that hands every received packet to a queue."""
57
+ """Datagram protocol that hands every received packet to a queue.
58
+
59
+ Errors the socket reports go on the same queue, so the request that is
60
+ waiting fails at once instead of waiting out its timeout.
61
+ """
54
62
 
55
63
  def __init__(self) -> None:
56
- self.queue: asyncio.Queue[tuple[bytes, tuple[str, int]]] = asyncio.Queue()
64
+ self.queue: asyncio.Queue[_QueueItem] = asyncio.Queue()
57
65
  self.transport: asyncio.DatagramTransport | None = None
58
66
 
59
- def connection_made(self, transport) -> None: # type: ignore[override]
60
- self.transport = transport
67
+ def connection_made(self, transport: asyncio.BaseTransport) -> None:
68
+ """Keep the transport; the endpoint sends through it."""
69
+ self.transport = transport # type: ignore[assignment]
61
70
 
62
71
  def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None:
72
+ """Queue every datagram for the request that is waiting."""
63
73
  self.queue.put_nowait((data, addr))
64
74
 
65
75
  def error_received(self, exc: Exception) -> None:
66
- # ICMP unreachable and the like. Surface it as a receive of nothing;
67
- # the retry loop will time out and raise NetworkTimeoutError.
68
- pass
76
+ """Queue a send failure or an ICMP error for the waiting request."""
77
+ self.queue.put_nowait((exc, None))
69
78
 
70
79
  def connection_lost(self, exc: Exception | None) -> None:
71
- pass
80
+ """Wake the waiting request if asyncio closed the transport on us."""
81
+ self.queue.put_nowait((exc, None) if exc is not None else _CLOSED)
72
82
 
73
83
  def drain(self) -> None:
74
84
  """Drop anything that arrived before the current request."""
75
85
  while not self.queue.empty():
76
86
  self.queue.get_nowait()
77
87
 
88
+ def raise_if_error(self) -> None:
89
+ """Raise the error a send just reported, if it reported one.
90
+
91
+ asyncio delivers a failed ``sendto`` to ``error_received`` before
92
+ ``sendto`` returns, so a fire-and-forget sender can check right
93
+ after sending and raise the ``OSError`` the way a plain socket did.
94
+ """
95
+ while not self.queue.empty():
96
+ item, _ = self.queue.get_nowait()
97
+ if isinstance(item, Exception):
98
+ raise item
99
+
78
100
 
79
101
  async def _open_endpoint(
80
102
  local_addr: tuple[str, int] | None = None,
@@ -93,6 +115,21 @@ async def _open_endpoint(
93
115
  return transport, protocol # type: ignore[return-value]
94
116
 
95
117
 
118
+ async def _resolve(host: str, port: int) -> tuple[str, int]:
119
+ """Resolve a destination once, off the event loop.
120
+
121
+ Sending to a hostname through an unconnected datagram socket would
122
+ resolve it with a blocking call on the loop and hide the failure. A
123
+ name that does not resolve raises ``socket.gaierror`` here, as the
124
+ original library's ``sendto`` did.
125
+ """
126
+ loop = asyncio.get_running_loop()
127
+ info = await loop.getaddrinfo(
128
+ host, port, family=socket.AF_INET, type=socket.SOCK_DGRAM
129
+ )
130
+ return info[0][4][:2] # type: ignore[return-value]
131
+
132
+
96
133
  def _hello_packet(local_ip_address: str, port: int) -> bytearray:
97
134
  packet = bytearray(0x30)
98
135
  packet[0x08:0x14] = Datetime.pack(Datetime.now())
@@ -117,13 +154,14 @@ async def scan(
117
154
  local_ip_address: str | None = None,
118
155
  discover_ip_address: str = DEFAULT_BCAST_ADDR,
119
156
  discover_ip_port: int = DEFAULT_PORT,
120
- ) -> AsyncIterator[HelloResponse]:
157
+ ) -> AsyncGenerator[HelloResponse]:
121
158
  """Broadcast a hello message and yield responses as they arrive.
122
159
 
123
160
  The hello is repeated every ``DEFAULT_RETRY_INTVL`` seconds until
124
161
  ``timeout`` elapses. Each device is yielded once.
125
162
  """
126
163
  local_addr = (local_ip_address, 0) if local_ip_address else None
164
+ target = await _resolve(discover_ip_address, discover_ip_port)
127
165
  transport, protocol = await _open_endpoint(local_addr=local_addr, broadcast=True)
128
166
  try:
129
167
  if local_ip_address:
@@ -138,7 +176,7 @@ async def scan(
138
176
  discovered: set[tuple[tuple[str, int], bytes, int]] = set()
139
177
 
140
178
  while (loop.time() - start) < timeout:
141
- transport.sendto(packet, (discover_ip_address, discover_ip_port))
179
+ transport.sendto(packet, target)
142
180
  deadline = min(DEFAULT_RETRY_INTVL, timeout - (loop.time() - start))
143
181
  slot_end = loop.time() + deadline
144
182
  while True:
@@ -149,7 +187,11 @@ async def scan(
149
187
  resp, host = await asyncio.wait_for(protocol.queue.get(), remaining)
150
188
  except TimeoutError:
151
189
  break
152
- if len(resp) < 0x80:
190
+ if resp is None:
191
+ return # The transport was closed under us.
192
+ if isinstance(resp, Exception):
193
+ raise resp
194
+ if host is None or len(resp) < 0x80:
153
195
  continue
154
196
  entry = _parse_hello(resp, host)
155
197
  key = (entry[1], entry[2], entry[0])
@@ -161,6 +203,19 @@ async def scan(
161
203
  transport.close()
162
204
 
163
205
 
206
+ async def send_setup_packet(
207
+ payload: bytes, ip_address: str, port: int = DEFAULT_PORT
208
+ ) -> None:
209
+ """Broadcast one Wi-Fi provisioning packet to a device in AP mode."""
210
+ target = await _resolve(ip_address, port)
211
+ transport, protocol = await _open_endpoint(broadcast=True)
212
+ try:
213
+ transport.sendto(payload, target)
214
+ protocol.raise_if_error()
215
+ finally:
216
+ transport.close()
217
+
218
+
164
219
  async def ping(ip_address: str, port: int = DEFAULT_PORT) -> None:
165
220
  """Send a ping packet to an address.
166
221
 
@@ -168,11 +223,13 @@ async def ping(ip_address: str, port: int = DEFAULT_PORT) -> None:
168
223
  Useful to prevent reboots when the cloud cannot be reached.
169
224
  It must be sent every 2 minutes in such cases.
170
225
  """
171
- transport, _ = await _open_endpoint(broadcast=True)
226
+ target = await _resolve(ip_address, port)
227
+ transport, protocol = await _open_endpoint(broadcast=True)
172
228
  try:
173
229
  packet = bytearray(0x30)
174
230
  packet[0x26] = 1
175
- transport.sendto(packet, (ip_address, port))
231
+ transport.sendto(packet, target)
232
+ protocol.raise_if_error()
176
233
  finally:
177
234
  transport.close()
178
235
 
@@ -213,12 +270,13 @@ class Device:
213
270
  self.aes = None
214
271
  self.update_aes(bytes.fromhex(self.__INIT_KEY))
215
272
 
216
- self._lock: asyncio.Lock | None = None
273
+ self._lock = asyncio.Lock()
217
274
  self._transport: asyncio.DatagramTransport | None = None
218
275
  self._protocol: _Protocol | None = None
219
276
  self._endpoint_addr: tuple[str, int] | None = None
220
277
  self._recent: collections.deque[int] = collections.deque(maxlen=_RECENT_MAX)
221
- self._reauth_lock: asyncio.Lock | None = None
278
+ self._reauth_lock = asyncio.Lock()
279
+ self._closes = 0 # Bumped by aclose(); guards an open racing a close.
222
280
  self._auth_generation = 0
223
281
 
224
282
  def __repr__(self) -> str:
@@ -277,9 +335,6 @@ class Device:
277
335
  packet[0x2D] = 0x01
278
336
  packet[0x30:0x36] = b"Test 1"
279
337
 
280
- if self._lock is None:
281
- self._lock = asyncio.Lock()
282
- self._reauth_lock = asyncio.Lock()
283
338
  async with self._lock:
284
339
  self.id = 0
285
340
  self.update_aes(bytes.fromhex(self.__INIT_KEY))
@@ -292,7 +347,7 @@ class Device:
292
347
  _LOGGER.debug("%s: authenticated, session id %d", self.host[0], self.id)
293
348
  return True
294
349
 
295
- async def hello(self, local_ip_address=None) -> bool:
350
+ async def hello(self, local_ip_address: str | None = None) -> bool:
296
351
  """Send a hello message to the device.
297
352
 
298
353
  Device information is checked before updating name and lock status.
@@ -385,6 +440,7 @@ class Device:
385
440
  A request in flight fails at once with ``ConnectionClosedError``
386
441
  rather than waiting out its timeout.
387
442
  """
443
+ self._closes += 1
388
444
  transport, protocol = self._transport, self._protocol
389
445
  self._transport = None
390
446
  self._protocol = None
@@ -395,13 +451,37 @@ class Device:
395
451
  if protocol is not None:
396
452
  protocol.queue.put_nowait(_CLOSED) # type: ignore[arg-type]
397
453
 
454
+ def _drop_endpoint(self) -> None:
455
+ """Throw the endpoint away after a failure; the next call reopens it.
456
+
457
+ A connected datagram socket can go bad for good (the interface
458
+ bounced, the host's address changed), and the original library
459
+ never noticed because it opened a socket per call. Dropping the
460
+ endpoint whenever a request fails restores that self-healing.
461
+ """
462
+ transport = self._transport
463
+ self._transport = None
464
+ self._protocol = None
465
+ self._endpoint_addr = None
466
+ if transport is not None:
467
+ transport.close()
468
+ _LOGGER.debug("%s: endpoint dropped after a failure", self.host[0])
469
+
398
470
  async def _endpoint(self) -> tuple[asyncio.DatagramTransport, _Protocol]:
399
471
  if self._transport is not None and self._endpoint_addr != self.host:
400
472
  # The caller changed host; the connected socket points at the
401
473
  # old address, so drop it.
402
474
  await self.aclose()
403
475
  if self._transport is None or self._transport.is_closing():
404
- self._transport, self._protocol = await _open_endpoint(remote_addr=self.host)
476
+ closes = self._closes
477
+ transport, protocol = await _open_endpoint(remote_addr=self.host)
478
+ if self._closes != closes:
479
+ # aclose() ran while the socket was being opened.
480
+ transport.close()
481
+ raise e.EndpointClosedError(
482
+ -4013, "Endpoint closed", "The device endpoint was closed"
483
+ )
484
+ self._transport, self._protocol = transport, protocol
405
485
  self._endpoint_addr = self.host
406
486
  _LOGGER.debug("%s: endpoint opened", self.host[0])
407
487
  return self._transport, self._protocol # type: ignore[return-value]
@@ -490,6 +570,19 @@ class Device:
490
570
  raise e.EndpointClosedError(
491
571
  -4013, "Endpoint closed", "The device endpoint was closed"
492
572
  )
573
+ if isinstance(resp, ConnectionRefusedError):
574
+ # ICMP port unreachable: the host is up and nothing is
575
+ # listening, or the device is rebooting. The original
576
+ # library's unconnected socket never saw these, so keep
577
+ # waiting and let the timeout decide, as it did.
578
+ _LOGGER.debug("%s: port unreachable, still waiting", self.host[0])
579
+ continue
580
+ if isinstance(resp, Exception):
581
+ # A send failure (no route, address gone) or a fatal
582
+ # transport error: fail now and throw the socket away.
583
+ _LOGGER.debug("%s: socket error: %s", self.host[0], resp)
584
+ self._drop_endpoint()
585
+ raise resp
493
586
  resp = self._validate(resp)
494
587
  reply_count = int.from_bytes(resp[0x28:0x2A], "little")
495
588
  if reply_count == count or reply_count not in self._recent:
@@ -501,33 +594,40 @@ class Device:
501
594
  )
502
595
  if loop.time() - start >= timeout:
503
596
  _LOGGER.debug("%s: no reply within %ss", self.host[0], timeout)
597
+ self._drop_endpoint()
504
598
  raise e.NetworkTimeoutError(
505
599
  -4000,
506
600
  "Network timeout",
507
601
  f"No response received within {timeout}s",
508
602
  ) from None
509
603
 
510
- async def send_packet(self, packet_type: int, payload: bytes) -> bytes:
604
+ async def send_packet(self, packet_type: int, payload: bytes | bytearray) -> bytes:
511
605
  """Send a packet to the device and return the raw response frame.
512
606
 
513
607
  If the device answers that the session key is no longer valid, the
514
608
  session is re-authenticated once and the request is sent again.
515
609
  Concurrent callers that hit the same expired key share one
516
- re-authentication and each retry once.
610
+ re-authentication and each retry once. If that re-authentication
611
+ fails (for example the device has been locked in the app), the
612
+ original reply is returned unchanged, so the caller sees the same
613
+ error the original library raised and can run its own recovery.
517
614
  """
518
- if self._lock is None:
519
- self._lock = asyncio.Lock()
520
- self._reauth_lock = asyncio.Lock()
521
- generation = self._auth_generation
522
615
  async with self._lock:
616
+ generation = self._auth_generation
523
617
  resp = await self._exchange(self._frame(packet_type, bytes(payload)))
524
618
 
525
619
  code = int.from_bytes(resp[0x22:0x24], "little", signed=True)
526
620
  if code in _REAUTH_CODES:
527
621
  _LOGGER.debug("%s: device answered %d, re-authenticating", self.host[0], code)
528
- async with self._reauth_lock: # type: ignore[union-attr]
622
+ async with self._reauth_lock:
529
623
  if self._auth_generation == generation:
530
- await self.auth()
624
+ try:
625
+ await self.auth()
626
+ except e.BroadlinkException as err:
627
+ _LOGGER.debug(
628
+ "%s: re-authentication failed: %s", self.host[0], err
629
+ )
630
+ return resp
531
631
  async with self._lock:
532
632
  resp = await self._exchange(self._frame(packet_type, bytes(payload)))
533
633
  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)