python-broadlink 1.0.4__tar.gz → 1.0.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. {python_broadlink-1.0.4 → python_broadlink-1.0.6}/CHANGELOG.md +67 -4
  2. {python_broadlink-1.0.4/python_broadlink.egg-info → python_broadlink-1.0.6}/PKG-INFO +8 -4
  3. {python_broadlink-1.0.4 → python_broadlink-1.0.6}/README.md +7 -3
  4. {python_broadlink-1.0.4 → python_broadlink-1.0.6}/broadlink/__init__.py +6 -6
  5. {python_broadlink-1.0.4 → python_broadlink-1.0.6}/broadlink/device.py +39 -7
  6. {python_broadlink-1.0.4 → python_broadlink-1.0.6}/broadlink/remote.py +82 -69
  7. {python_broadlink-1.0.4 → python_broadlink-1.0.6}/pyproject.toml +1 -1
  8. {python_broadlink-1.0.4 → python_broadlink-1.0.6/python_broadlink.egg-info}/PKG-INFO +8 -4
  9. {python_broadlink-1.0.4 → python_broadlink-1.0.6}/tests/test_loopback.py +17 -1
  10. {python_broadlink-1.0.4 → python_broadlink-1.0.6}/tests/test_transport.py +53 -0
  11. {python_broadlink-1.0.4 → python_broadlink-1.0.6}/LICENSE +0 -0
  12. {python_broadlink-1.0.4 → python_broadlink-1.0.6}/MANIFEST.in +0 -0
  13. {python_broadlink-1.0.4 → python_broadlink-1.0.6}/TROUBLESHOOTING.md +0 -0
  14. {python_broadlink-1.0.4 → python_broadlink-1.0.6}/broadlink/alarm.py +0 -0
  15. {python_broadlink-1.0.4 → python_broadlink-1.0.6}/broadlink/climate.py +0 -0
  16. {python_broadlink-1.0.4 → python_broadlink-1.0.6}/broadlink/const.py +0 -0
  17. {python_broadlink-1.0.4 → python_broadlink-1.0.6}/broadlink/cover.py +0 -0
  18. {python_broadlink-1.0.4 → python_broadlink-1.0.6}/broadlink/exceptions.py +0 -0
  19. {python_broadlink-1.0.4 → python_broadlink-1.0.6}/broadlink/helpers.py +0 -0
  20. {python_broadlink-1.0.4 → python_broadlink-1.0.6}/broadlink/hub.py +0 -0
  21. {python_broadlink-1.0.4 → python_broadlink-1.0.6}/broadlink/light.py +0 -0
  22. {python_broadlink-1.0.4 → python_broadlink-1.0.6}/broadlink/protocol.py +0 -0
  23. {python_broadlink-1.0.4 → python_broadlink-1.0.6}/broadlink/sensor.py +0 -0
  24. {python_broadlink-1.0.4 → python_broadlink-1.0.6}/broadlink/switch.py +0 -0
  25. {python_broadlink-1.0.4 → python_broadlink-1.0.6}/cli/README.md +0 -0
  26. {python_broadlink-1.0.4 → python_broadlink-1.0.6}/cli/broadlink_cli +0 -0
  27. {python_broadlink-1.0.4 → python_broadlink-1.0.6}/cli/broadlink_discovery +0 -0
  28. {python_broadlink-1.0.4 → python_broadlink-1.0.6}/protocol.md +0 -0
  29. {python_broadlink-1.0.4 → python_broadlink-1.0.6}/python_broadlink.egg-info/SOURCES.txt +0 -0
  30. {python_broadlink-1.0.4 → python_broadlink-1.0.6}/python_broadlink.egg-info/dependency_links.txt +0 -0
  31. {python_broadlink-1.0.4 → python_broadlink-1.0.6}/python_broadlink.egg-info/requires.txt +0 -0
  32. {python_broadlink-1.0.4 → python_broadlink-1.0.6}/python_broadlink.egg-info/top_level.txt +0 -0
  33. {python_broadlink-1.0.4 → python_broadlink-1.0.6}/setup.cfg +0 -0
  34. {python_broadlink-1.0.4 → python_broadlink-1.0.6}/tests/__init__.py +0 -0
  35. {python_broadlink-1.0.4 → python_broadlink-1.0.6}/tests/oracle/__init__.py +0 -0
  36. {python_broadlink-1.0.4 → python_broadlink-1.0.6}/tests/oracle/cases.py +0 -0
  37. {python_broadlink-1.0.4 → python_broadlink-1.0.6}/tests/oracle/fixtures.json +0 -0
  38. {python_broadlink-1.0.4 → python_broadlink-1.0.6}/tests/oracle/harness.py +0 -0
  39. {python_broadlink-1.0.4 → python_broadlink-1.0.6}/tests/oracle/record.py +0 -0
  40. {python_broadlink-1.0.4 → python_broadlink-1.0.6}/tests/test_capture.py +0 -0
  41. {python_broadlink-1.0.4 → python_broadlink-1.0.6}/tests/test_helpers.py +0 -0
  42. {python_broadlink-1.0.4 → python_broadlink-1.0.6}/tests/test_oracle.py +0 -0
  43. {python_broadlink-1.0.4 → python_broadlink-1.0.6}/tests/test_remote.py +0 -0
@@ -3,6 +3,68 @@
3
3
  All notable changes to this project are recorded here. The format follows
4
4
  Keep a Changelog; versions follow Semantic Versioning.
5
5
 
6
+ ## 1.0.6 - 2026-09-06
7
+
8
+ Small items from a sixth review, of 1.0.5, which found no defect in the
9
+ code. No change to the wire format or the public API.
10
+
11
+ ### Fixed
12
+
13
+ - A network failure during the library's own re-authentication (a timeout,
14
+ or the endpoint being closed) is raised as that failure. 1.0.3 to 1.0.5
15
+ returned the device's original expired-key answer instead, so the caller
16
+ raised `AuthorizationError` for what was really a timeout.
17
+ - An endpoint with no address on either side (discovery, `ping()`,
18
+ `setup()`) is bound to `0.0.0.0` explicitly, for the proactor loop on
19
+ Windows, which starts receiving as soon as the endpoint exists.
20
+ - The RM Max comment and the 1.0.0 changelog entry no longer claim more
21
+ than upstream #838 showed: the mapping follows that pull request's diff,
22
+ and its testers reported the device answering "locked" on
23
+ authentication, so it is listed as reported, not confirmed.
24
+
25
+ ### Changed
26
+
27
+ - An undecodable capture is logged at debug rather than warning, since the
28
+ window re-arms and carries on by itself.
29
+ - The RF capture loop closes its inner generator with `aclosing` like the
30
+ IR one.
31
+
32
+ ## 1.0.5 - 2026-09-06
33
+
34
+ Fixes from a fifth review, of 1.0.4. No change to the wire format or the
35
+ public API.
36
+
37
+ ### Fixed
38
+
39
+ - A connected socket can learn from ICMP that a host cannot be reached
40
+ (`EHOSTUNREACH`, typically a router answering for a device that is off),
41
+ and 1.0.4 raised that as an `OSError` at once. The original library's
42
+ unconnected socket never saw it and simply timed out, and Home
43
+ Assistant tolerates a timeout for a few polls where it marks a device
44
+ unavailable on the first `OSError`. Host unreachable, and Windows's
45
+ `ConnectionResetError` for port unreachable, are now treated as silence
46
+ like port unreachable already was: logged, the timeout decides, and the
47
+ socket is still dropped afterwards. Measured on the bench first: on the
48
+ test network neither an on-link address with no host behind it nor an
49
+ off-subnet one produced the ICMP, so this is insurance for networks
50
+ that do, not a fix for one that reproduced.
51
+ - `discover()` closes the `xdiscover()` generator it drains, like
52
+ `hello()` and `xdiscover()` itself.
53
+
54
+ ### Changed
55
+
56
+ - The capture window claim is a flag set on the window's first iteration
57
+ and cleared when its generator finishes or is closed, including by
58
+ asyncio's finalizer, in place of the weak reference and frame
59
+ inspection used since 1.0.0. Same behaviour, pinned by the same tests:
60
+ a dropped window gets one turn to be finalized and then blocks nobody,
61
+ a held or paused window is refused to a newcomer. One visible
62
+ difference: `CaptureInProgressError` now always comes from the new
63
+ window's first iteration, never from the `capture()` call itself.
64
+ - A comment next to the RM Max entry says why it sits in `rmpro`
65
+ (upstream #838's text says rm4pro, its tested diff says rmpro).
66
+ - README: a device belongs to the event loop it first talks on.
67
+
6
68
  ## 1.0.4 - 2026-09-06
7
69
 
8
70
  Fixes from a fourth review, of 1.0.3, which drove the real socket path the
@@ -278,16 +340,17 @@ history below starts at that fork point.
278
340
  byte.
279
341
  - Devices, carried over from pull requests against the original repository
280
342
  with their authors credited (the changes were squash-merged with
281
- `Co-authored-by` trailers naming each author): RM Max 0xAF8B (#838, Alexey Masolov);
282
- RM5 plus 0x5224 with a new `rm5plus` class (#831, Anil Daoud); RM mini 3
343
+ `Co-authored-by` trailers naming each author): RM5 plus 0x5224 with a new `rm5plus` class (#831, Anil Daoud); RM mini 3
283
344
  OEM 0xA544 (#823, Bartłomiej Nogaś); RM mini 3 CMCC 0x27C8 (#802,
284
345
  shuxin); LB26 R1 0xA517 (#812, techitapart); SP mini 3-AL 0x7D15 (#805,
285
346
  bbcbbk); LEDVANCE SMART+ WIFI CEILING TW 24W 0x6498 (#799, Felipe Martins
286
347
  Diel).
287
348
  - Devices reported in issues against the original repository, added by
288
349
  model name to the existing class for that family and not yet confirmed on
289
- hardware: MP1-1K3S2U 0x4EDA (#816) and SP4 0xA57A (#758). Please open an
290
- issue if either does not behave.
350
+ hardware: MP1-1K3S2U 0x4EDA (#816) and SP4 0xA57A (#758), and the RM Max
351
+ 0xAF8B from #838 (Alexey Masolov, credited), whose testers reported the
352
+ device answering "locked" on authentication. Please open an issue if any
353
+ of them does not behave.
291
354
  - `cryptography` 43 or newer is required, the first release with wheels for
292
355
  Python 3.13 (supersedes mjg59/python-broadlink#749).
293
356
  - A test suite. The `tests/oracle` package records, for every public method
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-broadlink
3
- Version: 1.0.4
3
+ Version: 1.0.6
4
4
  Summary: Python API for controlling Broadlink devices
5
5
  Author: DAB-LABS
6
6
  Author-email: Matthew Garrett <mjg59@srcf.ucam.org>
@@ -236,6 +236,11 @@ async with device:
236
236
  await device.aclose()
237
237
  ```
238
238
 
239
+ A device belongs to the event loop it first talks on: its socket and its
240
+ locks are bound to that loop, so a script that runs several
241
+ `asyncio.run(...)` calls should create the device inside each one rather
242
+ than reuse it across them.
243
+
239
244
  The socket reopens by itself on the next call, so closing is cheap and
240
245
  safe to do at any time. A request that is in flight when `aclose()` runs
241
246
  fails with `EndpointClosedError`. A request that fails for a network
@@ -321,9 +326,8 @@ By default the window closes after the first signal. Pass
321
326
  because the device holds only one code per learning session. A universal
322
327
  remote has a single receiver, so only one capture window can be open on a
323
328
  device at a time: opening a second one raises `CaptureInProgressError`
324
- while the first is still held, either from the `capture()` call itself or
325
- from the new window's first iteration, depending on what the first window
326
- was doing at that moment. Always close a window you leave early
329
+ from the new window's first iteration while the first is still held.
330
+ Always close a window you leave early
327
331
  (`aclosing` above does it), otherwise it stays open until Python collects
328
332
  the generator.
329
333
 
@@ -205,6 +205,11 @@ async with device:
205
205
  await device.aclose()
206
206
  ```
207
207
 
208
+ A device belongs to the event loop it first talks on: its socket and its
209
+ locks are bound to that loop, so a script that runs several
210
+ `asyncio.run(...)` calls should create the device inside each one rather
211
+ than reuse it across them.
212
+
208
213
  The socket reopens by itself on the next call, so closing is cheap and
209
214
  safe to do at any time. A request that is in flight when `aclose()` runs
210
215
  fails with `EndpointClosedError`. A request that fails for a network
@@ -290,9 +295,8 @@ By default the window closes after the first signal. Pass
290
295
  because the device holds only one code per learning session. A universal
291
296
  remote has a single receiver, so only one capture window can be open on a
292
297
  device at a time: opening a second one raises `CaptureInProgressError`
293
- while the first is still held, either from the `capture()` call itself or
294
- from the new window's first iteration, depending on what the first window
295
- was doing at that moment. Always close a window you leave early
298
+ from the new window's first iteration while the first is still held.
299
+ Always close a window you leave early
296
300
  (`aclosing` above does it), otherwise it stays open until Python collects
297
301
  the generator.
298
302
 
@@ -116,6 +116,9 @@ SUPPORTED_TYPES = {
116
116
  0x27A6: ("RM plus", "Broadlink"),
117
117
  0x27A9: ("RM pro+", "Broadlink"),
118
118
  0x27C3: ("RM pro+", "Broadlink"),
119
+ # Mapping follows the diff in upstream #838 (its text says rm4pro,
120
+ # its diff says rmpro). Testers in that thread reported the device
121
+ # answering "locked" on auth, so treat it as reported, not confirmed.
119
122
  0xAF8B: ("RM Max", "Broadlink"),
120
123
  },
121
124
  rmminib: {
@@ -281,12 +284,9 @@ async def discover(
281
284
  discover_ip_port: int = DEFAULT_PORT,
282
285
  ) -> list[Device]:
283
286
  """Discover devices connected to the local network."""
284
- return [
285
- device
286
- async for device in xdiscover(
287
- timeout, local_ip_address, discover_ip_address, discover_ip_port
288
- )
289
- ]
287
+ devices = xdiscover(timeout, local_ip_address, discover_ip_address, discover_ip_port)
288
+ async with contextlib.aclosing(devices):
289
+ return [device async for device in devices]
290
290
 
291
291
 
292
292
  async def xdiscover(
@@ -12,6 +12,7 @@ from __future__ import annotations
12
12
  import asyncio
13
13
  import collections
14
14
  import contextlib
15
+ import errno
15
16
  import logging
16
17
  import random
17
18
  import socket
@@ -53,6 +54,22 @@ _QueueItem = tuple[bytes | Exception | None, tuple[str, int] | None]
53
54
  error the socket reported (address ``None``), or ``_CLOSED``."""
54
55
 
55
56
 
57
+ def _is_silence(item: object) -> bool:
58
+ """True for the socket errors that mean "no device answered".
59
+
60
+ A connected datagram socket learns from ICMP that nobody is listening
61
+ (port unreachable, ``ConnectionRefusedError``; ``ConnectionResetError``
62
+ on Windows) or that the host cannot be reached (``EHOSTUNREACH``, from
63
+ a router answering for a host that is off). The original library used
64
+ an unconnected socket that never received any of these and simply
65
+ timed out, and callers such as Home Assistant treat a timeout more
66
+ leniently than an ``OSError``, so these are treated as silence.
67
+ """
68
+ if isinstance(item, ConnectionRefusedError | ConnectionResetError):
69
+ return True
70
+ return isinstance(item, OSError) and item.errno == errno.EHOSTUNREACH
71
+
72
+
56
73
  class _Protocol(asyncio.DatagramProtocol):
57
74
  """Datagram protocol that hands every received packet to a queue.
58
75
 
@@ -103,7 +120,14 @@ async def _open_endpoint(
103
120
  remote_addr: tuple[str, int] | None = None,
104
121
  broadcast: bool = False,
105
122
  ) -> tuple[asyncio.DatagramTransport, _Protocol]:
106
- """Create a UDP endpoint. Tests replace this to fake the network."""
123
+ """Create a UDP endpoint. Tests replace this to fake the network.
124
+
125
+ An endpoint with no address on either side is bound to ``0.0.0.0``
126
+ explicitly; the proactor loop on Windows starts receiving as soon as the
127
+ endpoint exists, which needs a bound socket.
128
+ """
129
+ if local_addr is None and remote_addr is None:
130
+ local_addr = ("0.0.0.0", 0)
107
131
  loop = asyncio.get_running_loop()
108
132
  transport, protocol = await loop.create_datagram_endpoint(
109
133
  _Protocol,
@@ -570,12 +594,15 @@ class Device:
570
594
  raise e.EndpointClosedError(
571
595
  -4013, "Endpoint closed", "The device endpoint was closed"
572
596
  )
573
- if isinstance(resp, ConnectionRefusedError):
574
- # ICMP port unreachable: the host is up and nothing is
575
- # listening, or the device is rebooting. The original
576
- # library's unconnected socket never saw these, so keep
577
- # waiting and let the timeout decide, as it did.
578
- _LOGGER.debug("%s: port unreachable, still waiting", self.host[0])
597
+ if _is_silence(resp):
598
+ # ICMP unreachable of one kind or another: the host is up
599
+ # with nothing listening, the device is off or rebooting,
600
+ # or a router answered for it. The original library's
601
+ # unconnected socket never saw these, so keep waiting and
602
+ # let the timeout decide, as it did.
603
+ _LOGGER.debug(
604
+ "%s: unreachable (%s), still waiting", self.host[0], resp
605
+ )
579
606
  continue
580
607
  if isinstance(resp, Exception):
581
608
  # A send failure (no route, address gone) or a fatal
@@ -623,6 +650,11 @@ class Device:
623
650
  if self._auth_generation == generation:
624
651
  try:
625
652
  await self.auth()
653
+ except (e.NetworkTimeoutError, e.EndpointClosedError):
654
+ # A network failure during re-authentication is
655
+ # reported as what it is, not as the device's
656
+ # original expired-key answer.
657
+ raise
626
658
  except e.BroadlinkException as err:
627
659
  _LOGGER.debug(
628
660
  "%s: re-authentication failed: %s", self.host[0], err
@@ -1,11 +1,11 @@
1
1
  """Support for universal remotes."""
2
2
 
3
3
  import asyncio
4
+ import contextlib
4
5
  import enum
5
6
  import logging
6
7
  import struct
7
8
  import time
8
- import weakref
9
9
  from collections.abc import AsyncGenerator, Awaitable, Callable
10
10
  from dataclasses import dataclass, field
11
11
  from typing import Self
@@ -221,51 +221,39 @@ class rmmini(Device):
221
221
  # against the value it saw when it armed the device and re-arms
222
222
  # after any send, since the device has one front end for both.
223
223
  self._tx_generation = 0
224
- # Weak reference to the async generator of the current capture
225
- # window, if any. See _claim_window.
226
- self._window: weakref.ReferenceType | None = None
224
+ # True while a capture window holds the device's receiver. Set by
225
+ # the window on its first iteration, cleared when its generator
226
+ # finishes or is closed, including by asyncio's finalizer.
227
+ self._capturing = False
227
228
 
228
229
  @property
229
230
  def capture_active(self) -> bool:
230
231
  """True while a capture window is open on this device."""
231
- window = self._window() if self._window is not None else None
232
- return window is not None and window.ag_frame is not None
232
+ return self._capturing
233
233
 
234
- def _check_window(self) -> None:
235
- """Fail fast at call time if another window is being iterated now."""
236
- old = self._window() if self._window is not None else None
237
- if old is not None and old.ag_frame is not None and old.ag_running:
238
- raise e.CaptureInProgressError("A capture window is already open")
239
-
240
- async def _claim_window(self, new: weakref.ReferenceType) -> None:
241
- """Make sure the previous window is really gone, then register ``new``.
234
+ async def _claim_window(self) -> None:
235
+ """Take the receiver for a new window, or refuse.
242
236
 
243
237
  A consumer that walked away from a window without closing it (for
244
238
  example ``break`` out of ``async for`` with no ``aclosing``) leaves
245
239
  the generator to asyncio's finalizer, which closes it on the next
246
- loop iteration once nothing references it. Give that a turn. If the
247
- window is still alive after that, someone still holds it, whether
248
- they are inside ``__anext__`` or paused between signals, and the new
249
- window is refused rather than taken from under them.
240
+ loop iteration once nothing references it, and closing it releases
241
+ the claim. Give that a turn. If the claim is still held after that,
242
+ someone still has the window, whether inside ``__anext__`` or
243
+ paused between signals, and the new window is refused rather than
244
+ taken from under them.
250
245
  """
251
- prev = self._window
252
- if prev is not None:
253
- old = prev()
254
- if old is not None and old.ag_frame is not None:
255
- if old.ag_running:
256
- raise e.CaptureInProgressError("A capture window is already open")
257
- del old # Hold no reference while the finalizer gets its turn.
258
- await asyncio.sleep(0)
259
- await asyncio.sleep(0)
260
- old = prev()
261
- if old is not None and old.ag_frame is not None:
262
- raise e.CaptureInProgressError(
263
- "A capture window is already open; close it with aclose() first"
264
- )
265
- if self._window is not prev:
266
- # Another claimant got in during the two turns above.
267
- raise e.CaptureInProgressError("A capture window is already open")
268
- self._window = new
246
+ if self._capturing:
247
+ await asyncio.sleep(0)
248
+ await asyncio.sleep(0)
249
+ if self._capturing:
250
+ raise e.CaptureInProgressError(
251
+ "A capture window is already open; close it with aclose() first"
252
+ )
253
+ self._capturing = True
254
+
255
+ def _release_window(self) -> None:
256
+ self._capturing = False
269
257
 
270
258
  async def _send(self, command: int, data: bytes = b"") -> bytes:
271
259
  """Send a packet to the device."""
@@ -319,13 +307,11 @@ class rmmini(Device):
319
307
  Use ``contextlib.aclosing`` (or iterate to the end) so the window is
320
308
  released promptly. Only one capture window can be open per device:
321
309
  opening one while another is still held raises
322
- ``CaptureInProgressError``. A window whose generator was dropped
323
- without being closed is finalized by asyncio on the next loop
324
- iteration and does not block.
310
+ ``CaptureInProgressError`` on its first iteration. A window whose
311
+ generator was dropped without being closed is finalized by asyncio
312
+ on the next loop iteration and does not block.
325
313
  """
326
- self._check_window()
327
- holder: list = []
328
- gen = self._capture_loop(
314
+ return self._capture_loop(
329
315
  self.enter_learning,
330
316
  window,
331
317
  stop_after_first,
@@ -333,10 +319,8 @@ class rmmini(Device):
333
319
  rearm_interval,
334
320
  SignalKind.IR,
335
321
  None,
336
- claim=holder,
322
+ claim=True,
337
323
  )
338
- holder.append(weakref.ref(gen))
339
- return gen
340
324
 
341
325
  async def _capture_loop(
342
326
  self,
@@ -348,18 +332,43 @@ class rmmini(Device):
348
332
  kind: SignalKind,
349
333
  frequency_mhz: float | None,
350
334
  *,
351
- claim: list | None = None,
335
+ claim: bool,
352
336
  ) -> AsyncGenerator[CapturedSignal]:
353
- # ``claim`` carries a weak reference to this generator (filled in by
354
- # the caller after creating it); None means the caller owns the
355
- # window claim, as capture_rf does for its inner loop.
337
+ # ``claim`` is False when the caller already holds the window, as
338
+ # capture_rf does for its inner loop.
356
339
  if window < 0:
357
340
  raise ValueError("window must be 0 (open-ended) or positive")
358
341
  if poll_interval <= 0 or rearm_interval <= 0:
359
342
  raise ValueError("poll_interval and rearm_interval must be positive")
360
343
  if claim:
361
- await self._claim_window(claim[0])
344
+ await self._claim_window()
345
+ try:
346
+ body = self._capture_body(
347
+ arm,
348
+ window,
349
+ stop_after_first,
350
+ poll_interval,
351
+ rearm_interval,
352
+ kind,
353
+ frequency_mhz,
354
+ )
355
+ async with contextlib.aclosing(body):
356
+ async for signal in body:
357
+ yield signal
358
+ finally:
359
+ if claim:
360
+ self._release_window()
362
361
 
362
+ async def _capture_body(
363
+ self,
364
+ arm: Callable[[], Awaitable[None]],
365
+ window: float,
366
+ stop_after_first: bool,
367
+ poll_interval: float,
368
+ rearm_interval: float,
369
+ kind: SignalKind,
370
+ frequency_mhz: float | None,
371
+ ) -> AsyncGenerator[CapturedSignal]:
363
372
  loop = asyncio.get_running_loop()
364
373
  deadline = loop.time() + window if window else None
365
374
  timeouts = 0
@@ -399,7 +408,7 @@ class rmmini(Device):
399
408
  except ValueError as err:
400
409
  # A packet the device returned but we cannot decode. Log
401
410
  # it, re-arm and keep the window open.
402
- _LOGGER.warning(
411
+ _LOGGER.debug(
403
412
  "%s: ignoring an undecodable capture (%s): %s",
404
413
  self.host[0],
405
414
  err,
@@ -475,18 +484,9 @@ class rmpro(rmmini):
475
484
  Each ``CapturedSignal`` carries the carrier in ``frequency_mhz``,
476
485
  which the packet itself does not record.
477
486
  """
478
- self._check_window()
479
- holder: list = []
480
- gen = self._capture_rf_loop(
481
- window,
482
- frequency,
483
- stop_after_first,
484
- poll_interval,
485
- rearm_interval,
486
- claim=holder,
487
+ return self._capture_rf_loop(
488
+ window, frequency, stop_after_first, poll_interval, rearm_interval
487
489
  )
488
- holder.append(weakref.ref(gen))
489
- return gen
490
490
 
491
491
  async def _capture_rf_loop(
492
492
  self,
@@ -495,13 +495,28 @@ class rmpro(rmmini):
495
495
  stop_after_first: bool,
496
496
  poll_interval: float,
497
497
  rearm_interval: float,
498
- *,
499
- claim: list,
500
498
  ) -> AsyncGenerator[CapturedSignal]:
501
499
  if window < 0 or poll_interval <= 0:
502
500
  raise ValueError("window must be 0 or positive, poll_interval positive")
503
- await self._claim_window(claim[0])
501
+ await self._claim_window()
502
+ try:
503
+ body = self._capture_rf_body(
504
+ window, frequency, stop_after_first, poll_interval, rearm_interval
505
+ )
506
+ async with contextlib.aclosing(body):
507
+ async for signal in body:
508
+ yield signal
509
+ finally:
510
+ self._release_window()
504
511
 
512
+ async def _capture_rf_body(
513
+ self,
514
+ window: float,
515
+ frequency: float | None,
516
+ stop_after_first: bool,
517
+ poll_interval: float,
518
+ rearm_interval: float,
519
+ ) -> AsyncGenerator[CapturedSignal]:
505
520
  loop = asyncio.get_running_loop()
506
521
  deadline = loop.time() + window if window else None
507
522
 
@@ -526,13 +541,11 @@ class rmpro(rmmini):
526
541
  rearm_interval,
527
542
  kind,
528
543
  frequency,
529
- claim=None,
544
+ claim=False,
530
545
  )
531
- try:
546
+ async with contextlib.aclosing(inner):
532
547
  async for signal in inner:
533
548
  yield signal
534
- finally:
535
- await inner.aclose()
536
549
 
537
550
  async def _sweep(self, deadline: float | None, poll_interval: float) -> float | None:
538
551
  """Sweep for the remote's carrier; return it in MHz, or None if the
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-broadlink"
7
- version = "1.0.4"
7
+ version = "1.0.6"
8
8
  description = "Python API for controlling Broadlink devices"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-broadlink
3
- Version: 1.0.4
3
+ Version: 1.0.6
4
4
  Summary: Python API for controlling Broadlink devices
5
5
  Author: DAB-LABS
6
6
  Author-email: Matthew Garrett <mjg59@srcf.ucam.org>
@@ -236,6 +236,11 @@ async with device:
236
236
  await device.aclose()
237
237
  ```
238
238
 
239
+ A device belongs to the event loop it first talks on: its socket and its
240
+ locks are bound to that loop, so a script that runs several
241
+ `asyncio.run(...)` calls should create the device inside each one rather
242
+ than reuse it across them.
243
+
239
244
  The socket reopens by itself on the next call, so closing is cheap and
240
245
  safe to do at any time. A request that is in flight when `aclose()` runs
241
246
  fails with `EndpointClosedError`. A request that fails for a network
@@ -321,9 +326,8 @@ By default the window closes after the first signal. Pass
321
326
  because the device holds only one code per learning session. A universal
322
327
  remote has a single receiver, so only one capture window can be open on a
323
328
  device at a time: opening a second one raises `CaptureInProgressError`
324
- while the first is still held, either from the `capture()` call itself or
325
- from the new window's first iteration, depending on what the first window
326
- was doing at that moment. Always close a window you leave early
329
+ from the new window's first iteration while the first is still held.
330
+ Always close a window you leave early
327
331
  (`aclosing` above does it), otherwise it stays open until Python collects
328
332
  the generator.
329
333
 
@@ -107,4 +107,20 @@ def test_endpoint_heals_after_a_socket_error_on_loopback(caplog):
107
107
  await dev.aclose()
108
108
 
109
109
  assert asyncio.run(go()) == 9
110
- assert "port unreachable" in caplog.text
110
+ assert "unreachable" in caplog.text
111
+
112
+
113
+ def test_unbound_endpoint_is_bound_explicitly():
114
+ """scan, ping and setup open an endpoint with no address on either
115
+ side; it is bound to 0.0.0.0 so it can receive on every platform."""
116
+
117
+ async def go():
118
+ transport, _ = await device_module._open_endpoint(broadcast=True)
119
+ try:
120
+ host, port = transport.get_extra_info("sockname")[:2]
121
+ return host, port
122
+ finally:
123
+ transport.close()
124
+
125
+ host, port = asyncio.run(go())
126
+ assert host == "0.0.0.0" and port > 0
@@ -483,6 +483,40 @@ def test_send_failure_fails_the_request_fast_and_heals(net):
483
483
  assert run(go())
484
484
 
485
485
 
486
+ @pytest.mark.parametrize(
487
+ "err",
488
+ [
489
+ ConnectionRefusedError(111, "Connection refused"),
490
+ ConnectionResetError(10054, "Connection reset"),
491
+ OSError(113, "No route to host"),
492
+ ],
493
+ ids=["port-unreachable", "windows-reset", "host-unreachable"],
494
+ )
495
+ def test_icmp_unreachable_is_silence_not_an_error(net, err):
496
+ """0.19.0's unconnected socket never saw ICMP errors: a device that was
497
+ off, rebooting, or behind a router answering for it produced silence
498
+ and then NetworkTimeoutError, which Home Assistant tolerates for a few
499
+ polls where it would mark the device unavailable on an OSError. The
500
+ connected socket must keep that contract."""
501
+ dev = fixed_device()
502
+ dev.timeout = 0.05
503
+
504
+ async def go():
505
+ await dev._endpoint()
506
+ ep = net.endpoints[-1]
507
+
508
+ def icmp_sendto(data, addr=None):
509
+ ep.protocol.error_received(err)
510
+
511
+ ep.sendto = icmp_sendto
512
+ with pytest.raises(e.NetworkTimeoutError):
513
+ await dev.send_packet(0x6A, b"")
514
+ assert ep.closed # dropped after the timeout, so the next call heals
515
+ assert dev._transport is None
516
+
517
+ run(go())
518
+
519
+
486
520
  def test_transport_lost_with_error_wakes_the_request(net):
487
521
  """If asyncio closes the transport from its side, the waiting request
488
522
  is told instead of waiting out its timeout."""
@@ -597,6 +631,25 @@ def test_failed_reauth_returns_the_original_reply(net):
597
631
  e.check_error(resp[0x22:0x24])
598
632
 
599
633
 
634
+ def test_timeout_during_reauth_is_reported_as_a_timeout(net):
635
+ """If the device answers the request with an expired-key code and then
636
+ goes silent during the re-authentication, that is a network failure and
637
+ is raised as one, not returned as the device's original answer."""
638
+ dev = fixed_device()
639
+ dev.timeout = 0.03
640
+
641
+ async def go():
642
+ await dev._endpoint()
643
+ ep = net.endpoints[-1]
644
+ ep.replies = [(make_response(dev, b"", error=0xFFF9), HOST)] # then silence
645
+ with pytest.raises(e.NetworkTimeoutError):
646
+ await dev.send_packet(0x6A, b"")
647
+ return [int.from_bytes(f[0x26:0x28], "little") for f, _ in ep.sent]
648
+
649
+ types = run(go())
650
+ assert types[0] == 0x6A and set(types[1:]) == {0x65} # auth was tried and resent
651
+
652
+
600
653
  def test_locked_device_surfaces_as_the_original_error(net):
601
654
  """Device locked in the app: request answered -7, auth answered -1. The
602
655
  caller gets the -7 frame back (its check_error raises