Skip to content

pulses_to_data tick constant (32.84) disagrees with protocol.md (2^-15 s), compressing externally-sourced IR by about 7% #839

Description

@DAB-LABS

Hello, and thank you for creating such a valuable backbone for Broadlink! 🍻

I maintain HAIR, an infrared management integration for Home Assistant. While bench testing I found what I believe is a wrong timing constant in the encoder, and it has become visible now that Home Assistant has shipped its own infrared platform.

In a nut shell: pulses_to_data and data_to_pulses share the same tick constant, and that constant looks to be about 7% off.

Because both directions use it, the error cancels out for anyone who converts a Broadlink-learned code back into microseconds and sends it out through a Broadlink again, which I think is why this has gone unnoticed for so long. It stops cancelling the moment real microsecond timings from any other source are handed to the encoder, and that is exactly what Home Assistant's infrared platform now does. Those sends come out roughly 7% short on every mark and space.

Tolerant protocols absorb it, stricter receivers ignore the code completely, which is how I got here: a user reported codes learned on an ESPHome receiver working fine from ESPHome but doing nothing at all through an RM3 Mini.

I have confirmed it on hardware across two independent bench runs, and it also reproduces on paper against the worked examples in protocol.md.

Happy to open a PR if that helps.

~ DAB

The following is a write-up from my coding assistant with all the technical details:

protocol.md says IR pulse lengths are in 2^-15 s units (about 30.5176 us) and gives the conversion "us * 269 / 8192", with worked examples:
8920 us -> 0x124 (292 ticks) and 4450 us -> 0x92 (146 ticks).

remote.py encodes with a different constant:

def pulses_to_data(pulses: List[int], tick: float = 32.84) -> bytes:

Running the doc's own examples through the code gives 8920 // 32.84 = 271 and 4450 // 32.84 = 135, about 7% short of the documented values. My guess at the origin: 269 / 8192 = 0.0328 is the us-to-ticks multiplier, and 32.84 looks like that figure read as the tick length in microseconds.

I bench tested it on real hardware to make sure the doc is the correct side of the disagreement. Two hand-built packets encoding the same NEC timings, one converted at 30.5176 us/tick and one at 32.84, sent from an RM through remote.send_command in Home Assistant and captured off the air by an ESPHome receiver:

Measure tick 30.5176 tick 32.84 ratio
Header mark 9116 us 8437 us 0.926
Header space 4483 us 4202 us 0.937
Whole frame 66820 us 62451 us 0.935

Predicted ratio if the hardware tick is 2^-15 s: 30.5176 / 32.84 = 0.929. The 30.5176 packet played back at its true NEC timings (9116 us captured vs 9003 built, normal receiver mark stretch). The 32.84 packet came back about 7% compressed across every mark and space. So the hardware tick is 2^-15 s as protocol.md documents, and pulses_to_data under-counts ticks.

Why this has gone unnoticed: data_to_pulses uses the same 32.84, so codes learned on a Broadlink and sent by a Broadlink round-trip through the same error and it cancels out. It only bites when true-microsecond timings from outside enter pulses_to_data, which is what Home Assistant's infrared platform does. Tolerant protocols like NEC absorb the 7% (both of my test frames still decoded); tighter receivers reject it. I got here from a user report of codes learned on ESPHome working from ESPHome but dead through an RM3 Mini.

For what it's worth on provenance: 32.84 looks like it entered in #105 (Nov 2017) as TICK = 32.84 in cli/broadlink_cli, with no comment on where the number came from, and it stayed a CLI-only convenience value for six years. #741 and #742 touched those same duration helpers in 2023 without revisiting it, and #788 promoted them to library API in 2024. No released sdist before 0.19.0 contains the constant at all (I checked every version back to 0.5 on PyPI). So this is a fairly recent piece of public API rather than a long-standing contract, which I hope makes it easier to correct.

Fix looks like one line, applied to both directions together so learned codes keep round-tripping:

tick: float = 1e6 / 32768  # 2^-15 s, per protocol.md

One caveat worth naming: changing both directions together keeps the learn-then-send round trip intact, but anyone who has PERSISTED microsecond values produced by the old data_to_pulses would find those stored values re-encode about 7% long afterwards. I'd expect that to be a small population, since most tooling stores the base64 packet rather than microseconds, but it is a real consequence rather than a pure no-op.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions