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