python-broadlink 1.0.3__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.3 → python_broadlink-1.0.4}/CHANGELOG.md +53 -0
- {python_broadlink-1.0.3/python_broadlink.egg-info → python_broadlink-1.0.4}/PKG-INFO +17 -5
- {python_broadlink-1.0.3 → python_broadlink-1.0.4}/README.md +16 -4
- {python_broadlink-1.0.3 → python_broadlink-1.0.4}/broadlink/__init__.py +6 -6
- {python_broadlink-1.0.3 → python_broadlink-1.0.4}/broadlink/device.py +88 -14
- {python_broadlink-1.0.3 → python_broadlink-1.0.4}/broadlink/remote.py +10 -9
- {python_broadlink-1.0.3 → python_broadlink-1.0.4}/broadlink/sensor.py +27 -27
- {python_broadlink-1.0.3 → python_broadlink-1.0.4}/pyproject.toml +1 -1
- {python_broadlink-1.0.3 → python_broadlink-1.0.4/python_broadlink.egg-info}/PKG-INFO +17 -5
- {python_broadlink-1.0.3 → python_broadlink-1.0.4}/python_broadlink.egg-info/SOURCES.txt +1 -0
- {python_broadlink-1.0.3 → python_broadlink-1.0.4}/tests/oracle/fixtures.json +1 -1
- {python_broadlink-1.0.3 → python_broadlink-1.0.4}/tests/oracle/harness.py +7 -0
- python_broadlink-1.0.4/tests/test_loopback.py +110 -0
- {python_broadlink-1.0.3 → python_broadlink-1.0.4}/tests/test_transport.py +142 -8
- {python_broadlink-1.0.3 → python_broadlink-1.0.4}/LICENSE +0 -0
- {python_broadlink-1.0.3 → python_broadlink-1.0.4}/MANIFEST.in +0 -0
- {python_broadlink-1.0.3 → python_broadlink-1.0.4}/TROUBLESHOOTING.md +0 -0
- {python_broadlink-1.0.3 → python_broadlink-1.0.4}/broadlink/alarm.py +0 -0
- {python_broadlink-1.0.3 → python_broadlink-1.0.4}/broadlink/climate.py +0 -0
- {python_broadlink-1.0.3 → python_broadlink-1.0.4}/broadlink/const.py +0 -0
- {python_broadlink-1.0.3 → python_broadlink-1.0.4}/broadlink/cover.py +0 -0
- {python_broadlink-1.0.3 → python_broadlink-1.0.4}/broadlink/exceptions.py +0 -0
- {python_broadlink-1.0.3 → python_broadlink-1.0.4}/broadlink/helpers.py +0 -0
- {python_broadlink-1.0.3 → python_broadlink-1.0.4}/broadlink/hub.py +0 -0
- {python_broadlink-1.0.3 → python_broadlink-1.0.4}/broadlink/light.py +0 -0
- {python_broadlink-1.0.3 → python_broadlink-1.0.4}/broadlink/protocol.py +0 -0
- {python_broadlink-1.0.3 → python_broadlink-1.0.4}/broadlink/switch.py +0 -0
- {python_broadlink-1.0.3 → python_broadlink-1.0.4}/cli/README.md +0 -0
- {python_broadlink-1.0.3 → python_broadlink-1.0.4}/cli/broadlink_cli +0 -0
- {python_broadlink-1.0.3 → python_broadlink-1.0.4}/cli/broadlink_discovery +0 -0
- {python_broadlink-1.0.3 → python_broadlink-1.0.4}/protocol.md +0 -0
- {python_broadlink-1.0.3 → python_broadlink-1.0.4}/python_broadlink.egg-info/dependency_links.txt +0 -0
- {python_broadlink-1.0.3 → python_broadlink-1.0.4}/python_broadlink.egg-info/requires.txt +0 -0
- {python_broadlink-1.0.3 → python_broadlink-1.0.4}/python_broadlink.egg-info/top_level.txt +0 -0
- {python_broadlink-1.0.3 → python_broadlink-1.0.4}/setup.cfg +0 -0
- {python_broadlink-1.0.3 → python_broadlink-1.0.4}/tests/__init__.py +0 -0
- {python_broadlink-1.0.3 → python_broadlink-1.0.4}/tests/oracle/__init__.py +0 -0
- {python_broadlink-1.0.3 → python_broadlink-1.0.4}/tests/oracle/cases.py +0 -0
- {python_broadlink-1.0.3 → python_broadlink-1.0.4}/tests/oracle/record.py +0 -0
- {python_broadlink-1.0.3 → python_broadlink-1.0.4}/tests/test_capture.py +0 -0
- {python_broadlink-1.0.3 → python_broadlink-1.0.4}/tests/test_helpers.py +0 -0
- {python_broadlink-1.0.3 → python_broadlink-1.0.4}/tests/test_oracle.py +0 -0
- {python_broadlink-1.0.3 → python_broadlink-1.0.4}/tests/test_remote.py +0 -0
|
@@ -3,6 +3,59 @@
|
|
|
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
|
+
|
|
6
59
|
## 1.0.3 - 2026-09-06
|
|
7
60
|
|
|
8
61
|
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.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>
|
|
@@ -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():
|
|
@@ -233,9 +238,14 @@ await device.aclose()
|
|
|
233
238
|
|
|
234
239
|
The socket reopens by itself on the next call, so closing is cheap and
|
|
235
240
|
safe to do at any time. A request that is in flight when `aclose()` runs
|
|
236
|
-
fails with `EndpointClosedError`.
|
|
237
|
-
|
|
238
|
-
|
|
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.
|
|
239
249
|
|
|
240
250
|
The next steps depend on the type of device you want to control.
|
|
241
251
|
|
|
@@ -311,7 +321,9 @@ By default the window closes after the first signal. Pass
|
|
|
311
321
|
because the device holds only one code per learning session. A universal
|
|
312
322
|
remote has a single receiver, so only one capture window can be open on a
|
|
313
323
|
device at a time: opening a second one raises `CaptureInProgressError`
|
|
314
|
-
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
|
|
315
327
|
(`aclosing` above does it), otherwise it stays open until Python collects
|
|
316
328
|
the generator.
|
|
317
329
|
|
|
@@ -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():
|
|
@@ -202,9 +207,14 @@ await device.aclose()
|
|
|
202
207
|
|
|
203
208
|
The socket reopens by itself on the next call, so closing is cheap and
|
|
204
209
|
safe to do at any time. A request that is in flight when `aclose()` runs
|
|
205
|
-
fails with `EndpointClosedError`.
|
|
206
|
-
|
|
207
|
-
|
|
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.
|
|
208
218
|
|
|
209
219
|
The next steps depend on the type of device you want to control.
|
|
210
220
|
|
|
@@ -280,7 +290,9 @@ By default the window closes after the first signal. Pass
|
|
|
280
290
|
because the device holds only one code per learning session. A universal
|
|
281
291
|
remote has a single receiver, so only one capture window can be open on a
|
|
282
292
|
device at a time: opening a second one raises `CaptureInProgressError`
|
|
283
|
-
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
|
|
284
296
|
(`aclosing` above does it), otherwise it stays open until Python collects
|
|
285
297
|
the generator.
|
|
286
298
|
|
|
@@ -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
|
|
@@ -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.
|
|
@@ -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,12 +48,20 @@ _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
67
|
def connection_made(self, transport: asyncio.BaseTransport) -> None:
|
|
@@ -65,18 +73,30 @@ class _Protocol(asyncio.DatagramProtocol):
|
|
|
65
73
|
self.queue.put_nowait((data, addr))
|
|
66
74
|
|
|
67
75
|
def error_received(self, exc: Exception) -> None:
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
pass
|
|
76
|
+
"""Queue a send failure or an ICMP error for the waiting request."""
|
|
77
|
+
self.queue.put_nowait((exc, None))
|
|
71
78
|
|
|
72
79
|
def connection_lost(self, exc: Exception | None) -> None:
|
|
73
|
-
"""
|
|
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)
|
|
74
82
|
|
|
75
83
|
def drain(self) -> None:
|
|
76
84
|
"""Drop anything that arrived before the current request."""
|
|
77
85
|
while not self.queue.empty():
|
|
78
86
|
self.queue.get_nowait()
|
|
79
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
|
+
|
|
80
100
|
|
|
81
101
|
async def _open_endpoint(
|
|
82
102
|
local_addr: tuple[str, int] | None = None,
|
|
@@ -95,6 +115,21 @@ async def _open_endpoint(
|
|
|
95
115
|
return transport, protocol # type: ignore[return-value]
|
|
96
116
|
|
|
97
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
|
+
|
|
98
133
|
def _hello_packet(local_ip_address: str, port: int) -> bytearray:
|
|
99
134
|
packet = bytearray(0x30)
|
|
100
135
|
packet[0x08:0x14] = Datetime.pack(Datetime.now())
|
|
@@ -119,13 +154,14 @@ async def scan(
|
|
|
119
154
|
local_ip_address: str | None = None,
|
|
120
155
|
discover_ip_address: str = DEFAULT_BCAST_ADDR,
|
|
121
156
|
discover_ip_port: int = DEFAULT_PORT,
|
|
122
|
-
) ->
|
|
157
|
+
) -> AsyncGenerator[HelloResponse]:
|
|
123
158
|
"""Broadcast a hello message and yield responses as they arrive.
|
|
124
159
|
|
|
125
160
|
The hello is repeated every ``DEFAULT_RETRY_INTVL`` seconds until
|
|
126
161
|
``timeout`` elapses. Each device is yielded once.
|
|
127
162
|
"""
|
|
128
163
|
local_addr = (local_ip_address, 0) if local_ip_address else None
|
|
164
|
+
target = await _resolve(discover_ip_address, discover_ip_port)
|
|
129
165
|
transport, protocol = await _open_endpoint(local_addr=local_addr, broadcast=True)
|
|
130
166
|
try:
|
|
131
167
|
if local_ip_address:
|
|
@@ -140,7 +176,7 @@ async def scan(
|
|
|
140
176
|
discovered: set[tuple[tuple[str, int], bytes, int]] = set()
|
|
141
177
|
|
|
142
178
|
while (loop.time() - start) < timeout:
|
|
143
|
-
transport.sendto(packet,
|
|
179
|
+
transport.sendto(packet, target)
|
|
144
180
|
deadline = min(DEFAULT_RETRY_INTVL, timeout - (loop.time() - start))
|
|
145
181
|
slot_end = loop.time() + deadline
|
|
146
182
|
while True:
|
|
@@ -151,7 +187,11 @@ async def scan(
|
|
|
151
187
|
resp, host = await asyncio.wait_for(protocol.queue.get(), remaining)
|
|
152
188
|
except TimeoutError:
|
|
153
189
|
break
|
|
154
|
-
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:
|
|
155
195
|
continue
|
|
156
196
|
entry = _parse_hello(resp, host)
|
|
157
197
|
key = (entry[1], entry[2], entry[0])
|
|
@@ -167,9 +207,11 @@ async def send_setup_packet(
|
|
|
167
207
|
payload: bytes, ip_address: str, port: int = DEFAULT_PORT
|
|
168
208
|
) -> None:
|
|
169
209
|
"""Broadcast one Wi-Fi provisioning packet to a device in AP mode."""
|
|
170
|
-
|
|
210
|
+
target = await _resolve(ip_address, port)
|
|
211
|
+
transport, protocol = await _open_endpoint(broadcast=True)
|
|
171
212
|
try:
|
|
172
|
-
transport.sendto(payload,
|
|
213
|
+
transport.sendto(payload, target)
|
|
214
|
+
protocol.raise_if_error()
|
|
173
215
|
finally:
|
|
174
216
|
transport.close()
|
|
175
217
|
|
|
@@ -181,11 +223,13 @@ async def ping(ip_address: str, port: int = DEFAULT_PORT) -> None:
|
|
|
181
223
|
Useful to prevent reboots when the cloud cannot be reached.
|
|
182
224
|
It must be sent every 2 minutes in such cases.
|
|
183
225
|
"""
|
|
184
|
-
|
|
226
|
+
target = await _resolve(ip_address, port)
|
|
227
|
+
transport, protocol = await _open_endpoint(broadcast=True)
|
|
185
228
|
try:
|
|
186
229
|
packet = bytearray(0x30)
|
|
187
230
|
packet[0x26] = 1
|
|
188
|
-
transport.sendto(packet,
|
|
231
|
+
transport.sendto(packet, target)
|
|
232
|
+
protocol.raise_if_error()
|
|
189
233
|
finally:
|
|
190
234
|
transport.close()
|
|
191
235
|
|
|
@@ -407,6 +451,22 @@ class Device:
|
|
|
407
451
|
if protocol is not None:
|
|
408
452
|
protocol.queue.put_nowait(_CLOSED) # type: ignore[arg-type]
|
|
409
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
|
+
|
|
410
470
|
async def _endpoint(self) -> tuple[asyncio.DatagramTransport, _Protocol]:
|
|
411
471
|
if self._transport is not None and self._endpoint_addr != self.host:
|
|
412
472
|
# The caller changed host; the connected socket points at the
|
|
@@ -510,6 +570,19 @@ class Device:
|
|
|
510
570
|
raise e.EndpointClosedError(
|
|
511
571
|
-4013, "Endpoint closed", "The device endpoint was closed"
|
|
512
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
|
|
513
586
|
resp = self._validate(resp)
|
|
514
587
|
reply_count = int.from_bytes(resp[0x28:0x2A], "little")
|
|
515
588
|
if reply_count == count or reply_count not in self._recent:
|
|
@@ -521,6 +594,7 @@ class Device:
|
|
|
521
594
|
)
|
|
522
595
|
if loop.time() - start >= timeout:
|
|
523
596
|
_LOGGER.debug("%s: no reply within %ss", self.host[0], timeout)
|
|
597
|
+
self._drop_endpoint()
|
|
524
598
|
raise e.NetworkTimeoutError(
|
|
525
599
|
-4000,
|
|
526
600
|
"Network timeout",
|
|
@@ -6,7 +6,7 @@ import logging
|
|
|
6
6
|
import struct
|
|
7
7
|
import time
|
|
8
8
|
import weakref
|
|
9
|
-
from collections.abc import
|
|
9
|
+
from collections.abc import AsyncGenerator, Awaitable, Callable
|
|
10
10
|
from dataclasses import dataclass, field
|
|
11
11
|
from typing import Self
|
|
12
12
|
|
|
@@ -18,9 +18,10 @@ _LOGGER = logging.getLogger(__name__)
|
|
|
18
18
|
TICK = 8192 / 269
|
|
19
19
|
"""Duration of one Broadlink timing unit in microseconds (about 30.45 us).
|
|
20
20
|
|
|
21
|
-
The
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
The value comes from protocol.md, whose conversion "us * 269 / 8192 works
|
|
22
|
+
very well" was measured against real firmware; 8192/269 is its inverse.
|
|
23
|
+
Earlier releases used 32.84, the right ratio applied the wrong way round,
|
|
24
|
+
which compressed externally sourced IR codes by about 7 percent
|
|
24
25
|
(mjg59/python-broadlink#839). Codes learned and replayed through the same
|
|
25
26
|
device were unaffected because both directions shared the constant.
|
|
26
27
|
"""
|
|
@@ -176,7 +177,7 @@ class CapturedSignal:
|
|
|
176
177
|
repeat: int = 0
|
|
177
178
|
frequency_mhz: float | None = None
|
|
178
179
|
type_byte: int | None = None
|
|
179
|
-
captured_at: float = field(default_factory=time.time, repr=False)
|
|
180
|
+
captured_at: float = field(default_factory=time.time, repr=False, compare=False)
|
|
180
181
|
|
|
181
182
|
@classmethod
|
|
182
183
|
def from_packet(
|
|
@@ -300,7 +301,7 @@ class rmmini(Device):
|
|
|
300
301
|
stop_after_first: bool = True,
|
|
301
302
|
poll_interval: float = DEFAULT_POLL_INTERVAL,
|
|
302
303
|
rearm_interval: float = DEFAULT_REARM_INTERVAL,
|
|
303
|
-
) ->
|
|
304
|
+
) -> AsyncGenerator[CapturedSignal]:
|
|
304
305
|
"""Open an infrared capture window and yield what the device hears.
|
|
305
306
|
|
|
306
307
|
The device is put into learning mode and polled every
|
|
@@ -348,7 +349,7 @@ class rmmini(Device):
|
|
|
348
349
|
frequency_mhz: float | None,
|
|
349
350
|
*,
|
|
350
351
|
claim: list | None = None,
|
|
351
|
-
) ->
|
|
352
|
+
) -> AsyncGenerator[CapturedSignal]:
|
|
352
353
|
# ``claim`` carries a weak reference to this generator (filled in by
|
|
353
354
|
# the caller after creating it); None means the caller owns the
|
|
354
355
|
# window claim, as capture_rf does for its inner loop.
|
|
@@ -457,7 +458,7 @@ class rmpro(rmmini):
|
|
|
457
458
|
stop_after_first: bool = True,
|
|
458
459
|
poll_interval: float = DEFAULT_POLL_INTERVAL,
|
|
459
460
|
rearm_interval: float = DEFAULT_REARM_INTERVAL,
|
|
460
|
-
) ->
|
|
461
|
+
) -> AsyncGenerator[CapturedSignal]:
|
|
461
462
|
"""Open a radio frequency capture window and yield what the device hears.
|
|
462
463
|
|
|
463
464
|
With ``frequency`` (in MHz, for example 433.92) the device goes
|
|
@@ -496,7 +497,7 @@ class rmpro(rmmini):
|
|
|
496
497
|
rearm_interval: float,
|
|
497
498
|
*,
|
|
498
499
|
claim: list,
|
|
499
|
-
) ->
|
|
500
|
+
) -> AsyncGenerator[CapturedSignal]:
|
|
500
501
|
if window < 0 or poll_interval <= 0:
|
|
501
502
|
raise ValueError("window must be 0 or positive, poll_interval positive")
|
|
502
503
|
await self._claim_window(claim[0])
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Support for sensors."""
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
import struct
|
|
4
4
|
|
|
5
5
|
from . import exceptions as e
|
|
6
6
|
from .device import Device
|
|
@@ -48,35 +48,35 @@ class a2(Device):
|
|
|
48
48
|
|
|
49
49
|
TYPE = "A2"
|
|
50
50
|
|
|
51
|
-
async def _send(self, operation: int, data:
|
|
52
|
-
"""Send a command to the device.
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
packet
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
packet
|
|
74
|
-
packet[
|
|
51
|
+
async def _send(self, operation: int, data: bytes = b"") -> bytes:
|
|
52
|
+
"""Send a command to the device.
|
|
53
|
+
|
|
54
|
+
The frame is the one the SP4 and LB1 families use: a two-byte
|
|
55
|
+
length, the A5A5 5A5A marker, a checksum, the operation, 0x0B and
|
|
56
|
+
a four-byte data length. The 0.19.0 code wrote a two-byte data
|
|
57
|
+
length and a length field two short, and real A2 units answered
|
|
58
|
+
every request with error -5 (mjg59/python-broadlink#826).
|
|
59
|
+
"""
|
|
60
|
+
packet = bytearray(14)
|
|
61
|
+
struct.pack_into(
|
|
62
|
+
"<HHHHBBI",
|
|
63
|
+
packet,
|
|
64
|
+
0,
|
|
65
|
+
12 + len(data),
|
|
66
|
+
0xA5A5,
|
|
67
|
+
0x5A5A,
|
|
68
|
+
0,
|
|
69
|
+
operation,
|
|
70
|
+
0x0B,
|
|
71
|
+
len(data),
|
|
72
|
+
)
|
|
73
|
+
packet.extend(data)
|
|
74
|
+
checksum = sum(packet[0x02:], 0xBEAF) & 0xFFFF
|
|
75
|
+
packet[0x06:0x08] = checksum.to_bytes(2, "little")
|
|
75
76
|
|
|
76
77
|
resp = await self.send_packet(0x6A, packet)
|
|
77
78
|
e.check_error(resp[0x22:0x24])
|
|
78
|
-
|
|
79
|
-
return payload
|
|
79
|
+
return self.decrypt(resp[0x38:])
|
|
80
80
|
|
|
81
81
|
async def check_sensors_raw(self) -> dict:
|
|
82
82
|
"""Return the state of the sensors in raw format."""
|
|
@@ -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>
|
|
@@ -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():
|
|
@@ -233,9 +238,14 @@ await device.aclose()
|
|
|
233
238
|
|
|
234
239
|
The socket reopens by itself on the next call, so closing is cheap and
|
|
235
240
|
safe to do at any time. A request that is in flight when `aclose()` runs
|
|
236
|
-
fails with `EndpointClosedError`.
|
|
237
|
-
|
|
238
|
-
|
|
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.
|
|
239
249
|
|
|
240
250
|
The next steps depend on the type of device you want to control.
|
|
241
251
|
|
|
@@ -311,7 +321,9 @@ By default the window closes after the first signal. Pass
|
|
|
311
321
|
because the device holds only one code per learning session. A universal
|
|
312
322
|
remote has a single receiver, so only one capture window can be open on a
|
|
313
323
|
device at a time: opening a second one raises `CaptureInProgressError`
|
|
314
|
-
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
|
|
315
327
|
(`aclosing` above does it), otherwise it stays open until Python collects
|
|
316
328
|
the generator.
|
|
317
329
|
|
|
@@ -18,6 +18,13 @@ responses. Results are normalized to plain JSON so they can be stored.
|
|
|
18
18
|
|
|
19
19
|
The runner accepts awaitables so the same cases can drive an asynchronous
|
|
20
20
|
``send_packet`` later without changing the cases.
|
|
21
|
+
|
|
22
|
+
Deliberate departures from 0.19.0, re-recorded on purpose and reviewed in
|
|
23
|
+
the pull request that made them:
|
|
24
|
+
|
|
25
|
+
- ``a2.check_sensors_raw`` (1.0.4): the request frame follows the SP4/LB1
|
|
26
|
+
layout (length 12, four-byte data length) instead of the 0.19.0 frame
|
|
27
|
+
the device rejected with error -5. Upstream #826.
|
|
21
28
|
"""
|
|
22
29
|
|
|
23
30
|
from __future__ import annotations
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""The real datagram path, on loopback.
|
|
2
|
+
|
|
3
|
+
Every other transport test replaces ``_open_endpoint`` with a fake, so the
|
|
4
|
+
lines that talk to asyncio's real datagram transport (``_Protocol``,
|
|
5
|
+
``_open_endpoint``) are only exercised here. A small fake device answers on
|
|
6
|
+
127.0.0.1 with real sockets.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import socket
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
import pytest
|
|
16
|
+
|
|
17
|
+
from broadlink import device as device_module
|
|
18
|
+
from broadlink import exceptions as e
|
|
19
|
+
from broadlink.device import Device
|
|
20
|
+
from tests.oracle.harness import MAC, make_response
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class FakeDevice(asyncio.DatagramProtocol):
|
|
24
|
+
"""Answers every request frame with a canned payload, counter echoed."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, dev: Device, payload: bytes) -> None:
|
|
27
|
+
self.dev = dev
|
|
28
|
+
self.payload = payload
|
|
29
|
+
self.received: list[bytes] = []
|
|
30
|
+
self.transport: asyncio.DatagramTransport | None = None
|
|
31
|
+
|
|
32
|
+
def connection_made(self, transport) -> None:
|
|
33
|
+
self.transport = transport
|
|
34
|
+
|
|
35
|
+
def datagram_received(self, data: bytes, addr) -> None:
|
|
36
|
+
self.received.append(data)
|
|
37
|
+
frame = bytearray(make_response(self.dev, self.payload))
|
|
38
|
+
frame[0x28:0x2A] = data[0x28:0x2A]
|
|
39
|
+
checksum = sum(frame, 0xBEAF) - sum(frame[0x20:0x22]) & 0xFFFF
|
|
40
|
+
frame[0x20:0x22] = checksum.to_bytes(2, "little")
|
|
41
|
+
assert self.transport is not None
|
|
42
|
+
self.transport.sendto(bytes(frame), addr)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def start_fake(dev: Device, payload: bytes):
|
|
46
|
+
loop = asyncio.get_running_loop()
|
|
47
|
+
transport, protocol = await loop.create_datagram_endpoint(
|
|
48
|
+
lambda: FakeDevice(dev, payload), local_addr=("127.0.0.1", 0)
|
|
49
|
+
)
|
|
50
|
+
return transport, protocol, transport.get_extra_info("sockname")[:2]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def unused_udp_port() -> int:
|
|
54
|
+
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
|
|
55
|
+
sock.bind(("127.0.0.1", 0))
|
|
56
|
+
return sock.getsockname()[1]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_request_and_concurrent_requests_over_real_sockets():
|
|
60
|
+
async def go():
|
|
61
|
+
dev = Device(("127.0.0.1", 1), MAC, 0x2737, name="Loopback")
|
|
62
|
+
transport, fake, addr = await start_fake(dev, bytes([7]) + bytes(15))
|
|
63
|
+
dev.host = addr
|
|
64
|
+
try:
|
|
65
|
+
async with dev:
|
|
66
|
+
resp = await dev.send_packet(0x6A, b"")
|
|
67
|
+
assert dev.decrypt(resp[0x38:])[0] == 7
|
|
68
|
+
results = await asyncio.gather(
|
|
69
|
+
*(dev.send_packet(0x6A, bytes([i])) for i in range(20))
|
|
70
|
+
)
|
|
71
|
+
assert all(dev.decrypt(r[0x38:])[0] == 7 for r in results)
|
|
72
|
+
assert isinstance(dev._protocol, device_module._Protocol)
|
|
73
|
+
assert dev._transport is None
|
|
74
|
+
return len(fake.received)
|
|
75
|
+
finally:
|
|
76
|
+
transport.close()
|
|
77
|
+
|
|
78
|
+
assert asyncio.run(go()) == 21
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@pytest.mark.skipif(sys.platform == "win32", reason="ICMP errors surface differently")
|
|
82
|
+
def test_endpoint_heals_after_a_socket_error_on_loopback(caplog):
|
|
83
|
+
"""A request to a loopback port nobody listens on draws an ICMP port
|
|
84
|
+
unreachable, which the connected socket reports on its next read. That
|
|
85
|
+
one is treated like silence (the original's unconnected socket never
|
|
86
|
+
saw it) so the request times out as before, but it must have reached
|
|
87
|
+
the protocol, and the device must reopen its socket for the next call
|
|
88
|
+
instead of reusing the dead one."""
|
|
89
|
+
caplog.set_level("DEBUG", logger="broadlink.device")
|
|
90
|
+
|
|
91
|
+
async def go():
|
|
92
|
+
dev = Device(("127.0.0.1", unused_udp_port()), MAC, 0x2737, name="Loopback")
|
|
93
|
+
dev.timeout = 0.3
|
|
94
|
+
try:
|
|
95
|
+
with pytest.raises(e.NetworkTimeoutError):
|
|
96
|
+
await dev.send_packet(0x6A, b"")
|
|
97
|
+
assert dev._transport is None
|
|
98
|
+
# Point it at a live fake device: the next call opens a new socket.
|
|
99
|
+
transport, _, addr = await start_fake(dev, bytes([9]) + bytes(15))
|
|
100
|
+
try:
|
|
101
|
+
dev.host = addr
|
|
102
|
+
resp = await dev.send_packet(0x6A, b"")
|
|
103
|
+
return dev.decrypt(resp[0x38:])[0]
|
|
104
|
+
finally:
|
|
105
|
+
transport.close()
|
|
106
|
+
finally:
|
|
107
|
+
await dev.aclose()
|
|
108
|
+
|
|
109
|
+
assert asyncio.run(go()) == 9
|
|
110
|
+
assert "port unreachable" in caplog.text
|
|
@@ -233,22 +233,52 @@ def stamped(dev: Device, payload: bytes, count: int, error: int = 0) -> bytes:
|
|
|
233
233
|
return bytes(frame)
|
|
234
234
|
|
|
235
235
|
|
|
236
|
-
def
|
|
237
|
-
"""
|
|
238
|
-
|
|
239
|
-
|
|
236
|
+
def test_timed_out_request_drops_its_endpoint(net):
|
|
237
|
+
"""A request that times out throws its socket away, as 0.19.0 did by
|
|
238
|
+
opening one per call, so a socket that has gone bad heals on the next
|
|
239
|
+
call and a late reply to the timed-out request lands on a port nobody
|
|
240
|
+
listens to any more."""
|
|
240
241
|
dev = fixed_device()
|
|
241
242
|
dev.timeout = 0.02
|
|
242
243
|
|
|
243
244
|
async def go():
|
|
244
245
|
await dev._endpoint()
|
|
245
|
-
|
|
246
|
+
first = net.endpoints[-1]
|
|
246
247
|
with pytest.raises(e.NetworkTimeoutError):
|
|
247
|
-
await dev.send_packet(0x6A, b"")
|
|
248
|
+
await dev.send_packet(0x6A, b"")
|
|
249
|
+
assert first.closed
|
|
250
|
+
assert dev._transport is None
|
|
251
|
+
# The late answer arrives on the old socket; the next request opens
|
|
252
|
+
# a new one and only sees its own reply.
|
|
253
|
+
late = stamped(dev, bytes([1]) + bytes(15), 0x8001)
|
|
254
|
+
first.protocol.queue.put_nowait((late, HOST))
|
|
255
|
+
dev.timeout = 1
|
|
256
|
+
net.replies = [(stamped(dev, bytes([2]) + bytes(15), 0x8002), HOST)]
|
|
257
|
+
resp = await dev.send_packet(0x6A, b"")
|
|
258
|
+
assert net.endpoints[-1] is not first
|
|
259
|
+
return dev.decrypt(resp[0x38:])[0]
|
|
260
|
+
|
|
261
|
+
assert run(go()) == 2
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def test_late_reply_to_cancelled_request_is_not_taken_as_next_reply(net):
|
|
265
|
+
"""The endpoint survives a cancelled request, so a slow answer to it can
|
|
266
|
+
arrive while the next request is waiting on the same socket. It must
|
|
267
|
+
not be returned as the answer to that request."""
|
|
268
|
+
dev = fixed_device()
|
|
269
|
+
|
|
270
|
+
async def go():
|
|
271
|
+
await dev._endpoint()
|
|
272
|
+
ep = net.endpoints[-1]
|
|
273
|
+
task = asyncio.get_running_loop().create_task(dev.send_packet(0x6A, b""))
|
|
274
|
+
await asyncio.sleep(0.005) # request 1 (count 0x8001) is on the wire
|
|
275
|
+
task.cancel()
|
|
276
|
+
with pytest.raises(asyncio.CancelledError):
|
|
277
|
+
await task
|
|
278
|
+
assert dev._transport is not None
|
|
248
279
|
# Its late reply lands while request 2 (count 0x8002) is waiting.
|
|
249
280
|
late = stamped(dev, bytes([1]) + bytes(15), 0x8001)
|
|
250
281
|
good = stamped(dev, bytes([2]) + bytes(15), 0x8002)
|
|
251
|
-
ep.replies = [late, good] and []
|
|
252
282
|
ep.protocol.queue.put_nowait((late, HOST))
|
|
253
283
|
|
|
254
284
|
async def answer_later():
|
|
@@ -256,7 +286,6 @@ def test_late_reply_to_timed_out_request_is_not_taken_as_next_reply(net):
|
|
|
256
286
|
ep.protocol.queue.put_nowait((good, HOST))
|
|
257
287
|
|
|
258
288
|
asyncio.get_running_loop().create_task(answer_later())
|
|
259
|
-
dev.timeout = 1
|
|
260
289
|
resp = await dev.send_packet(0x6A, b"")
|
|
261
290
|
return dev.decrypt(resp[0x38:])[0]
|
|
262
291
|
|
|
@@ -424,6 +453,53 @@ def test_host_change_reopens_endpoint(net):
|
|
|
424
453
|
assert net.endpoints[0].closed
|
|
425
454
|
|
|
426
455
|
|
|
456
|
+
def test_send_failure_fails_the_request_fast_and_heals(net):
|
|
457
|
+
"""A connected socket whose send fails (route gone, address changed)
|
|
458
|
+
reports it through error_received. The request must fail with that
|
|
459
|
+
OSError at once, not after the timeout, and the next call must get a
|
|
460
|
+
fresh socket rather than the dead one."""
|
|
461
|
+
dev = fixed_device()
|
|
462
|
+
dev.timeout = 5
|
|
463
|
+
|
|
464
|
+
async def go():
|
|
465
|
+
await dev._endpoint()
|
|
466
|
+
bad = net.endpoints[-1]
|
|
467
|
+
|
|
468
|
+
def failing_sendto(data, addr=None):
|
|
469
|
+
bad.protocol.error_received(OSError(101, "Network is unreachable"))
|
|
470
|
+
|
|
471
|
+
bad.sendto = failing_sendto
|
|
472
|
+
t0 = asyncio.get_running_loop().time()
|
|
473
|
+
with pytest.raises(OSError) as err:
|
|
474
|
+
await dev.send_packet(0x6A, b"")
|
|
475
|
+
assert err.value.errno == 101
|
|
476
|
+
assert asyncio.get_running_loop().time() - t0 < 1.0
|
|
477
|
+
assert bad.closed
|
|
478
|
+
assert dev._transport is None
|
|
479
|
+
net.replies = [(make_response(dev, b""), HOST)]
|
|
480
|
+
await dev.send_packet(0x6A, b"")
|
|
481
|
+
return net.endpoints[-1] is not bad
|
|
482
|
+
|
|
483
|
+
assert run(go())
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def test_transport_lost_with_error_wakes_the_request(net):
|
|
487
|
+
"""If asyncio closes the transport from its side, the waiting request
|
|
488
|
+
is told instead of waiting out its timeout."""
|
|
489
|
+
dev = fixed_device()
|
|
490
|
+
dev.timeout = 5
|
|
491
|
+
|
|
492
|
+
async def go():
|
|
493
|
+
task = asyncio.get_running_loop().create_task(dev.send_packet(0x6A, b""))
|
|
494
|
+
await asyncio.sleep(0.005)
|
|
495
|
+
net.endpoints[-1].protocol.connection_lost(OSError(22, "Invalid argument"))
|
|
496
|
+
with pytest.raises(OSError) as err:
|
|
497
|
+
await task
|
|
498
|
+
return err.value.errno
|
|
499
|
+
|
|
500
|
+
assert run(go()) == 22
|
|
501
|
+
|
|
502
|
+
|
|
427
503
|
# ------------------------------------------------------------------------- auth
|
|
428
504
|
|
|
429
505
|
|
|
@@ -768,6 +844,64 @@ def test_ping_packet(net):
|
|
|
768
844
|
assert net.endpoints[-1].closed
|
|
769
845
|
|
|
770
846
|
|
|
847
|
+
def test_unresolvable_hostname_raises_at_once(net, monkeypatch):
|
|
848
|
+
"""0.19.0 raised socket.gaierror from sendto for a bad hostname. The
|
|
849
|
+
async version resolves the name off the loop first and lets the same
|
|
850
|
+
error through, instead of waiting out the timeout (hello) or sending
|
|
851
|
+
nothing and returning (ping)."""
|
|
852
|
+
|
|
853
|
+
async def no_such_host(host, port):
|
|
854
|
+
raise socket.gaierror(socket.EAI_NONAME, "Name or service not known")
|
|
855
|
+
|
|
856
|
+
monkeypatch.setattr(device_module, "_resolve", no_such_host)
|
|
857
|
+
|
|
858
|
+
async def go():
|
|
859
|
+
loop = asyncio.get_running_loop()
|
|
860
|
+
t0 = loop.time()
|
|
861
|
+
with pytest.raises(socket.gaierror):
|
|
862
|
+
await broadlink.hello("nonexistent.invalid", timeout=5)
|
|
863
|
+
with pytest.raises(socket.gaierror):
|
|
864
|
+
await broadlink.ping("nonexistent.invalid")
|
|
865
|
+
with pytest.raises(socket.gaierror):
|
|
866
|
+
await broadlink.setup("ssid", "pass", 3, ip_address="nonexistent.invalid")
|
|
867
|
+
return loop.time() - t0
|
|
868
|
+
|
|
869
|
+
assert run(go()) < 1.0
|
|
870
|
+
assert net.endpoints == [] # nothing was opened for a name that failed
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
def test_resolve_returns_a_numeric_address():
|
|
874
|
+
assert run(device_module._resolve("127.0.0.1", 80)) == ("127.0.0.1", 80)
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
def test_send_failure_on_ping_and_setup_is_raised(net, monkeypatch):
|
|
878
|
+
"""ping and setup fire one datagram and do not wait for a reply; a send
|
|
879
|
+
failure still has to reach the caller, as it did from a plain socket."""
|
|
880
|
+
ep_holder = []
|
|
881
|
+
original = net.__call__
|
|
882
|
+
|
|
883
|
+
async def go():
|
|
884
|
+
|
|
885
|
+
async def open_and_break(**kwargs):
|
|
886
|
+
transport, protocol = await original(**kwargs)
|
|
887
|
+
|
|
888
|
+
def failing_sendto(data, addr=None):
|
|
889
|
+
protocol.error_received(OSError(101, "Network is unreachable"))
|
|
890
|
+
|
|
891
|
+
transport.sendto = failing_sendto
|
|
892
|
+
ep_holder.append(transport)
|
|
893
|
+
return transport, protocol
|
|
894
|
+
|
|
895
|
+
monkeypatch.setattr(device_module, "_open_endpoint", open_and_break)
|
|
896
|
+
with pytest.raises(OSError):
|
|
897
|
+
await broadlink.ping("192.0.2.1")
|
|
898
|
+
with pytest.raises(OSError):
|
|
899
|
+
await broadlink.setup("ssid", "pass", 3, ip_address="192.0.2.255")
|
|
900
|
+
return all(ep.closed for ep in ep_holder)
|
|
901
|
+
|
|
902
|
+
assert run(go())
|
|
903
|
+
|
|
904
|
+
|
|
771
905
|
# ------------------------------------------------------------------ gendevice
|
|
772
906
|
|
|
773
907
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_broadlink-1.0.3 → python_broadlink-1.0.4}/python_broadlink.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|