Skip to content

Fix the issues found in the review of 1.0.2 - #843

Closed
DAB-LABS wants to merge 8 commits into
mjg59:masterfrom
DAB-LABS:fixes-1.0.3
Closed

Fix the issues found in the review of 1.0.2#843
DAB-LABS wants to merge 8 commits into
mjg59:masterfrom
DAB-LABS:fixes-1.0.3

Conversation

@DAB-LABS

@DAB-LABS DAB-LABS commented Sep 6, 2026

Copy link
Copy Markdown

I had 1.0.2 reviewed the same way as the last two. Nothing in how the library talks to devices changed, and the review confirmed that again.

It did find an issue when a device's session key expires and the device has since been locked in the app, the library tried to log in again, failed, and raised the login failure instead of the error the device gave the original request. The original library never tried to log in again, so a program written against it, including Home Assistant, handles the first error and may have never seen the second. That is fixed, along with some smaller items.

Tested on Python 3.13 and 3.14, 261 tests, plus a live run against an RM4 Pro.

Coding assistant's notes on this change:

When re-authentication after an expired-key answer fails, the request's own reply is now returned, so the caller sees the same AuthorizationError or ConnectionClosedError the original library raised. The failure is logged at debug level. The README now states this contract and its worst case: one call can wait out up to three timeouts (the request, the authentication, and the repeat).

Also in this change: the authentication generation is read under the request lock, so a request queued behind auth() cannot skip a re-authentication it needs; aclose() racing an endpoint that is still opening no longer leaks the new socket; a new capture window re-checks for a rival claimant after giving the finalizer its turn; CapturedSignal.pulses and ParsedPacket.pulses are tuples, so the frozen dataclasses are hashable; check_error unpacks the error code as little-endian explicitly; the locks are created in init; send_packet accepts bytearray; setup() goes through a public send_setup_packet() helper; the CLI closes its device; and the README adds the A2 and the Hysen HY02/HY03 to the device list. A typing pass (py.typed, mypy in CI) is left for 1.1.

DAB-LABS and others added 8 commits September 4, 2026 11:39
Move packaging to pyproject.toml as the python-broadlink distribution
(import name unchanged), require Python 3.13 or newer, and replace the
flake8 workflow with ruff and pytest on 3.13 and 3.14 plus an sdist and
wheel build. Add a trusted-publishing workflow for version tags.

Add tests/oracle: a harness that records the exact request bytes every
public method of every device class sends and the result it decodes
from canned responses, frozen in fixtures.json (155 cases), plus
transport tests for send_packet framing, checksums, auth, discovery,
gendevice and setup, and tests for the pure helpers. No library
behavior changes; three import blocks were reordered for ruff.

Add a README note explaining the fork and a CHANGELOG.
Every method that reaches a device is now a coroutine, with the same
names, arguments and return values as before. Discovery, hello and
setup are coroutines and xdiscover is an async generator. The packet,
CRC and datetime helpers stay synchronous. There is no synchronous
compatibility layer.

Transport: each device keeps one UDP endpoint (asyncio
DatagramProtocol) for its lifetime and serializes requests on it with
an asyncio.Lock; the previous code opened a socket per call and
declared a lock it never acquired. Retry and timeout behaviour is
unchanged. An expired session key is re-authenticated once and the
request repeated. async with / aclose() release the endpoint.

Device classes are a mechanical port (async def and await); the
oracle suite recorded in the previous change passes unchanged, so
every method sends the same bytes and decodes the same results as
0.19.0. Transport tests use a fake endpoint and gain cases for lock
serialization, endpoint reuse, stale-reply draining and re-auth.
The CLI runs under asyncio.run. README and CHANGELOG describe the
break.

Live-checked against an RM4 Pro: discovery, hello, auth, sensors,
concurrent calls, learning primitives, send, and the timeout path.
* Add support for Broadlink RM Max (0xAF8B)

Place in the rmpro class which uses <I framing for IR learn/send
commands, matching the device's actual protocol. The rm4pro class
uses <HI framing which causes "device is locked" errors on this
hardware.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Update __init__.py

* Update remote.py

* Add an OEM device code for RM mini 3

* support RM mini 3 cmcc version

support RM mini 3 cmcc version

* Added support for another type of LB26 R1

* Update __init__.py

add 0x7d15 SP mini 3-AL

* Add support for LEDVANCE SMART+ WIFI CEILING TW 24W (0x6498)

* Refactor tick parameter to use constant value

* Add tests for TICK constant in remote module

Add unit tests for the TICK constant in remote module to ensure accuracy against protocol.md examples and validate behavior with real hardware.

* Round pulses to the nearest tick, add issue-reported device IDs, update tests and changelog

Follow-ups to the carried-over commits:

- pulses_to_data rounds instead of truncating, so a duration that is
  0.9 of a tick no longer becomes zero ticks.
- TICK gets a docstring explaining the 32768 Hz timebase and the history
  of the 32.84 value; the import block is sorted for ruff.
- 0x4EDA MP1-1K3S2U (mjg59#816) and 0xA57A SP4 (mjg59#758) added from issues, by
  family; 0x7D15 and 0x27C8 entries tidied into hex order and house style.
- cryptography floor raised to 43, the first release with 3.13 wheels
  (supersedes mjg59#749).
- tests/test_helpers.py re-pinned to the new tick and rounding; the old
  32.84 pins are gone.
- README device list and CHANGELOG updated.

---------

Co-authored-by: Alexey Masolov <amasolov@redhat.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Anil Daoud <anil.daoud@protonmail.com>
Co-authored-by: Bartłomiej Nogaś <bart.nogas@gmail.com>
Co-authored-by: shuxin <shuxin@users.noreply.github.com>
Co-authored-by: techitapart <70172453+techitapart@users.noreply.github.com>
Co-authored-by: bbcbbk <44605459+bbcbbk@users.noreply.github.com>
Co-authored-by: Felipe Martins Diel <felipe-diel@hotmail.com>
A learning session is arm, poll, wait, re-arm, one code at a time, with a
device that leaves learning mode without saying so and a send that ends the
session. capture() and capture_rf() own that loop and hand back clean
signals, so a consumer subscribes and reads instead of reimplementing the
dance (as the remote platform, the receiver PR and others each did).

- capture(window, stop_after_first, poll_interval, rearm_interval): async
  generator yielding CapturedSignal. Re-arms on a timer (default 15 s, under
  the 25-40 s the device was measured to hold a session) and after any
  send_data (a send ends the session; both from the bench). One window per
  device; a second raises CaptureInProgressError.
- capture_rf(window, frequency, ...) on the Pro classes: takes the carrier
  directly, or sweeps for it when not given. The sweep is unreliable on some
  firmware, so the known-frequency path is primary.
- CapturedSignal: device packet, pulses at the corrected tick, kind, repeat,
  and the RF carrier the packet does not itself record.
- pulses_to_data gains kind and repeat; parse_packet is the inverse;
  SignalKind names the bands. A returned RF packet does not always carry the
  canonical type byte (an RM4 Pro sends 0xB1 for 433 MHz), so kind is read by
  band and a capture is tagged from what it armed, never dropped on the byte.
- One shared front-end lock and a transmit generation counter already live on
  the device; capture reads the counter so a concurrent send re-arms the
  window.

Tests drive the loops against a scripted device that models the bench
findings; the transport oracle fixtures are unchanged.
- broadlink_cli --learn and --rflearn learn through capture() and
  capture_rf(), so a session no longer goes deaf when the device times out
  partway through the 30 s wait. New --window (seconds to listen), --keep
  (print every code heard) and --repeat (with --send --durations).
- cli/README.md: --rfscanlearn was a typo for --rflearn (mjg59#803, mjg59#830);
  install line is pip install python-broadlink; examples for --frequency,
  --window, --keep and --repeat.
- README: four device calls in the switch examples were missing await; the
  RF check_frequency example now unpacks the (found, frequency) tuple.
- CHANGELOG: 1.0.0 heading, the CLI changes, and the pulses_to_data
  bytes-instead-of-bytearray note.
- tests/test_capture.py timings expressed as multiples of a 30 ms unit
  (was 10 ms) for headroom on slower CI runners.
- Version 1.0.0.
I had 1.0.0 reviewed by an outside party before asking Home Assistant to
switch to it. The review confirmed the library talks to devices exactly the
way the original did, and found one real bug plus a few small things. This
fixes them. No change to the wire format or the public API. Tested on
Python 3.13 and 3.14 (252 tests) and live against an RM4 Pro.

Technical details:
- Replies are matched to their request by the packet counter the device
  echoes at offset 0x28. A reply for a request that already timed out is
  dropped; a reply whose counter matches nothing sent is still accepted,
  so firmware that does not echo the counter keeps working.
- Concurrent callers hitting an expired session key share one
  re-authentication. The logged-out code (-2) now triggers re-auth.
- Changing device.host reopens the socket.
- aclose() during a request fails it immediately.
- capture() treats ReadError (-10) as "nothing yet" alongside
  StorageError (-5); the CLI inherits this.
- An abandoned capture() generator no longer blocks the next one; new
  capture_active property.
- README: broadlink and python-broadlink cannot share an environment.
- Changelog: carried-over device commits were squash-merged with
  Co-authored-by credit; oracle description corrected.
- Version 1.0.1.
* Fix the issues found in the second review of 1.0.1

A second, adversarial review of 1.0.1 and a re-test of the first review's
findings turned up three real defects in the transport and capture code,
none in the wire format. This fixes them and takes the smaller items along.
Tested on 3.13 and 3.14, 257 tests, oracle fixtures unchanged.

Technical details:
- Reply matching remembers every recently used counter, not only
  timed-out ones, so the second answer to a resent request is dropped
  instead of being taken as the next request's reply.
- auth() resets the session and installs the new key under the request
  lock, so a queued request is never framed with id 0.
- A new capture window gives asyncio's finalizer a turn to close a dropped
  generator, then refuses if the old window is still alive, instead of
  taking it from a paused consumer. A refused attempt no longer displaces
  the live window.
- An undecodable returned packet is logged and skipped; the window
  re-arms.
- EndpointClosedError (-4013), a subclass of ConnectionClosedError, for
  aclose() during a request.
- hello() closes the scan generator; TimeoutError spelling; unused
  protocol future removed; debug logging on device and remote.
- README: Closing section, Timing section with the bench numbers, Python
  support note, return-value differences from 0.19.0.
- Version 1.0.2.

* Format with ruff and widen the lint rules

No behaviour change: 257 tests pass before and after, the oracle fixtures
are byte-identical, and repr/str output is unchanged. This is the one-time
formatting pass the pyproject comment promised once the async port landed.

Technical details:
- ruff format over the tree; CI now runs ruff format --check.
- Lint set widened from E9/F/I to E, W, F, I, UP, B, ASYNC, RUF. Safe
  autofixes applied (typing modernised to the 3.13 spellings, f-strings
  for the percent formatting in repr/str and the exceptions).
- Ignored with a reason in pyproject: ASYNC109 (protocol timeout, not a
  cancellation scope), RUF012 and E721 (inherited class tables and the
  exception __eq__), RUF006 in tests (helper tasks fired on purpose), and
  E501 in the three upstream modules with long example payloads.
- zip() calls carry an explicit strict=; hello()'s first-reply loop
  carries a noqa with its reason.
A third review, this one of 1.0.2, found one behaviour that was worse than
the original library's and a handful of small things. Nothing in the wire
format changed. This fixes them. Tested on 3.13 and 3.14, 261 tests,
oracle fixtures unchanged, and live against an RM4 Pro under -X dev.

Technical details:
- When re-authentication after an expired-key answer fails (a device
  locked in the app, say), the request's own reply is returned, so the
  caller sees the same AuthorizationError or ConnectionClosedError the
  original library raised, not an AuthenticationError from the retry. The
  failure is logged at debug.
- The auth generation is read under the request lock, so a request queued
  behind auth() cannot observe a stale one and skip a needed re-auth.
- aclose() racing an endpoint open no longer leaks the new socket; the
  open notices the close and raises EndpointClosedError.
- A new capture window re-checks for a rival claimant after giving the
  finalizer its turn.
- CapturedSignal.pulses and ParsedPacket.pulses are tuples, so the frozen
  dataclasses are hashable.
- check_error unpacks with "<h"; the locks are created in __init__;
  send_packet accepts bytearray; setup() goes through a public
  send_setup_packet() helper; the CLI closes its device.
- README: re-auth contract and worst case (three timeouts), A2 and Hysen
  HY02/HY03 in the device list, hello response mac is bytes.
- Version 1.0.3.
@DAB-LABS DAB-LABS closed this Sep 6, 2026
@DAB-LABS

DAB-LABS commented Sep 6, 2026

Copy link
Copy Markdown
Author

Opened against the wrong repository by mistake; this work lives in the DAB-LABS fork.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant