python-broadlink 1.0.0__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 +342 -0
- broadlink/alarm.py +43 -0
- broadlink/climate.py +474 -0
- broadlink/const.py +5 -0
- broadlink/cover.py +182 -0
- broadlink/device.py +470 -0
- broadlink/exceptions.py +161 -0
- broadlink/helpers.py +43 -0
- broadlink/hub.py +98 -0
- broadlink/light.py +200 -0
- broadlink/protocol.py +50 -0
- broadlink/remote.py +521 -0
- broadlink/sensor.py +90 -0
- broadlink/switch.py +472 -0
- python_broadlink-1.0.0.dist-info/METADATA +360 -0
- python_broadlink-1.0.0.dist-info/RECORD +19 -0
- python_broadlink-1.0.0.dist-info/WHEEL +5 -0
- python_broadlink-1.0.0.dist-info/licenses/LICENSE +22 -0
- python_broadlink-1.0.0.dist-info/top_level.txt +1 -0
broadlink/remote.py
ADDED
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
"""Support for universal remotes."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import enum
|
|
5
|
+
import struct
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import AsyncIterator, Awaitable, Callable, List, Optional, Tuple
|
|
9
|
+
|
|
10
|
+
from . import exceptions as e
|
|
11
|
+
from .device import Device
|
|
12
|
+
|
|
13
|
+
TICK = 8192 / 269
|
|
14
|
+
"""Duration of one Broadlink timing unit in microseconds (about 30.45 us).
|
|
15
|
+
|
|
16
|
+
The RM firmware counts pulses on a 32768 Hz clock (protocol.md: us * 269 / 8192).
|
|
17
|
+
Earlier releases used 32.84, the inverse of the right ratio applied the wrong
|
|
18
|
+
way round, which compressed externally sourced IR codes by about 7 percent
|
|
19
|
+
(mjg59/python-broadlink#839). Codes learned and replayed through the same
|
|
20
|
+
device were unaffected because both directions shared the constant.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
DEFAULT_POLL_INTERVAL = 0.5
|
|
24
|
+
"""Seconds between ``check_data`` polls while a capture window is open."""
|
|
25
|
+
|
|
26
|
+
DEFAULT_REARM_INTERVAL = 15.0
|
|
27
|
+
"""Seconds after which an open capture window re-enters learning mode.
|
|
28
|
+
|
|
29
|
+
The RM4 Pro leaves learning mode silently between 25 s and 40 s after
|
|
30
|
+
``enter_learning`` (bench, 2026-09-04), and any ``send_data`` also ends the
|
|
31
|
+
session, while ``check_data`` keeps answering with the same "nothing yet"
|
|
32
|
+
error, so an open window has to re-arm on a timer and after every send.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SignalKind(enum.IntEnum):
|
|
37
|
+
"""The kind of signal a packet carries.
|
|
38
|
+
|
|
39
|
+
The values are the canonical type bytes the library writes when it
|
|
40
|
+
builds a packet (protocol.md offset 0x00). Packets a device returns
|
|
41
|
+
from a learn session do not always use exactly these bytes -- an RM4
|
|
42
|
+
Pro returns 0xB1 for a 433 MHz capture, not 0xB2 -- so read a returned
|
|
43
|
+
packet's kind with ``classify`` rather than by equality.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
IR = 0x26
|
|
47
|
+
RF_433 = 0xB2
|
|
48
|
+
RF_315 = 0xD7
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def is_rf(self) -> bool:
|
|
52
|
+
return self is not SignalKind.IR
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def classify(cls, type_byte: int) -> "SignalKind":
|
|
56
|
+
"""Map a packet's raw first byte to a kind, tolerantly.
|
|
57
|
+
|
|
58
|
+
The RF learn path returns bytes in the 0xB_ (433 MHz) and 0xD_
|
|
59
|
+
(315 MHz) ranges whose low bits are not documented and vary by
|
|
60
|
+
firmware, so classify by range rather than by exact value. Raises
|
|
61
|
+
``ValueError`` for a byte in no known range.
|
|
62
|
+
"""
|
|
63
|
+
if type_byte == cls.IR:
|
|
64
|
+
return cls.IR
|
|
65
|
+
if type_byte & 0xF0 == 0xB0:
|
|
66
|
+
return cls.RF_433
|
|
67
|
+
if type_byte & 0xF0 == 0xD0:
|
|
68
|
+
return cls.RF_315
|
|
69
|
+
raise ValueError(f"Unknown packet type 0x{type_byte:02x}")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def pulses_to_data(
|
|
73
|
+
pulses: List[int],
|
|
74
|
+
tick: float = TICK,
|
|
75
|
+
*,
|
|
76
|
+
kind: SignalKind = SignalKind.IR,
|
|
77
|
+
repeat: int = 0,
|
|
78
|
+
) -> bytes:
|
|
79
|
+
"""Convert a microsecond duration sequence into a Broadlink packet.
|
|
80
|
+
|
|
81
|
+
``kind`` selects the type byte (IR, RF 433 MHz or RF 315 MHz) and
|
|
82
|
+
``repeat`` is the number of extra transmissions the device performs
|
|
83
|
+
after the first, 0 to 255 (protocol.md offset 0x01).
|
|
84
|
+
"""
|
|
85
|
+
if not 0 <= repeat <= 0xFF:
|
|
86
|
+
raise ValueError("repeat must be between 0 and 255")
|
|
87
|
+
result = bytearray(4)
|
|
88
|
+
result[0x00] = SignalKind(kind)
|
|
89
|
+
result[0x01] = repeat
|
|
90
|
+
|
|
91
|
+
for pulse in pulses:
|
|
92
|
+
div, mod = divmod(round(pulse / tick), 256)
|
|
93
|
+
if div:
|
|
94
|
+
result.append(0)
|
|
95
|
+
result.append(div)
|
|
96
|
+
result.append(mod)
|
|
97
|
+
|
|
98
|
+
data_len = len(result) - 4
|
|
99
|
+
result[0x02] = data_len & 0xFF
|
|
100
|
+
result[0x03] = data_len >> 8
|
|
101
|
+
|
|
102
|
+
return bytes(result)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def data_to_pulses(data: bytes, tick: float = TICK) -> List[int]:
|
|
106
|
+
"""Parse a Broadlink packet into a microsecond duration sequence."""
|
|
107
|
+
result = []
|
|
108
|
+
index = 4
|
|
109
|
+
end = min(256 * data[0x03] + data[0x02] + 4, len(data))
|
|
110
|
+
|
|
111
|
+
while index < end:
|
|
112
|
+
chunk = data[index]
|
|
113
|
+
index += 1
|
|
114
|
+
|
|
115
|
+
if chunk == 0:
|
|
116
|
+
try:
|
|
117
|
+
chunk = 256 * data[index] + data[index + 1]
|
|
118
|
+
except IndexError as err:
|
|
119
|
+
raise ValueError("Malformed data.") from err
|
|
120
|
+
index += 2
|
|
121
|
+
|
|
122
|
+
result.append(int(chunk * tick))
|
|
123
|
+
|
|
124
|
+
return result
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass(frozen=True)
|
|
128
|
+
class ParsedPacket:
|
|
129
|
+
"""The parts of a Broadlink packet: kind, repeat count and timings.
|
|
130
|
+
|
|
131
|
+
``type_byte`` is the packet's raw first byte; ``kind`` is that byte
|
|
132
|
+
classified into a band (see ``SignalKind.classify``), which for a
|
|
133
|
+
device-returned RF packet is not always the canonical value.
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
kind: SignalKind
|
|
137
|
+
repeat: int
|
|
138
|
+
pulses: List[int]
|
|
139
|
+
type_byte: int
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def parse_packet(data: bytes, tick: float = TICK) -> ParsedPacket:
|
|
143
|
+
"""Split a Broadlink packet into its kind, repeat count and timings.
|
|
144
|
+
|
|
145
|
+
Raises ``ValueError`` if the packet is shorter than its header or the
|
|
146
|
+
type byte is in no known band (IR, 433 MHz or 315 MHz).
|
|
147
|
+
"""
|
|
148
|
+
if len(data) < 4:
|
|
149
|
+
raise ValueError("Malformed data.")
|
|
150
|
+
kind = SignalKind.classify(data[0x00])
|
|
151
|
+
return ParsedPacket(kind, data[0x01], data_to_pulses(data, tick), data[0x00])
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@dataclass(frozen=True)
|
|
155
|
+
class CapturedSignal:
|
|
156
|
+
"""One signal captured by a universal remote.
|
|
157
|
+
|
|
158
|
+
``packet`` is the device's own bytes, ready for ``send_data`` and for
|
|
159
|
+
storage; ``pulses`` is the same signal as microsecond durations at the
|
|
160
|
+
corrected tick. ``kind`` is the band the signal was captured on;
|
|
161
|
+
``type_byte`` is the packet's raw first byte, which for RF is not always
|
|
162
|
+
the canonical value for the band. ``frequency_mhz`` is set for RF
|
|
163
|
+
captures only and holds the carrier the device swept to or was given,
|
|
164
|
+
which the packet itself does not record.
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
packet: bytes
|
|
168
|
+
kind: SignalKind
|
|
169
|
+
pulses: List[int] = field(repr=False)
|
|
170
|
+
repeat: int = 0
|
|
171
|
+
frequency_mhz: Optional[float] = None
|
|
172
|
+
type_byte: Optional[int] = None
|
|
173
|
+
captured_at: float = field(default_factory=time.time, repr=False)
|
|
174
|
+
|
|
175
|
+
@classmethod
|
|
176
|
+
def from_packet(
|
|
177
|
+
cls,
|
|
178
|
+
packet: bytes,
|
|
179
|
+
frequency_mhz: Optional[float] = None,
|
|
180
|
+
*,
|
|
181
|
+
kind: Optional[SignalKind] = None,
|
|
182
|
+
) -> "CapturedSignal":
|
|
183
|
+
"""Build a signal from a device-returned packet.
|
|
184
|
+
|
|
185
|
+
``kind`` overrides the band read from the packet's type byte. A
|
|
186
|
+
capture window knows what it armed, so it passes the kind it armed
|
|
187
|
+
for and a signal is never dropped over an unexpected type byte; the
|
|
188
|
+
raw byte is still kept in ``type_byte``. The timings are read from
|
|
189
|
+
the packet regardless of the type byte.
|
|
190
|
+
"""
|
|
191
|
+
if len(packet) < 4:
|
|
192
|
+
raise ValueError("Malformed data.")
|
|
193
|
+
type_byte = packet[0x00]
|
|
194
|
+
if kind is None:
|
|
195
|
+
kind = SignalKind.classify(type_byte)
|
|
196
|
+
return cls(
|
|
197
|
+
bytes(packet),
|
|
198
|
+
kind,
|
|
199
|
+
data_to_pulses(packet),
|
|
200
|
+
packet[0x01],
|
|
201
|
+
frequency_mhz,
|
|
202
|
+
type_byte,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class rmmini(Device):
|
|
207
|
+
"""Controls a Broadlink RM mini 3."""
|
|
208
|
+
|
|
209
|
+
TYPE = "RMMINI"
|
|
210
|
+
|
|
211
|
+
def __init__(self, *args, **kwargs) -> None:
|
|
212
|
+
super().__init__(*args, **kwargs)
|
|
213
|
+
# Bumped by every transmission. An open capture window compares it
|
|
214
|
+
# against the value it saw when it armed the device and re-arms
|
|
215
|
+
# after any send, since the device has one front end for both.
|
|
216
|
+
self._tx_generation = 0
|
|
217
|
+
self._capture_open = False
|
|
218
|
+
|
|
219
|
+
async def _send(self, command: int, data: bytes = b"") -> bytes:
|
|
220
|
+
"""Send a packet to the device."""
|
|
221
|
+
packet = struct.pack("<I", command) + data
|
|
222
|
+
resp = await self.send_packet(0x6A, packet)
|
|
223
|
+
e.check_error(resp[0x22:0x24])
|
|
224
|
+
payload = self.decrypt(resp[0x38:])
|
|
225
|
+
return payload[0x4:]
|
|
226
|
+
|
|
227
|
+
async def update(self) -> None:
|
|
228
|
+
"""Update device name and lock status."""
|
|
229
|
+
resp = await self._send(0x1)
|
|
230
|
+
self.name = resp[0x48:].split(b"\x00")[0].decode()
|
|
231
|
+
self.is_locked = bool(resp[0x87])
|
|
232
|
+
|
|
233
|
+
async def send_data(self, data: bytes) -> None:
|
|
234
|
+
"""Send a code to the device."""
|
|
235
|
+
self._tx_generation += 1
|
|
236
|
+
await self._send(0x2, data)
|
|
237
|
+
|
|
238
|
+
async def enter_learning(self) -> None:
|
|
239
|
+
"""Enter infrared learning mode."""
|
|
240
|
+
await self._send(0x3)
|
|
241
|
+
|
|
242
|
+
async def check_data(self) -> bytes:
|
|
243
|
+
"""Return the last captured code."""
|
|
244
|
+
return await self._send(0x4)
|
|
245
|
+
|
|
246
|
+
def capture(
|
|
247
|
+
self,
|
|
248
|
+
window: float = 30.0,
|
|
249
|
+
*,
|
|
250
|
+
stop_after_first: bool = True,
|
|
251
|
+
poll_interval: float = DEFAULT_POLL_INTERVAL,
|
|
252
|
+
rearm_interval: float = DEFAULT_REARM_INTERVAL,
|
|
253
|
+
) -> AsyncIterator[CapturedSignal]:
|
|
254
|
+
"""Open an infrared capture window and yield what the device hears.
|
|
255
|
+
|
|
256
|
+
The device is put into learning mode and polled every
|
|
257
|
+
``poll_interval`` seconds. Each code it reports is yielded as a
|
|
258
|
+
``CapturedSignal``. With ``stop_after_first`` the window closes
|
|
259
|
+
after the first code; otherwise the device is re-armed after each
|
|
260
|
+
code (it holds one code per learning session) and the window stays
|
|
261
|
+
open until ``window`` seconds have passed. ``window=0`` keeps it
|
|
262
|
+
open until the generator is closed.
|
|
263
|
+
|
|
264
|
+
The device leaves learning mode on its own after a while without
|
|
265
|
+
saying so, so the window re-arms it every ``rearm_interval`` seconds
|
|
266
|
+
and after every ``send_data`` on the same device. Closing the
|
|
267
|
+
generator sends nothing further; the device times out by itself.
|
|
268
|
+
Use ``contextlib.aclosing`` (or iterate to the end) so the window is
|
|
269
|
+
released promptly. Only one capture window can be open per device;
|
|
270
|
+
a second raises ``CaptureInProgressError``.
|
|
271
|
+
"""
|
|
272
|
+
return self._capture_loop(
|
|
273
|
+
self.enter_learning,
|
|
274
|
+
window,
|
|
275
|
+
stop_after_first,
|
|
276
|
+
poll_interval,
|
|
277
|
+
rearm_interval,
|
|
278
|
+
SignalKind.IR,
|
|
279
|
+
None,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
async def _capture_loop(
|
|
283
|
+
self,
|
|
284
|
+
arm: Callable[[], Awaitable[None]],
|
|
285
|
+
window: float,
|
|
286
|
+
stop_after_first: bool,
|
|
287
|
+
poll_interval: float,
|
|
288
|
+
rearm_interval: float,
|
|
289
|
+
kind: SignalKind,
|
|
290
|
+
frequency_mhz: Optional[float],
|
|
291
|
+
) -> AsyncIterator[CapturedSignal]:
|
|
292
|
+
if window < 0:
|
|
293
|
+
raise ValueError("window must be 0 (open-ended) or positive")
|
|
294
|
+
if poll_interval <= 0 or rearm_interval <= 0:
|
|
295
|
+
raise ValueError("poll_interval and rearm_interval must be positive")
|
|
296
|
+
if self._capture_open:
|
|
297
|
+
raise e.CaptureInProgressError("A capture window is already open")
|
|
298
|
+
|
|
299
|
+
self._capture_open = True
|
|
300
|
+
try:
|
|
301
|
+
loop = asyncio.get_running_loop()
|
|
302
|
+
deadline = loop.time() + window if window else None
|
|
303
|
+
timeouts = 0
|
|
304
|
+
|
|
305
|
+
await arm()
|
|
306
|
+
armed_at = loop.time()
|
|
307
|
+
generation = self._tx_generation
|
|
308
|
+
|
|
309
|
+
while True:
|
|
310
|
+
now = loop.time()
|
|
311
|
+
if deadline is not None and now >= deadline:
|
|
312
|
+
return
|
|
313
|
+
delay = poll_interval
|
|
314
|
+
if deadline is not None:
|
|
315
|
+
delay = min(delay, deadline - now)
|
|
316
|
+
await asyncio.sleep(delay)
|
|
317
|
+
|
|
318
|
+
try:
|
|
319
|
+
data = await self.check_data()
|
|
320
|
+
except e.StorageError:
|
|
321
|
+
data = b"" # The device's answer for "nothing yet".
|
|
322
|
+
except e.NetworkTimeoutError:
|
|
323
|
+
timeouts += 1
|
|
324
|
+
if timeouts >= 3:
|
|
325
|
+
raise
|
|
326
|
+
generation = -1 # Re-arm; the device's state is unknown.
|
|
327
|
+
continue
|
|
328
|
+
timeouts = 0
|
|
329
|
+
|
|
330
|
+
if data:
|
|
331
|
+
yield CapturedSignal.from_packet(data, frequency_mhz, kind=kind)
|
|
332
|
+
if stop_after_first:
|
|
333
|
+
return
|
|
334
|
+
generation = -1 # One code per session: re-arm.
|
|
335
|
+
|
|
336
|
+
now = loop.time()
|
|
337
|
+
if generation != self._tx_generation or now - armed_at >= rearm_interval:
|
|
338
|
+
await arm()
|
|
339
|
+
armed_at = loop.time()
|
|
340
|
+
generation = self._tx_generation
|
|
341
|
+
finally:
|
|
342
|
+
self._capture_open = False
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
class rmpro(rmmini):
|
|
346
|
+
"""Controls a Broadlink RM pro."""
|
|
347
|
+
|
|
348
|
+
TYPE = "RMPRO"
|
|
349
|
+
|
|
350
|
+
async def sweep_frequency(self) -> None:
|
|
351
|
+
"""Sweep frequency."""
|
|
352
|
+
await self._send(0x19)
|
|
353
|
+
|
|
354
|
+
async def check_frequency(self) -> Tuple[bool, float]:
|
|
355
|
+
"""Return True if the frequency was identified successfully."""
|
|
356
|
+
resp = await self._send(0x1A)
|
|
357
|
+
is_found = bool(resp[0])
|
|
358
|
+
frequency = struct.unpack("<I", resp[1:5])[0] / 1000.0
|
|
359
|
+
return is_found, frequency
|
|
360
|
+
|
|
361
|
+
async def find_rf_packet(self, frequency: Optional[float] = None) -> None:
|
|
362
|
+
"""Enter radiofrequency learning mode."""
|
|
363
|
+
payload = bytearray()
|
|
364
|
+
if frequency:
|
|
365
|
+
payload += struct.pack("<I", int(frequency * 1000))
|
|
366
|
+
await self._send(0x1B, payload)
|
|
367
|
+
|
|
368
|
+
async def cancel_sweep_frequency(self) -> None:
|
|
369
|
+
"""Cancel sweep frequency."""
|
|
370
|
+
await self._send(0x1E)
|
|
371
|
+
|
|
372
|
+
async def capture_rf(
|
|
373
|
+
self,
|
|
374
|
+
window: float = 30.0,
|
|
375
|
+
*,
|
|
376
|
+
frequency: Optional[float] = None,
|
|
377
|
+
stop_after_first: bool = True,
|
|
378
|
+
poll_interval: float = DEFAULT_POLL_INTERVAL,
|
|
379
|
+
rearm_interval: float = DEFAULT_REARM_INTERVAL,
|
|
380
|
+
) -> AsyncIterator[CapturedSignal]:
|
|
381
|
+
"""Open a radio frequency capture window and yield what the device hears.
|
|
382
|
+
|
|
383
|
+
With ``frequency`` (in MHz, for example 433.92) the device goes
|
|
384
|
+
straight into RF learning mode on that carrier. Without it the
|
|
385
|
+
device first sweeps for the carrier while the user HOLDS a button on
|
|
386
|
+
the remote, and only then learns the code from a fresh press; the
|
|
387
|
+
sweep is unreliable on some firmware and can report a carrier it
|
|
388
|
+
never really locked, so pass the frequency whenever it is known.
|
|
389
|
+
|
|
390
|
+
The window, polling, re-arm and stop-after-first semantics are those
|
|
391
|
+
of ``capture``; the sweep counts against the same ``window``. A
|
|
392
|
+
``send_data`` during the sweep restarts it. Each ``CapturedSignal``
|
|
393
|
+
carries the carrier in ``frequency_mhz``, which the packet itself
|
|
394
|
+
does not record.
|
|
395
|
+
"""
|
|
396
|
+
if self._capture_open:
|
|
397
|
+
raise e.CaptureInProgressError("A capture window is already open")
|
|
398
|
+
if window < 0 or poll_interval <= 0:
|
|
399
|
+
raise ValueError("window must be 0 or positive, poll_interval positive")
|
|
400
|
+
|
|
401
|
+
loop = asyncio.get_running_loop()
|
|
402
|
+
deadline = loop.time() + window if window else None
|
|
403
|
+
|
|
404
|
+
if frequency is None:
|
|
405
|
+
self._capture_open = True
|
|
406
|
+
try:
|
|
407
|
+
frequency = await self._sweep(deadline, poll_interval)
|
|
408
|
+
finally:
|
|
409
|
+
self._capture_open = False
|
|
410
|
+
if frequency is None:
|
|
411
|
+
return
|
|
412
|
+
if deadline is not None:
|
|
413
|
+
window = max(deadline - loop.time(), 0.0)
|
|
414
|
+
if window == 0:
|
|
415
|
+
return
|
|
416
|
+
|
|
417
|
+
async def arm() -> None:
|
|
418
|
+
await self.find_rf_packet(frequency)
|
|
419
|
+
|
|
420
|
+
kind = SignalKind.RF_315 if frequency < 400 else SignalKind.RF_433
|
|
421
|
+
async for signal in self._capture_loop(
|
|
422
|
+
arm, window, stop_after_first, poll_interval, rearm_interval, kind, frequency
|
|
423
|
+
):
|
|
424
|
+
yield signal
|
|
425
|
+
|
|
426
|
+
async def _sweep(
|
|
427
|
+
self, deadline: Optional[float], poll_interval: float
|
|
428
|
+
) -> Optional[float]:
|
|
429
|
+
"""Sweep for the remote's carrier; return it in MHz, or None if the
|
|
430
|
+
window ran out first."""
|
|
431
|
+
loop = asyncio.get_running_loop()
|
|
432
|
+
await self.sweep_frequency()
|
|
433
|
+
generation = self._tx_generation
|
|
434
|
+
while True:
|
|
435
|
+
now = loop.time()
|
|
436
|
+
if deadline is not None and now >= deadline:
|
|
437
|
+
await self.cancel_sweep_frequency()
|
|
438
|
+
return None
|
|
439
|
+
delay = poll_interval
|
|
440
|
+
if deadline is not None:
|
|
441
|
+
delay = min(delay, deadline - now)
|
|
442
|
+
await asyncio.sleep(delay)
|
|
443
|
+
if generation != self._tx_generation:
|
|
444
|
+
await self.sweep_frequency()
|
|
445
|
+
generation = self._tx_generation
|
|
446
|
+
continue
|
|
447
|
+
found, frequency = await self.check_frequency()
|
|
448
|
+
if found:
|
|
449
|
+
return frequency
|
|
450
|
+
|
|
451
|
+
async def check_sensors(self) -> dict:
|
|
452
|
+
"""Return the state of the sensors."""
|
|
453
|
+
resp = await self._send(0x1)
|
|
454
|
+
temp = struct.unpack("<bb", resp[:0x2])
|
|
455
|
+
return {"temperature": temp[0x0] + temp[0x1] / 10.0}
|
|
456
|
+
|
|
457
|
+
async def check_temperature(self) -> float:
|
|
458
|
+
"""Return the temperature."""
|
|
459
|
+
return (await self.check_sensors())["temperature"]
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
class rmminib(rmmini):
|
|
463
|
+
"""Controls a Broadlink RM mini 3 (new firmware)."""
|
|
464
|
+
|
|
465
|
+
TYPE = "RMMINIB"
|
|
466
|
+
|
|
467
|
+
async def _send(self, command: int, data: bytes = b"") -> bytes:
|
|
468
|
+
"""Send a packet to the device."""
|
|
469
|
+
packet = struct.pack("<HI", len(data) + 4, command) + data
|
|
470
|
+
resp = await self.send_packet(0x6A, packet)
|
|
471
|
+
e.check_error(resp[0x22:0x24])
|
|
472
|
+
payload = self.decrypt(resp[0x38:])
|
|
473
|
+
p_len = struct.unpack("<H", payload[:0x2])[0]
|
|
474
|
+
return payload[0x6:p_len+2]
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
class rm4mini(rmminib):
|
|
478
|
+
"""Controls a Broadlink RM4 mini."""
|
|
479
|
+
|
|
480
|
+
TYPE = "RM4MINI"
|
|
481
|
+
|
|
482
|
+
async def check_sensors(self) -> dict:
|
|
483
|
+
"""Return the state of the sensors."""
|
|
484
|
+
resp = await self._send(0x24)
|
|
485
|
+
temp = struct.unpack("<bb", resp[:0x2])
|
|
486
|
+
return {
|
|
487
|
+
"temperature": temp[0x0] + temp[0x1] / 100.0,
|
|
488
|
+
"humidity": resp[0x2] + resp[0x3] / 100.0,
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
async def check_temperature(self) -> float:
|
|
492
|
+
"""Return the temperature."""
|
|
493
|
+
return (await self.check_sensors())["temperature"]
|
|
494
|
+
|
|
495
|
+
async def check_humidity(self) -> float:
|
|
496
|
+
"""Return the humidity."""
|
|
497
|
+
return (await self.check_sensors())["humidity"]
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
class rm4pro(rm4mini, rmpro):
|
|
501
|
+
"""Controls a Broadlink RM4 pro."""
|
|
502
|
+
|
|
503
|
+
TYPE = "RM4PRO"
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
class rm(rmpro):
|
|
507
|
+
"""For backwards compatibility."""
|
|
508
|
+
|
|
509
|
+
TYPE = "RM2"
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
class rm4(rm4pro):
|
|
513
|
+
"""For backwards compatibility."""
|
|
514
|
+
|
|
515
|
+
TYPE = "RM4"
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
class rm5plus(rmminib):
|
|
519
|
+
"""Controls a Broadlink RM5 Plus."""
|
|
520
|
+
|
|
521
|
+
TYPE = "RM5PLUS"
|
broadlink/sensor.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Support for sensors."""
|
|
2
|
+
from typing import Sequence
|
|
3
|
+
|
|
4
|
+
from . import exceptions as e
|
|
5
|
+
from .device import Device
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class a1(Device):
|
|
9
|
+
"""Controls a Broadlink A1."""
|
|
10
|
+
|
|
11
|
+
TYPE = "A1"
|
|
12
|
+
|
|
13
|
+
_SENSORS_AND_LEVELS = (
|
|
14
|
+
("light", ("dark", "dim", "normal", "bright")),
|
|
15
|
+
("air_quality", ("excellent", "good", "normal", "bad")),
|
|
16
|
+
("noise", ("quiet", "normal", "noisy")),
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
async def check_sensors(self) -> dict:
|
|
20
|
+
"""Return the state of the sensors."""
|
|
21
|
+
data = await self.check_sensors_raw()
|
|
22
|
+
for sensor, levels in self._SENSORS_AND_LEVELS:
|
|
23
|
+
try:
|
|
24
|
+
data[sensor] = levels[data[sensor]]
|
|
25
|
+
except IndexError:
|
|
26
|
+
data[sensor] = "unknown"
|
|
27
|
+
return data
|
|
28
|
+
|
|
29
|
+
async def check_sensors_raw(self) -> dict:
|
|
30
|
+
"""Return the state of the sensors in raw format."""
|
|
31
|
+
packet = bytearray([0x1])
|
|
32
|
+
resp = await self.send_packet(0x6A, packet)
|
|
33
|
+
e.check_error(resp[0x22:0x24])
|
|
34
|
+
data = self.decrypt(resp[0x38:])
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
"temperature": data[0x04] + data[0x05] / 10.0,
|
|
38
|
+
"humidity": data[0x06] + data[0x07] / 10.0,
|
|
39
|
+
"light": data[0x08],
|
|
40
|
+
"air_quality": data[0x0A],
|
|
41
|
+
"noise": data[0x0C],
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class a2(Device):
|
|
46
|
+
"""Controls a Broadlink A2."""
|
|
47
|
+
|
|
48
|
+
TYPE = "A2"
|
|
49
|
+
|
|
50
|
+
async def _send(self, operation: int, data: Sequence = b""):
|
|
51
|
+
"""Send a command to the device."""
|
|
52
|
+
packet = bytearray(12)
|
|
53
|
+
packet[0x02] = 0xA5
|
|
54
|
+
packet[0x03] = 0xA5
|
|
55
|
+
packet[0x04] = 0x5A
|
|
56
|
+
packet[0x05] = 0x5A
|
|
57
|
+
packet[0x08] = operation
|
|
58
|
+
packet[0x09] = 0x0B
|
|
59
|
+
|
|
60
|
+
if data:
|
|
61
|
+
data_len = len(data)
|
|
62
|
+
packet[0x0A] = data_len & 0xFF
|
|
63
|
+
packet[0x0B] = data_len >> 8
|
|
64
|
+
packet += bytes(2)
|
|
65
|
+
packet.extend(data)
|
|
66
|
+
|
|
67
|
+
checksum = sum(packet, 0xBEAF) & 0xFFFF
|
|
68
|
+
packet[0x06] = checksum & 0xFF
|
|
69
|
+
packet[0x07] = checksum >> 8
|
|
70
|
+
|
|
71
|
+
packet_len = len(packet) - 2
|
|
72
|
+
packet[0x00] = packet_len & 0xFF
|
|
73
|
+
packet[0x01] = packet_len >> 8
|
|
74
|
+
|
|
75
|
+
resp = await self.send_packet(0x6A, packet)
|
|
76
|
+
e.check_error(resp[0x22:0x24])
|
|
77
|
+
payload = self.decrypt(resp[0x38:])
|
|
78
|
+
return payload
|
|
79
|
+
|
|
80
|
+
async def check_sensors_raw(self) -> dict:
|
|
81
|
+
"""Return the state of the sensors in raw format."""
|
|
82
|
+
data = await self._send(1)
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
"temperature": data[0x13] * 256 + data[0x14],
|
|
86
|
+
"humidity": data[0x15] * 256 + data[0x16],
|
|
87
|
+
"pm10": data[0x0D] * 256 + data[0x0E],
|
|
88
|
+
"pm2_5": data[0x0F] * 256 + data[0x10],
|
|
89
|
+
"pm1": data[0x11] * 256 + data[0x12],
|
|
90
|
+
}
|