python-broadlink 1.0.3__tar.gz → 1.0.5__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.3 → python_broadlink-1.0.5}/CHANGELOG.md +89 -0
- {python_broadlink-1.0.3/python_broadlink.egg-info → python_broadlink-1.0.5}/PKG-INFO +21 -5
- {python_broadlink-1.0.3 → python_broadlink-1.0.5}/README.md +20 -4
- {python_broadlink-1.0.3 → python_broadlink-1.0.5}/broadlink/__init__.py +12 -12
- {python_broadlink-1.0.3 → python_broadlink-1.0.5}/broadlink/device.py +108 -14
- {python_broadlink-1.0.3 → python_broadlink-1.0.5}/broadlink/remote.py +89 -77
- {python_broadlink-1.0.3 → python_broadlink-1.0.5}/broadlink/sensor.py +27 -27
- {python_broadlink-1.0.3 → python_broadlink-1.0.5}/pyproject.toml +1 -1
- {python_broadlink-1.0.3 → python_broadlink-1.0.5/python_broadlink.egg-info}/PKG-INFO +21 -5
- {python_broadlink-1.0.3 → python_broadlink-1.0.5}/python_broadlink.egg-info/SOURCES.txt +1 -0
- {python_broadlink-1.0.3 → python_broadlink-1.0.5}/tests/oracle/fixtures.json +1 -1
- {python_broadlink-1.0.3 → python_broadlink-1.0.5}/tests/oracle/harness.py +7 -0
- python_broadlink-1.0.5/tests/test_loopback.py +110 -0
- {python_broadlink-1.0.3 → python_broadlink-1.0.5}/tests/test_transport.py +176 -8
- {python_broadlink-1.0.3 → python_broadlink-1.0.5}/LICENSE +0 -0
- {python_broadlink-1.0.3 → python_broadlink-1.0.5}/MANIFEST.in +0 -0
- {python_broadlink-1.0.3 → python_broadlink-1.0.5}/TROUBLESHOOTING.md +0 -0
- {python_broadlink-1.0.3 → python_broadlink-1.0.5}/broadlink/alarm.py +0 -0
- {python_broadlink-1.0.3 → python_broadlink-1.0.5}/broadlink/climate.py +0 -0
- {python_broadlink-1.0.3 → python_broadlink-1.0.5}/broadlink/const.py +0 -0
- {python_broadlink-1.0.3 → python_broadlink-1.0.5}/broadlink/cover.py +0 -0
- {python_broadlink-1.0.3 → python_broadlink-1.0.5}/broadlink/exceptions.py +0 -0
- {python_broadlink-1.0.3 → python_broadlink-1.0.5}/broadlink/helpers.py +0 -0
- {python_broadlink-1.0.3 → python_broadlink-1.0.5}/broadlink/hub.py +0 -0
- {python_broadlink-1.0.3 → python_broadlink-1.0.5}/broadlink/light.py +0 -0
- {python_broadlink-1.0.3 → python_broadlink-1.0.5}/broadlink/protocol.py +0 -0
- {python_broadlink-1.0.3 → python_broadlink-1.0.5}/broadlink/switch.py +0 -0
- {python_broadlink-1.0.3 → python_broadlink-1.0.5}/cli/README.md +0 -0
- {python_broadlink-1.0.3 → python_broadlink-1.0.5}/cli/broadlink_cli +0 -0
- {python_broadlink-1.0.3 → python_broadlink-1.0.5}/cli/broadlink_discovery +0 -0
- {python_broadlink-1.0.3 → python_broadlink-1.0.5}/protocol.md +0 -0
- {python_broadlink-1.0.3 → python_broadlink-1.0.5}/python_broadlink.egg-info/dependency_links.txt +0 -0
- {python_broadlink-1.0.3 → python_broadlink-1.0.5}/python_broadlink.egg-info/requires.txt +0 -0
- {python_broadlink-1.0.3 → python_broadlink-1.0.5}/python_broadlink.egg-info/top_level.txt +0 -0
- {python_broadlink-1.0.3 → python_broadlink-1.0.5}/setup.cfg +0 -0
- {python_broadlink-1.0.3 → python_broadlink-1.0.5}/tests/__init__.py +0 -0
- {python_broadlink-1.0.3 → python_broadlink-1.0.5}/tests/oracle/__init__.py +0 -0
- {python_broadlink-1.0.3 → python_broadlink-1.0.5}/tests/oracle/cases.py +0 -0
- {python_broadlink-1.0.3 → python_broadlink-1.0.5}/tests/oracle/record.py +0 -0
- {python_broadlink-1.0.3 → python_broadlink-1.0.5}/tests/test_capture.py +0 -0
- {python_broadlink-1.0.3 → python_broadlink-1.0.5}/tests/test_helpers.py +0 -0
- {python_broadlink-1.0.3 → python_broadlink-1.0.5}/tests/test_oracle.py +0 -0
- {python_broadlink-1.0.3 → python_broadlink-1.0.5}/tests/test_remote.py +0 -0
|
@@ -3,6 +3,95 @@
|
|
|
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.5 - 2026-09-06
|
|
7
|
+
|
|
8
|
+
Fixes from a fifth review, of 1.0.4. No change to the wire format or the
|
|
9
|
+
public API.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- A connected socket can learn from ICMP that a host cannot be reached
|
|
14
|
+
(`EHOSTUNREACH`, typically a router answering for a device that is off),
|
|
15
|
+
and 1.0.4 raised that as an `OSError` at once. The original library's
|
|
16
|
+
unconnected socket never saw it and simply timed out, and Home
|
|
17
|
+
Assistant tolerates a timeout for a few polls where it marks a device
|
|
18
|
+
unavailable on the first `OSError`. Host unreachable, and Windows's
|
|
19
|
+
`ConnectionResetError` for port unreachable, are now treated as silence
|
|
20
|
+
like port unreachable already was: logged, the timeout decides, and the
|
|
21
|
+
socket is still dropped afterwards. Measured on the bench first: on the
|
|
22
|
+
test network neither an on-link address with no host behind it nor an
|
|
23
|
+
off-subnet one produced the ICMP, so this is insurance for networks
|
|
24
|
+
that do, not a fix for one that reproduced.
|
|
25
|
+
- `discover()` closes the `xdiscover()` generator it drains, like
|
|
26
|
+
`hello()` and `xdiscover()` itself.
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
|
|
30
|
+
- The capture window claim is a flag set on the window's first iteration
|
|
31
|
+
and cleared when its generator finishes or is closed, including by
|
|
32
|
+
asyncio's finalizer, in place of the weak reference and frame
|
|
33
|
+
inspection used since 1.0.0. Same behaviour, pinned by the same tests:
|
|
34
|
+
a dropped window gets one turn to be finalized and then blocks nobody,
|
|
35
|
+
a held or paused window is refused to a newcomer. One visible
|
|
36
|
+
difference: `CaptureInProgressError` now always comes from the new
|
|
37
|
+
window's first iteration, never from the `capture()` call itself.
|
|
38
|
+
- A comment next to the RM Max entry says why it sits in `rmpro`
|
|
39
|
+
(upstream #838's text says rm4pro, its tested diff says rmpro).
|
|
40
|
+
- README: a device belongs to the event loop it first talks on.
|
|
41
|
+
|
|
42
|
+
## 1.0.4 - 2026-09-06
|
|
43
|
+
|
|
44
|
+
Fixes from a fourth review, of 1.0.3, which drove the real socket path the
|
|
45
|
+
test suite fakes and found two behaviours the original library had and
|
|
46
|
+
this one had lost. One device fix carried from upstream.
|
|
47
|
+
|
|
48
|
+
### Fixed
|
|
49
|
+
|
|
50
|
+
- A connected socket that went bad (interface bounce, host address change,
|
|
51
|
+
container network restart) was never replaced: the request waited out
|
|
52
|
+
its timeout and every later request did the same until `aclose()`. The
|
|
53
|
+
original opened a socket per call, so it healed on the next one. Now a
|
|
54
|
+
send failure the socket reports (no route, address gone) fails the
|
|
55
|
+
waiting request at once with that `OSError`, and a request that fails
|
|
56
|
+
for a network reason (that, or a timeout) drops the socket so the next
|
|
57
|
+
call opens a fresh one. A transport asyncio closes from its side also
|
|
58
|
+
wakes the waiting request instead of leaving it to time out. An ICMP
|
|
59
|
+
"port unreachable" (a host that is up with nothing listening, or a
|
|
60
|
+
device mid-reboot) is logged and treated as silence, since the
|
|
61
|
+
original's unconnected socket never saw those, so the timeout decides
|
|
62
|
+
as before.
|
|
63
|
+
- `discover()`, `hello()`, `ping()` and `setup()` passed hostnames straight
|
|
64
|
+
to `sendto`, which resolved them with a blocking call on the event loop
|
|
65
|
+
and swallowed the failure: a name that did not resolve made `hello()`
|
|
66
|
+
wait out its timeout and `ping()` return without sending. The
|
|
67
|
+
destination is now resolved once, off the loop, and `socket.gaierror`
|
|
68
|
+
propagates as it did from the original's socket. A send failure in
|
|
69
|
+
`ping()` and `setup()` is raised too.
|
|
70
|
+
- The A2 air quality sensor's request frame was two bytes short and
|
|
71
|
+
declared the wrong length, and real units answered every read with
|
|
72
|
+
error -5. The frame now follows the SP4/LB1 layout, which is byte for
|
|
73
|
+
byte the packet upstream pull request #826 tested on an A2. That is the
|
|
74
|
+
one oracle case re-recorded on purpose; the fix is carried on the
|
|
75
|
+
strength of that report, not of hardware we have.
|
|
76
|
+
- `xdiscover()` closes the `scan()` generator it wraps, so the discovery
|
|
77
|
+
socket is closed when the caller stops iterating rather than by the
|
|
78
|
+
finalizer a few turns later (1.0.2 claimed this and only `Device.hello()`
|
|
79
|
+
did it).
|
|
80
|
+
- Two identical captures compare equal: `CapturedSignal.captured_at` no
|
|
81
|
+
longer takes part in equality or hashing.
|
|
82
|
+
- Async generator functions are annotated `AsyncGenerator`, which has the
|
|
83
|
+
`aclose()` the library and the README call; `AsyncIterator` does not.
|
|
84
|
+
- The `TICK` docstring tells the same story as the README: 8192/269 from
|
|
85
|
+
protocol.md's measured conversion, not a 32768 Hz clock.
|
|
86
|
+
|
|
87
|
+
### Added
|
|
88
|
+
|
|
89
|
+
- A loopback test module that drives the real datagram endpoint, including
|
|
90
|
+
the socket-error path, since every other transport test fakes it.
|
|
91
|
+
- README: which errors `discover()` and `hello()` raise, that a failed
|
|
92
|
+
request drops its socket, and that `CaptureInProgressError` can come
|
|
93
|
+
from the `capture()` call or from the first iteration.
|
|
94
|
+
|
|
6
95
|
## 1.0.3 - 2026-09-06
|
|
7
96
|
|
|
8
97
|
Fixes from a third review, this one of 1.0.2. No change to the wire
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-broadlink
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.5
|
|
4
4
|
Summary: Python API for controlling Broadlink devices
|
|
5
5
|
Author: DAB-LABS
|
|
6
6
|
Author-email: Matthew Garrett <mjg59@srcf.ucam.org>
|
|
@@ -193,6 +193,11 @@ If the device is locked, it may not be discoverable with broadcast. In such case
|
|
|
193
193
|
device = await broadlink.hello("192.168.0.16")
|
|
194
194
|
```
|
|
195
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
|
+
|
|
196
201
|
If you are a perfomance freak, use `broadlink.xdiscover()` to create devices instantly:
|
|
197
202
|
```python3
|
|
198
203
|
async for device in broadlink.xdiscover():
|
|
@@ -231,11 +236,21 @@ async with device:
|
|
|
231
236
|
await device.aclose()
|
|
232
237
|
```
|
|
233
238
|
|
|
239
|
+
A device belongs to the event loop it first talks on: its socket and its
|
|
240
|
+
locks are bound to that loop, so a script that runs several
|
|
241
|
+
`asyncio.run(...)` calls should create the device inside each one rather
|
|
242
|
+
than reuse it across them.
|
|
243
|
+
|
|
234
244
|
The socket reopens by itself on the next call, so closing is cheap and
|
|
235
245
|
safe to do at any time. A request that is in flight when `aclose()` runs
|
|
236
|
-
fails with `EndpointClosedError`.
|
|
237
|
-
|
|
238
|
-
|
|
246
|
+
fails with `EndpointClosedError`. A request that fails for a network
|
|
247
|
+
reason (a timeout, or an `OSError` from the socket such as "network is
|
|
248
|
+
unreachable" after an interface change, raised at once) also drops the socket, so the
|
|
249
|
+
next call starts fresh rather than reusing one that has gone bad, which
|
|
250
|
+
is how the original library behaved by opening a socket per call. An
|
|
251
|
+
integration that creates devices should close them when it unloads; a
|
|
252
|
+
device that is never closed holds its socket until it is garbage
|
|
253
|
+
collected.
|
|
239
254
|
|
|
240
255
|
The next steps depend on the type of device you want to control.
|
|
241
256
|
|
|
@@ -311,7 +326,8 @@ By default the window closes after the first signal. Pass
|
|
|
311
326
|
because the device holds only one code per learning session. A universal
|
|
312
327
|
remote has a single receiver, so only one capture window can be open on a
|
|
313
328
|
device at a time: opening a second one raises `CaptureInProgressError`
|
|
314
|
-
while the first is still held.
|
|
329
|
+
from the new window's first iteration while the first is still held.
|
|
330
|
+
Always close a window you leave early
|
|
315
331
|
(`aclosing` above does it), otherwise it stays open until Python collects
|
|
316
332
|
the generator.
|
|
317
333
|
|
|
@@ -162,6 +162,11 @@ If the device is locked, it may not be discoverable with broadcast. In such case
|
|
|
162
162
|
device = await broadlink.hello("192.168.0.16")
|
|
163
163
|
```
|
|
164
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
|
+
|
|
165
170
|
If you are a perfomance freak, use `broadlink.xdiscover()` to create devices instantly:
|
|
166
171
|
```python3
|
|
167
172
|
async for device in broadlink.xdiscover():
|
|
@@ -200,11 +205,21 @@ async with device:
|
|
|
200
205
|
await device.aclose()
|
|
201
206
|
```
|
|
202
207
|
|
|
208
|
+
A device belongs to the event loop it first talks on: its socket and its
|
|
209
|
+
locks are bound to that loop, so a script that runs several
|
|
210
|
+
`asyncio.run(...)` calls should create the device inside each one rather
|
|
211
|
+
than reuse it across them.
|
|
212
|
+
|
|
203
213
|
The socket reopens by itself on the next call, so closing is cheap and
|
|
204
214
|
safe to do at any time. A request that is in flight when `aclose()` runs
|
|
205
|
-
fails with `EndpointClosedError`.
|
|
206
|
-
|
|
207
|
-
|
|
215
|
+
fails with `EndpointClosedError`. A request that fails for a network
|
|
216
|
+
reason (a timeout, or an `OSError` from the socket such as "network is
|
|
217
|
+
unreachable" after an interface change, raised at once) also drops the socket, so the
|
|
218
|
+
next call starts fresh rather than reusing one that has gone bad, which
|
|
219
|
+
is how the original library behaved by opening a socket per call. An
|
|
220
|
+
integration that creates devices should close them when it unloads; a
|
|
221
|
+
device that is never closed holds its socket until it is garbage
|
|
222
|
+
collected.
|
|
208
223
|
|
|
209
224
|
The next steps depend on the type of device you want to control.
|
|
210
225
|
|
|
@@ -280,7 +295,8 @@ By default the window closes after the first signal. Pass
|
|
|
280
295
|
because the device holds only one code per learning session. A universal
|
|
281
296
|
remote has a single receiver, so only one capture window can be open on a
|
|
282
297
|
device at a time: opening a second one raises `CaptureInProgressError`
|
|
283
|
-
while the first is still held.
|
|
298
|
+
from the new window's first iteration while the first is still held.
|
|
299
|
+
Always close a window you leave early
|
|
284
300
|
(`aclosing` above does it), otherwise it stays open until Python collects
|
|
285
301
|
the generator.
|
|
286
302
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""The python-broadlink library."""
|
|
2
2
|
|
|
3
3
|
import contextlib
|
|
4
|
-
from collections.abc import
|
|
4
|
+
from collections.abc import AsyncGenerator
|
|
5
5
|
|
|
6
6
|
from . import exceptions as e
|
|
7
7
|
from .alarm import S1C
|
|
@@ -116,6 +116,9 @@ SUPPORTED_TYPES = {
|
|
|
116
116
|
0x27A6: ("RM plus", "Broadlink"),
|
|
117
117
|
0x27A9: ("RM pro+", "Broadlink"),
|
|
118
118
|
0x27C3: ("RM pro+", "Broadlink"),
|
|
119
|
+
# The RM Max answers the RM pro framing; the RM4 framing (length
|
|
120
|
+
# prefix) gets "device is locked" from it. Tested on hardware in
|
|
121
|
+
# upstream #838, whose text says rm4pro but whose diff says rmpro.
|
|
119
122
|
0xAF8B: ("RM Max", "Broadlink"),
|
|
120
123
|
},
|
|
121
124
|
rmminib: {
|
|
@@ -281,12 +284,9 @@ async def discover(
|
|
|
281
284
|
discover_ip_port: int = DEFAULT_PORT,
|
|
282
285
|
) -> list[Device]:
|
|
283
286
|
"""Discover devices connected to the local network."""
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
async for device in
|
|
287
|
-
timeout, local_ip_address, discover_ip_address, discover_ip_port
|
|
288
|
-
)
|
|
289
|
-
]
|
|
287
|
+
devices = xdiscover(timeout, local_ip_address, discover_ip_address, discover_ip_port)
|
|
288
|
+
async with contextlib.aclosing(devices):
|
|
289
|
+
return [device async for device in devices]
|
|
290
290
|
|
|
291
291
|
|
|
292
292
|
async def xdiscover(
|
|
@@ -294,15 +294,15 @@ async def xdiscover(
|
|
|
294
294
|
local_ip_address: str | None = None,
|
|
295
295
|
discover_ip_address: str = DEFAULT_BCAST_ADDR,
|
|
296
296
|
discover_ip_port: int = DEFAULT_PORT,
|
|
297
|
-
) ->
|
|
297
|
+
) -> AsyncGenerator[Device]:
|
|
298
298
|
"""Discover devices connected to the local network.
|
|
299
299
|
|
|
300
300
|
Yields each device as soon as it answers.
|
|
301
301
|
"""
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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)
|
|
306
306
|
|
|
307
307
|
|
|
308
308
|
# Setup a new Broadlink device via AP Mode. Review the README to see how to enter AP Mode.
|
|
@@ -12,10 +12,11 @@ from __future__ import annotations
|
|
|
12
12
|
import asyncio
|
|
13
13
|
import collections
|
|
14
14
|
import contextlib
|
|
15
|
+
import errno
|
|
15
16
|
import logging
|
|
16
17
|
import random
|
|
17
18
|
import socket
|
|
18
|
-
from collections.abc import
|
|
19
|
+
from collections.abc import AsyncGenerator
|
|
19
20
|
|
|
20
21
|
from cryptography.hazmat.backends import default_backend
|
|
21
22
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
@@ -48,12 +49,36 @@ _RECENT_MAX = 64
|
|
|
48
49
|
_CLOSED = (None, None)
|
|
49
50
|
"""Sentinel put on the receive queue when the endpoint is closed."""
|
|
50
51
|
|
|
52
|
+
_QueueItem = tuple[bytes | Exception | None, tuple[str, int] | None]
|
|
53
|
+
"""What the receive queue carries: a datagram with its source address, an
|
|
54
|
+
error the socket reported (address ``None``), or ``_CLOSED``."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _is_silence(item: object) -> bool:
|
|
58
|
+
"""True for the socket errors that mean "no device answered".
|
|
59
|
+
|
|
60
|
+
A connected datagram socket learns from ICMP that nobody is listening
|
|
61
|
+
(port unreachable, ``ConnectionRefusedError``; ``ConnectionResetError``
|
|
62
|
+
on Windows) or that the host cannot be reached (``EHOSTUNREACH``, from
|
|
63
|
+
a router answering for a host that is off). The original library used
|
|
64
|
+
an unconnected socket that never received any of these and simply
|
|
65
|
+
timed out, and callers such as Home Assistant treat a timeout more
|
|
66
|
+
leniently than an ``OSError``, so these are treated as silence.
|
|
67
|
+
"""
|
|
68
|
+
if isinstance(item, ConnectionRefusedError | ConnectionResetError):
|
|
69
|
+
return True
|
|
70
|
+
return isinstance(item, OSError) and item.errno == errno.EHOSTUNREACH
|
|
71
|
+
|
|
51
72
|
|
|
52
73
|
class _Protocol(asyncio.DatagramProtocol):
|
|
53
|
-
"""Datagram protocol that hands every received packet to a queue.
|
|
74
|
+
"""Datagram protocol that hands every received packet to a queue.
|
|
75
|
+
|
|
76
|
+
Errors the socket reports go on the same queue, so the request that is
|
|
77
|
+
waiting fails at once instead of waiting out its timeout.
|
|
78
|
+
"""
|
|
54
79
|
|
|
55
80
|
def __init__(self) -> None:
|
|
56
|
-
self.queue: asyncio.Queue[
|
|
81
|
+
self.queue: asyncio.Queue[_QueueItem] = asyncio.Queue()
|
|
57
82
|
self.transport: asyncio.DatagramTransport | None = None
|
|
58
83
|
|
|
59
84
|
def connection_made(self, transport: asyncio.BaseTransport) -> None:
|
|
@@ -65,18 +90,30 @@ class _Protocol(asyncio.DatagramProtocol):
|
|
|
65
90
|
self.queue.put_nowait((data, addr))
|
|
66
91
|
|
|
67
92
|
def error_received(self, exc: Exception) -> None:
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
pass
|
|
93
|
+
"""Queue a send failure or an ICMP error for the waiting request."""
|
|
94
|
+
self.queue.put_nowait((exc, None))
|
|
71
95
|
|
|
72
96
|
def connection_lost(self, exc: Exception | None) -> None:
|
|
73
|
-
"""
|
|
97
|
+
"""Wake the waiting request if asyncio closed the transport on us."""
|
|
98
|
+
self.queue.put_nowait((exc, None) if exc is not None else _CLOSED)
|
|
74
99
|
|
|
75
100
|
def drain(self) -> None:
|
|
76
101
|
"""Drop anything that arrived before the current request."""
|
|
77
102
|
while not self.queue.empty():
|
|
78
103
|
self.queue.get_nowait()
|
|
79
104
|
|
|
105
|
+
def raise_if_error(self) -> None:
|
|
106
|
+
"""Raise the error a send just reported, if it reported one.
|
|
107
|
+
|
|
108
|
+
asyncio delivers a failed ``sendto`` to ``error_received`` before
|
|
109
|
+
``sendto`` returns, so a fire-and-forget sender can check right
|
|
110
|
+
after sending and raise the ``OSError`` the way a plain socket did.
|
|
111
|
+
"""
|
|
112
|
+
while not self.queue.empty():
|
|
113
|
+
item, _ = self.queue.get_nowait()
|
|
114
|
+
if isinstance(item, Exception):
|
|
115
|
+
raise item
|
|
116
|
+
|
|
80
117
|
|
|
81
118
|
async def _open_endpoint(
|
|
82
119
|
local_addr: tuple[str, int] | None = None,
|
|
@@ -95,6 +132,21 @@ async def _open_endpoint(
|
|
|
95
132
|
return transport, protocol # type: ignore[return-value]
|
|
96
133
|
|
|
97
134
|
|
|
135
|
+
async def _resolve(host: str, port: int) -> tuple[str, int]:
|
|
136
|
+
"""Resolve a destination once, off the event loop.
|
|
137
|
+
|
|
138
|
+
Sending to a hostname through an unconnected datagram socket would
|
|
139
|
+
resolve it with a blocking call on the loop and hide the failure. A
|
|
140
|
+
name that does not resolve raises ``socket.gaierror`` here, as the
|
|
141
|
+
original library's ``sendto`` did.
|
|
142
|
+
"""
|
|
143
|
+
loop = asyncio.get_running_loop()
|
|
144
|
+
info = await loop.getaddrinfo(
|
|
145
|
+
host, port, family=socket.AF_INET, type=socket.SOCK_DGRAM
|
|
146
|
+
)
|
|
147
|
+
return info[0][4][:2] # type: ignore[return-value]
|
|
148
|
+
|
|
149
|
+
|
|
98
150
|
def _hello_packet(local_ip_address: str, port: int) -> bytearray:
|
|
99
151
|
packet = bytearray(0x30)
|
|
100
152
|
packet[0x08:0x14] = Datetime.pack(Datetime.now())
|
|
@@ -119,13 +171,14 @@ async def scan(
|
|
|
119
171
|
local_ip_address: str | None = None,
|
|
120
172
|
discover_ip_address: str = DEFAULT_BCAST_ADDR,
|
|
121
173
|
discover_ip_port: int = DEFAULT_PORT,
|
|
122
|
-
) ->
|
|
174
|
+
) -> AsyncGenerator[HelloResponse]:
|
|
123
175
|
"""Broadcast a hello message and yield responses as they arrive.
|
|
124
176
|
|
|
125
177
|
The hello is repeated every ``DEFAULT_RETRY_INTVL`` seconds until
|
|
126
178
|
``timeout`` elapses. Each device is yielded once.
|
|
127
179
|
"""
|
|
128
180
|
local_addr = (local_ip_address, 0) if local_ip_address else None
|
|
181
|
+
target = await _resolve(discover_ip_address, discover_ip_port)
|
|
129
182
|
transport, protocol = await _open_endpoint(local_addr=local_addr, broadcast=True)
|
|
130
183
|
try:
|
|
131
184
|
if local_ip_address:
|
|
@@ -140,7 +193,7 @@ async def scan(
|
|
|
140
193
|
discovered: set[tuple[tuple[str, int], bytes, int]] = set()
|
|
141
194
|
|
|
142
195
|
while (loop.time() - start) < timeout:
|
|
143
|
-
transport.sendto(packet,
|
|
196
|
+
transport.sendto(packet, target)
|
|
144
197
|
deadline = min(DEFAULT_RETRY_INTVL, timeout - (loop.time() - start))
|
|
145
198
|
slot_end = loop.time() + deadline
|
|
146
199
|
while True:
|
|
@@ -151,7 +204,11 @@ async def scan(
|
|
|
151
204
|
resp, host = await asyncio.wait_for(protocol.queue.get(), remaining)
|
|
152
205
|
except TimeoutError:
|
|
153
206
|
break
|
|
154
|
-
if
|
|
207
|
+
if resp is None:
|
|
208
|
+
return # The transport was closed under us.
|
|
209
|
+
if isinstance(resp, Exception):
|
|
210
|
+
raise resp
|
|
211
|
+
if host is None or len(resp) < 0x80:
|
|
155
212
|
continue
|
|
156
213
|
entry = _parse_hello(resp, host)
|
|
157
214
|
key = (entry[1], entry[2], entry[0])
|
|
@@ -167,9 +224,11 @@ async def send_setup_packet(
|
|
|
167
224
|
payload: bytes, ip_address: str, port: int = DEFAULT_PORT
|
|
168
225
|
) -> None:
|
|
169
226
|
"""Broadcast one Wi-Fi provisioning packet to a device in AP mode."""
|
|
170
|
-
|
|
227
|
+
target = await _resolve(ip_address, port)
|
|
228
|
+
transport, protocol = await _open_endpoint(broadcast=True)
|
|
171
229
|
try:
|
|
172
|
-
transport.sendto(payload,
|
|
230
|
+
transport.sendto(payload, target)
|
|
231
|
+
protocol.raise_if_error()
|
|
173
232
|
finally:
|
|
174
233
|
transport.close()
|
|
175
234
|
|
|
@@ -181,11 +240,13 @@ async def ping(ip_address: str, port: int = DEFAULT_PORT) -> None:
|
|
|
181
240
|
Useful to prevent reboots when the cloud cannot be reached.
|
|
182
241
|
It must be sent every 2 minutes in such cases.
|
|
183
242
|
"""
|
|
184
|
-
|
|
243
|
+
target = await _resolve(ip_address, port)
|
|
244
|
+
transport, protocol = await _open_endpoint(broadcast=True)
|
|
185
245
|
try:
|
|
186
246
|
packet = bytearray(0x30)
|
|
187
247
|
packet[0x26] = 1
|
|
188
|
-
transport.sendto(packet,
|
|
248
|
+
transport.sendto(packet, target)
|
|
249
|
+
protocol.raise_if_error()
|
|
189
250
|
finally:
|
|
190
251
|
transport.close()
|
|
191
252
|
|
|
@@ -407,6 +468,22 @@ class Device:
|
|
|
407
468
|
if protocol is not None:
|
|
408
469
|
protocol.queue.put_nowait(_CLOSED) # type: ignore[arg-type]
|
|
409
470
|
|
|
471
|
+
def _drop_endpoint(self) -> None:
|
|
472
|
+
"""Throw the endpoint away after a failure; the next call reopens it.
|
|
473
|
+
|
|
474
|
+
A connected datagram socket can go bad for good (the interface
|
|
475
|
+
bounced, the host's address changed), and the original library
|
|
476
|
+
never noticed because it opened a socket per call. Dropping the
|
|
477
|
+
endpoint whenever a request fails restores that self-healing.
|
|
478
|
+
"""
|
|
479
|
+
transport = self._transport
|
|
480
|
+
self._transport = None
|
|
481
|
+
self._protocol = None
|
|
482
|
+
self._endpoint_addr = None
|
|
483
|
+
if transport is not None:
|
|
484
|
+
transport.close()
|
|
485
|
+
_LOGGER.debug("%s: endpoint dropped after a failure", self.host[0])
|
|
486
|
+
|
|
410
487
|
async def _endpoint(self) -> tuple[asyncio.DatagramTransport, _Protocol]:
|
|
411
488
|
if self._transport is not None and self._endpoint_addr != self.host:
|
|
412
489
|
# The caller changed host; the connected socket points at the
|
|
@@ -510,6 +587,22 @@ class Device:
|
|
|
510
587
|
raise e.EndpointClosedError(
|
|
511
588
|
-4013, "Endpoint closed", "The device endpoint was closed"
|
|
512
589
|
)
|
|
590
|
+
if _is_silence(resp):
|
|
591
|
+
# ICMP unreachable of one kind or another: the host is up
|
|
592
|
+
# with nothing listening, the device is off or rebooting,
|
|
593
|
+
# or a router answered for it. The original library's
|
|
594
|
+
# unconnected socket never saw these, so keep waiting and
|
|
595
|
+
# let the timeout decide, as it did.
|
|
596
|
+
_LOGGER.debug(
|
|
597
|
+
"%s: unreachable (%s), still waiting", self.host[0], resp
|
|
598
|
+
)
|
|
599
|
+
continue
|
|
600
|
+
if isinstance(resp, Exception):
|
|
601
|
+
# A send failure (no route, address gone) or a fatal
|
|
602
|
+
# transport error: fail now and throw the socket away.
|
|
603
|
+
_LOGGER.debug("%s: socket error: %s", self.host[0], resp)
|
|
604
|
+
self._drop_endpoint()
|
|
605
|
+
raise resp
|
|
513
606
|
resp = self._validate(resp)
|
|
514
607
|
reply_count = int.from_bytes(resp[0x28:0x2A], "little")
|
|
515
608
|
if reply_count == count or reply_count not in self._recent:
|
|
@@ -521,6 +614,7 @@ class Device:
|
|
|
521
614
|
)
|
|
522
615
|
if loop.time() - start >= timeout:
|
|
523
616
|
_LOGGER.debug("%s: no reply within %ss", self.host[0], timeout)
|
|
617
|
+
self._drop_endpoint()
|
|
524
618
|
raise e.NetworkTimeoutError(
|
|
525
619
|
-4000,
|
|
526
620
|
"Network timeout",
|