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/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
+ }