python-broadlink 1.0.2__py3-none-any.whl → 1.0.3__py3-none-any.whl
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.
- broadlink/__init__.py +2 -8
- broadlink/device.py +44 -18
- broadlink/exceptions.py +1 -1
- broadlink/remote.py +11 -6
- {python_broadlink-1.0.2.dist-info → python_broadlink-1.0.3.dist-info}/METADATA +17 -5
- {python_broadlink-1.0.2.dist-info → python_broadlink-1.0.3.dist-info}/RECORD +9 -9
- {python_broadlink-1.0.2.dist-info → python_broadlink-1.0.3.dist-info}/WHEEL +0 -0
- {python_broadlink-1.0.2.dist-info → python_broadlink-1.0.3.dist-info}/licenses/LICENSE +0 -0
- {python_broadlink-1.0.2.dist-info → python_broadlink-1.0.3.dist-info}/top_level.txt +0 -0
broadlink/__init__.py
CHANGED
|
@@ -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)
|
broadlink/device.py
CHANGED
|
@@ -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
|
broadlink/exceptions.py
CHANGED
|
@@ -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)
|
broadlink/remote.py
CHANGED
|
@@ -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:
|
|
@@ -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
|
|
@@ -1,19 +1,19 @@
|
|
|
1
|
-
broadlink/__init__.py,sha256=
|
|
1
|
+
broadlink/__init__.py,sha256=wXL3gDxcmE563q4Kjsx9vzWn8Jgopj9Ls7kl4sC0qNQ,10821
|
|
2
2
|
broadlink/alarm.py,sha256=tmeNI-AaPna5nbgoTqz_AGmuQVItGTmt7zyNiRLsSlQ,1260
|
|
3
3
|
broadlink/climate.py,sha256=fciXwQ0P2_kOaJiXAprfXuDyhLdHU--mE6pCYkVWdF8,15592
|
|
4
4
|
broadlink/const.py,sha256=Y0XSxYr1k27FKqHWBirJsUB0HGxQ1riIZ9rfVEJaIL4,120
|
|
5
5
|
broadlink/cover.py,sha256=-wW1BPmf5Z8oeLuqDVEpUyU-PgId5CJ91M3g3RjgVAU,5448
|
|
6
|
-
broadlink/device.py,sha256=
|
|
7
|
-
broadlink/exceptions.py,sha256=
|
|
6
|
+
broadlink/device.py,sha256=dP2RbnxZve_m9bJsis51KfDwPNxxJuGapPltYuWF50M,21507
|
|
7
|
+
broadlink/exceptions.py,sha256=S8xOGdzyFGd6ULXUlblDwE-MADoJIR49itEo0j_fZNU,5226
|
|
8
8
|
broadlink/helpers.py,sha256=Zbw7lNRMB08P5FPAtKMJLExBjts_Gq64tJbR7-JwxX8,1294
|
|
9
9
|
broadlink/hub.py,sha256=e78lDKcdtDNqSOZkQRqLMPMFR0PgT2g3E7VgULCq-QM,2869
|
|
10
10
|
broadlink/light.py,sha256=fWCoaf6JmDfcc7ggRmehss9D3cp66evUDlcB50Iu2LI,7660
|
|
11
11
|
broadlink/protocol.py,sha256=eRCm0fGdApVp5ko_fOP79lazQklda8N6x30ty6P0TiA,1848
|
|
12
|
-
broadlink/remote.py,sha256=
|
|
12
|
+
broadlink/remote.py,sha256=1TO3xo_rhp9_Zt8yFRL3Im_IUH9Nbvt9pwyn9gstk18,22418
|
|
13
13
|
broadlink/sensor.py,sha256=dhetprwgv45gkyvHgS2jJrYVuSQ2z-IXJynykgXo_2k,2701
|
|
14
14
|
broadlink/switch.py,sha256=0VurdoGf3CTrYBSQU4ahVSLC_FYHqIRWr3dLkBwIatI,14985
|
|
15
|
-
python_broadlink-1.0.
|
|
16
|
-
python_broadlink-1.0.
|
|
17
|
-
python_broadlink-1.0.
|
|
18
|
-
python_broadlink-1.0.
|
|
19
|
-
python_broadlink-1.0.
|
|
15
|
+
python_broadlink-1.0.3.dist-info/licenses/LICENSE,sha256=h0N_LWq34pY0W-Ky3ayO6Re5ejG0XvutPlW-GL8eG5I,1111
|
|
16
|
+
python_broadlink-1.0.3.dist-info/METADATA,sha256=YQblobXPiUvcd2rGgxclX3M_JuFMp77kB4d6uSAuJNs,15046
|
|
17
|
+
python_broadlink-1.0.3.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
18
|
+
python_broadlink-1.0.3.dist-info/top_level.txt,sha256=_Qx7d8oNTOLeDkAN-HHDoq1FlRNlsiaMZlyMWgHXAVc,10
|
|
19
|
+
python_broadlink-1.0.3.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|