python-broadlink 1.0.3__py3-none-any.whl → 1.0.5__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 +12 -12
- broadlink/device.py +108 -14
- broadlink/remote.py +89 -77
- broadlink/sensor.py +27 -27
- {python_broadlink-1.0.3.dist-info → python_broadlink-1.0.5.dist-info}/METADATA +21 -5
- {python_broadlink-1.0.3.dist-info → python_broadlink-1.0.5.dist-info}/RECORD +9 -9
- {python_broadlink-1.0.3.dist-info → python_broadlink-1.0.5.dist-info}/WHEEL +0 -0
- {python_broadlink-1.0.3.dist-info → python_broadlink-1.0.5.dist-info}/licenses/LICENSE +0 -0
- {python_broadlink-1.0.3.dist-info → python_broadlink-1.0.5.dist-info}/top_level.txt +0 -0
broadlink/__init__.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""The python-broadlink library."""
|
|
2
2
|
|
|
3
3
|
import contextlib
|
|
4
|
-
from collections.abc import
|
|
4
|
+
from collections.abc import AsyncGenerator
|
|
5
5
|
|
|
6
6
|
from . import exceptions as e
|
|
7
7
|
from .alarm import S1C
|
|
@@ -116,6 +116,9 @@ SUPPORTED_TYPES = {
|
|
|
116
116
|
0x27A6: ("RM plus", "Broadlink"),
|
|
117
117
|
0x27A9: ("RM pro+", "Broadlink"),
|
|
118
118
|
0x27C3: ("RM pro+", "Broadlink"),
|
|
119
|
+
# The RM Max answers the RM pro framing; the RM4 framing (length
|
|
120
|
+
# prefix) gets "device is locked" from it. Tested on hardware in
|
|
121
|
+
# upstream #838, whose text says rm4pro but whose diff says rmpro.
|
|
119
122
|
0xAF8B: ("RM Max", "Broadlink"),
|
|
120
123
|
},
|
|
121
124
|
rmminib: {
|
|
@@ -281,12 +284,9 @@ async def discover(
|
|
|
281
284
|
discover_ip_port: int = DEFAULT_PORT,
|
|
282
285
|
) -> list[Device]:
|
|
283
286
|
"""Discover devices connected to the local network."""
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
async for device in
|
|
287
|
-
timeout, local_ip_address, discover_ip_address, discover_ip_port
|
|
288
|
-
)
|
|
289
|
-
]
|
|
287
|
+
devices = xdiscover(timeout, local_ip_address, discover_ip_address, discover_ip_port)
|
|
288
|
+
async with contextlib.aclosing(devices):
|
|
289
|
+
return [device async for device in devices]
|
|
290
290
|
|
|
291
291
|
|
|
292
292
|
async def xdiscover(
|
|
@@ -294,15 +294,15 @@ async def xdiscover(
|
|
|
294
294
|
local_ip_address: str | None = None,
|
|
295
295
|
discover_ip_address: str = DEFAULT_BCAST_ADDR,
|
|
296
296
|
discover_ip_port: int = DEFAULT_PORT,
|
|
297
|
-
) ->
|
|
297
|
+
) -> AsyncGenerator[Device]:
|
|
298
298
|
"""Discover devices connected to the local network.
|
|
299
299
|
|
|
300
300
|
Yields each device as soon as it answers.
|
|
301
301
|
"""
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
302
|
+
responses = scan(timeout, local_ip_address, discover_ip_address, discover_ip_port)
|
|
303
|
+
async with contextlib.aclosing(responses):
|
|
304
|
+
async for resp in responses:
|
|
305
|
+
yield gendevice(*resp)
|
|
306
306
|
|
|
307
307
|
|
|
308
308
|
# Setup a new Broadlink device via AP Mode. Review the README to see how to enter AP Mode.
|
broadlink/device.py
CHANGED
|
@@ -12,10 +12,11 @@ from __future__ import annotations
|
|
|
12
12
|
import asyncio
|
|
13
13
|
import collections
|
|
14
14
|
import contextlib
|
|
15
|
+
import errno
|
|
15
16
|
import logging
|
|
16
17
|
import random
|
|
17
18
|
import socket
|
|
18
|
-
from collections.abc import
|
|
19
|
+
from collections.abc import AsyncGenerator
|
|
19
20
|
|
|
20
21
|
from cryptography.hazmat.backends import default_backend
|
|
21
22
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
@@ -48,12 +49,36 @@ _RECENT_MAX = 64
|
|
|
48
49
|
_CLOSED = (None, None)
|
|
49
50
|
"""Sentinel put on the receive queue when the endpoint is closed."""
|
|
50
51
|
|
|
52
|
+
_QueueItem = tuple[bytes | Exception | None, tuple[str, int] | None]
|
|
53
|
+
"""What the receive queue carries: a datagram with its source address, an
|
|
54
|
+
error the socket reported (address ``None``), or ``_CLOSED``."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _is_silence(item: object) -> bool:
|
|
58
|
+
"""True for the socket errors that mean "no device answered".
|
|
59
|
+
|
|
60
|
+
A connected datagram socket learns from ICMP that nobody is listening
|
|
61
|
+
(port unreachable, ``ConnectionRefusedError``; ``ConnectionResetError``
|
|
62
|
+
on Windows) or that the host cannot be reached (``EHOSTUNREACH``, from
|
|
63
|
+
a router answering for a host that is off). The original library used
|
|
64
|
+
an unconnected socket that never received any of these and simply
|
|
65
|
+
timed out, and callers such as Home Assistant treat a timeout more
|
|
66
|
+
leniently than an ``OSError``, so these are treated as silence.
|
|
67
|
+
"""
|
|
68
|
+
if isinstance(item, ConnectionRefusedError | ConnectionResetError):
|
|
69
|
+
return True
|
|
70
|
+
return isinstance(item, OSError) and item.errno == errno.EHOSTUNREACH
|
|
71
|
+
|
|
51
72
|
|
|
52
73
|
class _Protocol(asyncio.DatagramProtocol):
|
|
53
|
-
"""Datagram protocol that hands every received packet to a queue.
|
|
74
|
+
"""Datagram protocol that hands every received packet to a queue.
|
|
75
|
+
|
|
76
|
+
Errors the socket reports go on the same queue, so the request that is
|
|
77
|
+
waiting fails at once instead of waiting out its timeout.
|
|
78
|
+
"""
|
|
54
79
|
|
|
55
80
|
def __init__(self) -> None:
|
|
56
|
-
self.queue: asyncio.Queue[
|
|
81
|
+
self.queue: asyncio.Queue[_QueueItem] = asyncio.Queue()
|
|
57
82
|
self.transport: asyncio.DatagramTransport | None = None
|
|
58
83
|
|
|
59
84
|
def connection_made(self, transport: asyncio.BaseTransport) -> None:
|
|
@@ -65,18 +90,30 @@ class _Protocol(asyncio.DatagramProtocol):
|
|
|
65
90
|
self.queue.put_nowait((data, addr))
|
|
66
91
|
|
|
67
92
|
def error_received(self, exc: Exception) -> None:
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
pass
|
|
93
|
+
"""Queue a send failure or an ICMP error for the waiting request."""
|
|
94
|
+
self.queue.put_nowait((exc, None))
|
|
71
95
|
|
|
72
96
|
def connection_lost(self, exc: Exception | None) -> None:
|
|
73
|
-
"""
|
|
97
|
+
"""Wake the waiting request if asyncio closed the transport on us."""
|
|
98
|
+
self.queue.put_nowait((exc, None) if exc is not None else _CLOSED)
|
|
74
99
|
|
|
75
100
|
def drain(self) -> None:
|
|
76
101
|
"""Drop anything that arrived before the current request."""
|
|
77
102
|
while not self.queue.empty():
|
|
78
103
|
self.queue.get_nowait()
|
|
79
104
|
|
|
105
|
+
def raise_if_error(self) -> None:
|
|
106
|
+
"""Raise the error a send just reported, if it reported one.
|
|
107
|
+
|
|
108
|
+
asyncio delivers a failed ``sendto`` to ``error_received`` before
|
|
109
|
+
``sendto`` returns, so a fire-and-forget sender can check right
|
|
110
|
+
after sending and raise the ``OSError`` the way a plain socket did.
|
|
111
|
+
"""
|
|
112
|
+
while not self.queue.empty():
|
|
113
|
+
item, _ = self.queue.get_nowait()
|
|
114
|
+
if isinstance(item, Exception):
|
|
115
|
+
raise item
|
|
116
|
+
|
|
80
117
|
|
|
81
118
|
async def _open_endpoint(
|
|
82
119
|
local_addr: tuple[str, int] | None = None,
|
|
@@ -95,6 +132,21 @@ async def _open_endpoint(
|
|
|
95
132
|
return transport, protocol # type: ignore[return-value]
|
|
96
133
|
|
|
97
134
|
|
|
135
|
+
async def _resolve(host: str, port: int) -> tuple[str, int]:
|
|
136
|
+
"""Resolve a destination once, off the event loop.
|
|
137
|
+
|
|
138
|
+
Sending to a hostname through an unconnected datagram socket would
|
|
139
|
+
resolve it with a blocking call on the loop and hide the failure. A
|
|
140
|
+
name that does not resolve raises ``socket.gaierror`` here, as the
|
|
141
|
+
original library's ``sendto`` did.
|
|
142
|
+
"""
|
|
143
|
+
loop = asyncio.get_running_loop()
|
|
144
|
+
info = await loop.getaddrinfo(
|
|
145
|
+
host, port, family=socket.AF_INET, type=socket.SOCK_DGRAM
|
|
146
|
+
)
|
|
147
|
+
return info[0][4][:2] # type: ignore[return-value]
|
|
148
|
+
|
|
149
|
+
|
|
98
150
|
def _hello_packet(local_ip_address: str, port: int) -> bytearray:
|
|
99
151
|
packet = bytearray(0x30)
|
|
100
152
|
packet[0x08:0x14] = Datetime.pack(Datetime.now())
|
|
@@ -119,13 +171,14 @@ async def scan(
|
|
|
119
171
|
local_ip_address: str | None = None,
|
|
120
172
|
discover_ip_address: str = DEFAULT_BCAST_ADDR,
|
|
121
173
|
discover_ip_port: int = DEFAULT_PORT,
|
|
122
|
-
) ->
|
|
174
|
+
) -> AsyncGenerator[HelloResponse]:
|
|
123
175
|
"""Broadcast a hello message and yield responses as they arrive.
|
|
124
176
|
|
|
125
177
|
The hello is repeated every ``DEFAULT_RETRY_INTVL`` seconds until
|
|
126
178
|
``timeout`` elapses. Each device is yielded once.
|
|
127
179
|
"""
|
|
128
180
|
local_addr = (local_ip_address, 0) if local_ip_address else None
|
|
181
|
+
target = await _resolve(discover_ip_address, discover_ip_port)
|
|
129
182
|
transport, protocol = await _open_endpoint(local_addr=local_addr, broadcast=True)
|
|
130
183
|
try:
|
|
131
184
|
if local_ip_address:
|
|
@@ -140,7 +193,7 @@ async def scan(
|
|
|
140
193
|
discovered: set[tuple[tuple[str, int], bytes, int]] = set()
|
|
141
194
|
|
|
142
195
|
while (loop.time() - start) < timeout:
|
|
143
|
-
transport.sendto(packet,
|
|
196
|
+
transport.sendto(packet, target)
|
|
144
197
|
deadline = min(DEFAULT_RETRY_INTVL, timeout - (loop.time() - start))
|
|
145
198
|
slot_end = loop.time() + deadline
|
|
146
199
|
while True:
|
|
@@ -151,7 +204,11 @@ async def scan(
|
|
|
151
204
|
resp, host = await asyncio.wait_for(protocol.queue.get(), remaining)
|
|
152
205
|
except TimeoutError:
|
|
153
206
|
break
|
|
154
|
-
if
|
|
207
|
+
if resp is None:
|
|
208
|
+
return # The transport was closed under us.
|
|
209
|
+
if isinstance(resp, Exception):
|
|
210
|
+
raise resp
|
|
211
|
+
if host is None or len(resp) < 0x80:
|
|
155
212
|
continue
|
|
156
213
|
entry = _parse_hello(resp, host)
|
|
157
214
|
key = (entry[1], entry[2], entry[0])
|
|
@@ -167,9 +224,11 @@ async def send_setup_packet(
|
|
|
167
224
|
payload: bytes, ip_address: str, port: int = DEFAULT_PORT
|
|
168
225
|
) -> None:
|
|
169
226
|
"""Broadcast one Wi-Fi provisioning packet to a device in AP mode."""
|
|
170
|
-
|
|
227
|
+
target = await _resolve(ip_address, port)
|
|
228
|
+
transport, protocol = await _open_endpoint(broadcast=True)
|
|
171
229
|
try:
|
|
172
|
-
transport.sendto(payload,
|
|
230
|
+
transport.sendto(payload, target)
|
|
231
|
+
protocol.raise_if_error()
|
|
173
232
|
finally:
|
|
174
233
|
transport.close()
|
|
175
234
|
|
|
@@ -181,11 +240,13 @@ async def ping(ip_address: str, port: int = DEFAULT_PORT) -> None:
|
|
|
181
240
|
Useful to prevent reboots when the cloud cannot be reached.
|
|
182
241
|
It must be sent every 2 minutes in such cases.
|
|
183
242
|
"""
|
|
184
|
-
|
|
243
|
+
target = await _resolve(ip_address, port)
|
|
244
|
+
transport, protocol = await _open_endpoint(broadcast=True)
|
|
185
245
|
try:
|
|
186
246
|
packet = bytearray(0x30)
|
|
187
247
|
packet[0x26] = 1
|
|
188
|
-
transport.sendto(packet,
|
|
248
|
+
transport.sendto(packet, target)
|
|
249
|
+
protocol.raise_if_error()
|
|
189
250
|
finally:
|
|
190
251
|
transport.close()
|
|
191
252
|
|
|
@@ -407,6 +468,22 @@ class Device:
|
|
|
407
468
|
if protocol is not None:
|
|
408
469
|
protocol.queue.put_nowait(_CLOSED) # type: ignore[arg-type]
|
|
409
470
|
|
|
471
|
+
def _drop_endpoint(self) -> None:
|
|
472
|
+
"""Throw the endpoint away after a failure; the next call reopens it.
|
|
473
|
+
|
|
474
|
+
A connected datagram socket can go bad for good (the interface
|
|
475
|
+
bounced, the host's address changed), and the original library
|
|
476
|
+
never noticed because it opened a socket per call. Dropping the
|
|
477
|
+
endpoint whenever a request fails restores that self-healing.
|
|
478
|
+
"""
|
|
479
|
+
transport = self._transport
|
|
480
|
+
self._transport = None
|
|
481
|
+
self._protocol = None
|
|
482
|
+
self._endpoint_addr = None
|
|
483
|
+
if transport is not None:
|
|
484
|
+
transport.close()
|
|
485
|
+
_LOGGER.debug("%s: endpoint dropped after a failure", self.host[0])
|
|
486
|
+
|
|
410
487
|
async def _endpoint(self) -> tuple[asyncio.DatagramTransport, _Protocol]:
|
|
411
488
|
if self._transport is not None and self._endpoint_addr != self.host:
|
|
412
489
|
# The caller changed host; the connected socket points at the
|
|
@@ -510,6 +587,22 @@ class Device:
|
|
|
510
587
|
raise e.EndpointClosedError(
|
|
511
588
|
-4013, "Endpoint closed", "The device endpoint was closed"
|
|
512
589
|
)
|
|
590
|
+
if _is_silence(resp):
|
|
591
|
+
# ICMP unreachable of one kind or another: the host is up
|
|
592
|
+
# with nothing listening, the device is off or rebooting,
|
|
593
|
+
# or a router answered for it. The original library's
|
|
594
|
+
# unconnected socket never saw these, so keep waiting and
|
|
595
|
+
# let the timeout decide, as it did.
|
|
596
|
+
_LOGGER.debug(
|
|
597
|
+
"%s: unreachable (%s), still waiting", self.host[0], resp
|
|
598
|
+
)
|
|
599
|
+
continue
|
|
600
|
+
if isinstance(resp, Exception):
|
|
601
|
+
# A send failure (no route, address gone) or a fatal
|
|
602
|
+
# transport error: fail now and throw the socket away.
|
|
603
|
+
_LOGGER.debug("%s: socket error: %s", self.host[0], resp)
|
|
604
|
+
self._drop_endpoint()
|
|
605
|
+
raise resp
|
|
513
606
|
resp = self._validate(resp)
|
|
514
607
|
reply_count = int.from_bytes(resp[0x28:0x2A], "little")
|
|
515
608
|
if reply_count == count or reply_count not in self._recent:
|
|
@@ -521,6 +614,7 @@ class Device:
|
|
|
521
614
|
)
|
|
522
615
|
if loop.time() - start >= timeout:
|
|
523
616
|
_LOGGER.debug("%s: no reply within %ss", self.host[0], timeout)
|
|
617
|
+
self._drop_endpoint()
|
|
524
618
|
raise e.NetworkTimeoutError(
|
|
525
619
|
-4000,
|
|
526
620
|
"Network timeout",
|
broadlink/remote.py
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
"""Support for universal remotes."""
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
+
import contextlib
|
|
4
5
|
import enum
|
|
5
6
|
import logging
|
|
6
7
|
import struct
|
|
7
8
|
import time
|
|
8
|
-
import
|
|
9
|
-
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
9
|
+
from collections.abc import AsyncGenerator, Awaitable, Callable
|
|
10
10
|
from dataclasses import dataclass, field
|
|
11
11
|
from typing import Self
|
|
12
12
|
|
|
@@ -18,9 +18,10 @@ _LOGGER = logging.getLogger(__name__)
|
|
|
18
18
|
TICK = 8192 / 269
|
|
19
19
|
"""Duration of one Broadlink timing unit in microseconds (about 30.45 us).
|
|
20
20
|
|
|
21
|
-
The
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
The value comes from protocol.md, whose conversion "us * 269 / 8192 works
|
|
22
|
+
very well" was measured against real firmware; 8192/269 is its inverse.
|
|
23
|
+
Earlier releases used 32.84, the right ratio applied the wrong way round,
|
|
24
|
+
which compressed externally sourced IR codes by about 7 percent
|
|
24
25
|
(mjg59/python-broadlink#839). Codes learned and replayed through the same
|
|
25
26
|
device were unaffected because both directions shared the constant.
|
|
26
27
|
"""
|
|
@@ -176,7 +177,7 @@ class CapturedSignal:
|
|
|
176
177
|
repeat: int = 0
|
|
177
178
|
frequency_mhz: float | None = None
|
|
178
179
|
type_byte: int | None = None
|
|
179
|
-
captured_at: float = field(default_factory=time.time, repr=False)
|
|
180
|
+
captured_at: float = field(default_factory=time.time, repr=False, compare=False)
|
|
180
181
|
|
|
181
182
|
@classmethod
|
|
182
183
|
def from_packet(
|
|
@@ -220,51 +221,39 @@ class rmmini(Device):
|
|
|
220
221
|
# against the value it saw when it armed the device and re-arms
|
|
221
222
|
# after any send, since the device has one front end for both.
|
|
222
223
|
self._tx_generation = 0
|
|
223
|
-
#
|
|
224
|
-
# window,
|
|
225
|
-
|
|
224
|
+
# True while a capture window holds the device's receiver. Set by
|
|
225
|
+
# the window on its first iteration, cleared when its generator
|
|
226
|
+
# finishes or is closed, including by asyncio's finalizer.
|
|
227
|
+
self._capturing = False
|
|
226
228
|
|
|
227
229
|
@property
|
|
228
230
|
def capture_active(self) -> bool:
|
|
229
231
|
"""True while a capture window is open on this device."""
|
|
230
|
-
|
|
231
|
-
return window is not None and window.ag_frame is not None
|
|
232
|
+
return self._capturing
|
|
232
233
|
|
|
233
|
-
def
|
|
234
|
-
"""
|
|
235
|
-
old = self._window() if self._window is not None else None
|
|
236
|
-
if old is not None and old.ag_frame is not None and old.ag_running:
|
|
237
|
-
raise e.CaptureInProgressError("A capture window is already open")
|
|
238
|
-
|
|
239
|
-
async def _claim_window(self, new: weakref.ReferenceType) -> None:
|
|
240
|
-
"""Make sure the previous window is really gone, then register ``new``.
|
|
234
|
+
async def _claim_window(self) -> None:
|
|
235
|
+
"""Take the receiver for a new window, or refuse.
|
|
241
236
|
|
|
242
237
|
A consumer that walked away from a window without closing it (for
|
|
243
238
|
example ``break`` out of ``async for`` with no ``aclosing``) leaves
|
|
244
239
|
the generator to asyncio's finalizer, which closes it on the next
|
|
245
|
-
loop iteration once nothing references it
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
window is refused rather than
|
|
240
|
+
loop iteration once nothing references it, and closing it releases
|
|
241
|
+
the claim. Give that a turn. If the claim is still held after that,
|
|
242
|
+
someone still has the window, whether inside ``__anext__`` or
|
|
243
|
+
paused between signals, and the new window is refused rather than
|
|
244
|
+
taken from under them.
|
|
249
245
|
"""
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
if
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
raise e.CaptureInProgressError(
|
|
262
|
-
"A capture window is already open; close it with aclose() first"
|
|
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")
|
|
267
|
-
self._window = new
|
|
246
|
+
if self._capturing:
|
|
247
|
+
await asyncio.sleep(0)
|
|
248
|
+
await asyncio.sleep(0)
|
|
249
|
+
if self._capturing:
|
|
250
|
+
raise e.CaptureInProgressError(
|
|
251
|
+
"A capture window is already open; close it with aclose() first"
|
|
252
|
+
)
|
|
253
|
+
self._capturing = True
|
|
254
|
+
|
|
255
|
+
def _release_window(self) -> None:
|
|
256
|
+
self._capturing = False
|
|
268
257
|
|
|
269
258
|
async def _send(self, command: int, data: bytes = b"") -> bytes:
|
|
270
259
|
"""Send a packet to the device."""
|
|
@@ -300,7 +289,7 @@ class rmmini(Device):
|
|
|
300
289
|
stop_after_first: bool = True,
|
|
301
290
|
poll_interval: float = DEFAULT_POLL_INTERVAL,
|
|
302
291
|
rearm_interval: float = DEFAULT_REARM_INTERVAL,
|
|
303
|
-
) ->
|
|
292
|
+
) -> AsyncGenerator[CapturedSignal]:
|
|
304
293
|
"""Open an infrared capture window and yield what the device hears.
|
|
305
294
|
|
|
306
295
|
The device is put into learning mode and polled every
|
|
@@ -318,13 +307,11 @@ class rmmini(Device):
|
|
|
318
307
|
Use ``contextlib.aclosing`` (or iterate to the end) so the window is
|
|
319
308
|
released promptly. Only one capture window can be open per device:
|
|
320
309
|
opening one while another is still held raises
|
|
321
|
-
``CaptureInProgressError
|
|
322
|
-
without being closed is finalized by asyncio
|
|
323
|
-
iteration and does not block.
|
|
310
|
+
``CaptureInProgressError`` on its first iteration. A window whose
|
|
311
|
+
generator was dropped without being closed is finalized by asyncio
|
|
312
|
+
on the next loop iteration and does not block.
|
|
324
313
|
"""
|
|
325
|
-
self.
|
|
326
|
-
holder: list = []
|
|
327
|
-
gen = self._capture_loop(
|
|
314
|
+
return self._capture_loop(
|
|
328
315
|
self.enter_learning,
|
|
329
316
|
window,
|
|
330
317
|
stop_after_first,
|
|
@@ -332,10 +319,8 @@ class rmmini(Device):
|
|
|
332
319
|
rearm_interval,
|
|
333
320
|
SignalKind.IR,
|
|
334
321
|
None,
|
|
335
|
-
claim=
|
|
322
|
+
claim=True,
|
|
336
323
|
)
|
|
337
|
-
holder.append(weakref.ref(gen))
|
|
338
|
-
return gen
|
|
339
324
|
|
|
340
325
|
async def _capture_loop(
|
|
341
326
|
self,
|
|
@@ -347,18 +332,43 @@ class rmmini(Device):
|
|
|
347
332
|
kind: SignalKind,
|
|
348
333
|
frequency_mhz: float | None,
|
|
349
334
|
*,
|
|
350
|
-
claim:
|
|
351
|
-
) ->
|
|
352
|
-
# ``claim``
|
|
353
|
-
#
|
|
354
|
-
# window claim, as capture_rf does for its inner loop.
|
|
335
|
+
claim: bool,
|
|
336
|
+
) -> AsyncGenerator[CapturedSignal]:
|
|
337
|
+
# ``claim`` is False when the caller already holds the window, as
|
|
338
|
+
# capture_rf does for its inner loop.
|
|
355
339
|
if window < 0:
|
|
356
340
|
raise ValueError("window must be 0 (open-ended) or positive")
|
|
357
341
|
if poll_interval <= 0 or rearm_interval <= 0:
|
|
358
342
|
raise ValueError("poll_interval and rearm_interval must be positive")
|
|
359
343
|
if claim:
|
|
360
|
-
await self._claim_window(
|
|
344
|
+
await self._claim_window()
|
|
345
|
+
try:
|
|
346
|
+
body = self._capture_body(
|
|
347
|
+
arm,
|
|
348
|
+
window,
|
|
349
|
+
stop_after_first,
|
|
350
|
+
poll_interval,
|
|
351
|
+
rearm_interval,
|
|
352
|
+
kind,
|
|
353
|
+
frequency_mhz,
|
|
354
|
+
)
|
|
355
|
+
async with contextlib.aclosing(body):
|
|
356
|
+
async for signal in body:
|
|
357
|
+
yield signal
|
|
358
|
+
finally:
|
|
359
|
+
if claim:
|
|
360
|
+
self._release_window()
|
|
361
361
|
|
|
362
|
+
async def _capture_body(
|
|
363
|
+
self,
|
|
364
|
+
arm: Callable[[], Awaitable[None]],
|
|
365
|
+
window: float,
|
|
366
|
+
stop_after_first: bool,
|
|
367
|
+
poll_interval: float,
|
|
368
|
+
rearm_interval: float,
|
|
369
|
+
kind: SignalKind,
|
|
370
|
+
frequency_mhz: float | None,
|
|
371
|
+
) -> AsyncGenerator[CapturedSignal]:
|
|
362
372
|
loop = asyncio.get_running_loop()
|
|
363
373
|
deadline = loop.time() + window if window else None
|
|
364
374
|
timeouts = 0
|
|
@@ -457,7 +467,7 @@ class rmpro(rmmini):
|
|
|
457
467
|
stop_after_first: bool = True,
|
|
458
468
|
poll_interval: float = DEFAULT_POLL_INTERVAL,
|
|
459
469
|
rearm_interval: float = DEFAULT_REARM_INTERVAL,
|
|
460
|
-
) ->
|
|
470
|
+
) -> AsyncGenerator[CapturedSignal]:
|
|
461
471
|
"""Open a radio frequency capture window and yield what the device hears.
|
|
462
472
|
|
|
463
473
|
With ``frequency`` (in MHz, for example 433.92) the device goes
|
|
@@ -474,18 +484,9 @@ class rmpro(rmmini):
|
|
|
474
484
|
Each ``CapturedSignal`` carries the carrier in ``frequency_mhz``,
|
|
475
485
|
which the packet itself does not record.
|
|
476
486
|
"""
|
|
477
|
-
self.
|
|
478
|
-
|
|
479
|
-
gen = self._capture_rf_loop(
|
|
480
|
-
window,
|
|
481
|
-
frequency,
|
|
482
|
-
stop_after_first,
|
|
483
|
-
poll_interval,
|
|
484
|
-
rearm_interval,
|
|
485
|
-
claim=holder,
|
|
487
|
+
return self._capture_rf_loop(
|
|
488
|
+
window, frequency, stop_after_first, poll_interval, rearm_interval
|
|
486
489
|
)
|
|
487
|
-
holder.append(weakref.ref(gen))
|
|
488
|
-
return gen
|
|
489
490
|
|
|
490
491
|
async def _capture_rf_loop(
|
|
491
492
|
self,
|
|
@@ -494,13 +495,26 @@ class rmpro(rmmini):
|
|
|
494
495
|
stop_after_first: bool,
|
|
495
496
|
poll_interval: float,
|
|
496
497
|
rearm_interval: float,
|
|
497
|
-
|
|
498
|
-
claim: list,
|
|
499
|
-
) -> AsyncIterator[CapturedSignal]:
|
|
498
|
+
) -> AsyncGenerator[CapturedSignal]:
|
|
500
499
|
if window < 0 or poll_interval <= 0:
|
|
501
500
|
raise ValueError("window must be 0 or positive, poll_interval positive")
|
|
502
|
-
await self._claim_window(
|
|
501
|
+
await self._claim_window()
|
|
502
|
+
try:
|
|
503
|
+
async for signal in self._capture_rf_body(
|
|
504
|
+
window, frequency, stop_after_first, poll_interval, rearm_interval
|
|
505
|
+
):
|
|
506
|
+
yield signal
|
|
507
|
+
finally:
|
|
508
|
+
self._release_window()
|
|
503
509
|
|
|
510
|
+
async def _capture_rf_body(
|
|
511
|
+
self,
|
|
512
|
+
window: float,
|
|
513
|
+
frequency: float | None,
|
|
514
|
+
stop_after_first: bool,
|
|
515
|
+
poll_interval: float,
|
|
516
|
+
rearm_interval: float,
|
|
517
|
+
) -> AsyncGenerator[CapturedSignal]:
|
|
504
518
|
loop = asyncio.get_running_loop()
|
|
505
519
|
deadline = loop.time() + window if window else None
|
|
506
520
|
|
|
@@ -525,13 +539,11 @@ class rmpro(rmmini):
|
|
|
525
539
|
rearm_interval,
|
|
526
540
|
kind,
|
|
527
541
|
frequency,
|
|
528
|
-
claim=
|
|
542
|
+
claim=False,
|
|
529
543
|
)
|
|
530
|
-
|
|
544
|
+
async with contextlib.aclosing(inner):
|
|
531
545
|
async for signal in inner:
|
|
532
546
|
yield signal
|
|
533
|
-
finally:
|
|
534
|
-
await inner.aclose()
|
|
535
547
|
|
|
536
548
|
async def _sweep(self, deadline: float | None, poll_interval: float) -> float | None:
|
|
537
549
|
"""Sweep for the remote's carrier; return it in MHz, or None if the
|
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.5
|
|
4
4
|
Summary: Python API for controlling Broadlink devices
|
|
5
5
|
Author: DAB-LABS
|
|
6
6
|
Author-email: Matthew Garrett <mjg59@srcf.ucam.org>
|
|
@@ -193,6 +193,11 @@ If the device is locked, it may not be discoverable with broadcast. In such case
|
|
|
193
193
|
device = await broadlink.hello("192.168.0.16")
|
|
194
194
|
```
|
|
195
195
|
|
|
196
|
+
`discover()` and `hello()` raise `NetworkTimeoutError` when nothing answers
|
|
197
|
+
within the timeout, `socket.gaierror` when a hostname does not resolve, and
|
|
198
|
+
`OSError` when the socket cannot be opened or the send fails (no route, for
|
|
199
|
+
example), the same errors the original library raised from its socket.
|
|
200
|
+
|
|
196
201
|
If you are a perfomance freak, use `broadlink.xdiscover()` to create devices instantly:
|
|
197
202
|
```python3
|
|
198
203
|
async for device in broadlink.xdiscover():
|
|
@@ -231,11 +236,21 @@ async with device:
|
|
|
231
236
|
await device.aclose()
|
|
232
237
|
```
|
|
233
238
|
|
|
239
|
+
A device belongs to the event loop it first talks on: its socket and its
|
|
240
|
+
locks are bound to that loop, so a script that runs several
|
|
241
|
+
`asyncio.run(...)` calls should create the device inside each one rather
|
|
242
|
+
than reuse it across them.
|
|
243
|
+
|
|
234
244
|
The socket reopens by itself on the next call, so closing is cheap and
|
|
235
245
|
safe to do at any time. A request that is in flight when `aclose()` runs
|
|
236
|
-
fails with `EndpointClosedError`.
|
|
237
|
-
|
|
238
|
-
|
|
246
|
+
fails with `EndpointClosedError`. A request that fails for a network
|
|
247
|
+
reason (a timeout, or an `OSError` from the socket such as "network is
|
|
248
|
+
unreachable" after an interface change, raised at once) also drops the socket, so the
|
|
249
|
+
next call starts fresh rather than reusing one that has gone bad, which
|
|
250
|
+
is how the original library behaved by opening a socket per call. An
|
|
251
|
+
integration that creates devices should close them when it unloads; a
|
|
252
|
+
device that is never closed holds its socket until it is garbage
|
|
253
|
+
collected.
|
|
239
254
|
|
|
240
255
|
The next steps depend on the type of device you want to control.
|
|
241
256
|
|
|
@@ -311,7 +326,8 @@ By default the window closes after the first signal. Pass
|
|
|
311
326
|
because the device holds only one code per learning session. A universal
|
|
312
327
|
remote has a single receiver, so only one capture window can be open on a
|
|
313
328
|
device at a time: opening a second one raises `CaptureInProgressError`
|
|
314
|
-
while the first is still held.
|
|
329
|
+
from the new window's first iteration while the first is still held.
|
|
330
|
+
Always close a window you leave early
|
|
315
331
|
(`aclosing` above does it), otherwise it stays open until Python collects
|
|
316
332
|
the generator.
|
|
317
333
|
|
|
@@ -1,19 +1,19 @@
|
|
|
1
|
-
broadlink/__init__.py,sha256=
|
|
1
|
+
broadlink/__init__.py,sha256=OJCaOs_IOgLqdaVe4bH3ALQEs-IVFV3lGFd5da7o2w4,11138
|
|
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=
|
|
6
|
+
broadlink/device.py,sha256=Y3uzlrZ0CaAIhpNO7ap4DvKlLpqNQVKp9kXjcKxTqRI,25786
|
|
7
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=sXDvBneN5dppAyMi3-d1ysVPu6-tF1ugHMzpBEMr-_E,22382
|
|
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.5.dist-info/licenses/LICENSE,sha256=h0N_LWq34pY0W-Ky3ayO6Re5ejG0XvutPlW-GL8eG5I,1111
|
|
16
|
+
python_broadlink-1.0.5.dist-info/METADATA,sha256=h7AZlQhasZUDjRbtIEB_JmUAsRPIkf7gsUXl7fb3QvA,15948
|
|
17
|
+
python_broadlink-1.0.5.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
18
|
+
python_broadlink-1.0.5.dist-info/top_level.txt,sha256=_Qx7d8oNTOLeDkAN-HHDoq1FlRNlsiaMZlyMWgHXAVc,10
|
|
19
|
+
python_broadlink-1.0.5.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|