From 5c745dff1ecf60018d581055d1a29812dd8063a5 Mon Sep 17 00:00:00 2001 From: DAB-LABS <128871138+DAB-LABS@users.noreply.github.com> Date: Sun, 6 Sep 2026 03:59:55 +0000 Subject: [PATCH] Small items from the review of 1.0.5 A sixth review, of 1.0.5, found no defect in the code. This takes its small items. No change to the wire format or the public API. Tested on 3.13 and 3.14, 274 tests under -X dev with ResourceWarning as an error, oracle fixtures unchanged, and live against an RM4 Pro. Technical details: - A NetworkTimeoutError or EndpointClosedError during the library's own re-authentication propagates instead of being swallowed in favour of the device's original expired-key reply, so a network failure is reported as one. - _open_endpoint binds to ("0.0.0.0", 0) when neither address is given (discovery, ping, setup), for the proactor loop on Windows. - The RM Max comment and the 1.0.0 changelog entry say only what upstream #838 showed: mapping per its diff, testers reported "locked" on auth, listed as reported rather than confirmed. - The undecodable-capture message is debug, not warning. - _capture_rf_loop closes its inner generator with aclosing. - Version 1.0.6. --- CHANGELOG.md | 35 +++++++++++++++++++++++++++++++---- broadlink/__init__.py | 6 +++--- broadlink/device.py | 14 +++++++++++++- broadlink/remote.py | 10 ++++++---- pyproject.toml | 2 +- tests/test_loopback.py | 16 ++++++++++++++++ tests/test_transport.py | 19 +++++++++++++++++++ 7 files changed, 89 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 18f56eab..e78b6517 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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 diff --git a/broadlink/__init__.py b/broadlink/__init__.py index 59c1012e..a941c18f 100644 --- a/broadlink/__init__.py +++ b/broadlink/__init__.py @@ -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: { diff --git a/broadlink/device.py b/broadlink/device.py index 45665345..81c0ac31 100644 --- a/broadlink/device.py +++ b/broadlink/device.py @@ -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, @@ -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 diff --git a/broadlink/remote.py b/broadlink/remote.py index e90251a3..8f48652e 100644 --- a/broadlink/remote.py +++ b/broadlink/remote.py @@ -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, @@ -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() diff --git a/pyproject.toml b/pyproject.toml index e07e4e22..f5c9553d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/tests/test_loopback.py b/tests/test_loopback.py index ff294eee..a6bb0276 100644 --- a/tests/test_loopback.py +++ b/tests/test_loopback.py @@ -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 diff --git a/tests/test_transport.py b/tests/test_transport.py index a179c3c5..341bb71c 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -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