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.
- {python_broadlink-1.0.2 → python_broadlink-1.0.4}/CHANGELOG.md +98 -0
- {python_broadlink-1.0.2/python_broadlink.egg-info → python_broadlink-1.0.4}/PKG-INFO +33 -9
- {python_broadlink-1.0.2 → python_broadlink-1.0.4}/README.md +32 -8
- {python_broadlink-1.0.2 → python_broadlink-1.0.4}/broadlink/__init__.py +8 -14
- {python_broadlink-1.0.2 → python_broadlink-1.0.4}/broadlink/device.py +129 -29
- {python_broadlink-1.0.2 → python_broadlink-1.0.4}/broadlink/exceptions.py +1 -1
- {python_broadlink-1.0.2 → python_broadlink-1.0.4}/broadlink/remote.py +21 -15
- {python_broadlink-1.0.2 → python_broadlink-1.0.4}/broadlink/sensor.py +27 -27
- {python_broadlink-1.0.2 → python_broadlink-1.0.4}/cli/broadlink_cli +8 -1
- {python_broadlink-1.0.2 → python_broadlink-1.0.4}/pyproject.toml +1 -1
- {python_broadlink-1.0.2 → python_broadlink-1.0.4/python_broadlink.egg-info}/PKG-INFO +33 -9
- {python_broadlink-1.0.2 → python_broadlink-1.0.4}/python_broadlink.egg-info/SOURCES.txt +1 -0
- {python_broadlink-1.0.2 → python_broadlink-1.0.4}/tests/oracle/fixtures.json +1 -1
- {python_broadlink-1.0.2 → python_broadlink-1.0.4}/tests/oracle/harness.py +7 -0
- {python_broadlink-1.0.2 → python_broadlink-1.0.4}/tests/test_capture.py +9 -3
- python_broadlink-1.0.4/tests/test_loopback.py +110 -0
- {python_broadlink-1.0.2 → python_broadlink-1.0.4}/tests/test_transport.py +252 -13
- {python_broadlink-1.0.2 → python_broadlink-1.0.4}/LICENSE +0 -0
- {python_broadlink-1.0.2 → python_broadlink-1.0.4}/MANIFEST.in +0 -0
- {python_broadlink-1.0.2 → python_broadlink-1.0.4}/TROUBLESHOOTING.md +0 -0
- {python_broadlink-1.0.2 → python_broadlink-1.0.4}/broadlink/alarm.py +0 -0
- {python_broadlink-1.0.2 → python_broadlink-1.0.4}/broadlink/climate.py +0 -0
- {python_broadlink-1.0.2 → python_broadlink-1.0.4}/broadlink/const.py +0 -0
- {python_broadlink-1.0.2 → python_broadlink-1.0.4}/broadlink/cover.py +0 -0
- {python_broadlink-1.0.2 → python_broadlink-1.0.4}/broadlink/helpers.py +0 -0
- {python_broadlink-1.0.2 → python_broadlink-1.0.4}/broadlink/hub.py +0 -0
- {python_broadlink-1.0.2 → python_broadlink-1.0.4}/broadlink/light.py +0 -0
- {python_broadlink-1.0.2 → python_broadlink-1.0.4}/broadlink/protocol.py +0 -0
- {python_broadlink-1.0.2 → python_broadlink-1.0.4}/broadlink/switch.py +0 -0
- {python_broadlink-1.0.2 → python_broadlink-1.0.4}/cli/README.md +0 -0
- {python_broadlink-1.0.2 → python_broadlink-1.0.4}/cli/broadlink_discovery +0 -0
- {python_broadlink-1.0.2 → python_broadlink-1.0.4}/protocol.md +0 -0
- {python_broadlink-1.0.2 → python_broadlink-1.0.4}/python_broadlink.egg-info/dependency_links.txt +0 -0
- {python_broadlink-1.0.2 → python_broadlink-1.0.4}/python_broadlink.egg-info/requires.txt +0 -0
- {python_broadlink-1.0.2 → python_broadlink-1.0.4}/python_broadlink.egg-info/top_level.txt +0 -0
- {python_broadlink-1.0.2 → python_broadlink-1.0.4}/setup.cfg +0 -0
- {python_broadlink-1.0.2 → python_broadlink-1.0.4}/tests/__init__.py +0 -0
- {python_broadlink-1.0.2 → python_broadlink-1.0.4}/tests/oracle/__init__.py +0 -0
- {python_broadlink-1.0.2 → python_broadlink-1.0.4}/tests/oracle/cases.py +0 -0
- {python_broadlink-1.0.2 → python_broadlink-1.0.4}/tests/oracle/record.py +0 -0
- {python_broadlink-1.0.2 → python_broadlink-1.0.4}/tests/test_helpers.py +0 -0
- {python_broadlink-1.0.2 → python_broadlink-1.0.4}/tests/test_oracle.py +0 -0
- {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.
|
|
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,
|
|
55
|
-
`
|
|
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
|
|
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`.
|
|
225
|
-
|
|
226
|
-
|
|
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
|
|
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,
|
|
24
|
-
`
|
|
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
|
|
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`.
|
|
194
|
-
|
|
195
|
-
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
) ->
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
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
|
|
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[
|
|
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:
|
|
60
|
-
|
|
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
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
) ->
|
|
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,
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
622
|
+
async with self._reauth_lock:
|
|
529
623
|
if self._auth_generation == generation:
|
|
530
|
-
|
|
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)
|