Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 31 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,32 @@
All notable changes to this project are recorded here. The format follows
Keep a Changelog; versions follow Semantic Versioning.

## 1.0.6 - 2026-09-06

Small items from a sixth review, of 1.0.5, which found no defect in the
code. No change to the wire format or the public API.

### Fixed

- A network failure during the library's own re-authentication (a timeout,
or the endpoint being closed) is raised as that failure. 1.0.3 to 1.0.5
returned the device's original expired-key answer instead, so the caller
raised `AuthorizationError` for what was really a timeout.
- An endpoint with no address on either side (discovery, `ping()`,
`setup()`) is bound to `0.0.0.0` explicitly, for the proactor loop on
Windows, which starts receiving as soon as the endpoint exists.
- The RM Max comment and the 1.0.0 changelog entry no longer claim more
than upstream #838 showed: the mapping follows that pull request's diff,
and its testers reported the device answering "locked" on
authentication, so it is listed as reported, not confirmed.

### Changed

- An undecodable capture is logged at debug rather than warning, since the
window re-arms and carries on by itself.
- The RF capture loop closes its inner generator with `aclosing` like the
IR one.

## 1.0.5 - 2026-09-06

Fixes from a fifth review, of 1.0.4. No change to the wire format or the
Expand Down Expand Up @@ -314,16 +340,17 @@ history below starts at that fork point.
byte.
- Devices, carried over from pull requests against the original repository
with their authors credited (the changes were squash-merged with
`Co-authored-by` trailers naming each author): RM Max 0xAF8B (#838, Alexey Masolov);
RM5 plus 0x5224 with a new `rm5plus` class (#831, Anil Daoud); RM mini 3
`Co-authored-by` trailers naming each author): 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.
hardware: MP1-1K3S2U 0x4EDA (#816) and SP4 0xA57A (#758), and the RM Max
0xAF8B from #838 (Alexey Masolov, credited), whose testers reported the
device answering "locked" on authentication. Please open an issue if any
of them 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, for every public method
Expand Down
6 changes: 3 additions & 3 deletions broadlink/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,9 +116,9 @@
0x27A6: ("RM plus", "Broadlink"),
0x27A9: ("RM pro+", "Broadlink"),
0x27C3: ("RM pro+", "Broadlink"),
# The RM Max answers the RM pro framing; the RM4 framing (length
# prefix) gets "device is locked" from it. Tested on hardware in
# upstream #838, whose text says rm4pro but whose diff says rmpro.
# Mapping follows the diff in upstream #838 (its text says rm4pro,
# its diff says rmpro). Testers in that thread reported the device
# answering "locked" on auth, so treat it as reported, not confirmed.
0xAF8B: ("RM Max", "Broadlink"),
},
rmminib: {
Expand Down
14 changes: 13 additions & 1 deletion broadlink/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,14 @@ async def _open_endpoint(
remote_addr: tuple[str, int] | None = None,
broadcast: bool = False,
) -> tuple[asyncio.DatagramTransport, _Protocol]:
"""Create a UDP endpoint. Tests replace this to fake the network."""
"""Create a UDP endpoint. Tests replace this to fake the network.

An endpoint with no address on either side is bound to ``0.0.0.0``
explicitly; the proactor loop on Windows starts receiving as soon as the
endpoint exists, which needs a bound socket.
"""
if local_addr is None and remote_addr is None:
local_addr = ("0.0.0.0", 0)
loop = asyncio.get_running_loop()
transport, protocol = await loop.create_datagram_endpoint(
_Protocol,
Expand Down Expand Up @@ -643,6 +650,11 @@ async def send_packet(self, packet_type: int, payload: bytes | bytearray) -> byt
if self._auth_generation == generation:
try:
await self.auth()
except (e.NetworkTimeoutError, e.EndpointClosedError):
# A network failure during re-authentication is
# reported as what it is, not as the device's
# original expired-key answer.
raise
except e.BroadlinkException as err:
_LOGGER.debug(
"%s: re-authentication failed: %s", self.host[0], err
Expand Down
10 changes: 6 additions & 4 deletions broadlink/remote.py
Original file line number Diff line number Diff line change
Expand Up @@ -408,7 +408,7 @@ async def _capture_body(
except ValueError as err:
# A packet the device returned but we cannot decode. Log
# it, re-arm and keep the window open.
_LOGGER.warning(
_LOGGER.debug(
"%s: ignoring an undecodable capture (%s): %s",
self.host[0],
err,
Expand Down Expand Up @@ -500,10 +500,12 @@ async def _capture_rf_loop(
raise ValueError("window must be 0 or positive, poll_interval positive")
await self._claim_window()
try:
async for signal in self._capture_rf_body(
body = self._capture_rf_body(
window, frequency, stop_after_first, poll_interval, rearm_interval
):
yield signal
)
async with contextlib.aclosing(body):
async for signal in body:
yield signal
finally:
self._release_window()

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "python-broadlink"
version = "1.0.5"
version = "1.0.6"
description = "Python API for controlling Broadlink devices"
readme = "README.md"
license = "MIT"
Expand Down
16 changes: 16 additions & 0 deletions tests/test_loopback.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,19 @@ async def go():

assert asyncio.run(go()) == 9
assert "unreachable" in caplog.text


def test_unbound_endpoint_is_bound_explicitly():
"""scan, ping and setup open an endpoint with no address on either
side; it is bound to 0.0.0.0 so it can receive on every platform."""

async def go():
transport, _ = await device_module._open_endpoint(broadcast=True)
try:
host, port = transport.get_extra_info("sockname")[:2]
return host, port
finally:
transport.close()

host, port = asyncio.run(go())
assert host == "0.0.0.0" and port > 0
19 changes: 19 additions & 0 deletions tests/test_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -631,6 +631,25 @@ async def go():
e.check_error(resp[0x22:0x24])


def test_timeout_during_reauth_is_reported_as_a_timeout(net):
"""If the device answers the request with an expired-key code and then
goes silent during the re-authentication, that is a network failure and
is raised as one, not returned as the device's original answer."""
dev = fixed_device()
dev.timeout = 0.03

async def go():
await dev._endpoint()
ep = net.endpoints[-1]
ep.replies = [(make_response(dev, b"", error=0xFFF9), HOST)] # then silence
with pytest.raises(e.NetworkTimeoutError):
await dev.send_packet(0x6A, b"")
return [int.from_bytes(f[0x26:0x28], "little") for f, _ in ep.sent]

types = run(go())
assert types[0] == 0x6A and set(types[1:]) == {0x65} # auth was tried and resent


def test_locked_device_surfaces_as_the_original_error(net):
"""Device locked in the app: request answered -7, auth answered -1. The
caller gets the -7 frame back (its check_error raises
Expand Down
Loading