python-broadlink 1.0.0__tar.gz → 1.0.1__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.0 → python_broadlink-1.0.1}/CHANGELOG.md +55 -5
- {python_broadlink-1.0.0/python_broadlink.egg-info → python_broadlink-1.0.1}/PKG-INFO +11 -4
- {python_broadlink-1.0.0 → python_broadlink-1.0.1}/README.md +10 -3
- {python_broadlink-1.0.0 → python_broadlink-1.0.1}/broadlink/device.py +86 -32
- {python_broadlink-1.0.0 → python_broadlink-1.0.1}/broadlink/remote.py +123 -62
- {python_broadlink-1.0.0 → python_broadlink-1.0.1}/pyproject.toml +1 -1
- {python_broadlink-1.0.0 → python_broadlink-1.0.1/python_broadlink.egg-info}/PKG-INFO +11 -4
- {python_broadlink-1.0.0 → python_broadlink-1.0.1}/tests/test_capture.py +74 -9
- {python_broadlink-1.0.0 → python_broadlink-1.0.1}/tests/test_transport.py +144 -0
- {python_broadlink-1.0.0 → python_broadlink-1.0.1}/LICENSE +0 -0
- {python_broadlink-1.0.0 → python_broadlink-1.0.1}/MANIFEST.in +0 -0
- {python_broadlink-1.0.0 → python_broadlink-1.0.1}/TROUBLESHOOTING.md +0 -0
- {python_broadlink-1.0.0 → python_broadlink-1.0.1}/broadlink/__init__.py +0 -0
- {python_broadlink-1.0.0 → python_broadlink-1.0.1}/broadlink/alarm.py +0 -0
- {python_broadlink-1.0.0 → python_broadlink-1.0.1}/broadlink/climate.py +0 -0
- {python_broadlink-1.0.0 → python_broadlink-1.0.1}/broadlink/const.py +0 -0
- {python_broadlink-1.0.0 → python_broadlink-1.0.1}/broadlink/cover.py +0 -0
- {python_broadlink-1.0.0 → python_broadlink-1.0.1}/broadlink/exceptions.py +0 -0
- {python_broadlink-1.0.0 → python_broadlink-1.0.1}/broadlink/helpers.py +0 -0
- {python_broadlink-1.0.0 → python_broadlink-1.0.1}/broadlink/hub.py +0 -0
- {python_broadlink-1.0.0 → python_broadlink-1.0.1}/broadlink/light.py +0 -0
- {python_broadlink-1.0.0 → python_broadlink-1.0.1}/broadlink/protocol.py +0 -0
- {python_broadlink-1.0.0 → python_broadlink-1.0.1}/broadlink/sensor.py +0 -0
- {python_broadlink-1.0.0 → python_broadlink-1.0.1}/broadlink/switch.py +0 -0
- {python_broadlink-1.0.0 → python_broadlink-1.0.1}/cli/README.md +0 -0
- {python_broadlink-1.0.0 → python_broadlink-1.0.1}/cli/broadlink_cli +0 -0
- {python_broadlink-1.0.0 → python_broadlink-1.0.1}/cli/broadlink_discovery +0 -0
- {python_broadlink-1.0.0 → python_broadlink-1.0.1}/protocol.md +0 -0
- {python_broadlink-1.0.0 → python_broadlink-1.0.1}/python_broadlink.egg-info/SOURCES.txt +0 -0
- {python_broadlink-1.0.0 → python_broadlink-1.0.1}/python_broadlink.egg-info/dependency_links.txt +0 -0
- {python_broadlink-1.0.0 → python_broadlink-1.0.1}/python_broadlink.egg-info/requires.txt +0 -0
- {python_broadlink-1.0.0 → python_broadlink-1.0.1}/python_broadlink.egg-info/top_level.txt +0 -0
- {python_broadlink-1.0.0 → python_broadlink-1.0.1}/setup.cfg +0 -0
- {python_broadlink-1.0.0 → python_broadlink-1.0.1}/tests/__init__.py +0 -0
- {python_broadlink-1.0.0 → python_broadlink-1.0.1}/tests/oracle/__init__.py +0 -0
- {python_broadlink-1.0.0 → python_broadlink-1.0.1}/tests/oracle/cases.py +0 -0
- {python_broadlink-1.0.0 → python_broadlink-1.0.1}/tests/oracle/fixtures.json +0 -0
- {python_broadlink-1.0.0 → python_broadlink-1.0.1}/tests/oracle/harness.py +0 -0
- {python_broadlink-1.0.0 → python_broadlink-1.0.1}/tests/oracle/record.py +0 -0
- {python_broadlink-1.0.0 → python_broadlink-1.0.1}/tests/test_helpers.py +0 -0
- {python_broadlink-1.0.0 → python_broadlink-1.0.1}/tests/test_oracle.py +0 -0
- {python_broadlink-1.0.0 → python_broadlink-1.0.1}/tests/test_remote.py +0 -0
|
@@ -3,6 +3,51 @@
|
|
|
3
3
|
All notable changes to this project are recorded here. The format follows
|
|
4
4
|
Keep a Changelog; versions follow Semantic Versioning.
|
|
5
5
|
|
|
6
|
+
## 1.0.1 - 2026-09-05
|
|
7
|
+
|
|
8
|
+
Fixes from an independent review of 1.0.0, most of them in the transport.
|
|
9
|
+
None changes the wire format or the public API.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- A reply to a request that had already timed out could be delivered as the
|
|
14
|
+
reply to the next request on the same device, because the persistent
|
|
15
|
+
endpoint (new in 1.0.0) is not thrown away between calls the way the old
|
|
16
|
+
per-call socket was. Replies are now matched to their request by the
|
|
17
|
+
packet counter the device echoes at offset 0x28; a reply carrying the
|
|
18
|
+
counter of a request that already timed out is discarded, and a reply
|
|
19
|
+
whose counter matches nothing the device sent is still accepted, so
|
|
20
|
+
firmware that does not echo the counter is unaffected. Confirmed on an
|
|
21
|
+
RM4 Pro, which echoes it.
|
|
22
|
+
- `capture()` treated only `StorageError` (-5) as "nothing captured yet".
|
|
23
|
+
Some firmware answers `ReadError` (-10); both are now treated as "nothing
|
|
24
|
+
yet", matching what the original CLI and Home Assistant do while polling.
|
|
25
|
+
The CLI's `--learn` and `--rflearn` inherit the fix.
|
|
26
|
+
- Abandoning a capture generator without closing it (for example `break`
|
|
27
|
+
out of `async for` to take one code) no longer blocks the next
|
|
28
|
+
`capture()` on the same device: opening a new window closes an abandoned
|
|
29
|
+
one. Opening a window while another is actively being iterated still
|
|
30
|
+
raises `CaptureInProgressError`. A new read-only `Device.capture_active`
|
|
31
|
+
property reports whether a window is open.
|
|
32
|
+
- Re-authentication is now shared between concurrent callers: when several
|
|
33
|
+
requests hit an expired session key at once, the library authenticates
|
|
34
|
+
once and every caller retries, instead of one caller re-authenticating
|
|
35
|
+
and the others surfacing the raw error. The logged-out code (-2) now
|
|
36
|
+
triggers re-authentication as well, matching Home Assistant's own retry.
|
|
37
|
+
- Changing `device.host` after the endpoint is open now reopens it against
|
|
38
|
+
the new address instead of continuing to talk to the old one.
|
|
39
|
+
- `aclose()` while a request is in flight fails that request at once with
|
|
40
|
+
`ConnectionClosedError` instead of waiting out the timeout.
|
|
41
|
+
|
|
42
|
+
### Documentation
|
|
43
|
+
|
|
44
|
+
- The README explains that `broadlink` and `python-broadlink` install the
|
|
45
|
+
same package name and cannot coexist, and how to recover if both were
|
|
46
|
+
installed.
|
|
47
|
+
- The changelog no longer describes the carried-over device commits as
|
|
48
|
+
"intact" (they were squash-merged with `Co-authored-by` credit) and no
|
|
49
|
+
longer overstates what the oracle records.
|
|
50
|
+
|
|
6
51
|
## 1.0.0 - 2026-09-05
|
|
7
52
|
|
|
8
53
|
This is the first release of `python-broadlink`, a maintained fork of
|
|
@@ -39,6 +84,8 @@ history below starts at that fork point.
|
|
|
39
84
|
#830).
|
|
40
85
|
- `pulses_to_data` returns `bytes` (it returned a `bytearray`, against its
|
|
41
86
|
own annotation).
|
|
87
|
+
- The device's request lock is now a private `_lock` that is actually
|
|
88
|
+
acquired; the unused public `Device.lock` attribute is gone.
|
|
42
89
|
- Packaging moved to `pyproject.toml`; `setup.py` and the stale
|
|
43
90
|
`requirements.txt` pin are gone. The distribution name is now
|
|
44
91
|
`python-broadlink`; the import name stays `broadlink`. Python 3.13 or
|
|
@@ -80,7 +127,8 @@ history below starts at that fork point.
|
|
|
80
127
|
read by band and a capture is tagged from what it armed rather than the
|
|
81
128
|
byte.
|
|
82
129
|
- Devices, carried over from pull requests against the original repository
|
|
83
|
-
with their authors
|
|
130
|
+
with their authors credited (the changes were squash-merged with
|
|
131
|
+
`Co-authored-by` trailers naming each author): RM Max 0xAF8B (#838, Alexey Masolov);
|
|
84
132
|
RM5 plus 0x5224 with a new `rm5plus` class (#831, Anil Daoud); RM mini 3
|
|
85
133
|
OEM 0xA544 (#823, Bartłomiej Nogaś); RM mini 3 CMCC 0x27C8 (#802,
|
|
86
134
|
shuxin); LB26 R1 0xA517 (#812, techitapart); SP mini 3-AL 0x7D15 (#805,
|
|
@@ -92,7 +140,9 @@ history below starts at that fork point.
|
|
|
92
140
|
issue if either does not behave.
|
|
93
141
|
- `cryptography` 43 or newer is required, the first release with wheels for
|
|
94
142
|
Python 3.13 (supersedes mjg59/python-broadlink#749).
|
|
95
|
-
- A test suite. The `tests/oracle` package records
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
can be checked
|
|
143
|
+
- A test suite. The `tests/oracle` package records, for every public method
|
|
144
|
+
of every device class, the request each one hands to the transport (its
|
|
145
|
+
packet type and plaintext payload) and the result it decodes from a canned
|
|
146
|
+
response, so that a later reimplementation can be checked against the
|
|
147
|
+
original method by method; the framing, encryption and checksum layer is
|
|
148
|
+
covered separately by `tests/test_transport.py`.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-broadlink
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.1
|
|
4
4
|
Summary: Python API for controlling Broadlink devices
|
|
5
5
|
Author: DAB-LABS
|
|
6
6
|
Author-email: Matthew Garrett <mjg59@srcf.ucam.org>
|
|
@@ -91,9 +91,16 @@ Use pip3 to install the latest version of this module.
|
|
|
91
91
|
pip3 install python-broadlink
|
|
92
92
|
```
|
|
93
93
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
94
|
+
Both this distribution and the original `broadlink` install a package named
|
|
95
|
+
`broadlink`, so only one can be present in an environment at a time. Pip
|
|
96
|
+
does not warn about this: installing one on top of the other appears to
|
|
97
|
+
succeed, and whichever was installed last is the one that `import broadlink`
|
|
98
|
+
finds. If both were installed, uninstall both (`pip3 uninstall broadlink
|
|
99
|
+
python-broadlink`) and reinstall this one, since `pip3 uninstall broadlink`
|
|
100
|
+
alone removes the shared files and leaves `python-broadlink` registered but
|
|
101
|
+
unimportable. This matters most where another package pins `broadlink`:
|
|
102
|
+
installing it into the same environment silently replaces this async
|
|
103
|
+
library with the original synchronous one.
|
|
97
104
|
|
|
98
105
|
## Basic functions
|
|
99
106
|
|
|
@@ -60,9 +60,16 @@ Use pip3 to install the latest version of this module.
|
|
|
60
60
|
pip3 install python-broadlink
|
|
61
61
|
```
|
|
62
62
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
63
|
+
Both this distribution and the original `broadlink` install a package named
|
|
64
|
+
`broadlink`, so only one can be present in an environment at a time. Pip
|
|
65
|
+
does not warn about this: installing one on top of the other appears to
|
|
66
|
+
succeed, and whichever was installed last is the one that `import broadlink`
|
|
67
|
+
finds. If both were installed, uninstall both (`pip3 uninstall broadlink
|
|
68
|
+
python-broadlink`) and reinstall this one, since `pip3 uninstall broadlink`
|
|
69
|
+
alone removes the shared files and leaves `python-broadlink` registered but
|
|
70
|
+
unimportable. This matters most where another package pins `broadlink`:
|
|
71
|
+
installing it into the same environment silently replaces this async
|
|
72
|
+
library with the original synchronous one.
|
|
66
73
|
|
|
67
74
|
## Basic functions
|
|
68
75
|
|
|
@@ -10,6 +10,7 @@ speaks unprompted, so each device keeps a single datagram endpoint and an
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
12
|
import asyncio
|
|
13
|
+
import collections
|
|
13
14
|
import random
|
|
14
15
|
import socket
|
|
15
16
|
from collections.abc import AsyncIterator
|
|
@@ -30,8 +31,17 @@ from .protocol import Datetime
|
|
|
30
31
|
HelloResponse = Tuple[int, Tuple[str, int], bytes, str, bool]
|
|
31
32
|
|
|
32
33
|
# Device error codes that mean the session key is no longer accepted and a
|
|
33
|
-
# fresh auth() will fix it. -
|
|
34
|
-
|
|
34
|
+
# fresh auth() will fix it. -2: logged out; -7: control key expired;
|
|
35
|
+
# -4012: control id error.
|
|
36
|
+
_REAUTH_CODES = {-2, -7, -4012}
|
|
37
|
+
|
|
38
|
+
# How many timed-out request counters to remember, so that a reply to one
|
|
39
|
+
# of them arriving late is recognised and dropped instead of being taken as
|
|
40
|
+
# the answer to a later request.
|
|
41
|
+
_ABANDONED_MAX = 32
|
|
42
|
+
|
|
43
|
+
_CLOSED = (None, None)
|
|
44
|
+
"""Sentinel put on the receive queue when the endpoint is closed."""
|
|
35
45
|
|
|
36
46
|
|
|
37
47
|
class _Protocol(asyncio.DatagramProtocol):
|
|
@@ -203,7 +213,10 @@ class Device:
|
|
|
203
213
|
self._lock: Optional[asyncio.Lock] = None
|
|
204
214
|
self._transport: Optional[asyncio.DatagramTransport] = None
|
|
205
215
|
self._protocol: Optional[_Protocol] = None
|
|
206
|
-
self.
|
|
216
|
+
self._endpoint_addr: Optional[Tuple[str, int]] = None
|
|
217
|
+
self._abandoned: collections.deque[int] = collections.deque(maxlen=_ABANDONED_MAX)
|
|
218
|
+
self._reauth_lock: Optional[asyncio.Lock] = None
|
|
219
|
+
self._auth_generation = 0
|
|
207
220
|
|
|
208
221
|
def __repr__(self) -> str:
|
|
209
222
|
"""Return a formal representation of the device."""
|
|
@@ -275,6 +288,7 @@ class Device:
|
|
|
275
288
|
|
|
276
289
|
self.id = int.from_bytes(payload[:0x4], "little")
|
|
277
290
|
self.update_aes(payload[0x04:0x14])
|
|
291
|
+
self._auth_generation += 1
|
|
278
292
|
return True
|
|
279
293
|
|
|
280
294
|
async def hello(self, local_ip_address=None) -> bool:
|
|
@@ -363,17 +377,30 @@ class Device:
|
|
|
363
377
|
# -------------------------------------------------------- transport
|
|
364
378
|
|
|
365
379
|
async def aclose(self) -> None:
|
|
366
|
-
"""Close the device's endpoint. It is reopened on the next call.
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
380
|
+
"""Close the device's endpoint. It is reopened on the next call.
|
|
381
|
+
|
|
382
|
+
A request in flight fails at once with ``ConnectionClosedError``
|
|
383
|
+
rather than waiting out its timeout.
|
|
384
|
+
"""
|
|
385
|
+
transport, protocol = self._transport, self._protocol
|
|
386
|
+
self._transport = None
|
|
387
|
+
self._protocol = None
|
|
388
|
+
self._endpoint_addr = None
|
|
389
|
+
if transport is not None:
|
|
390
|
+
transport.close()
|
|
391
|
+
if protocol is not None:
|
|
392
|
+
protocol.queue.put_nowait(_CLOSED) # type: ignore[arg-type]
|
|
371
393
|
|
|
372
394
|
async def _endpoint(self) -> tuple[asyncio.DatagramTransport, _Protocol]:
|
|
395
|
+
if self._transport is not None and self._endpoint_addr != self.host:
|
|
396
|
+
# The caller changed host; the connected socket points at the
|
|
397
|
+
# old address, so drop it.
|
|
398
|
+
await self.aclose()
|
|
373
399
|
if self._transport is None or self._transport.is_closing():
|
|
374
400
|
self._transport, self._protocol = await _open_endpoint(
|
|
375
401
|
remote_addr=self.host
|
|
376
402
|
)
|
|
403
|
+
self._endpoint_addr = self.host
|
|
377
404
|
return self._transport, self._protocol # type: ignore[return-value]
|
|
378
405
|
|
|
379
406
|
def _frame(self, packet_type: int, payload: bytes) -> bytes:
|
|
@@ -419,28 +446,53 @@ class Device:
|
|
|
419
446
|
return resp
|
|
420
447
|
|
|
421
448
|
async def _exchange(self, packet: bytes) -> bytes:
|
|
422
|
-
"""Send one frame and wait for
|
|
449
|
+
"""Send one frame and wait for its reply, resending on silence.
|
|
450
|
+
|
|
451
|
+
Replies carry the request's packet counter (offset 0x28), so a reply
|
|
452
|
+
is matched to the request by counter. A reply whose counter belongs
|
|
453
|
+
to a request that already timed out is dropped; one with a counter
|
|
454
|
+
this device has never sent is accepted, for firmware that may not
|
|
455
|
+
echo it.
|
|
456
|
+
"""
|
|
423
457
|
transport, protocol = await self._endpoint()
|
|
424
458
|
protocol.drain()
|
|
425
459
|
loop = asyncio.get_running_loop()
|
|
426
460
|
start = loop.time()
|
|
427
461
|
timeout = self.timeout
|
|
462
|
+
count = int.from_bytes(packet[0x28:0x2A], "little")
|
|
428
463
|
|
|
429
464
|
while True:
|
|
430
465
|
transport.sendto(packet)
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
466
|
+
resend_at = loop.time() + DEFAULT_RETRY_INTVL
|
|
467
|
+
while True:
|
|
468
|
+
now = loop.time()
|
|
469
|
+
if now - start >= timeout:
|
|
470
|
+
break
|
|
471
|
+
wait = min(resend_at, start + timeout) - now
|
|
472
|
+
try:
|
|
473
|
+
resp, _ = await asyncio.wait_for(protocol.queue.get(), max(wait, 0))
|
|
474
|
+
except asyncio.TimeoutError:
|
|
475
|
+
if loop.time() - start >= timeout:
|
|
476
|
+
break
|
|
477
|
+
if loop.time() >= resend_at:
|
|
478
|
+
break # Resend.
|
|
479
|
+
continue
|
|
480
|
+
if resp is None:
|
|
481
|
+
raise e.ConnectionClosedError(
|
|
482
|
+
-4013, "Connection closed", "The device endpoint was closed"
|
|
483
|
+
)
|
|
484
|
+
resp = self._validate(resp)
|
|
485
|
+
reply_count = int.from_bytes(resp[0x28:0x2A], "little")
|
|
486
|
+
if reply_count == count or reply_count not in self._abandoned:
|
|
487
|
+
return resp
|
|
488
|
+
# A late answer to a request we gave up on: keep waiting.
|
|
489
|
+
if loop.time() - start >= timeout:
|
|
490
|
+
self._abandoned.append(count)
|
|
491
|
+
raise e.NetworkTimeoutError(
|
|
492
|
+
-4000,
|
|
493
|
+
"Network timeout",
|
|
494
|
+
f"No response received within {timeout}s",
|
|
495
|
+
) from None
|
|
444
496
|
|
|
445
497
|
async def send_packet(
|
|
446
498
|
self, packet_type: int, payload: bytes, *, _reauth: bool = True
|
|
@@ -449,22 +501,24 @@ class Device:
|
|
|
449
501
|
|
|
450
502
|
If the device answers that the session key is no longer valid, the
|
|
451
503
|
session is re-authenticated once and the request is sent again.
|
|
504
|
+
Concurrent callers that hit the same expired key share one
|
|
505
|
+
re-authentication and each retry once.
|
|
452
506
|
"""
|
|
453
507
|
if self._lock is None:
|
|
454
508
|
self._lock = asyncio.Lock()
|
|
509
|
+
self._reauth_lock = asyncio.Lock()
|
|
510
|
+
generation = self._auth_generation
|
|
455
511
|
async with self._lock:
|
|
456
512
|
resp = await self._exchange(self._frame(packet_type, bytes(payload)))
|
|
457
513
|
|
|
458
|
-
if _reauth
|
|
514
|
+
if _reauth:
|
|
459
515
|
code = int.from_bytes(resp[0x22:0x24], "little", signed=True)
|
|
460
516
|
if code in _REAUTH_CODES:
|
|
461
|
-
self.
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
finally:
|
|
469
|
-
self._reauth_ok = True
|
|
517
|
+
async with self._reauth_lock: # type: ignore[union-attr]
|
|
518
|
+
if self._auth_generation == generation:
|
|
519
|
+
await self.auth()
|
|
520
|
+
async with self._lock:
|
|
521
|
+
resp = await self._exchange(
|
|
522
|
+
self._frame(packet_type, bytes(payload))
|
|
523
|
+
)
|
|
470
524
|
return resp
|
|
@@ -4,6 +4,7 @@ import asyncio
|
|
|
4
4
|
import enum
|
|
5
5
|
import struct
|
|
6
6
|
import time
|
|
7
|
+
import weakref
|
|
7
8
|
from dataclasses import dataclass, field
|
|
8
9
|
from typing import AsyncIterator, Awaitable, Callable, List, Optional, Tuple
|
|
9
10
|
|
|
@@ -214,7 +215,40 @@ class rmmini(Device):
|
|
|
214
215
|
# against the value it saw when it armed the device and re-arms
|
|
215
216
|
# after any send, since the device has one front end for both.
|
|
216
217
|
self._tx_generation = 0
|
|
217
|
-
|
|
218
|
+
# Weak reference to the async generator of the current capture
|
|
219
|
+
# window, if any. See _claim_window.
|
|
220
|
+
self._window: Optional[weakref.ReferenceType] = None
|
|
221
|
+
|
|
222
|
+
@property
|
|
223
|
+
def capture_active(self) -> bool:
|
|
224
|
+
"""True while a capture window is open on this device."""
|
|
225
|
+
window = self._window() if self._window is not None else None
|
|
226
|
+
return window is not None and window.ag_frame is not None
|
|
227
|
+
|
|
228
|
+
def _check_window(self) -> Optional[weakref.ReferenceType]:
|
|
229
|
+
"""Refuse a new window while another is being iterated; return the
|
|
230
|
+
reference to a previous window that the new one should close."""
|
|
231
|
+
old = self._window() if self._window is not None else None
|
|
232
|
+
if old is None or old.ag_frame is None:
|
|
233
|
+
return None
|
|
234
|
+
if old.ag_running:
|
|
235
|
+
raise e.CaptureInProgressError("A capture window is already open")
|
|
236
|
+
return self._window
|
|
237
|
+
|
|
238
|
+
async def _claim_window(self, prev: Optional[weakref.ReferenceType]) -> None:
|
|
239
|
+
"""Close a previous window whose consumer walked away from it.
|
|
240
|
+
|
|
241
|
+
A window whose consumer is still iterating it is live and a new one
|
|
242
|
+
is refused at call time (``_check_window``). One the consumer broke
|
|
243
|
+
out of without closing the generator is not live; it is closed here
|
|
244
|
+
so that it cannot block the device forever.
|
|
245
|
+
"""
|
|
246
|
+
old = prev() if prev is not None else None
|
|
247
|
+
if old is None or old.ag_frame is None:
|
|
248
|
+
return
|
|
249
|
+
if old.ag_running:
|
|
250
|
+
raise e.CaptureInProgressError("A capture window is already open")
|
|
251
|
+
await old.aclose()
|
|
218
252
|
|
|
219
253
|
async def _send(self, command: int, data: bytes = b"") -> bytes:
|
|
220
254
|
"""Send a packet to the device."""
|
|
@@ -266,10 +300,13 @@ class rmmini(Device):
|
|
|
266
300
|
and after every ``send_data`` on the same device. Closing the
|
|
267
301
|
generator sends nothing further; the device times out by itself.
|
|
268
302
|
Use ``contextlib.aclosing`` (or iterate to the end) so the window is
|
|
269
|
-
released promptly. Only one capture window can be open per device
|
|
270
|
-
|
|
303
|
+
released promptly. Only one capture window can be open per device:
|
|
304
|
+
opening one while another is being iterated raises
|
|
305
|
+
``CaptureInProgressError``, and opening one after breaking out of
|
|
306
|
+
another without closing it closes the old one.
|
|
271
307
|
"""
|
|
272
|
-
|
|
308
|
+
prev = self._check_window()
|
|
309
|
+
gen = self._capture_loop(
|
|
273
310
|
self.enter_learning,
|
|
274
311
|
window,
|
|
275
312
|
stop_after_first,
|
|
@@ -277,7 +314,10 @@ class rmmini(Device):
|
|
|
277
314
|
rearm_interval,
|
|
278
315
|
SignalKind.IR,
|
|
279
316
|
None,
|
|
317
|
+
prev=prev,
|
|
280
318
|
)
|
|
319
|
+
self._window = weakref.ref(gen)
|
|
320
|
+
return gen
|
|
281
321
|
|
|
282
322
|
async def _capture_loop(
|
|
283
323
|
self,
|
|
@@ -288,58 +328,60 @@ class rmmini(Device):
|
|
|
288
328
|
rearm_interval: float,
|
|
289
329
|
kind: SignalKind,
|
|
290
330
|
frequency_mhz: Optional[float],
|
|
331
|
+
*,
|
|
332
|
+
prev: Optional[weakref.ReferenceType] = None,
|
|
333
|
+
claim: bool = True,
|
|
291
334
|
) -> AsyncIterator[CapturedSignal]:
|
|
292
335
|
if window < 0:
|
|
293
336
|
raise ValueError("window must be 0 (open-ended) or positive")
|
|
294
337
|
if poll_interval <= 0 or rearm_interval <= 0:
|
|
295
338
|
raise ValueError("poll_interval and rearm_interval must be positive")
|
|
296
|
-
if
|
|
297
|
-
|
|
339
|
+
if claim:
|
|
340
|
+
await self._claim_window(prev)
|
|
298
341
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
342
|
+
loop = asyncio.get_running_loop()
|
|
343
|
+
deadline = loop.time() + window if window else None
|
|
344
|
+
timeouts = 0
|
|
345
|
+
|
|
346
|
+
await arm()
|
|
347
|
+
armed_at = loop.time()
|
|
348
|
+
generation = self._tx_generation
|
|
304
349
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
350
|
+
while True:
|
|
351
|
+
now = loop.time()
|
|
352
|
+
if deadline is not None and now >= deadline:
|
|
353
|
+
return
|
|
354
|
+
delay = poll_interval
|
|
355
|
+
if deadline is not None:
|
|
356
|
+
delay = min(delay, deadline - now)
|
|
357
|
+
await asyncio.sleep(delay)
|
|
308
358
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
359
|
+
try:
|
|
360
|
+
data = await self.check_data()
|
|
361
|
+
except (e.StorageError, e.ReadError):
|
|
362
|
+
# "Nothing yet": -5 on the RM4 Pro, -10 on some older
|
|
363
|
+
# firmware (upstream's CLI and Home Assistant tolerate
|
|
364
|
+
# both).
|
|
365
|
+
data = b""
|
|
366
|
+
except e.NetworkTimeoutError:
|
|
367
|
+
timeouts += 1
|
|
368
|
+
if timeouts >= 3:
|
|
369
|
+
raise
|
|
370
|
+
generation = -1 # Re-arm; the device's state is unknown.
|
|
371
|
+
continue
|
|
372
|
+
timeouts = 0
|
|
373
|
+
|
|
374
|
+
if data:
|
|
375
|
+
yield CapturedSignal.from_packet(data, frequency_mhz, kind=kind)
|
|
376
|
+
if stop_after_first:
|
|
312
377
|
return
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
except e.StorageError:
|
|
321
|
-
data = b"" # The device's answer for "nothing yet".
|
|
322
|
-
except e.NetworkTimeoutError:
|
|
323
|
-
timeouts += 1
|
|
324
|
-
if timeouts >= 3:
|
|
325
|
-
raise
|
|
326
|
-
generation = -1 # Re-arm; the device's state is unknown.
|
|
327
|
-
continue
|
|
328
|
-
timeouts = 0
|
|
329
|
-
|
|
330
|
-
if data:
|
|
331
|
-
yield CapturedSignal.from_packet(data, frequency_mhz, kind=kind)
|
|
332
|
-
if stop_after_first:
|
|
333
|
-
return
|
|
334
|
-
generation = -1 # One code per session: re-arm.
|
|
335
|
-
|
|
336
|
-
now = loop.time()
|
|
337
|
-
if generation != self._tx_generation or now - armed_at >= rearm_interval:
|
|
338
|
-
await arm()
|
|
339
|
-
armed_at = loop.time()
|
|
340
|
-
generation = self._tx_generation
|
|
341
|
-
finally:
|
|
342
|
-
self._capture_open = False
|
|
378
|
+
generation = -1 # One code per session: re-arm.
|
|
379
|
+
|
|
380
|
+
now = loop.time()
|
|
381
|
+
if generation != self._tx_generation or now - armed_at >= rearm_interval:
|
|
382
|
+
await arm()
|
|
383
|
+
armed_at = loop.time()
|
|
384
|
+
generation = self._tx_generation
|
|
343
385
|
|
|
344
386
|
|
|
345
387
|
class rmpro(rmmini):
|
|
@@ -369,7 +411,7 @@ class rmpro(rmmini):
|
|
|
369
411
|
"""Cancel sweep frequency."""
|
|
370
412
|
await self._send(0x1E)
|
|
371
413
|
|
|
372
|
-
|
|
414
|
+
def capture_rf(
|
|
373
415
|
self,
|
|
374
416
|
window: float = 30.0,
|
|
375
417
|
*,
|
|
@@ -389,24 +431,38 @@ class rmpro(rmmini):
|
|
|
389
431
|
|
|
390
432
|
The window, polling, re-arm and stop-after-first semantics are those
|
|
391
433
|
of ``capture``; the sweep counts against the same ``window``. A
|
|
392
|
-
``send_data`` during the sweep restarts it.
|
|
393
|
-
|
|
394
|
-
|
|
434
|
+
``send_data`` during the sweep restarts it. Closing the generator
|
|
435
|
+
during a sweep sends nothing; the device ends the sweep on its own.
|
|
436
|
+
Each ``CapturedSignal`` carries the carrier in ``frequency_mhz``,
|
|
437
|
+
which the packet itself does not record.
|
|
395
438
|
"""
|
|
396
|
-
|
|
397
|
-
|
|
439
|
+
prev = self._check_window()
|
|
440
|
+
gen = self._capture_rf_loop(
|
|
441
|
+
window, frequency, stop_after_first, poll_interval, rearm_interval,
|
|
442
|
+
prev=prev,
|
|
443
|
+
)
|
|
444
|
+
self._window = weakref.ref(gen)
|
|
445
|
+
return gen
|
|
446
|
+
|
|
447
|
+
async def _capture_rf_loop(
|
|
448
|
+
self,
|
|
449
|
+
window: float,
|
|
450
|
+
frequency: Optional[float],
|
|
451
|
+
stop_after_first: bool,
|
|
452
|
+
poll_interval: float,
|
|
453
|
+
rearm_interval: float,
|
|
454
|
+
*,
|
|
455
|
+
prev: Optional[weakref.ReferenceType],
|
|
456
|
+
) -> AsyncIterator[CapturedSignal]:
|
|
398
457
|
if window < 0 or poll_interval <= 0:
|
|
399
458
|
raise ValueError("window must be 0 or positive, poll_interval positive")
|
|
459
|
+
await self._claim_window(prev)
|
|
400
460
|
|
|
401
461
|
loop = asyncio.get_running_loop()
|
|
402
462
|
deadline = loop.time() + window if window else None
|
|
403
463
|
|
|
404
464
|
if frequency is None:
|
|
405
|
-
self.
|
|
406
|
-
try:
|
|
407
|
-
frequency = await self._sweep(deadline, poll_interval)
|
|
408
|
-
finally:
|
|
409
|
-
self._capture_open = False
|
|
465
|
+
frequency = await self._sweep(deadline, poll_interval)
|
|
410
466
|
if frequency is None:
|
|
411
467
|
return
|
|
412
468
|
if deadline is not None:
|
|
@@ -418,10 +474,15 @@ class rmpro(rmmini):
|
|
|
418
474
|
await self.find_rf_packet(frequency)
|
|
419
475
|
|
|
420
476
|
kind = SignalKind.RF_315 if frequency < 400 else SignalKind.RF_433
|
|
421
|
-
|
|
422
|
-
arm, window, stop_after_first, poll_interval, rearm_interval, kind, frequency
|
|
423
|
-
|
|
424
|
-
|
|
477
|
+
inner = self._capture_loop(
|
|
478
|
+
arm, window, stop_after_first, poll_interval, rearm_interval, kind, frequency,
|
|
479
|
+
claim=False,
|
|
480
|
+
)
|
|
481
|
+
try:
|
|
482
|
+
async for signal in inner:
|
|
483
|
+
yield signal
|
|
484
|
+
finally:
|
|
485
|
+
await inner.aclose()
|
|
425
486
|
|
|
426
487
|
async def _sweep(
|
|
427
488
|
self, deadline: Optional[float], poll_interval: float
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-broadlink
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.1
|
|
4
4
|
Summary: Python API for controlling Broadlink devices
|
|
5
5
|
Author: DAB-LABS
|
|
6
6
|
Author-email: Matthew Garrett <mjg59@srcf.ucam.org>
|
|
@@ -91,9 +91,16 @@ Use pip3 to install the latest version of this module.
|
|
|
91
91
|
pip3 install python-broadlink
|
|
92
92
|
```
|
|
93
93
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
94
|
+
Both this distribution and the original `broadlink` install a package named
|
|
95
|
+
`broadlink`, so only one can be present in an environment at a time. Pip
|
|
96
|
+
does not warn about this: installing one on top of the other appears to
|
|
97
|
+
succeed, and whichever was installed last is the one that `import broadlink`
|
|
98
|
+
finds. If both were installed, uninstall both (`pip3 uninstall broadlink
|
|
99
|
+
python-broadlink`) and reinstall this one, since `pip3 uninstall broadlink`
|
|
100
|
+
alone removes the shared files and leaves `python-broadlink` registered but
|
|
101
|
+
unimportable. This matters most where another package pins `broadlink`:
|
|
102
|
+
installing it into the same environment silently replaces this async
|
|
103
|
+
library with the original synchronous one.
|
|
97
104
|
|
|
98
105
|
## Basic functions
|
|
99
106
|
|
|
@@ -50,6 +50,7 @@ class FakeRM:
|
|
|
50
50
|
self.sweeping = False
|
|
51
51
|
self.sweep_answers: list[tuple[bool, float]] = []
|
|
52
52
|
self.timeouts_to_raise = 0
|
|
53
|
+
self.nothing_yet_code = -5 # -10 on some older firmware
|
|
53
54
|
device.send_packet = self.send_packet # type: ignore[method-assign]
|
|
54
55
|
|
|
55
56
|
# -- what the test does to the device
|
|
@@ -98,7 +99,7 @@ class FakeRM:
|
|
|
98
99
|
self.timeouts_to_raise -= 1
|
|
99
100
|
return b"", "timeout"
|
|
100
101
|
if self.pending is None:
|
|
101
|
-
return b"",
|
|
102
|
+
return b"", self.nothing_yet_code
|
|
102
103
|
code, self.pending = self.pending, None
|
|
103
104
|
return code, 0
|
|
104
105
|
if command == CMD_SEND:
|
|
@@ -166,7 +167,7 @@ def test_capture_yields_first_signal_and_closes(cls_name, devtype):
|
|
|
166
167
|
assert fake.commands[0][0] == CMD_LEARN
|
|
167
168
|
assert fake.count(CMD_LEARN) == 1
|
|
168
169
|
assert fake.count(CMD_CHECK) >= 2
|
|
169
|
-
assert device.
|
|
170
|
+
assert device.capture_active is False
|
|
170
171
|
|
|
171
172
|
|
|
172
173
|
def test_capture_window_elapses_with_nothing():
|
|
@@ -175,7 +176,7 @@ def test_capture_window_elapses_with_nothing():
|
|
|
175
176
|
assert signals == []
|
|
176
177
|
assert fake.count(CMD_LEARN) == 1
|
|
177
178
|
assert fake.count(CMD_CHECK) >= 3
|
|
178
|
-
assert device.
|
|
179
|
+
assert device.capture_active is False
|
|
179
180
|
|
|
180
181
|
|
|
181
182
|
async def _collect(gen):
|
|
@@ -287,7 +288,7 @@ def test_open_ended_window_runs_until_closed():
|
|
|
287
288
|
|
|
288
289
|
got = run(go())
|
|
289
290
|
assert len(got) == 1
|
|
290
|
-
assert device.
|
|
291
|
+
assert device.capture_active is False
|
|
291
292
|
# Closing sends nothing further to the device.
|
|
292
293
|
assert fake.commands[-1][0] in (CMD_CHECK, CMD_LEARN)
|
|
293
294
|
|
|
@@ -302,13 +303,77 @@ def test_second_window_is_refused():
|
|
|
302
303
|
await asyncio.sleep(2 * UNIT)
|
|
303
304
|
with pytest.raises(e.CaptureInProgressError):
|
|
304
305
|
await _collect(device.capture(window=1, **FAST))
|
|
305
|
-
assert device.
|
|
306
|
+
assert device.capture_active is True
|
|
306
307
|
task.cancel()
|
|
307
308
|
with pytest.raises(asyncio.CancelledError):
|
|
308
309
|
await task
|
|
309
310
|
|
|
310
311
|
run(go())
|
|
311
|
-
assert device.
|
|
312
|
+
assert device.capture_active is False
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def test_abandoned_window_is_closed_by_the_next_one():
|
|
316
|
+
"""A consumer that breaks out of the loop without closing the generator
|
|
317
|
+
must not block the device; the next window closes the old one."""
|
|
318
|
+
device, fake = make()
|
|
319
|
+
|
|
320
|
+
async def go():
|
|
321
|
+
asyncio.get_running_loop().create_task(press_later(fake, IR, 2 * UNIT))
|
|
322
|
+
first = device.capture(window=1, stop_after_first=False, **FAST)
|
|
323
|
+
async for s in first:
|
|
324
|
+
got = s
|
|
325
|
+
break # Walk away without aclose().
|
|
326
|
+
assert device.capture_active is True # The old generator is suspended.
|
|
327
|
+
assert not first.ag_running
|
|
328
|
+
asyncio.get_running_loop().create_task(press_later(fake, RF, 2 * UNIT))
|
|
329
|
+
second = [s async for s in device.capture(window=1, **FAST)]
|
|
330
|
+
assert first.ag_frame is None # Closed by the second window.
|
|
331
|
+
return got, second
|
|
332
|
+
|
|
333
|
+
got, second = run(go())
|
|
334
|
+
assert got.packet == IR
|
|
335
|
+
assert [s.packet for s in second] == [RF]
|
|
336
|
+
assert device.capture_active is False
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def test_unstarted_window_does_not_block():
|
|
340
|
+
device, fake = make()
|
|
341
|
+
|
|
342
|
+
async def go():
|
|
343
|
+
_unused = device.capture(window=1, **FAST) # never iterated
|
|
344
|
+
asyncio.get_running_loop().create_task(press_later(fake, IR, 2 * UNIT))
|
|
345
|
+
return [s async for s in device.capture(window=1, **FAST)]
|
|
346
|
+
|
|
347
|
+
assert len(run(go())) == 1
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def test_older_firmware_read_error_means_nothing_yet():
|
|
351
|
+
device, fake = make()
|
|
352
|
+
fake.nothing_yet_code = -10 # ReadError
|
|
353
|
+
|
|
354
|
+
async def go():
|
|
355
|
+
asyncio.get_running_loop().create_task(press_later(fake, IR, 3 * UNIT))
|
|
356
|
+
return [s async for s in device.capture(window=1, **FAST)]
|
|
357
|
+
|
|
358
|
+
signals = run(go())
|
|
359
|
+
assert len(signals) == 1
|
|
360
|
+
assert fake.count(CMD_CHECK) >= 2
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def test_capture_rf_abandoned_then_ir_window():
|
|
364
|
+
device, fake = make()
|
|
365
|
+
|
|
366
|
+
async def go():
|
|
367
|
+
asyncio.get_running_loop().create_task(press_later(fake, RF, 2 * UNIT))
|
|
368
|
+
rf = device.capture_rf(window=1, frequency=433.92, stop_after_first=False, **FAST)
|
|
369
|
+
async for _ in rf:
|
|
370
|
+
break
|
|
371
|
+
asyncio.get_running_loop().create_task(press_later(fake, IR, 2 * UNIT))
|
|
372
|
+
return [s async for s in device.capture(window=1, **FAST)]
|
|
373
|
+
|
|
374
|
+
signals = run(go())
|
|
375
|
+
assert [s.kind for s in signals] == [SignalKind.IR]
|
|
376
|
+
assert device.capture_active is False
|
|
312
377
|
|
|
313
378
|
|
|
314
379
|
def test_transport_timeouts_rearm_then_give_up():
|
|
@@ -327,7 +392,7 @@ def test_transport_timeouts_rearm_then_give_up():
|
|
|
327
392
|
fake.timeouts_to_raise = 3
|
|
328
393
|
with pytest.raises(e.NetworkTimeoutError):
|
|
329
394
|
run(_collect(device.capture(window=1, **FAST)))
|
|
330
|
-
assert device.
|
|
395
|
+
assert device.capture_active is False
|
|
331
396
|
|
|
332
397
|
|
|
333
398
|
def test_capture_rejects_bad_arguments():
|
|
@@ -417,7 +482,7 @@ def test_capture_rf_sweep_that_never_locks_is_cancelled():
|
|
|
417
482
|
assert fake.count(CMD_CANCEL_SWEEP) == 1
|
|
418
483
|
assert fake.count(CMD_FIND_RF) == 0
|
|
419
484
|
assert fake.sweeping is False
|
|
420
|
-
assert device.
|
|
485
|
+
assert device.capture_active is False
|
|
421
486
|
|
|
422
487
|
|
|
423
488
|
def test_send_during_sweep_restarts_it():
|
|
@@ -457,7 +522,7 @@ def test_capture_rf_refused_while_ir_window_open():
|
|
|
457
522
|
await task
|
|
458
523
|
|
|
459
524
|
run(go())
|
|
460
|
-
assert device.
|
|
525
|
+
assert device.capture_active is False
|
|
461
526
|
|
|
462
527
|
|
|
463
528
|
def test_rf_capture_is_only_on_pro_classes():
|
|
@@ -223,6 +223,89 @@ def test_stale_reply_is_drained_before_a_request(net):
|
|
|
223
223
|
assert run(go()) == 7
|
|
224
224
|
|
|
225
225
|
|
|
226
|
+
def stamped(dev: Device, payload: bytes, count: int, error: int = 0) -> bytes:
|
|
227
|
+
"""A response frame that echoes a packet counter, as real firmware does."""
|
|
228
|
+
frame = bytearray(make_response(dev, payload, error))
|
|
229
|
+
frame[0x28:0x2A] = count.to_bytes(2, "little")
|
|
230
|
+
checksum = sum(frame, 0xBEAF) - sum(frame[0x20:0x22]) & 0xFFFF
|
|
231
|
+
frame[0x20:0x22] = checksum.to_bytes(2, "little")
|
|
232
|
+
return bytes(frame)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def test_late_reply_to_timed_out_request_is_not_taken_as_next_reply(net):
|
|
236
|
+
"""The defect that 0.19.0 could not have because it threw its socket away
|
|
237
|
+
after every call: a slow answer to request 1 arriving after request 1
|
|
238
|
+
timed out must not be returned as the answer to request 2."""
|
|
239
|
+
dev = fixed_device()
|
|
240
|
+
dev.timeout = 0.02
|
|
241
|
+
|
|
242
|
+
async def go():
|
|
243
|
+
await dev._endpoint()
|
|
244
|
+
ep = net.endpoints[-1]
|
|
245
|
+
with pytest.raises(e.NetworkTimeoutError):
|
|
246
|
+
await dev.send_packet(0x6A, b"") # request 1, count 0x8001, no answer
|
|
247
|
+
# Its late reply lands while request 2 (count 0x8002) is waiting.
|
|
248
|
+
late = stamped(dev, bytes([1]) + bytes(15), 0x8001)
|
|
249
|
+
good = stamped(dev, bytes([2]) + bytes(15), 0x8002)
|
|
250
|
+
ep.replies = [late, good] and []
|
|
251
|
+
ep.protocol.queue.put_nowait((late, HOST))
|
|
252
|
+
|
|
253
|
+
async def answer_later():
|
|
254
|
+
await asyncio.sleep(0.005)
|
|
255
|
+
ep.protocol.queue.put_nowait((good, HOST))
|
|
256
|
+
|
|
257
|
+
asyncio.get_running_loop().create_task(answer_later())
|
|
258
|
+
dev.timeout = 1
|
|
259
|
+
resp = await dev.send_packet(0x6A, b"")
|
|
260
|
+
return dev.decrypt(resp[0x38:])[0]
|
|
261
|
+
|
|
262
|
+
assert run(go()) == 2
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def test_reply_with_unknown_counter_is_accepted(net):
|
|
266
|
+
"""Firmware that does not echo the counter must keep working."""
|
|
267
|
+
dev = fixed_device()
|
|
268
|
+
|
|
269
|
+
async def go():
|
|
270
|
+
net.replies = [(stamped(dev, bytes([5]) + bytes(15), 0x0000), HOST)]
|
|
271
|
+
resp = await dev.send_packet(0x6A, b"")
|
|
272
|
+
return dev.decrypt(resp[0x38:])[0]
|
|
273
|
+
|
|
274
|
+
assert run(go()) == 5
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def test_aclose_fails_inflight_request_fast(net):
|
|
278
|
+
dev = fixed_device()
|
|
279
|
+
dev.timeout = 5
|
|
280
|
+
net.replies = []
|
|
281
|
+
|
|
282
|
+
async def go():
|
|
283
|
+
task = asyncio.get_running_loop().create_task(dev.send_packet(0x6A, b""))
|
|
284
|
+
await asyncio.sleep(0.01)
|
|
285
|
+
t0 = asyncio.get_running_loop().time()
|
|
286
|
+
await dev.aclose()
|
|
287
|
+
with pytest.raises(e.ConnectionClosedError):
|
|
288
|
+
await task
|
|
289
|
+
return asyncio.get_running_loop().time() - t0
|
|
290
|
+
|
|
291
|
+
assert run(go()) < 1.0
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def test_host_change_reopens_endpoint(net):
|
|
295
|
+
dev = fixed_device()
|
|
296
|
+
|
|
297
|
+
async def go():
|
|
298
|
+
net.replies = [(make_response(dev, b""), HOST)]
|
|
299
|
+
await dev.send_packet(0x6A, b"")
|
|
300
|
+
dev.host = ("192.0.2.99", 80)
|
|
301
|
+
net.replies = [(make_response(dev, b""), ("192.0.2.99", 80))]
|
|
302
|
+
await dev.send_packet(0x6A, b"")
|
|
303
|
+
|
|
304
|
+
run(go())
|
|
305
|
+
assert [ep.remote_addr for ep in net.endpoints] == [HOST, ("192.0.2.99", 80)]
|
|
306
|
+
assert net.endpoints[0].closed
|
|
307
|
+
|
|
308
|
+
|
|
226
309
|
# ------------------------------------------------------------------------- auth
|
|
227
310
|
|
|
228
311
|
|
|
@@ -311,6 +394,67 @@ def test_reauth_is_not_attempted_twice(net):
|
|
|
311
394
|
run(go())
|
|
312
395
|
|
|
313
396
|
|
|
397
|
+
def test_concurrent_callers_share_one_reauth(net):
|
|
398
|
+
dev = fixed_device()
|
|
399
|
+
dev.id = 5
|
|
400
|
+
session_key = bytes.fromhex("00112233445566778899aabbccddeeff")
|
|
401
|
+
fresh = fixed_device()
|
|
402
|
+
auth_reply = make_response(fresh, (0x42).to_bytes(4, "little") + session_key)
|
|
403
|
+
renewed = fixed_device()
|
|
404
|
+
renewed.update_aes(session_key)
|
|
405
|
+
counters = {"value": 1}
|
|
406
|
+
|
|
407
|
+
async def go():
|
|
408
|
+
await dev._endpoint()
|
|
409
|
+
ep = net.endpoints[-1]
|
|
410
|
+
authed = {"done": False}
|
|
411
|
+
|
|
412
|
+
def sendto(data, addr=None):
|
|
413
|
+
ep.sent.append((bytes(data), addr or ep.remote_addr))
|
|
414
|
+
ptype = int.from_bytes(data[0x26:0x28], "little")
|
|
415
|
+
if ptype == 0x65:
|
|
416
|
+
authed["done"] = True
|
|
417
|
+
reply = auth_reply
|
|
418
|
+
elif not authed["done"]:
|
|
419
|
+
reply = make_response(dev, b"", error=0xFFF9) # -7 expired
|
|
420
|
+
else:
|
|
421
|
+
n = counters["value"]
|
|
422
|
+
counters["value"] += 1
|
|
423
|
+
reply = make_response(renewed, bytes([n]) + bytes(15))
|
|
424
|
+
ep.protocol.queue.put_nowait((reply, ep.remote_addr))
|
|
425
|
+
|
|
426
|
+
ep.sendto = sendto
|
|
427
|
+
a, b = await asyncio.gather(dev.send_packet(0x6A, b"a"), dev.send_packet(0x6A, b"b"))
|
|
428
|
+
return ep, {dev.decrypt(a[0x38:])[0], dev.decrypt(b[0x38:])[0]}
|
|
429
|
+
|
|
430
|
+
ep, values = run(go())
|
|
431
|
+
types = [int.from_bytes(f[0x26:0x28], "little") for f, _ in ep.sent]
|
|
432
|
+
assert types.count(0x65) == 1 # exactly one auth despite two expired requests
|
|
433
|
+
assert values == {1, 2}
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def test_logged_out_code_triggers_reauth(net):
|
|
437
|
+
dev = fixed_device()
|
|
438
|
+
session_key = bytes.fromhex("00112233445566778899aabbccddeeff")
|
|
439
|
+
fresh = fixed_device()
|
|
440
|
+
auth_reply = make_response(fresh, (0x42).to_bytes(4, "little") + session_key)
|
|
441
|
+
renewed = fixed_device()
|
|
442
|
+
renewed.update_aes(session_key)
|
|
443
|
+
|
|
444
|
+
async def go():
|
|
445
|
+
await dev._endpoint()
|
|
446
|
+
ep = net.endpoints[-1]
|
|
447
|
+
ep.replies = [
|
|
448
|
+
(make_response(dev, b"", error=0xFFFE), HOST), # -2 logged out
|
|
449
|
+
(auth_reply, HOST),
|
|
450
|
+
(make_response(renewed, bytes([3]) + bytes(15)), HOST),
|
|
451
|
+
]
|
|
452
|
+
resp = await dev.send_packet(0x6A, b"")
|
|
453
|
+
return dev.decrypt(resp[0x38:])[0]
|
|
454
|
+
|
|
455
|
+
assert run(go()) == 3
|
|
456
|
+
|
|
457
|
+
|
|
314
458
|
# ------------------------------------------------------------------- discovery
|
|
315
459
|
|
|
316
460
|
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_broadlink-1.0.0 → python_broadlink-1.0.1}/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
|
|
File without changes
|