Skip to content
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,35 @@ history below starts at that fork point.
3.14, and builds the sdist and wheel on every pull request. Releases are
published to PyPI from version tags using trusted publishing.

### Fixed

- The IR tick constant used by `pulses_to_data` and `data_to_pulses` is now
`TICK = 8192 / 269` (about 30.45 us), matching the device's 32768 Hz
timebase as documented in `protocol.md`. The previous value, 32.84, was
the inverse ratio applied the wrong way round and compressed IR codes
built from true microsecond timings by about 7 percent. Codes learned and
replayed through the same device were unaffected. Verified on an RM4 Pro
against an independent receiver in both directions.
(mjg59/python-broadlink#839, #841)
- `pulses_to_data` rounds each duration to the nearest tick instead of
truncating, which removes up to one tick of systematic shortening per
pulse.

### Added

- Devices, carried over from pull requests against the original repository
with their authors' commits intact: RM Max 0xAF8B (#838, Alexey Masolov);
RM5 plus 0x5224 with a new `rm5plus` class (#831, Anil Daoud); RM mini 3
OEM 0xA544 (#823, Bartłomiej Nogaś); RM mini 3 CMCC 0x27C8 (#802,
shuxin); LB26 R1 0xA517 (#812, techitapart); SP mini 3-AL 0x7D15 (#805,
bbcbbk); LEDVANCE SMART+ WIFI CEILING TW 24W 0x6498 (#799, Felipe Martins
Diel).
- Devices reported in issues against the original repository, added by
model name to the existing class for that family and not yet confirmed on
hardware: MP1-1K3S2U 0x4EDA (#816) and SP4 0xA57A (#758). Please open an
issue if either does not behave.
- `cryptography` 43 or newer is required, the first release with wheels for
Python 3.13 (supersedes mjg59/python-broadlink#749).
- A test suite. The `tests/oracle` package records the exact request bytes
every public method of every device class sends, and the results it
decodes from canned responses, so that later changes to the transport
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,14 @@ behaviour, pin the original distribution (`broadlink==0.19.0`) instead.

The following devices are supported:

- **Universal remotes**: RM home, RM mini 3, RM plus, RM pro, RM pro+, RM4 mini, RM4 pro, RM4C mini, RM4S, RM4 TV mate
- **Smart plugs**: SP mini, SP mini 3, SP mini+, SP1, SP2, SP2-BR, SP2-CL, SP2-IN, SP2-UK, SP3, SP3-EU, SP3S-EU, SP3S-US, SP4L-AU, SP4L-EU, SP4L-UK, SP4M, SP4M-US, Ankuoo NEO, Ankuoo NEO PRO, Efergy Ego, BG AHC/U-01
- **Universal remotes**: RM home, RM mini 3, RM plus, RM pro, RM pro+, RM4 mini, RM4 pro, RM4C mini, RM4S, RM4 TV mate, RM Max, RM5 plus
- **Smart plugs**: SP mini, SP mini 3, SP mini+, SP1, SP2, SP2-BR, SP2-CL, SP2-IN, SP2-UK, SP3, SP3-EU, SP3S-EU, SP3S-US, SP4L-AU, SP4L-EU, SP4L-UK, SP4M, SP4M-US, SP mini 3-AL, Ankuoo NEO, Ankuoo NEO PRO, Efergy Ego, BG AHC/U-01
- **Switches**: MCB1, SC1, SCB1E, SCB2
- **Outlets**: BG 800, BG 900
- **Power strips**: MP1-1K3S2U, MP1-1K4S, MP2
- **Environment sensors**: A1
- **Alarm kits**: S1C, S2KIT
- **Light bulbs**: LB1, LB26 R1, LB27 R1, SB800TD
- **Light bulbs**: LB1, LB26 R1, LB27 R1, SB800TD, LEDVANCE SMART+ WIFI CEILING TW 24W
- **Curtain motors**: Dooya DT360E-45/20
- **Thermostats**: Hysen HY02B05H
- **Hubs**: S3
Expand Down
13 changes: 12 additions & 1 deletion broadlink/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from .device import Device, _open_endpoint, ping, scan
from .hub import s3
from .light import lb1, lb2
from .remote import rm, rm4, rm4mini, rm4pro, rmmini, rmminib, rmpro
from .remote import rm, rm4, rm4mini, rm4pro, rm5plus, rmmini, rmminib, rmpro
from .sensor import a1, a2
from .switch import bg1, ehc31, mp1, mp1s, sp1, sp2, sp2s, sp3, sp3s, sp4, sp4b

Expand Down Expand Up @@ -63,6 +63,7 @@
0x7583: ("SP mini 3", "Broadlink"),
0x7587: ("SP4L-UK", "Broadlink"),
0x7D11: ("SP mini 3", "Broadlink"),
0x7D15: ("SP mini 3-AL", "Broadlink (OEM)"),
0xA4F9: ("WS4", "Broadlink (OEM)"),
0xA569: ("SP4L-UK", "Broadlink"),
0xA56A: ("MCB1", "Broadlink"),
Expand All @@ -71,6 +72,7 @@
0xA576: ("SP4L-AU", "Broadlink"),
0xA589: ("SP4L-UK", "Broadlink"),
0xA5D3: ("SP4L-EU", "Broadlink"),
0xA57A: ("SP4", "Broadlink"),
0xA6F4: ("SP4D-US", "Broadlink"),
},
sp4b: {
Expand All @@ -90,13 +92,15 @@
0x27B7: ("RM mini 3", "Broadlink"),
0x27C2: ("RM mini 3", "Broadlink"),
0x27C7: ("RM mini 3", "Broadlink"),
0x27C8: ("RM mini 3", "Broadlink"), # CMCC version
0x27CC: ("RM mini 3", "Broadlink"),
0x27CD: ("RM mini 3", "Broadlink"),
0x27D0: ("RM mini 3", "Broadlink"),
0x27D1: ("RM mini 3", "Broadlink"),
0x27D3: ("RM mini 3", "Broadlink"),
0x27DC: ("RM mini 3", "Broadlink"),
0x27DE: ("RM mini 3", "Broadlink"),
0xA544: ("RM mini 3", "Broadlink (OEM)"),
},
rmpro: {
0x2712: ("RM pro/pro+", "Broadlink"),
Expand All @@ -112,6 +116,7 @@
0x27A6: ("RM plus", "Broadlink"),
0x27A9: ("RM pro+", "Broadlink"),
0x27C3: ("RM pro+", "Broadlink"),
0xAF8B: ("RM Max", "Broadlink"),
},
rmminib: {
0x5F36: ("RM mini 3", "Broadlink"),
Expand Down Expand Up @@ -147,6 +152,9 @@
0x649B: ("RM4 pro", "Broadlink"),
0x653C: ("RM4 pro", "Broadlink"),
},
rm5plus: {
0x5224: ("RM5 plus", "Broadlink"),
},
a1: {
0x2714: ("A1", "Broadlink"),
},
Expand All @@ -155,6 +163,7 @@
},
mp1: {
0x4EB5: ("MP1-1K4S", "Broadlink"),
0x4EDA: ("MP1-1K3S2U", "Broadlink"),
0x4F1B: ("MP1-1K3S2U", "Broadlink (OEM)"),
0x4F65: ("MP1-1K3S2U", "Broadlink"),
},
Expand All @@ -173,11 +182,13 @@
0x644C: ("LB27 R1", "Broadlink"),
0x644E: ("LB26 R1", "Broadlink"),
0x6488: ("LB27 C1", "Broadlink"),
0x6498: ("SMART+ WIFI CEILING TW 24W", "LEDVANCE"),
},
lb2: {
0xA4F4: ("LB27 R1", "Broadlink"),
0xA5F7: ("LB27 R1", "Broadlink"),
0xA6EF: ("EFCF60WSMT", "Luceco"),
0xA517: ("LB26 R1", "Broadlink"),
},
S1C: {
0x2722: ("S2KIT", "Broadlink"),
Expand Down
23 changes: 20 additions & 3 deletions broadlink/remote.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,29 @@
"""Support for universal remotes."""

import struct
from typing import List, Optional, Tuple

from . import exceptions as e
from .device import Device

TICK = 8192 / 269
"""Duration of one Broadlink timing unit in microseconds (about 30.45 us).

The RM firmware counts pulses on a 32768 Hz clock (protocol.md: us * 269 / 8192).
Earlier releases used 32.84, the inverse of the right ratio applied the wrong
way round, which compressed externally sourced IR codes by about 7 percent
(mjg59/python-broadlink#839). Codes learned and replayed through the same
device were unaffected because both directions shared the constant.
"""


def pulses_to_data(pulses: List[int], tick: float = 32.84) -> bytes:
def pulses_to_data(pulses: List[int], tick: float = TICK) -> bytes:
"""Convert a microsecond duration sequence into a Broadlink IR packet."""
result = bytearray(4)
result[0x00] = 0x26

for pulse in pulses:
div, mod = divmod(int(pulse // tick), 256)
div, mod = divmod(round(pulse / tick), 256)
if div:
result.append(0)
result.append(div)
Expand All @@ -25,7 +36,7 @@ def pulses_to_data(pulses: List[int], tick: float = 32.84) -> bytes:
return result


def data_to_pulses(data: bytes, tick: float = 32.84) -> List[int]:
def data_to_pulses(data: bytes, tick: float = TICK) -> List[int]:
"""Parse a Broadlink packet into a microsecond duration sequence."""
result = []
index = 4
Expand Down Expand Up @@ -171,3 +182,9 @@ class rm4(rm4pro):
"""For backwards compatibility."""

TYPE = "RM4"


class rm5plus(rmminib):
"""Controls a Broadlink RM5 Plus."""

TYPE = "RM5PLUS"
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ classifiers = [
"Topic :: Home Automation",
]
dependencies = [
"cryptography>=3.2",
"cryptography>=43",
]

[project.optional-dependencies]
Expand Down
38 changes: 27 additions & 11 deletions tests/test_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,36 +8,52 @@

from broadlink.helpers import CRC16
from broadlink.protocol import Datetime
from broadlink.remote import data_to_pulses, pulses_to_data
from broadlink.remote import TICK, data_to_pulses, pulses_to_data

# NOTE: these pin the 0.19.0 behavior of the pulse helpers, including the
# 32.84 tick that upstream issue #839 identifies as wrong. They are expected
# to change, deliberately and in the same pull request, when the tick fix
# lands; until then they document what shipped.

def test_tick_constant():
# 32768 Hz timebase: protocol.md's "us * 269 / 8192".
assert TICK == pytest.approx(8192 / 269)
assert TICK == pytest.approx(30.4535, abs=1e-4)


def test_pulses_to_data_header_and_short_pulses():
data = pulses_to_data([328, 656], tick=32.84)
data = pulses_to_data([328, 656])
assert data[0] == 0x26
assert data[1] == 0x00
assert int.from_bytes(data[2:4], "little") == 2
assert data[4:] == bytes([9, 19]) # floor(328/32.84)=9, floor(656/32.84)=19
# round(328/30.4535)=11, round(656/30.4535)=22
assert data[4:] == bytes([11, 22])


def test_pulses_to_data_rounds_to_nearest_tick():
# 0.6 of a tick rounds up; 0.4 rounds down. The old code floored both.
assert pulses_to_data([TICK * 10.6])[4] == 11
assert pulses_to_data([TICK * 10.4])[4] == 10


def test_pulses_to_data_long_pulse_uses_three_byte_form():
data = pulses_to_data([10000], tick=32.84)
ticks = int(10000 // 32.84) # 304
data = pulses_to_data([10000])
ticks = round(10000 / TICK) # 328
assert ticks > 255
assert data[4:] == bytes([0, ticks >> 8, ticks & 0xFF])
assert int.from_bytes(data[2:4], "little") == 3


def test_explicit_tick_argument_still_honored():
# Callers may still pass their own tick.
assert pulses_to_data([328, 656], tick=32.84)[4:] == bytes([10, 20])
assert data_to_pulses(bytes([0x26, 0, 1, 0, 10]), tick=32.84) == [328]


def test_data_to_pulses_round_trip_at_same_tick():
pulses = [9000, 4500, 560, 560, 560, 1690, 40000]
data = pulses_to_data(pulses)
back = data_to_pulses(data)
# Both directions use the same tick, so the round trip lands within a tick.
# Rounding on the way in (and int() on the way out) keeps the round
# trip within half a tick plus one microsecond.
for a, b in zip(pulses, back, strict=True):
assert abs(a - b) <= 33
assert abs(a - b) <= TICK / 2 + 1


def test_data_to_pulses_honors_declared_length():
Expand Down
63 changes: 63 additions & 0 deletions tests/test_remote.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""Tests for the tick constant used by pulses_to_data / data_to_pulses (GH #839)."""
import unittest

from broadlink.remote import TICK, data_to_pulses, pulses_to_data

OLD_TICK = 32.84 # the constant this PR replaces


class TestTickConstant(unittest.TestCase):
"""TICK must match protocol.md's worked examples (its "us * 269 / 8192" formula)."""

def test_tick_matches_protocol_md(self):
# protocol.md's literal, verified-by-example formula.
self.assertAlmostEqual(TICK, 8192 / 269, places=4)

def test_protocol_md_worked_examples(self):
# protocol.md's own worked examples, applying its own formula
# (us * 269 / 8192) literally: 8920 us -> 0x124 (292 ticks),
# 4450 us -> 0x92 (146 ticks). TICK = 8192 / 269 reproduces both
# exactly; the alternative reading "2^-15 s" (1e6 / 2**15,
# 30.5176 us) is 0.2% different and lands one tick short on the
# second example under floor division. See PR discussion for why
# 8192/269 is the better-evidenced choice pending hardware bench.
for us, expected_ticks in ((8920, 292), (4450, 146)):
got = int(us // TICK)
self.assertLessEqual(abs(got - expected_ticks), 1)
old_got = int(us // OLD_TICK)
self.assertGreater(abs(old_got - expected_ticks), 5)

def test_round_trip(self):
# Learn-then-send is unaffected by which tick is used, as long as
# both directions agree -- this must hold for TICK just as it held
# for the old constant.
pulses = [9000, 4500, 560, 1690, 560, 560]
packet = pulses_to_data(pulses)
decoded = data_to_pulses(packet)
for original, result in zip(pulses, decoded):
self.assertAlmostEqual(result, original, delta=TICK)

def test_true_microsecond_nec_leader_is_now_correct(self):
# A real NEC leader (9000/4500 us) built from TRUE microseconds
# (e.g. Home Assistant's infrared platform, not a Broadlink round
# trip) must decode back to ~9000/4500, not ~7% short.
packet = pulses_to_data([9000, 4500])
decoded = data_to_pulses(packet)
self.assertAlmostEqual(decoded[0], 9000, delta=50)
self.assertAlmostEqual(decoded[1], 4500, delta=50)

def test_old_constant_was_seven_percent_short_on_real_hardware(self):
# The silicon's timebase is fixed regardless of what the software
# assumed, so what the old code actually put on the wire for a
# true-microsecond input is tick_count * TICK, not
# tick_count * OLD_TICK. This reproduces the ~7% figure from the
# issue's hardware bench (8362us/8437us measured vs ~9000/9116us
# true, same ballpark once packet framing rounding is folded in).
buggy_packet = pulses_to_data([9000, 4500], tick=OLD_TICK)
actually_transmitted = data_to_pulses(buggy_packet, tick=TICK)
self.assertLess(actually_transmitted[0], 9000 - 500)
self.assertLess(actually_transmitted[1], 4500 - 250)


if __name__ == "__main__":
unittest.main()
Loading