python-broadlink 1.0.2__tar.gz → 1.0.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {python_broadlink-1.0.2 → python_broadlink-1.0.3}/CHANGELOG.md +45 -0
- {python_broadlink-1.0.2/python_broadlink.egg-info → python_broadlink-1.0.3}/PKG-INFO +17 -5
- {python_broadlink-1.0.2 → python_broadlink-1.0.3}/README.md +16 -4
- {python_broadlink-1.0.2 → python_broadlink-1.0.3}/broadlink/__init__.py +2 -8
- {python_broadlink-1.0.2 → python_broadlink-1.0.3}/broadlink/device.py +44 -18
- {python_broadlink-1.0.2 → python_broadlink-1.0.3}/broadlink/exceptions.py +1 -1
- {python_broadlink-1.0.2 → python_broadlink-1.0.3}/broadlink/remote.py +11 -6
- {python_broadlink-1.0.2 → python_broadlink-1.0.3}/cli/broadlink_cli +8 -1
- {python_broadlink-1.0.2 → python_broadlink-1.0.3}/pyproject.toml +1 -1
- {python_broadlink-1.0.2 → python_broadlink-1.0.3/python_broadlink.egg-info}/PKG-INFO +17 -5
- {python_broadlink-1.0.2 → python_broadlink-1.0.3}/tests/test_capture.py +9 -3
- {python_broadlink-1.0.2 → python_broadlink-1.0.3}/tests/test_transport.py +110 -5
- {python_broadlink-1.0.2 → python_broadlink-1.0.3}/LICENSE +0 -0
- {python_broadlink-1.0.2 → python_broadlink-1.0.3}/MANIFEST.in +0 -0
- {python_broadlink-1.0.2 → python_broadlink-1.0.3}/TROUBLESHOOTING.md +0 -0
- {python_broadlink-1.0.2 → python_broadlink-1.0.3}/broadlink/alarm.py +0 -0
- {python_broadlink-1.0.2 → python_broadlink-1.0.3}/broadlink/climate.py +0 -0
- {python_broadlink-1.0.2 → python_broadlink-1.0.3}/broadlink/const.py +0 -0
- {python_broadlink-1.0.2 → python_broadlink-1.0.3}/broadlink/cover.py +0 -0
- {python_broadlink-1.0.2 → python_broadlink-1.0.3}/broadlink/helpers.py +0 -0
- {python_broadlink-1.0.2 → python_broadlink-1.0.3}/broadlink/hub.py +0 -0
- {python_broadlink-1.0.2 → python_broadlink-1.0.3}/broadlink/light.py +0 -0
- {python_broadlink-1.0.2 → python_broadlink-1.0.3}/broadlink/protocol.py +0 -0
- {python_broadlink-1.0.2 → python_broadlink-1.0.3}/broadlink/sensor.py +0 -0
- {python_broadlink-1.0.2 → python_broadlink-1.0.3}/broadlink/switch.py +0 -0
- {python_broadlink-1.0.2 → python_broadlink-1.0.3}/cli/README.md +0 -0
- {python_broadlink-1.0.2 → python_broadlink-1.0.3}/cli/broadlink_discovery +0 -0
- {python_broadlink-1.0.2 → python_broadlink-1.0.3}/protocol.md +0 -0
- {python_broadlink-1.0.2 → python_broadlink-1.0.3}/python_broadlink.egg-info/SOURCES.txt +0 -0
- {python_broadlink-1.0.2 → python_broadlink-1.0.3}/python_broadlink.egg-info/dependency_links.txt +0 -0
- {python_broadlink-1.0.2 → python_broadlink-1.0.3}/python_broadlink.egg-info/requires.txt +0 -0
- {python_broadlink-1.0.2 → python_broadlink-1.0.3}/python_broadlink.egg-info/top_level.txt +0 -0
- {python_broadlink-1.0.2 → python_broadlink-1.0.3}/setup.cfg +0 -0
- {python_broadlink-1.0.2 → python_broadlink-1.0.3}/tests/__init__.py +0 -0
- {python_broadlink-1.0.2 → python_broadlink-1.0.3}/tests/oracle/__init__.py +0 -0
- {python_broadlink-1.0.2 → python_broadlink-1.0.3}/tests/oracle/cases.py +0 -0
- {python_broadlink-1.0.2 → python_broadlink-1.0.3}/tests/oracle/fixtures.json +0 -0
- {python_broadlink-1.0.2 → python_broadlink-1.0.3}/tests/oracle/harness.py +0 -0
- {python_broadlink-1.0.2 → python_broadlink-1.0.3}/tests/oracle/record.py +0 -0
- {python_broadlink-1.0.2 → python_broadlink-1.0.3}/tests/test_helpers.py +0 -0
- {python_broadlink-1.0.2 → python_broadlink-1.0.3}/tests/test_oracle.py +0 -0
- {python_broadlink-1.0.2 → python_broadlink-1.0.3}/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.3 - 2026-09-06
|
|
7
|
+
|
|
8
|
+
Fixes from a third review, this one of 1.0.2. No change to the wire
|
|
9
|
+
format. One small API change: `pulses` on a captured signal is a tuple.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- When the device answered that the session key had expired and the
|
|
14
|
+
re-authentication then failed (for example because the device had been
|
|
15
|
+
locked in the app), the call raised `AuthenticationError` from the
|
|
16
|
+
re-authentication instead of the error the device gave the request. The
|
|
17
|
+
original library never re-authenticated, so a program written against
|
|
18
|
+
it, Home Assistant's integration included, handles the request's own
|
|
19
|
+
error and never expected the other one. The failed re-authentication is
|
|
20
|
+
now logged and the request's original reply is returned, so the caller
|
|
21
|
+
sees the same `AuthorizationError` or `ConnectionClosedError` it always
|
|
22
|
+
did.
|
|
23
|
+
- The authentication generation was read before the request lock was
|
|
24
|
+
taken rather than under it, so a request queued behind an `auth()`
|
|
25
|
+
could observe a stale generation and skip a re-authentication it needed.
|
|
26
|
+
- `aclose()` racing an endpoint that was still being opened could leave
|
|
27
|
+
the new socket open and unreferenced. The open now notices the close
|
|
28
|
+
and fails with `EndpointClosedError`.
|
|
29
|
+
- After a new capture window gives the finalizer its turn, it re-checks
|
|
30
|
+
that no other window claimed the device in the meantime.
|
|
31
|
+
- `CapturedSignal` and `ParsedPacket` are frozen dataclasses, but they
|
|
32
|
+
held a list, so they could not be hashed or put in a set. `pulses` is
|
|
33
|
+
now a `tuple[int, ...]`.
|
|
34
|
+
- `check_error` unpacks the error code as little-endian explicitly
|
|
35
|
+
(`"<h"`), matching the rest of the code, instead of native order.
|
|
36
|
+
- The CLI closes the device it opens instead of leaving that to
|
|
37
|
+
`asyncio.run`, which warned under `python -X dev`.
|
|
38
|
+
- The locks are created in `__init__` rather than lazily in two places.
|
|
39
|
+
|
|
40
|
+
### Changed
|
|
41
|
+
|
|
42
|
+
- `send_packet` accepts a `bytearray` payload as well as `bytes`.
|
|
43
|
+
- `setup()` sends its provisioning packet through a new
|
|
44
|
+
`send_setup_packet()` helper in `broadlink.device` instead of reaching
|
|
45
|
+
into a private function.
|
|
46
|
+
- README: the re-authentication contract and its worst case (one call can
|
|
47
|
+
wait out up to three timeouts), the A2 sensor and the Hysen HY02/HY03
|
|
48
|
+
in the device list, and the hello response's `mac` being `bytes` in the
|
|
49
|
+
list of differences from 0.19.0.
|
|
50
|
+
|
|
6
51
|
## 1.0.2 - 2026-09-05
|
|
7
52
|
|
|
8
53
|
Fixes from a second, adversarial review of 1.0.1 and a re-test of the
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-broadlink
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.3
|
|
4
4
|
Summary: Python API for controlling Broadlink devices
|
|
5
5
|
Author: DAB-LABS
|
|
6
6
|
Author-email: Matthew Garrett <mjg59@srcf.ucam.org>
|
|
@@ -51,8 +51,9 @@ Every call that reaches a device is a coroutine and must be awaited. That
|
|
|
51
51
|
is the main change from the original library's API: method names and
|
|
52
52
|
arguments are the same, and so are return values, with the small
|
|
53
53
|
exceptions listed in `CHANGELOG.md` (the IR tick constant, `pulses_to_data`
|
|
54
|
-
returning `bytes`, the unused `Device.lock` attribute removed,
|
|
55
|
-
`
|
|
54
|
+
returning `bytes`, the unused `Device.lock` attribute removed, `timeout`
|
|
55
|
+
parameters typed as floats, and the `mac` in a hello response typed as
|
|
56
|
+
`bytes`).
|
|
56
57
|
|
|
57
58
|
```python
|
|
58
59
|
import asyncio
|
|
@@ -81,11 +82,11 @@ The following devices are supported:
|
|
|
81
82
|
- **Switches**: MCB1, SC1, SCB1E, SCB2
|
|
82
83
|
- **Outlets**: BG 800, BG 900
|
|
83
84
|
- **Power strips**: MP1-1K3S2U, MP1-1K4S, MP2
|
|
84
|
-
- **Environment sensors**: A1
|
|
85
|
+
- **Environment sensors**: A1, A2
|
|
85
86
|
- **Alarm kits**: S1C, S2KIT
|
|
86
87
|
- **Light bulbs**: LB1, LB26 R1, LB27 R1, SB800TD, LEDVANCE SMART+ WIFI CEILING TW 24W
|
|
87
88
|
- **Curtain motors**: Dooya DT360E-45/20
|
|
88
|
-
- **Thermostats**: Hysen
|
|
89
|
+
- **Thermostats**: Hysen HY02/HY03
|
|
89
90
|
- **Hubs**: S3
|
|
90
91
|
|
|
91
92
|
## Timing
|
|
@@ -204,6 +205,17 @@ After discovering the device, call the `auth()` method to obtain the authenticat
|
|
|
204
205
|
await device.auth()
|
|
205
206
|
```
|
|
206
207
|
|
|
208
|
+
The session key expires on the device after a while. When a request comes
|
|
209
|
+
back with an expired-key answer, the library authenticates again and
|
|
210
|
+
repeats the request once, so a long-running program does not need to
|
|
211
|
+
handle that itself. If the second authentication fails, for example
|
|
212
|
+
because the device was locked in the app in the meantime, the call raises
|
|
213
|
+
the error the device gave the first time, the same `AuthorizationError`
|
|
214
|
+
or `ConnectionClosedError` the original library raised, and it is up to
|
|
215
|
+
the caller to decide what to do. In the worst case one call can wait out
|
|
216
|
+
three timeouts (the request, the authentication, and the repeat), each
|
|
217
|
+
bounded by `device.timeout`.
|
|
218
|
+
|
|
207
219
|
### Closing
|
|
208
220
|
|
|
209
221
|
Each device keeps one UDP socket open for its lifetime (the original
|
|
@@ -20,8 +20,9 @@ Every call that reaches a device is a coroutine and must be awaited. That
|
|
|
20
20
|
is the main change from the original library's API: method names and
|
|
21
21
|
arguments are the same, and so are return values, with the small
|
|
22
22
|
exceptions listed in `CHANGELOG.md` (the IR tick constant, `pulses_to_data`
|
|
23
|
-
returning `bytes`, the unused `Device.lock` attribute removed,
|
|
24
|
-
`
|
|
23
|
+
returning `bytes`, the unused `Device.lock` attribute removed, `timeout`
|
|
24
|
+
parameters typed as floats, and the `mac` in a hello response typed as
|
|
25
|
+
`bytes`).
|
|
25
26
|
|
|
26
27
|
```python
|
|
27
28
|
import asyncio
|
|
@@ -50,11 +51,11 @@ The following devices are supported:
|
|
|
50
51
|
- **Switches**: MCB1, SC1, SCB1E, SCB2
|
|
51
52
|
- **Outlets**: BG 800, BG 900
|
|
52
53
|
- **Power strips**: MP1-1K3S2U, MP1-1K4S, MP2
|
|
53
|
-
- **Environment sensors**: A1
|
|
54
|
+
- **Environment sensors**: A1, A2
|
|
54
55
|
- **Alarm kits**: S1C, S2KIT
|
|
55
56
|
- **Light bulbs**: LB1, LB26 R1, LB27 R1, SB800TD, LEDVANCE SMART+ WIFI CEILING TW 24W
|
|
56
57
|
- **Curtain motors**: Dooya DT360E-45/20
|
|
57
|
-
- **Thermostats**: Hysen
|
|
58
|
+
- **Thermostats**: Hysen HY02/HY03
|
|
58
59
|
- **Hubs**: S3
|
|
59
60
|
|
|
60
61
|
## Timing
|
|
@@ -173,6 +174,17 @@ After discovering the device, call the `auth()` method to obtain the authenticat
|
|
|
173
174
|
await device.auth()
|
|
174
175
|
```
|
|
175
176
|
|
|
177
|
+
The session key expires on the device after a while. When a request comes
|
|
178
|
+
back with an expired-key answer, the library authenticates again and
|
|
179
|
+
repeats the request once, so a long-running program does not need to
|
|
180
|
+
handle that itself. If the second authentication fails, for example
|
|
181
|
+
because the device was locked in the app in the meantime, the call raises
|
|
182
|
+
the error the device gave the first time, the same `AuthorizationError`
|
|
183
|
+
or `ConnectionClosedError` the original library raised, and it is up to
|
|
184
|
+
the caller to decide what to do. In the worst case one call can wait out
|
|
185
|
+
three timeouts (the request, the authentication, and the repeat), each
|
|
186
|
+
bounded by `device.timeout`.
|
|
187
|
+
|
|
176
188
|
### Closing
|
|
177
189
|
|
|
178
190
|
Each device keeps one UDP socket open for its lifetime (the original
|
|
@@ -1,16 +1,14 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
1
|
"""The python-broadlink library."""
|
|
3
2
|
|
|
4
3
|
import contextlib
|
|
5
4
|
from collections.abc import AsyncIterator
|
|
6
|
-
from typing import Optional, Union
|
|
7
5
|
|
|
8
6
|
from . import exceptions as e
|
|
9
7
|
from .alarm import S1C
|
|
10
8
|
from .climate import hvac, hysen
|
|
11
9
|
from .const import DEFAULT_BCAST_ADDR, DEFAULT_PORT, DEFAULT_TIMEOUT
|
|
12
10
|
from .cover import dooya, dooya2, wser
|
|
13
|
-
from .device import Device,
|
|
11
|
+
from .device import Device, ping, scan, send_setup_packet
|
|
14
12
|
from .hub import s3
|
|
15
13
|
from .light import lb1, lb2
|
|
16
14
|
from .remote import rm, rm4, rm4mini, rm4pro, rm5plus, rmmini, rmminib, rmpro
|
|
@@ -340,8 +338,4 @@ async def setup(
|
|
|
340
338
|
payload[0x20] = checksum & 0xFF # Checksum 1 position
|
|
341
339
|
payload[0x21] = checksum >> 8 # Checksum 2 position
|
|
342
340
|
|
|
343
|
-
|
|
344
|
-
try:
|
|
345
|
-
transport.sendto(payload, (ip_address, DEFAULT_PORT))
|
|
346
|
-
finally:
|
|
347
|
-
transport.close()
|
|
341
|
+
await send_setup_packet(bytes(payload), ip_address)
|
|
@@ -56,10 +56,12 @@ class _Protocol(asyncio.DatagramProtocol):
|
|
|
56
56
|
self.queue: asyncio.Queue[tuple[bytes, tuple[str, int]]] = asyncio.Queue()
|
|
57
57
|
self.transport: asyncio.DatagramTransport | None = None
|
|
58
58
|
|
|
59
|
-
def connection_made(self, transport) -> None:
|
|
60
|
-
|
|
59
|
+
def connection_made(self, transport: asyncio.BaseTransport) -> None:
|
|
60
|
+
"""Keep the transport; the endpoint sends through it."""
|
|
61
|
+
self.transport = transport # type: ignore[assignment]
|
|
61
62
|
|
|
62
63
|
def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None:
|
|
64
|
+
"""Queue every datagram for the request that is waiting."""
|
|
63
65
|
self.queue.put_nowait((data, addr))
|
|
64
66
|
|
|
65
67
|
def error_received(self, exc: Exception) -> None:
|
|
@@ -68,7 +70,7 @@ class _Protocol(asyncio.DatagramProtocol):
|
|
|
68
70
|
pass
|
|
69
71
|
|
|
70
72
|
def connection_lost(self, exc: Exception | None) -> None:
|
|
71
|
-
|
|
73
|
+
"""Nothing to do; a waiting request is told through the queue."""
|
|
72
74
|
|
|
73
75
|
def drain(self) -> None:
|
|
74
76
|
"""Drop anything that arrived before the current request."""
|
|
@@ -161,6 +163,17 @@ async def scan(
|
|
|
161
163
|
transport.close()
|
|
162
164
|
|
|
163
165
|
|
|
166
|
+
async def send_setup_packet(
|
|
167
|
+
payload: bytes, ip_address: str, port: int = DEFAULT_PORT
|
|
168
|
+
) -> None:
|
|
169
|
+
"""Broadcast one Wi-Fi provisioning packet to a device in AP mode."""
|
|
170
|
+
transport, _ = await _open_endpoint(broadcast=True)
|
|
171
|
+
try:
|
|
172
|
+
transport.sendto(payload, (ip_address, port))
|
|
173
|
+
finally:
|
|
174
|
+
transport.close()
|
|
175
|
+
|
|
176
|
+
|
|
164
177
|
async def ping(ip_address: str, port: int = DEFAULT_PORT) -> None:
|
|
165
178
|
"""Send a ping packet to an address.
|
|
166
179
|
|
|
@@ -213,12 +226,13 @@ class Device:
|
|
|
213
226
|
self.aes = None
|
|
214
227
|
self.update_aes(bytes.fromhex(self.__INIT_KEY))
|
|
215
228
|
|
|
216
|
-
self._lock
|
|
229
|
+
self._lock = asyncio.Lock()
|
|
217
230
|
self._transport: asyncio.DatagramTransport | None = None
|
|
218
231
|
self._protocol: _Protocol | None = None
|
|
219
232
|
self._endpoint_addr: tuple[str, int] | None = None
|
|
220
233
|
self._recent: collections.deque[int] = collections.deque(maxlen=_RECENT_MAX)
|
|
221
|
-
self._reauth_lock
|
|
234
|
+
self._reauth_lock = asyncio.Lock()
|
|
235
|
+
self._closes = 0 # Bumped by aclose(); guards an open racing a close.
|
|
222
236
|
self._auth_generation = 0
|
|
223
237
|
|
|
224
238
|
def __repr__(self) -> str:
|
|
@@ -277,9 +291,6 @@ class Device:
|
|
|
277
291
|
packet[0x2D] = 0x01
|
|
278
292
|
packet[0x30:0x36] = b"Test 1"
|
|
279
293
|
|
|
280
|
-
if self._lock is None:
|
|
281
|
-
self._lock = asyncio.Lock()
|
|
282
|
-
self._reauth_lock = asyncio.Lock()
|
|
283
294
|
async with self._lock:
|
|
284
295
|
self.id = 0
|
|
285
296
|
self.update_aes(bytes.fromhex(self.__INIT_KEY))
|
|
@@ -292,7 +303,7 @@ class Device:
|
|
|
292
303
|
_LOGGER.debug("%s: authenticated, session id %d", self.host[0], self.id)
|
|
293
304
|
return True
|
|
294
305
|
|
|
295
|
-
async def hello(self, local_ip_address=None) -> bool:
|
|
306
|
+
async def hello(self, local_ip_address: str | None = None) -> bool:
|
|
296
307
|
"""Send a hello message to the device.
|
|
297
308
|
|
|
298
309
|
Device information is checked before updating name and lock status.
|
|
@@ -385,6 +396,7 @@ class Device:
|
|
|
385
396
|
A request in flight fails at once with ``ConnectionClosedError``
|
|
386
397
|
rather than waiting out its timeout.
|
|
387
398
|
"""
|
|
399
|
+
self._closes += 1
|
|
388
400
|
transport, protocol = self._transport, self._protocol
|
|
389
401
|
self._transport = None
|
|
390
402
|
self._protocol = None
|
|
@@ -401,7 +413,15 @@ class Device:
|
|
|
401
413
|
# old address, so drop it.
|
|
402
414
|
await self.aclose()
|
|
403
415
|
if self._transport is None or self._transport.is_closing():
|
|
404
|
-
|
|
416
|
+
closes = self._closes
|
|
417
|
+
transport, protocol = await _open_endpoint(remote_addr=self.host)
|
|
418
|
+
if self._closes != closes:
|
|
419
|
+
# aclose() ran while the socket was being opened.
|
|
420
|
+
transport.close()
|
|
421
|
+
raise e.EndpointClosedError(
|
|
422
|
+
-4013, "Endpoint closed", "The device endpoint was closed"
|
|
423
|
+
)
|
|
424
|
+
self._transport, self._protocol = transport, protocol
|
|
405
425
|
self._endpoint_addr = self.host
|
|
406
426
|
_LOGGER.debug("%s: endpoint opened", self.host[0])
|
|
407
427
|
return self._transport, self._protocol # type: ignore[return-value]
|
|
@@ -507,27 +527,33 @@ class Device:
|
|
|
507
527
|
f"No response received within {timeout}s",
|
|
508
528
|
) from None
|
|
509
529
|
|
|
510
|
-
async def send_packet(self, packet_type: int, payload: bytes) -> bytes:
|
|
530
|
+
async def send_packet(self, packet_type: int, payload: bytes | bytearray) -> bytes:
|
|
511
531
|
"""Send a packet to the device and return the raw response frame.
|
|
512
532
|
|
|
513
533
|
If the device answers that the session key is no longer valid, the
|
|
514
534
|
session is re-authenticated once and the request is sent again.
|
|
515
535
|
Concurrent callers that hit the same expired key share one
|
|
516
|
-
re-authentication and each retry once.
|
|
536
|
+
re-authentication and each retry once. If that re-authentication
|
|
537
|
+
fails (for example the device has been locked in the app), the
|
|
538
|
+
original reply is returned unchanged, so the caller sees the same
|
|
539
|
+
error the original library raised and can run its own recovery.
|
|
517
540
|
"""
|
|
518
|
-
if self._lock is None:
|
|
519
|
-
self._lock = asyncio.Lock()
|
|
520
|
-
self._reauth_lock = asyncio.Lock()
|
|
521
|
-
generation = self._auth_generation
|
|
522
541
|
async with self._lock:
|
|
542
|
+
generation = self._auth_generation
|
|
523
543
|
resp = await self._exchange(self._frame(packet_type, bytes(payload)))
|
|
524
544
|
|
|
525
545
|
code = int.from_bytes(resp[0x22:0x24], "little", signed=True)
|
|
526
546
|
if code in _REAUTH_CODES:
|
|
527
547
|
_LOGGER.debug("%s: device answered %d, re-authenticating", self.host[0], code)
|
|
528
|
-
async with self._reauth_lock:
|
|
548
|
+
async with self._reauth_lock:
|
|
529
549
|
if self._auth_generation == generation:
|
|
530
|
-
|
|
550
|
+
try:
|
|
551
|
+
await self.auth()
|
|
552
|
+
except e.BroadlinkException as err:
|
|
553
|
+
_LOGGER.debug(
|
|
554
|
+
"%s: re-authentication failed: %s", self.host[0], err
|
|
555
|
+
)
|
|
556
|
+
return resp
|
|
531
557
|
async with self._lock:
|
|
532
558
|
resp = await self._exchange(self._frame(packet_type, bytes(payload)))
|
|
533
559
|
return resp
|
|
@@ -168,6 +168,6 @@ def exception(err_code: int) -> BroadlinkException:
|
|
|
168
168
|
|
|
169
169
|
def check_error(error: bytes) -> None:
|
|
170
170
|
"""Raise exception if an error occurred."""
|
|
171
|
-
error_code = struct.unpack("h", error)[0]
|
|
171
|
+
error_code = struct.unpack("<h", error)[0]
|
|
172
172
|
if error_code:
|
|
173
173
|
raise exception(error_code)
|
|
@@ -8,6 +8,7 @@ import time
|
|
|
8
8
|
import weakref
|
|
9
9
|
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
10
10
|
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Self
|
|
11
12
|
|
|
12
13
|
from . import exceptions as e
|
|
13
14
|
from .device import Device
|
|
@@ -53,10 +54,11 @@ class SignalKind(enum.IntEnum):
|
|
|
53
54
|
|
|
54
55
|
@property
|
|
55
56
|
def is_rf(self) -> bool:
|
|
57
|
+
"""True for the radio bands."""
|
|
56
58
|
return self is not SignalKind.IR
|
|
57
59
|
|
|
58
60
|
@classmethod
|
|
59
|
-
def classify(cls, type_byte: int) ->
|
|
61
|
+
def classify(cls, type_byte: int) -> Self:
|
|
60
62
|
"""Map a packet's raw first byte to a kind, tolerantly.
|
|
61
63
|
|
|
62
64
|
The RF learn path returns bytes in the 0xB_ (433 MHz) and 0xD_
|
|
@@ -139,7 +141,7 @@ class ParsedPacket:
|
|
|
139
141
|
|
|
140
142
|
kind: SignalKind
|
|
141
143
|
repeat: int
|
|
142
|
-
pulses:
|
|
144
|
+
pulses: tuple[int, ...]
|
|
143
145
|
type_byte: int
|
|
144
146
|
|
|
145
147
|
|
|
@@ -152,7 +154,7 @@ def parse_packet(data: bytes, tick: float = TICK) -> ParsedPacket:
|
|
|
152
154
|
if len(data) < 4:
|
|
153
155
|
raise ValueError("Malformed data.")
|
|
154
156
|
kind = SignalKind.classify(data[0x00])
|
|
155
|
-
return ParsedPacket(kind, data[0x01], data_to_pulses(data, tick), data[0x00])
|
|
157
|
+
return ParsedPacket(kind, data[0x01], tuple(data_to_pulses(data, tick)), data[0x00])
|
|
156
158
|
|
|
157
159
|
|
|
158
160
|
@dataclass(frozen=True)
|
|
@@ -170,7 +172,7 @@ class CapturedSignal:
|
|
|
170
172
|
|
|
171
173
|
packet: bytes
|
|
172
174
|
kind: SignalKind
|
|
173
|
-
pulses:
|
|
175
|
+
pulses: tuple[int, ...] = field(repr=False)
|
|
174
176
|
repeat: int = 0
|
|
175
177
|
frequency_mhz: float | None = None
|
|
176
178
|
type_byte: int | None = None
|
|
@@ -183,7 +185,7 @@ class CapturedSignal:
|
|
|
183
185
|
frequency_mhz: float | None = None,
|
|
184
186
|
*,
|
|
185
187
|
kind: SignalKind | None = None,
|
|
186
|
-
) ->
|
|
188
|
+
) -> Self:
|
|
187
189
|
"""Build a signal from a device-returned packet.
|
|
188
190
|
|
|
189
191
|
``kind`` overrides the band read from the packet's type byte. A
|
|
@@ -200,7 +202,7 @@ class CapturedSignal:
|
|
|
200
202
|
return cls(
|
|
201
203
|
bytes(packet),
|
|
202
204
|
kind,
|
|
203
|
-
data_to_pulses(packet),
|
|
205
|
+
tuple(data_to_pulses(packet)),
|
|
204
206
|
packet[0x01],
|
|
205
207
|
frequency_mhz,
|
|
206
208
|
type_byte,
|
|
@@ -259,6 +261,9 @@ class rmmini(Device):
|
|
|
259
261
|
raise e.CaptureInProgressError(
|
|
260
262
|
"A capture window is already open; close it with aclose() first"
|
|
261
263
|
)
|
|
264
|
+
if self._window is not prev:
|
|
265
|
+
# Another claimant got in during the two turns above.
|
|
266
|
+
raise e.CaptureInProgressError("A capture window is already open")
|
|
262
267
|
self._window = new
|
|
263
268
|
|
|
264
269
|
async def _send(self, command: int, data: bytes = b"") -> bytes:
|
|
@@ -4,7 +4,7 @@ import asyncio
|
|
|
4
4
|
import base64
|
|
5
5
|
import sys
|
|
6
6
|
import time
|
|
7
|
-
from contextlib import aclosing
|
|
7
|
+
from contextlib import AsyncExitStack, aclosing
|
|
8
8
|
from typing import List
|
|
9
9
|
|
|
10
10
|
import broadlink
|
|
@@ -96,6 +96,12 @@ args = parser.parse_args()
|
|
|
96
96
|
|
|
97
97
|
|
|
98
98
|
async def main():
|
|
99
|
+
async with AsyncExitStack() as stack:
|
|
100
|
+
await run_commands(stack)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
async def run_commands(stack: AsyncExitStack):
|
|
104
|
+
"""Run the requested commands; the device is closed with the stack."""
|
|
99
105
|
dev = None
|
|
100
106
|
|
|
101
107
|
if args.device:
|
|
@@ -110,6 +116,7 @@ async def main():
|
|
|
110
116
|
|
|
111
117
|
if args.host or args.device:
|
|
112
118
|
dev = broadlink.gendevice(devtype, (host, DEFAULT_PORT), mac)
|
|
119
|
+
await stack.enter_async_context(dev)
|
|
113
120
|
await dev.auth()
|
|
114
121
|
|
|
115
122
|
if args.joinwifi:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-broadlink
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.3
|
|
4
4
|
Summary: Python API for controlling Broadlink devices
|
|
5
5
|
Author: DAB-LABS
|
|
6
6
|
Author-email: Matthew Garrett <mjg59@srcf.ucam.org>
|
|
@@ -51,8 +51,9 @@ Every call that reaches a device is a coroutine and must be awaited. That
|
|
|
51
51
|
is the main change from the original library's API: method names and
|
|
52
52
|
arguments are the same, and so are return values, with the small
|
|
53
53
|
exceptions listed in `CHANGELOG.md` (the IR tick constant, `pulses_to_data`
|
|
54
|
-
returning `bytes`, the unused `Device.lock` attribute removed,
|
|
55
|
-
`
|
|
54
|
+
returning `bytes`, the unused `Device.lock` attribute removed, `timeout`
|
|
55
|
+
parameters typed as floats, and the `mac` in a hello response typed as
|
|
56
|
+
`bytes`).
|
|
56
57
|
|
|
57
58
|
```python
|
|
58
59
|
import asyncio
|
|
@@ -81,11 +82,11 @@ The following devices are supported:
|
|
|
81
82
|
- **Switches**: MCB1, SC1, SCB1E, SCB2
|
|
82
83
|
- **Outlets**: BG 800, BG 900
|
|
83
84
|
- **Power strips**: MP1-1K3S2U, MP1-1K4S, MP2
|
|
84
|
-
- **Environment sensors**: A1
|
|
85
|
+
- **Environment sensors**: A1, A2
|
|
85
86
|
- **Alarm kits**: S1C, S2KIT
|
|
86
87
|
- **Light bulbs**: LB1, LB26 R1, LB27 R1, SB800TD, LEDVANCE SMART+ WIFI CEILING TW 24W
|
|
87
88
|
- **Curtain motors**: Dooya DT360E-45/20
|
|
88
|
-
- **Thermostats**: Hysen
|
|
89
|
+
- **Thermostats**: Hysen HY02/HY03
|
|
89
90
|
- **Hubs**: S3
|
|
90
91
|
|
|
91
92
|
## Timing
|
|
@@ -204,6 +205,17 @@ After discovering the device, call the `auth()` method to obtain the authenticat
|
|
|
204
205
|
await device.auth()
|
|
205
206
|
```
|
|
206
207
|
|
|
208
|
+
The session key expires on the device after a while. When a request comes
|
|
209
|
+
back with an expired-key answer, the library authenticates again and
|
|
210
|
+
repeats the request once, so a long-running program does not need to
|
|
211
|
+
handle that itself. If the second authentication fails, for example
|
|
212
|
+
because the device was locked in the app in the meantime, the call raises
|
|
213
|
+
the error the device gave the first time, the same `AuthorizationError`
|
|
214
|
+
or `ConnectionClosedError` the original library raised, and it is up to
|
|
215
|
+
the caller to decide what to do. In the worst case one call can wait out
|
|
216
|
+
three timeouts (the request, the authentication, and the repeat), each
|
|
217
|
+
bounded by `device.timeout`.
|
|
218
|
+
|
|
207
219
|
### Closing
|
|
208
220
|
|
|
209
221
|
Each device keeps one UDP socket open for its lifetime (the original
|
|
@@ -168,7 +168,7 @@ def test_capture_yields_first_signal_and_closes(cls_name, devtype):
|
|
|
168
168
|
assert isinstance(sig, CapturedSignal)
|
|
169
169
|
assert sig.packet == IR
|
|
170
170
|
assert sig.kind is SignalKind.IR
|
|
171
|
-
assert sig.pulses == data_to_pulses(IR)
|
|
171
|
+
assert sig.pulses == tuple(data_to_pulses(IR))
|
|
172
172
|
assert sig.frequency_mhz is None
|
|
173
173
|
assert fake.commands[0][0] == CMD_LEARN
|
|
174
174
|
assert fake.count(CMD_LEARN) == 1
|
|
@@ -661,7 +661,7 @@ def test_parse_packet_round_trip():
|
|
|
661
661
|
parsed = parse_packet(packet)
|
|
662
662
|
assert parsed.kind is kind
|
|
663
663
|
assert parsed.repeat == 1
|
|
664
|
-
assert parsed.pulses == data_to_pulses(packet)
|
|
664
|
+
assert parsed.pulses == tuple(data_to_pulses(packet))
|
|
665
665
|
for a, b in zip(pulses, parsed.pulses, strict=True):
|
|
666
666
|
assert abs(a - b) <= 16
|
|
667
667
|
|
|
@@ -712,10 +712,16 @@ def test_signal_kind_flags():
|
|
|
712
712
|
assert SignalKind(0x26) is SignalKind.IR
|
|
713
713
|
|
|
714
714
|
|
|
715
|
+
def test_captured_signal_is_hashable():
|
|
716
|
+
a = CapturedSignal.from_packet(RF, 433.92)
|
|
717
|
+
assert isinstance(hash(a), int) # a frozen value type belongs in a set
|
|
718
|
+
assert len({parse_packet(RF), parse_packet(RF)}) == 1
|
|
719
|
+
|
|
720
|
+
|
|
715
721
|
def test_captured_signal_from_packet():
|
|
716
722
|
sig = CapturedSignal.from_packet(RF, 433.92)
|
|
717
723
|
assert sig.kind is SignalKind.RF_433
|
|
718
724
|
assert sig.repeat == 0
|
|
719
|
-
assert sig.pulses == data_to_pulses(RF)
|
|
725
|
+
assert sig.pulses == tuple(data_to_pulses(RF))
|
|
720
726
|
assert sig.frequency_mhz == 433.92
|
|
721
727
|
assert sig.captured_at > 0
|
|
@@ -75,7 +75,6 @@ class FakeNet:
|
|
|
75
75
|
def net(monkeypatch):
|
|
76
76
|
fake = FakeNet()
|
|
77
77
|
monkeypatch.setattr(device_module, "_open_endpoint", fake)
|
|
78
|
-
monkeypatch.setattr(broadlink, "_open_endpoint", fake)
|
|
79
78
|
# Keep the retry loop from waiting on real time.
|
|
80
79
|
monkeypatch.setattr(device_module, "DEFAULT_RETRY_INTVL", 0.005)
|
|
81
80
|
return fake
|
|
@@ -386,6 +385,30 @@ def test_aclose_fails_inflight_request_fast(net):
|
|
|
386
385
|
assert run(go()) < 1.0
|
|
387
386
|
|
|
388
387
|
|
|
388
|
+
def test_aclose_during_endpoint_open_does_not_leak(net, monkeypatch):
|
|
389
|
+
"""aclose() landing while create_datagram_endpoint is still running must
|
|
390
|
+
not leave the freshly opened socket behind."""
|
|
391
|
+
dev = fixed_device()
|
|
392
|
+
slow = net
|
|
393
|
+
|
|
394
|
+
async def slow_open(**kwargs):
|
|
395
|
+
await asyncio.sleep(0.02)
|
|
396
|
+
return await slow(**kwargs)
|
|
397
|
+
|
|
398
|
+
monkeypatch.setattr(device_module, "_open_endpoint", slow_open)
|
|
399
|
+
|
|
400
|
+
async def go():
|
|
401
|
+
task = asyncio.get_running_loop().create_task(dev.send_packet(0x6A, b""))
|
|
402
|
+
await asyncio.sleep(0.005) # inside the slow open
|
|
403
|
+
await dev.aclose()
|
|
404
|
+
with pytest.raises(e.EndpointClosedError):
|
|
405
|
+
await task
|
|
406
|
+
|
|
407
|
+
run(go())
|
|
408
|
+
assert dev._transport is None
|
|
409
|
+
assert all(ep.closed for ep in net.endpoints)
|
|
410
|
+
|
|
411
|
+
|
|
389
412
|
def test_host_change_reopens_endpoint(net):
|
|
390
413
|
dev = fixed_device()
|
|
391
414
|
|
|
@@ -475,18 +498,100 @@ def test_expired_session_is_reauthenticated_once(net):
|
|
|
475
498
|
assert dev.decrypt(resp[0x38:])[0] == 9
|
|
476
499
|
|
|
477
500
|
|
|
478
|
-
def
|
|
501
|
+
def test_failed_reauth_returns_the_original_reply(net):
|
|
502
|
+
"""When the library's own re-authentication fails, the caller must see
|
|
503
|
+
the reply the device gave to its request, exactly as 0.19.0 would have
|
|
504
|
+
shown it, so the caller's own recovery (Home Assistant's reauth flow)
|
|
505
|
+
still runs."""
|
|
479
506
|
dev = fixed_device()
|
|
480
507
|
|
|
481
508
|
async def go():
|
|
482
509
|
await dev._endpoint()
|
|
483
510
|
ep = net.endpoints[-1]
|
|
484
511
|
expired = (make_response(dev, b"", error=0xFFF9), HOST)
|
|
485
|
-
ep.replies = [expired, expired] # request fails, auth fails
|
|
486
|
-
|
|
512
|
+
ep.replies = [expired, expired] # request fails -7, auth fails -7
|
|
513
|
+
resp = await dev.send_packet(0x6A, b"")
|
|
514
|
+
return ep, resp
|
|
487
515
|
|
|
516
|
+
ep, resp = run(go())
|
|
517
|
+
assert int.from_bytes(resp[0x22:0x24], "little", signed=True) == -7
|
|
518
|
+
types = [int.from_bytes(f[0x26:0x28], "little") for f, _ in ep.sent]
|
|
519
|
+
assert types == [0x6A, 0x65] # one auth attempt, no blind retry
|
|
488
520
|
with pytest.raises(e.AuthorizationError):
|
|
489
|
-
|
|
521
|
+
e.check_error(resp[0x22:0x24])
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def test_locked_device_surfaces_as_the_original_error(net):
|
|
525
|
+
"""Device locked in the app: request answered -7, auth answered -1. The
|
|
526
|
+
caller gets the -7 frame back (its check_error raises
|
|
527
|
+
AuthorizationError), and its own auth() call then sees the -1."""
|
|
528
|
+
dev = fixed_device()
|
|
529
|
+
|
|
530
|
+
async def go():
|
|
531
|
+
await dev._endpoint()
|
|
532
|
+
ep = net.endpoints[-1]
|
|
533
|
+
|
|
534
|
+
def sendto(data, addr=None):
|
|
535
|
+
ep.sent.append((bytes(data), addr or ep.remote_addr))
|
|
536
|
+
ptype = int.from_bytes(data[0x26:0x28], "little")
|
|
537
|
+
error = 0xFFFF if ptype == 0x65 else 0xFFF9 # -1 to auth, -7 to requests
|
|
538
|
+
ep.protocol.queue.put_nowait((make_response(dev, b"", error=error), HOST))
|
|
539
|
+
|
|
540
|
+
ep.sendto = sendto
|
|
541
|
+
resp = await dev.send_packet(0x6A, b"")
|
|
542
|
+
code = int.from_bytes(resp[0x22:0x24], "little", signed=True)
|
|
543
|
+
with pytest.raises(e.AuthenticationError):
|
|
544
|
+
await dev.auth()
|
|
545
|
+
return ep, code
|
|
546
|
+
|
|
547
|
+
ep, code = run(go())
|
|
548
|
+
assert code == -7
|
|
549
|
+
types = [int.from_bytes(f[0x26:0x28], "little") for f, _ in ep.sent]
|
|
550
|
+
assert types == [0x6A, 0x65, 0x65]
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def test_request_queued_behind_an_auth_still_reauths_if_needed(net):
|
|
554
|
+
"""The auth generation is read under the lock, so a request that was
|
|
555
|
+
queued while another caller's auth() ran, and still gets -7, performs
|
|
556
|
+
its own re-authentication instead of assuming the earlier one covers
|
|
557
|
+
it."""
|
|
558
|
+
dev = fixed_device()
|
|
559
|
+
dev.id = 5
|
|
560
|
+
session_key = bytes.fromhex("00112233445566778899aabbccddeeff")
|
|
561
|
+
fresh = fixed_device()
|
|
562
|
+
auth_reply = make_response(fresh, (0x42).to_bytes(4, "little") + session_key)
|
|
563
|
+
renewed = fixed_device()
|
|
564
|
+
renewed.update_aes(session_key)
|
|
565
|
+
|
|
566
|
+
async def go():
|
|
567
|
+
await dev._endpoint()
|
|
568
|
+
ep = net.endpoints[-1]
|
|
569
|
+
auths = {"n": 0}
|
|
570
|
+
loop = asyncio.get_running_loop()
|
|
571
|
+
|
|
572
|
+
def sendto(data, addr=None):
|
|
573
|
+
ep.sent.append((bytes(data), addr or ep.remote_addr))
|
|
574
|
+
ptype = int.from_bytes(data[0x26:0x28], "little")
|
|
575
|
+
if ptype == 0x65:
|
|
576
|
+
auths["n"] += 1
|
|
577
|
+
reply = auth_reply
|
|
578
|
+
elif auths["n"] < 2:
|
|
579
|
+
reply = make_response(dev, b"", error=0xFFF9) # still -7 after auth #1
|
|
580
|
+
else:
|
|
581
|
+
reply = make_response(renewed, bytes([9]) + bytes(15))
|
|
582
|
+
loop.call_later(0.002, ep.protocol.queue.put_nowait, (reply, ep.remote_addr))
|
|
583
|
+
|
|
584
|
+
ep.sendto = sendto
|
|
585
|
+
first_auth = loop.create_task(dev.auth())
|
|
586
|
+
await asyncio.sleep(0) # let auth() take the lock first
|
|
587
|
+
resp = await dev.send_packet(0x6A, b"")
|
|
588
|
+
await first_auth
|
|
589
|
+
return ep, resp
|
|
590
|
+
|
|
591
|
+
ep, resp = run(go())
|
|
592
|
+
types = [int.from_bytes(f[0x26:0x28], "little") for f, _ in ep.sent]
|
|
593
|
+
assert types == [0x65, 0x6A, 0x65, 0x6A]
|
|
594
|
+
assert dev.decrypt(resp[0x38:])[0] == 9
|
|
490
595
|
|
|
491
596
|
|
|
492
597
|
def test_concurrent_callers_share_one_reauth(net):
|
|
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.2 → python_broadlink-1.0.3}/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
|