python-broadlink 1.0.2__py3-none-any.whl → 1.0.4__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 +8 -14
- broadlink/device.py +129 -29
- broadlink/exceptions.py +1 -1
- broadlink/remote.py +21 -15
- broadlink/sensor.py +27 -27
- {python_broadlink-1.0.2.dist-info → python_broadlink-1.0.4.dist-info}/METADATA +33 -9
- {python_broadlink-1.0.2.dist-info → python_broadlink-1.0.4.dist-info}/RECORD +10 -10
- {python_broadlink-1.0.2.dist-info → python_broadlink-1.0.4.dist-info}/WHEEL +0 -0
- {python_broadlink-1.0.2.dist-info → python_broadlink-1.0.4.dist-info}/licenses/LICENSE +0 -0
- {python_broadlink-1.0.2.dist-info → python_broadlink-1.0.4.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
|
-
from collections.abc import
|
|
6
|
-
from typing import Optional, Union
|
|
4
|
+
from collections.abc import AsyncGenerator
|
|
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
|
|
@@ -296,15 +294,15 @@ async def xdiscover(
|
|
|
296
294
|
local_ip_address: str | None = None,
|
|
297
295
|
discover_ip_address: str = DEFAULT_BCAST_ADDR,
|
|
298
296
|
discover_ip_port: int = DEFAULT_PORT,
|
|
299
|
-
) ->
|
|
297
|
+
) -> AsyncGenerator[Device]:
|
|
300
298
|
"""Discover devices connected to the local network.
|
|
301
299
|
|
|
302
300
|
Yields each device as soon as it answers.
|
|
303
301
|
"""
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
302
|
+
responses = scan(timeout, local_ip_address, discover_ip_address, discover_ip_port)
|
|
303
|
+
async with contextlib.aclosing(responses):
|
|
304
|
+
async for resp in responses:
|
|
305
|
+
yield gendevice(*resp)
|
|
308
306
|
|
|
309
307
|
|
|
310
308
|
# Setup a new Broadlink device via AP Mode. Review the README to see how to enter AP Mode.
|
|
@@ -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
|
@@ -15,7 +15,7 @@ import contextlib
|
|
|
15
15
|
import logging
|
|
16
16
|
import random
|
|
17
17
|
import socket
|
|
18
|
-
from collections.abc import
|
|
18
|
+
from collections.abc import AsyncGenerator
|
|
19
19
|
|
|
20
20
|
from cryptography.hazmat.backends import default_backend
|
|
21
21
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
@@ -48,33 +48,55 @@ _RECENT_MAX = 64
|
|
|
48
48
|
_CLOSED = (None, None)
|
|
49
49
|
"""Sentinel put on the receive queue when the endpoint is closed."""
|
|
50
50
|
|
|
51
|
+
_QueueItem = tuple[bytes | Exception | None, tuple[str, int] | None]
|
|
52
|
+
"""What the receive queue carries: a datagram with its source address, an
|
|
53
|
+
error the socket reported (address ``None``), or ``_CLOSED``."""
|
|
54
|
+
|
|
51
55
|
|
|
52
56
|
class _Protocol(asyncio.DatagramProtocol):
|
|
53
|
-
"""Datagram protocol that hands every received packet to a queue.
|
|
57
|
+
"""Datagram protocol that hands every received packet to a queue.
|
|
58
|
+
|
|
59
|
+
Errors the socket reports go on the same queue, so the request that is
|
|
60
|
+
waiting fails at once instead of waiting out its timeout.
|
|
61
|
+
"""
|
|
54
62
|
|
|
55
63
|
def __init__(self) -> None:
|
|
56
|
-
self.queue: asyncio.Queue[
|
|
64
|
+
self.queue: asyncio.Queue[_QueueItem] = asyncio.Queue()
|
|
57
65
|
self.transport: asyncio.DatagramTransport | None = None
|
|
58
66
|
|
|
59
|
-
def connection_made(self, transport) -> None:
|
|
60
|
-
|
|
67
|
+
def connection_made(self, transport: asyncio.BaseTransport) -> None:
|
|
68
|
+
"""Keep the transport; the endpoint sends through it."""
|
|
69
|
+
self.transport = transport # type: ignore[assignment]
|
|
61
70
|
|
|
62
71
|
def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None:
|
|
72
|
+
"""Queue every datagram for the request that is waiting."""
|
|
63
73
|
self.queue.put_nowait((data, addr))
|
|
64
74
|
|
|
65
75
|
def error_received(self, exc: Exception) -> None:
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
pass
|
|
76
|
+
"""Queue a send failure or an ICMP error for the waiting request."""
|
|
77
|
+
self.queue.put_nowait((exc, None))
|
|
69
78
|
|
|
70
79
|
def connection_lost(self, exc: Exception | None) -> None:
|
|
71
|
-
|
|
80
|
+
"""Wake the waiting request if asyncio closed the transport on us."""
|
|
81
|
+
self.queue.put_nowait((exc, None) if exc is not None else _CLOSED)
|
|
72
82
|
|
|
73
83
|
def drain(self) -> None:
|
|
74
84
|
"""Drop anything that arrived before the current request."""
|
|
75
85
|
while not self.queue.empty():
|
|
76
86
|
self.queue.get_nowait()
|
|
77
87
|
|
|
88
|
+
def raise_if_error(self) -> None:
|
|
89
|
+
"""Raise the error a send just reported, if it reported one.
|
|
90
|
+
|
|
91
|
+
asyncio delivers a failed ``sendto`` to ``error_received`` before
|
|
92
|
+
``sendto`` returns, so a fire-and-forget sender can check right
|
|
93
|
+
after sending and raise the ``OSError`` the way a plain socket did.
|
|
94
|
+
"""
|
|
95
|
+
while not self.queue.empty():
|
|
96
|
+
item, _ = self.queue.get_nowait()
|
|
97
|
+
if isinstance(item, Exception):
|
|
98
|
+
raise item
|
|
99
|
+
|
|
78
100
|
|
|
79
101
|
async def _open_endpoint(
|
|
80
102
|
local_addr: tuple[str, int] | None = None,
|
|
@@ -93,6 +115,21 @@ async def _open_endpoint(
|
|
|
93
115
|
return transport, protocol # type: ignore[return-value]
|
|
94
116
|
|
|
95
117
|
|
|
118
|
+
async def _resolve(host: str, port: int) -> tuple[str, int]:
|
|
119
|
+
"""Resolve a destination once, off the event loop.
|
|
120
|
+
|
|
121
|
+
Sending to a hostname through an unconnected datagram socket would
|
|
122
|
+
resolve it with a blocking call on the loop and hide the failure. A
|
|
123
|
+
name that does not resolve raises ``socket.gaierror`` here, as the
|
|
124
|
+
original library's ``sendto`` did.
|
|
125
|
+
"""
|
|
126
|
+
loop = asyncio.get_running_loop()
|
|
127
|
+
info = await loop.getaddrinfo(
|
|
128
|
+
host, port, family=socket.AF_INET, type=socket.SOCK_DGRAM
|
|
129
|
+
)
|
|
130
|
+
return info[0][4][:2] # type: ignore[return-value]
|
|
131
|
+
|
|
132
|
+
|
|
96
133
|
def _hello_packet(local_ip_address: str, port: int) -> bytearray:
|
|
97
134
|
packet = bytearray(0x30)
|
|
98
135
|
packet[0x08:0x14] = Datetime.pack(Datetime.now())
|
|
@@ -117,13 +154,14 @@ async def scan(
|
|
|
117
154
|
local_ip_address: str | None = None,
|
|
118
155
|
discover_ip_address: str = DEFAULT_BCAST_ADDR,
|
|
119
156
|
discover_ip_port: int = DEFAULT_PORT,
|
|
120
|
-
) ->
|
|
157
|
+
) -> AsyncGenerator[HelloResponse]:
|
|
121
158
|
"""Broadcast a hello message and yield responses as they arrive.
|
|
122
159
|
|
|
123
160
|
The hello is repeated every ``DEFAULT_RETRY_INTVL`` seconds until
|
|
124
161
|
``timeout`` elapses. Each device is yielded once.
|
|
125
162
|
"""
|
|
126
163
|
local_addr = (local_ip_address, 0) if local_ip_address else None
|
|
164
|
+
target = await _resolve(discover_ip_address, discover_ip_port)
|
|
127
165
|
transport, protocol = await _open_endpoint(local_addr=local_addr, broadcast=True)
|
|
128
166
|
try:
|
|
129
167
|
if local_ip_address:
|
|
@@ -138,7 +176,7 @@ async def scan(
|
|
|
138
176
|
discovered: set[tuple[tuple[str, int], bytes, int]] = set()
|
|
139
177
|
|
|
140
178
|
while (loop.time() - start) < timeout:
|
|
141
|
-
transport.sendto(packet,
|
|
179
|
+
transport.sendto(packet, target)
|
|
142
180
|
deadline = min(DEFAULT_RETRY_INTVL, timeout - (loop.time() - start))
|
|
143
181
|
slot_end = loop.time() + deadline
|
|
144
182
|
while True:
|
|
@@ -149,7 +187,11 @@ async def scan(
|
|
|
149
187
|
resp, host = await asyncio.wait_for(protocol.queue.get(), remaining)
|
|
150
188
|
except TimeoutError:
|
|
151
189
|
break
|
|
152
|
-
if
|
|
190
|
+
if resp is None:
|
|
191
|
+
return # The transport was closed under us.
|
|
192
|
+
if isinstance(resp, Exception):
|
|
193
|
+
raise resp
|
|
194
|
+
if host is None or len(resp) < 0x80:
|
|
153
195
|
continue
|
|
154
196
|
entry = _parse_hello(resp, host)
|
|
155
197
|
key = (entry[1], entry[2], entry[0])
|
|
@@ -161,6 +203,19 @@ async def scan(
|
|
|
161
203
|
transport.close()
|
|
162
204
|
|
|
163
205
|
|
|
206
|
+
async def send_setup_packet(
|
|
207
|
+
payload: bytes, ip_address: str, port: int = DEFAULT_PORT
|
|
208
|
+
) -> None:
|
|
209
|
+
"""Broadcast one Wi-Fi provisioning packet to a device in AP mode."""
|
|
210
|
+
target = await _resolve(ip_address, port)
|
|
211
|
+
transport, protocol = await _open_endpoint(broadcast=True)
|
|
212
|
+
try:
|
|
213
|
+
transport.sendto(payload, target)
|
|
214
|
+
protocol.raise_if_error()
|
|
215
|
+
finally:
|
|
216
|
+
transport.close()
|
|
217
|
+
|
|
218
|
+
|
|
164
219
|
async def ping(ip_address: str, port: int = DEFAULT_PORT) -> None:
|
|
165
220
|
"""Send a ping packet to an address.
|
|
166
221
|
|
|
@@ -168,11 +223,13 @@ async def ping(ip_address: str, port: int = DEFAULT_PORT) -> None:
|
|
|
168
223
|
Useful to prevent reboots when the cloud cannot be reached.
|
|
169
224
|
It must be sent every 2 minutes in such cases.
|
|
170
225
|
"""
|
|
171
|
-
|
|
226
|
+
target = await _resolve(ip_address, port)
|
|
227
|
+
transport, protocol = await _open_endpoint(broadcast=True)
|
|
172
228
|
try:
|
|
173
229
|
packet = bytearray(0x30)
|
|
174
230
|
packet[0x26] = 1
|
|
175
|
-
transport.sendto(packet,
|
|
231
|
+
transport.sendto(packet, target)
|
|
232
|
+
protocol.raise_if_error()
|
|
176
233
|
finally:
|
|
177
234
|
transport.close()
|
|
178
235
|
|
|
@@ -213,12 +270,13 @@ class Device:
|
|
|
213
270
|
self.aes = None
|
|
214
271
|
self.update_aes(bytes.fromhex(self.__INIT_KEY))
|
|
215
272
|
|
|
216
|
-
self._lock
|
|
273
|
+
self._lock = asyncio.Lock()
|
|
217
274
|
self._transport: asyncio.DatagramTransport | None = None
|
|
218
275
|
self._protocol: _Protocol | None = None
|
|
219
276
|
self._endpoint_addr: tuple[str, int] | None = None
|
|
220
277
|
self._recent: collections.deque[int] = collections.deque(maxlen=_RECENT_MAX)
|
|
221
|
-
self._reauth_lock
|
|
278
|
+
self._reauth_lock = asyncio.Lock()
|
|
279
|
+
self._closes = 0 # Bumped by aclose(); guards an open racing a close.
|
|
222
280
|
self._auth_generation = 0
|
|
223
281
|
|
|
224
282
|
def __repr__(self) -> str:
|
|
@@ -277,9 +335,6 @@ class Device:
|
|
|
277
335
|
packet[0x2D] = 0x01
|
|
278
336
|
packet[0x30:0x36] = b"Test 1"
|
|
279
337
|
|
|
280
|
-
if self._lock is None:
|
|
281
|
-
self._lock = asyncio.Lock()
|
|
282
|
-
self._reauth_lock = asyncio.Lock()
|
|
283
338
|
async with self._lock:
|
|
284
339
|
self.id = 0
|
|
285
340
|
self.update_aes(bytes.fromhex(self.__INIT_KEY))
|
|
@@ -292,7 +347,7 @@ class Device:
|
|
|
292
347
|
_LOGGER.debug("%s: authenticated, session id %d", self.host[0], self.id)
|
|
293
348
|
return True
|
|
294
349
|
|
|
295
|
-
async def hello(self, local_ip_address=None) -> bool:
|
|
350
|
+
async def hello(self, local_ip_address: str | None = None) -> bool:
|
|
296
351
|
"""Send a hello message to the device.
|
|
297
352
|
|
|
298
353
|
Device information is checked before updating name and lock status.
|
|
@@ -385,6 +440,7 @@ class Device:
|
|
|
385
440
|
A request in flight fails at once with ``ConnectionClosedError``
|
|
386
441
|
rather than waiting out its timeout.
|
|
387
442
|
"""
|
|
443
|
+
self._closes += 1
|
|
388
444
|
transport, protocol = self._transport, self._protocol
|
|
389
445
|
self._transport = None
|
|
390
446
|
self._protocol = None
|
|
@@ -395,13 +451,37 @@ class Device:
|
|
|
395
451
|
if protocol is not None:
|
|
396
452
|
protocol.queue.put_nowait(_CLOSED) # type: ignore[arg-type]
|
|
397
453
|
|
|
454
|
+
def _drop_endpoint(self) -> None:
|
|
455
|
+
"""Throw the endpoint away after a failure; the next call reopens it.
|
|
456
|
+
|
|
457
|
+
A connected datagram socket can go bad for good (the interface
|
|
458
|
+
bounced, the host's address changed), and the original library
|
|
459
|
+
never noticed because it opened a socket per call. Dropping the
|
|
460
|
+
endpoint whenever a request fails restores that self-healing.
|
|
461
|
+
"""
|
|
462
|
+
transport = self._transport
|
|
463
|
+
self._transport = None
|
|
464
|
+
self._protocol = None
|
|
465
|
+
self._endpoint_addr = None
|
|
466
|
+
if transport is not None:
|
|
467
|
+
transport.close()
|
|
468
|
+
_LOGGER.debug("%s: endpoint dropped after a failure", self.host[0])
|
|
469
|
+
|
|
398
470
|
async def _endpoint(self) -> tuple[asyncio.DatagramTransport, _Protocol]:
|
|
399
471
|
if self._transport is not None and self._endpoint_addr != self.host:
|
|
400
472
|
# The caller changed host; the connected socket points at the
|
|
401
473
|
# old address, so drop it.
|
|
402
474
|
await self.aclose()
|
|
403
475
|
if self._transport is None or self._transport.is_closing():
|
|
404
|
-
|
|
476
|
+
closes = self._closes
|
|
477
|
+
transport, protocol = await _open_endpoint(remote_addr=self.host)
|
|
478
|
+
if self._closes != closes:
|
|
479
|
+
# aclose() ran while the socket was being opened.
|
|
480
|
+
transport.close()
|
|
481
|
+
raise e.EndpointClosedError(
|
|
482
|
+
-4013, "Endpoint closed", "The device endpoint was closed"
|
|
483
|
+
)
|
|
484
|
+
self._transport, self._protocol = transport, protocol
|
|
405
485
|
self._endpoint_addr = self.host
|
|
406
486
|
_LOGGER.debug("%s: endpoint opened", self.host[0])
|
|
407
487
|
return self._transport, self._protocol # type: ignore[return-value]
|
|
@@ -490,6 +570,19 @@ class Device:
|
|
|
490
570
|
raise e.EndpointClosedError(
|
|
491
571
|
-4013, "Endpoint closed", "The device endpoint was closed"
|
|
492
572
|
)
|
|
573
|
+
if isinstance(resp, ConnectionRefusedError):
|
|
574
|
+
# ICMP port unreachable: the host is up and nothing is
|
|
575
|
+
# listening, or the device is rebooting. The original
|
|
576
|
+
# library's unconnected socket never saw these, so keep
|
|
577
|
+
# waiting and let the timeout decide, as it did.
|
|
578
|
+
_LOGGER.debug("%s: port unreachable, still waiting", self.host[0])
|
|
579
|
+
continue
|
|
580
|
+
if isinstance(resp, Exception):
|
|
581
|
+
# A send failure (no route, address gone) or a fatal
|
|
582
|
+
# transport error: fail now and throw the socket away.
|
|
583
|
+
_LOGGER.debug("%s: socket error: %s", self.host[0], resp)
|
|
584
|
+
self._drop_endpoint()
|
|
585
|
+
raise resp
|
|
493
586
|
resp = self._validate(resp)
|
|
494
587
|
reply_count = int.from_bytes(resp[0x28:0x2A], "little")
|
|
495
588
|
if reply_count == count or reply_count not in self._recent:
|
|
@@ -501,33 +594,40 @@ class Device:
|
|
|
501
594
|
)
|
|
502
595
|
if loop.time() - start >= timeout:
|
|
503
596
|
_LOGGER.debug("%s: no reply within %ss", self.host[0], timeout)
|
|
597
|
+
self._drop_endpoint()
|
|
504
598
|
raise e.NetworkTimeoutError(
|
|
505
599
|
-4000,
|
|
506
600
|
"Network timeout",
|
|
507
601
|
f"No response received within {timeout}s",
|
|
508
602
|
) from None
|
|
509
603
|
|
|
510
|
-
async def send_packet(self, packet_type: int, payload: bytes) -> bytes:
|
|
604
|
+
async def send_packet(self, packet_type: int, payload: bytes | bytearray) -> bytes:
|
|
511
605
|
"""Send a packet to the device and return the raw response frame.
|
|
512
606
|
|
|
513
607
|
If the device answers that the session key is no longer valid, the
|
|
514
608
|
session is re-authenticated once and the request is sent again.
|
|
515
609
|
Concurrent callers that hit the same expired key share one
|
|
516
|
-
re-authentication and each retry once.
|
|
610
|
+
re-authentication and each retry once. If that re-authentication
|
|
611
|
+
fails (for example the device has been locked in the app), the
|
|
612
|
+
original reply is returned unchanged, so the caller sees the same
|
|
613
|
+
error the original library raised and can run its own recovery.
|
|
517
614
|
"""
|
|
518
|
-
if self._lock is None:
|
|
519
|
-
self._lock = asyncio.Lock()
|
|
520
|
-
self._reauth_lock = asyncio.Lock()
|
|
521
|
-
generation = self._auth_generation
|
|
522
615
|
async with self._lock:
|
|
616
|
+
generation = self._auth_generation
|
|
523
617
|
resp = await self._exchange(self._frame(packet_type, bytes(payload)))
|
|
524
618
|
|
|
525
619
|
code = int.from_bytes(resp[0x22:0x24], "little", signed=True)
|
|
526
620
|
if code in _REAUTH_CODES:
|
|
527
621
|
_LOGGER.debug("%s: device answered %d, re-authenticating", self.host[0], code)
|
|
528
|
-
async with self._reauth_lock:
|
|
622
|
+
async with self._reauth_lock:
|
|
529
623
|
if self._auth_generation == generation:
|
|
530
|
-
|
|
624
|
+
try:
|
|
625
|
+
await self.auth()
|
|
626
|
+
except e.BroadlinkException as err:
|
|
627
|
+
_LOGGER.debug(
|
|
628
|
+
"%s: re-authentication failed: %s", self.host[0], err
|
|
629
|
+
)
|
|
630
|
+
return resp
|
|
531
631
|
async with self._lock:
|
|
532
632
|
resp = await self._exchange(self._frame(packet_type, bytes(payload)))
|
|
533
633
|
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
|
@@ -6,8 +6,9 @@ import logging
|
|
|
6
6
|
import struct
|
|
7
7
|
import time
|
|
8
8
|
import weakref
|
|
9
|
-
from collections.abc import
|
|
9
|
+
from collections.abc import AsyncGenerator, Awaitable, Callable
|
|
10
10
|
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Self
|
|
11
12
|
|
|
12
13
|
from . import exceptions as e
|
|
13
14
|
from .device import Device
|
|
@@ -17,9 +18,10 @@ _LOGGER = logging.getLogger(__name__)
|
|
|
17
18
|
TICK = 8192 / 269
|
|
18
19
|
"""Duration of one Broadlink timing unit in microseconds (about 30.45 us).
|
|
19
20
|
|
|
20
|
-
The
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
The value comes from protocol.md, whose conversion "us * 269 / 8192 works
|
|
22
|
+
very well" was measured against real firmware; 8192/269 is its inverse.
|
|
23
|
+
Earlier releases used 32.84, the right ratio applied the wrong way round,
|
|
24
|
+
which compressed externally sourced IR codes by about 7 percent
|
|
23
25
|
(mjg59/python-broadlink#839). Codes learned and replayed through the same
|
|
24
26
|
device were unaffected because both directions shared the constant.
|
|
25
27
|
"""
|
|
@@ -53,10 +55,11 @@ class SignalKind(enum.IntEnum):
|
|
|
53
55
|
|
|
54
56
|
@property
|
|
55
57
|
def is_rf(self) -> bool:
|
|
58
|
+
"""True for the radio bands."""
|
|
56
59
|
return self is not SignalKind.IR
|
|
57
60
|
|
|
58
61
|
@classmethod
|
|
59
|
-
def classify(cls, type_byte: int) ->
|
|
62
|
+
def classify(cls, type_byte: int) -> Self:
|
|
60
63
|
"""Map a packet's raw first byte to a kind, tolerantly.
|
|
61
64
|
|
|
62
65
|
The RF learn path returns bytes in the 0xB_ (433 MHz) and 0xD_
|
|
@@ -139,7 +142,7 @@ class ParsedPacket:
|
|
|
139
142
|
|
|
140
143
|
kind: SignalKind
|
|
141
144
|
repeat: int
|
|
142
|
-
pulses:
|
|
145
|
+
pulses: tuple[int, ...]
|
|
143
146
|
type_byte: int
|
|
144
147
|
|
|
145
148
|
|
|
@@ -152,7 +155,7 @@ def parse_packet(data: bytes, tick: float = TICK) -> ParsedPacket:
|
|
|
152
155
|
if len(data) < 4:
|
|
153
156
|
raise ValueError("Malformed data.")
|
|
154
157
|
kind = SignalKind.classify(data[0x00])
|
|
155
|
-
return ParsedPacket(kind, data[0x01], data_to_pulses(data, tick), data[0x00])
|
|
158
|
+
return ParsedPacket(kind, data[0x01], tuple(data_to_pulses(data, tick)), data[0x00])
|
|
156
159
|
|
|
157
160
|
|
|
158
161
|
@dataclass(frozen=True)
|
|
@@ -170,11 +173,11 @@ class CapturedSignal:
|
|
|
170
173
|
|
|
171
174
|
packet: bytes
|
|
172
175
|
kind: SignalKind
|
|
173
|
-
pulses:
|
|
176
|
+
pulses: tuple[int, ...] = field(repr=False)
|
|
174
177
|
repeat: int = 0
|
|
175
178
|
frequency_mhz: float | None = None
|
|
176
179
|
type_byte: int | None = None
|
|
177
|
-
captured_at: float = field(default_factory=time.time, repr=False)
|
|
180
|
+
captured_at: float = field(default_factory=time.time, repr=False, compare=False)
|
|
178
181
|
|
|
179
182
|
@classmethod
|
|
180
183
|
def from_packet(
|
|
@@ -183,7 +186,7 @@ class CapturedSignal:
|
|
|
183
186
|
frequency_mhz: float | None = None,
|
|
184
187
|
*,
|
|
185
188
|
kind: SignalKind | None = None,
|
|
186
|
-
) ->
|
|
189
|
+
) -> Self:
|
|
187
190
|
"""Build a signal from a device-returned packet.
|
|
188
191
|
|
|
189
192
|
``kind`` overrides the band read from the packet's type byte. A
|
|
@@ -200,7 +203,7 @@ class CapturedSignal:
|
|
|
200
203
|
return cls(
|
|
201
204
|
bytes(packet),
|
|
202
205
|
kind,
|
|
203
|
-
data_to_pulses(packet),
|
|
206
|
+
tuple(data_to_pulses(packet)),
|
|
204
207
|
packet[0x01],
|
|
205
208
|
frequency_mhz,
|
|
206
209
|
type_byte,
|
|
@@ -259,6 +262,9 @@ class rmmini(Device):
|
|
|
259
262
|
raise e.CaptureInProgressError(
|
|
260
263
|
"A capture window is already open; close it with aclose() first"
|
|
261
264
|
)
|
|
265
|
+
if self._window is not prev:
|
|
266
|
+
# Another claimant got in during the two turns above.
|
|
267
|
+
raise e.CaptureInProgressError("A capture window is already open")
|
|
262
268
|
self._window = new
|
|
263
269
|
|
|
264
270
|
async def _send(self, command: int, data: bytes = b"") -> bytes:
|
|
@@ -295,7 +301,7 @@ class rmmini(Device):
|
|
|
295
301
|
stop_after_first: bool = True,
|
|
296
302
|
poll_interval: float = DEFAULT_POLL_INTERVAL,
|
|
297
303
|
rearm_interval: float = DEFAULT_REARM_INTERVAL,
|
|
298
|
-
) ->
|
|
304
|
+
) -> AsyncGenerator[CapturedSignal]:
|
|
299
305
|
"""Open an infrared capture window and yield what the device hears.
|
|
300
306
|
|
|
301
307
|
The device is put into learning mode and polled every
|
|
@@ -343,7 +349,7 @@ class rmmini(Device):
|
|
|
343
349
|
frequency_mhz: float | None,
|
|
344
350
|
*,
|
|
345
351
|
claim: list | None = None,
|
|
346
|
-
) ->
|
|
352
|
+
) -> AsyncGenerator[CapturedSignal]:
|
|
347
353
|
# ``claim`` carries a weak reference to this generator (filled in by
|
|
348
354
|
# the caller after creating it); None means the caller owns the
|
|
349
355
|
# window claim, as capture_rf does for its inner loop.
|
|
@@ -452,7 +458,7 @@ class rmpro(rmmini):
|
|
|
452
458
|
stop_after_first: bool = True,
|
|
453
459
|
poll_interval: float = DEFAULT_POLL_INTERVAL,
|
|
454
460
|
rearm_interval: float = DEFAULT_REARM_INTERVAL,
|
|
455
|
-
) ->
|
|
461
|
+
) -> AsyncGenerator[CapturedSignal]:
|
|
456
462
|
"""Open a radio frequency capture window and yield what the device hears.
|
|
457
463
|
|
|
458
464
|
With ``frequency`` (in MHz, for example 433.92) the device goes
|
|
@@ -491,7 +497,7 @@ class rmpro(rmmini):
|
|
|
491
497
|
rearm_interval: float,
|
|
492
498
|
*,
|
|
493
499
|
claim: list,
|
|
494
|
-
) ->
|
|
500
|
+
) -> AsyncGenerator[CapturedSignal]:
|
|
495
501
|
if window < 0 or poll_interval <= 0:
|
|
496
502
|
raise ValueError("window must be 0 or positive, poll_interval positive")
|
|
497
503
|
await self._claim_window(claim[0])
|
broadlink/sensor.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Support for sensors."""
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
import struct
|
|
4
4
|
|
|
5
5
|
from . import exceptions as e
|
|
6
6
|
from .device import Device
|
|
@@ -48,35 +48,35 @@ class a2(Device):
|
|
|
48
48
|
|
|
49
49
|
TYPE = "A2"
|
|
50
50
|
|
|
51
|
-
async def _send(self, operation: int, data:
|
|
52
|
-
"""Send a command to the device.
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
packet
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
packet
|
|
74
|
-
packet[
|
|
51
|
+
async def _send(self, operation: int, data: bytes = b"") -> bytes:
|
|
52
|
+
"""Send a command to the device.
|
|
53
|
+
|
|
54
|
+
The frame is the one the SP4 and LB1 families use: a two-byte
|
|
55
|
+
length, the A5A5 5A5A marker, a checksum, the operation, 0x0B and
|
|
56
|
+
a four-byte data length. The 0.19.0 code wrote a two-byte data
|
|
57
|
+
length and a length field two short, and real A2 units answered
|
|
58
|
+
every request with error -5 (mjg59/python-broadlink#826).
|
|
59
|
+
"""
|
|
60
|
+
packet = bytearray(14)
|
|
61
|
+
struct.pack_into(
|
|
62
|
+
"<HHHHBBI",
|
|
63
|
+
packet,
|
|
64
|
+
0,
|
|
65
|
+
12 + len(data),
|
|
66
|
+
0xA5A5,
|
|
67
|
+
0x5A5A,
|
|
68
|
+
0,
|
|
69
|
+
operation,
|
|
70
|
+
0x0B,
|
|
71
|
+
len(data),
|
|
72
|
+
)
|
|
73
|
+
packet.extend(data)
|
|
74
|
+
checksum = sum(packet[0x02:], 0xBEAF) & 0xFFFF
|
|
75
|
+
packet[0x06:0x08] = checksum.to_bytes(2, "little")
|
|
75
76
|
|
|
76
77
|
resp = await self.send_packet(0x6A, packet)
|
|
77
78
|
e.check_error(resp[0x22:0x24])
|
|
78
|
-
|
|
79
|
-
return payload
|
|
79
|
+
return self.decrypt(resp[0x38:])
|
|
80
80
|
|
|
81
81
|
async def check_sensors_raw(self) -> dict:
|
|
82
82
|
"""Return the state of the sensors in raw format."""
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-broadlink
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.4
|
|
4
4
|
Summary: Python API for controlling Broadlink devices
|
|
5
5
|
Author: DAB-LABS
|
|
6
6
|
Author-email: Matthew Garrett <mjg59@srcf.ucam.org>
|
|
@@ -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
|
|
@@ -192,6 +193,11 @@ If the device is locked, it may not be discoverable with broadcast. In such case
|
|
|
192
193
|
device = await broadlink.hello("192.168.0.16")
|
|
193
194
|
```
|
|
194
195
|
|
|
196
|
+
`discover()` and `hello()` raise `NetworkTimeoutError` when nothing answers
|
|
197
|
+
within the timeout, `socket.gaierror` when a hostname does not resolve, and
|
|
198
|
+
`OSError` when the socket cannot be opened or the send fails (no route, for
|
|
199
|
+
example), the same errors the original library raised from its socket.
|
|
200
|
+
|
|
195
201
|
If you are a perfomance freak, use `broadlink.xdiscover()` to create devices instantly:
|
|
196
202
|
```python3
|
|
197
203
|
async for device in broadlink.xdiscover():
|
|
@@ -204,6 +210,17 @@ After discovering the device, call the `auth()` method to obtain the authenticat
|
|
|
204
210
|
await device.auth()
|
|
205
211
|
```
|
|
206
212
|
|
|
213
|
+
The session key expires on the device after a while. When a request comes
|
|
214
|
+
back with an expired-key answer, the library authenticates again and
|
|
215
|
+
repeats the request once, so a long-running program does not need to
|
|
216
|
+
handle that itself. If the second authentication fails, for example
|
|
217
|
+
because the device was locked in the app in the meantime, the call raises
|
|
218
|
+
the error the device gave the first time, the same `AuthorizationError`
|
|
219
|
+
or `ConnectionClosedError` the original library raised, and it is up to
|
|
220
|
+
the caller to decide what to do. In the worst case one call can wait out
|
|
221
|
+
three timeouts (the request, the authentication, and the repeat), each
|
|
222
|
+
bounded by `device.timeout`.
|
|
223
|
+
|
|
207
224
|
### Closing
|
|
208
225
|
|
|
209
226
|
Each device keeps one UDP socket open for its lifetime (the original
|
|
@@ -221,9 +238,14 @@ await device.aclose()
|
|
|
221
238
|
|
|
222
239
|
The socket reopens by itself on the next call, so closing is cheap and
|
|
223
240
|
safe to do at any time. A request that is in flight when `aclose()` runs
|
|
224
|
-
fails with `EndpointClosedError`.
|
|
225
|
-
|
|
226
|
-
|
|
241
|
+
fails with `EndpointClosedError`. A request that fails for a network
|
|
242
|
+
reason (a timeout, or an `OSError` from the socket such as "network is
|
|
243
|
+
unreachable" after an interface change, raised at once) also drops the socket, so the
|
|
244
|
+
next call starts fresh rather than reusing one that has gone bad, which
|
|
245
|
+
is how the original library behaved by opening a socket per call. An
|
|
246
|
+
integration that creates devices should close them when it unloads; a
|
|
247
|
+
device that is never closed holds its socket until it is garbage
|
|
248
|
+
collected.
|
|
227
249
|
|
|
228
250
|
The next steps depend on the type of device you want to control.
|
|
229
251
|
|
|
@@ -299,7 +321,9 @@ By default the window closes after the first signal. Pass
|
|
|
299
321
|
because the device holds only one code per learning session. A universal
|
|
300
322
|
remote has a single receiver, so only one capture window can be open on a
|
|
301
323
|
device at a time: opening a second one raises `CaptureInProgressError`
|
|
302
|
-
while the first is still held
|
|
324
|
+
while the first is still held, either from the `capture()` call itself or
|
|
325
|
+
from the new window's first iteration, depending on what the first window
|
|
326
|
+
was doing at that moment. Always close a window you leave early
|
|
303
327
|
(`aclosing` above does it), otherwise it stays open until Python collects
|
|
304
328
|
the generator.
|
|
305
329
|
|
|
@@ -1,19 +1,19 @@
|
|
|
1
|
-
broadlink/__init__.py,sha256=
|
|
1
|
+
broadlink/__init__.py,sha256=CYuVoYuKsXTd69yx7X9lIYr3DEPFlUL88bE4rcZMqb4,10890
|
|
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=-aAZfV_CSWqIeFowewQo5Dn3vvp2YclZ4E19qOc_hVQ,24883
|
|
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=
|
|
13
|
-
broadlink/sensor.py,sha256=
|
|
12
|
+
broadlink/remote.py,sha256=3UjIvyps9BmCCPemGiWCm5BgCsNpT2TjD3dKznQ93JI,22487
|
|
13
|
+
broadlink/sensor.py,sha256=Pa4fJnG28wPFIf7HSN4JQ3cIOfAdTCu5I0spnBG12Qg,2791
|
|
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.4.dist-info/licenses/LICENSE,sha256=h0N_LWq34pY0W-Ky3ayO6Re5ejG0XvutPlW-GL8eG5I,1111
|
|
16
|
+
python_broadlink-1.0.4.dist-info/METADATA,sha256=5uZ80FW7Vd0OzTsttoPeI78P0oXJHbLWXRkh92Rb-vE,15819
|
|
17
|
+
python_broadlink-1.0.4.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
18
|
+
python_broadlink-1.0.4.dist-info/top_level.txt,sha256=_Qx7d8oNTOLeDkAN-HHDoq1FlRNlsiaMZlyMWgHXAVc,10
|
|
19
|
+
python_broadlink-1.0.4.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|