python-broadlink 1.0.0__tar.gz → 1.0.1__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 (42) hide show
  1. {python_broadlink-1.0.0 → python_broadlink-1.0.1}/CHANGELOG.md +55 -5
  2. {python_broadlink-1.0.0/python_broadlink.egg-info → python_broadlink-1.0.1}/PKG-INFO +11 -4
  3. {python_broadlink-1.0.0 → python_broadlink-1.0.1}/README.md +10 -3
  4. {python_broadlink-1.0.0 → python_broadlink-1.0.1}/broadlink/device.py +86 -32
  5. {python_broadlink-1.0.0 → python_broadlink-1.0.1}/broadlink/remote.py +123 -62
  6. {python_broadlink-1.0.0 → python_broadlink-1.0.1}/pyproject.toml +1 -1
  7. {python_broadlink-1.0.0 → python_broadlink-1.0.1/python_broadlink.egg-info}/PKG-INFO +11 -4
  8. {python_broadlink-1.0.0 → python_broadlink-1.0.1}/tests/test_capture.py +74 -9
  9. {python_broadlink-1.0.0 → python_broadlink-1.0.1}/tests/test_transport.py +144 -0
  10. {python_broadlink-1.0.0 → python_broadlink-1.0.1}/LICENSE +0 -0
  11. {python_broadlink-1.0.0 → python_broadlink-1.0.1}/MANIFEST.in +0 -0
  12. {python_broadlink-1.0.0 → python_broadlink-1.0.1}/TROUBLESHOOTING.md +0 -0
  13. {python_broadlink-1.0.0 → python_broadlink-1.0.1}/broadlink/__init__.py +0 -0
  14. {python_broadlink-1.0.0 → python_broadlink-1.0.1}/broadlink/alarm.py +0 -0
  15. {python_broadlink-1.0.0 → python_broadlink-1.0.1}/broadlink/climate.py +0 -0
  16. {python_broadlink-1.0.0 → python_broadlink-1.0.1}/broadlink/const.py +0 -0
  17. {python_broadlink-1.0.0 → python_broadlink-1.0.1}/broadlink/cover.py +0 -0
  18. {python_broadlink-1.0.0 → python_broadlink-1.0.1}/broadlink/exceptions.py +0 -0
  19. {python_broadlink-1.0.0 → python_broadlink-1.0.1}/broadlink/helpers.py +0 -0
  20. {python_broadlink-1.0.0 → python_broadlink-1.0.1}/broadlink/hub.py +0 -0
  21. {python_broadlink-1.0.0 → python_broadlink-1.0.1}/broadlink/light.py +0 -0
  22. {python_broadlink-1.0.0 → python_broadlink-1.0.1}/broadlink/protocol.py +0 -0
  23. {python_broadlink-1.0.0 → python_broadlink-1.0.1}/broadlink/sensor.py +0 -0
  24. {python_broadlink-1.0.0 → python_broadlink-1.0.1}/broadlink/switch.py +0 -0
  25. {python_broadlink-1.0.0 → python_broadlink-1.0.1}/cli/README.md +0 -0
  26. {python_broadlink-1.0.0 → python_broadlink-1.0.1}/cli/broadlink_cli +0 -0
  27. {python_broadlink-1.0.0 → python_broadlink-1.0.1}/cli/broadlink_discovery +0 -0
  28. {python_broadlink-1.0.0 → python_broadlink-1.0.1}/protocol.md +0 -0
  29. {python_broadlink-1.0.0 → python_broadlink-1.0.1}/python_broadlink.egg-info/SOURCES.txt +0 -0
  30. {python_broadlink-1.0.0 → python_broadlink-1.0.1}/python_broadlink.egg-info/dependency_links.txt +0 -0
  31. {python_broadlink-1.0.0 → python_broadlink-1.0.1}/python_broadlink.egg-info/requires.txt +0 -0
  32. {python_broadlink-1.0.0 → python_broadlink-1.0.1}/python_broadlink.egg-info/top_level.txt +0 -0
  33. {python_broadlink-1.0.0 → python_broadlink-1.0.1}/setup.cfg +0 -0
  34. {python_broadlink-1.0.0 → python_broadlink-1.0.1}/tests/__init__.py +0 -0
  35. {python_broadlink-1.0.0 → python_broadlink-1.0.1}/tests/oracle/__init__.py +0 -0
  36. {python_broadlink-1.0.0 → python_broadlink-1.0.1}/tests/oracle/cases.py +0 -0
  37. {python_broadlink-1.0.0 → python_broadlink-1.0.1}/tests/oracle/fixtures.json +0 -0
  38. {python_broadlink-1.0.0 → python_broadlink-1.0.1}/tests/oracle/harness.py +0 -0
  39. {python_broadlink-1.0.0 → python_broadlink-1.0.1}/tests/oracle/record.py +0 -0
  40. {python_broadlink-1.0.0 → python_broadlink-1.0.1}/tests/test_helpers.py +0 -0
  41. {python_broadlink-1.0.0 → python_broadlink-1.0.1}/tests/test_oracle.py +0 -0
  42. {python_broadlink-1.0.0 → python_broadlink-1.0.1}/tests/test_remote.py +0 -0
@@ -3,6 +3,51 @@
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.1 - 2026-09-05
7
+
8
+ Fixes from an independent review of 1.0.0, most of them in the transport.
9
+ None changes the wire format or the public API.
10
+
11
+ ### Fixed
12
+
13
+ - A reply to a request that had already timed out could be delivered as the
14
+ reply to the next request on the same device, because the persistent
15
+ endpoint (new in 1.0.0) is not thrown away between calls the way the old
16
+ per-call socket was. Replies are now matched to their request by the
17
+ packet counter the device echoes at offset 0x28; a reply carrying the
18
+ counter of a request that already timed out is discarded, and a reply
19
+ whose counter matches nothing the device sent is still accepted, so
20
+ firmware that does not echo the counter is unaffected. Confirmed on an
21
+ RM4 Pro, which echoes it.
22
+ - `capture()` treated only `StorageError` (-5) as "nothing captured yet".
23
+ Some firmware answers `ReadError` (-10); both are now treated as "nothing
24
+ yet", matching what the original CLI and Home Assistant do while polling.
25
+ The CLI's `--learn` and `--rflearn` inherit the fix.
26
+ - Abandoning a capture generator without closing it (for example `break`
27
+ out of `async for` to take one code) no longer blocks the next
28
+ `capture()` on the same device: opening a new window closes an abandoned
29
+ one. Opening a window while another is actively being iterated still
30
+ raises `CaptureInProgressError`. A new read-only `Device.capture_active`
31
+ property reports whether a window is open.
32
+ - Re-authentication is now shared between concurrent callers: when several
33
+ requests hit an expired session key at once, the library authenticates
34
+ once and every caller retries, instead of one caller re-authenticating
35
+ and the others surfacing the raw error. The logged-out code (-2) now
36
+ triggers re-authentication as well, matching Home Assistant's own retry.
37
+ - Changing `device.host` after the endpoint is open now reopens it against
38
+ the new address instead of continuing to talk to the old one.
39
+ - `aclose()` while a request is in flight fails that request at once with
40
+ `ConnectionClosedError` instead of waiting out the timeout.
41
+
42
+ ### Documentation
43
+
44
+ - The README explains that `broadlink` and `python-broadlink` install the
45
+ same package name and cannot coexist, and how to recover if both were
46
+ installed.
47
+ - The changelog no longer describes the carried-over device commits as
48
+ "intact" (they were squash-merged with `Co-authored-by` credit) and no
49
+ longer overstates what the oracle records.
50
+
6
51
  ## 1.0.0 - 2026-09-05
7
52
 
8
53
  This is the first release of `python-broadlink`, a maintained fork of
@@ -39,6 +84,8 @@ history below starts at that fork point.
39
84
  #830).
40
85
  - `pulses_to_data` returns `bytes` (it returned a `bytearray`, against its
41
86
  own annotation).
87
+ - The device's request lock is now a private `_lock` that is actually
88
+ acquired; the unused public `Device.lock` attribute is gone.
42
89
  - Packaging moved to `pyproject.toml`; `setup.py` and the stale
43
90
  `requirements.txt` pin are gone. The distribution name is now
44
91
  `python-broadlink`; the import name stays `broadlink`. Python 3.13 or
@@ -80,7 +127,8 @@ history below starts at that fork point.
80
127
  read by band and a capture is tagged from what it armed rather than the
81
128
  byte.
82
129
  - Devices, carried over from pull requests against the original repository
83
- with their authors' commits intact: RM Max 0xAF8B (#838, Alexey Masolov);
130
+ with their authors credited (the changes were squash-merged with
131
+ `Co-authored-by` trailers naming each author): RM Max 0xAF8B (#838, Alexey Masolov);
84
132
  RM5 plus 0x5224 with a new `rm5plus` class (#831, Anil Daoud); RM mini 3
85
133
  OEM 0xA544 (#823, Bartłomiej Nogaś); RM mini 3 CMCC 0x27C8 (#802,
86
134
  shuxin); LB26 R1 0xA517 (#812, techitapart); SP mini 3-AL 0x7D15 (#805,
@@ -92,7 +140,9 @@ history below starts at that fork point.
92
140
  issue if either does not behave.
93
141
  - `cryptography` 43 or newer is required, the first release with wheels for
94
142
  Python 3.13 (supersedes mjg59/python-broadlink#749).
95
- - A test suite. The `tests/oracle` package records the exact request bytes
96
- every public method of every device class sends, and the results it
97
- decodes from canned responses, so that later changes to the transport
98
- can be checked byte for byte against the original behavior.
143
+ - A test suite. The `tests/oracle` package records, for every public method
144
+ of every device class, the request each one hands to the transport (its
145
+ packet type and plaintext payload) and the result it decodes from a canned
146
+ response, so that a later reimplementation can be checked against the
147
+ original method by method; the framing, encryption and checksum layer is
148
+ covered separately by `tests/test_transport.py`.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-broadlink
3
- Version: 1.0.0
3
+ Version: 1.0.1
4
4
  Summary: Python API for controlling Broadlink devices
5
5
  Author: DAB-LABS
6
6
  Author-email: Matthew Garrett <mjg59@srcf.ucam.org>
@@ -91,9 +91,16 @@ Use pip3 to install the latest version of this module.
91
91
  pip3 install python-broadlink
92
92
  ```
93
93
 
94
- If the original `broadlink` distribution is also installed in the same
95
- environment, remove it first (`pip3 uninstall broadlink`); both provide the
96
- `broadlink` package.
94
+ Both this distribution and the original `broadlink` install a package named
95
+ `broadlink`, so only one can be present in an environment at a time. Pip
96
+ does not warn about this: installing one on top of the other appears to
97
+ succeed, and whichever was installed last is the one that `import broadlink`
98
+ finds. If both were installed, uninstall both (`pip3 uninstall broadlink
99
+ python-broadlink`) and reinstall this one, since `pip3 uninstall broadlink`
100
+ alone removes the shared files and leaves `python-broadlink` registered but
101
+ unimportable. This matters most where another package pins `broadlink`:
102
+ installing it into the same environment silently replaces this async
103
+ library with the original synchronous one.
97
104
 
98
105
  ## Basic functions
99
106
 
@@ -60,9 +60,16 @@ Use pip3 to install the latest version of this module.
60
60
  pip3 install python-broadlink
61
61
  ```
62
62
 
63
- If the original `broadlink` distribution is also installed in the same
64
- environment, remove it first (`pip3 uninstall broadlink`); both provide the
65
- `broadlink` package.
63
+ Both this distribution and the original `broadlink` install a package named
64
+ `broadlink`, so only one can be present in an environment at a time. Pip
65
+ does not warn about this: installing one on top of the other appears to
66
+ succeed, and whichever was installed last is the one that `import broadlink`
67
+ finds. If both were installed, uninstall both (`pip3 uninstall broadlink
68
+ python-broadlink`) and reinstall this one, since `pip3 uninstall broadlink`
69
+ alone removes the shared files and leaves `python-broadlink` registered but
70
+ unimportable. This matters most where another package pins `broadlink`:
71
+ installing it into the same environment silently replaces this async
72
+ library with the original synchronous one.
66
73
 
67
74
  ## Basic functions
68
75
 
@@ -10,6 +10,7 @@ speaks unprompted, so each device keeps a single datagram endpoint and an
10
10
  from __future__ import annotations
11
11
 
12
12
  import asyncio
13
+ import collections
13
14
  import random
14
15
  import socket
15
16
  from collections.abc import AsyncIterator
@@ -30,8 +31,17 @@ from .protocol import Datetime
30
31
  HelloResponse = Tuple[int, Tuple[str, int], bytes, str, bool]
31
32
 
32
33
  # Device error codes that mean the session key is no longer accepted and a
33
- # fresh auth() will fix it. -7: control key expired; -4012: control id error.
34
- _REAUTH_CODES = {-7, -4012}
34
+ # fresh auth() will fix it. -2: logged out; -7: control key expired;
35
+ # -4012: control id error.
36
+ _REAUTH_CODES = {-2, -7, -4012}
37
+
38
+ # How many timed-out request counters to remember, so that a reply to one
39
+ # of them arriving late is recognised and dropped instead of being taken as
40
+ # the answer to a later request.
41
+ _ABANDONED_MAX = 32
42
+
43
+ _CLOSED = (None, None)
44
+ """Sentinel put on the receive queue when the endpoint is closed."""
35
45
 
36
46
 
37
47
  class _Protocol(asyncio.DatagramProtocol):
@@ -203,7 +213,10 @@ class Device:
203
213
  self._lock: Optional[asyncio.Lock] = None
204
214
  self._transport: Optional[asyncio.DatagramTransport] = None
205
215
  self._protocol: Optional[_Protocol] = None
206
- self._reauth_ok = True
216
+ self._endpoint_addr: Optional[Tuple[str, int]] = None
217
+ self._abandoned: collections.deque[int] = collections.deque(maxlen=_ABANDONED_MAX)
218
+ self._reauth_lock: Optional[asyncio.Lock] = None
219
+ self._auth_generation = 0
207
220
 
208
221
  def __repr__(self) -> str:
209
222
  """Return a formal representation of the device."""
@@ -275,6 +288,7 @@ class Device:
275
288
 
276
289
  self.id = int.from_bytes(payload[:0x4], "little")
277
290
  self.update_aes(payload[0x04:0x14])
291
+ self._auth_generation += 1
278
292
  return True
279
293
 
280
294
  async def hello(self, local_ip_address=None) -> bool:
@@ -363,17 +377,30 @@ class Device:
363
377
  # -------------------------------------------------------- transport
364
378
 
365
379
  async def aclose(self) -> None:
366
- """Close the device's endpoint. It is reopened on the next call."""
367
- if self._transport is not None:
368
- self._transport.close()
369
- self._transport = None
370
- self._protocol = None
380
+ """Close the device's endpoint. It is reopened on the next call.
381
+
382
+ A request in flight fails at once with ``ConnectionClosedError``
383
+ rather than waiting out its timeout.
384
+ """
385
+ transport, protocol = self._transport, self._protocol
386
+ self._transport = None
387
+ self._protocol = None
388
+ self._endpoint_addr = None
389
+ if transport is not None:
390
+ transport.close()
391
+ if protocol is not None:
392
+ protocol.queue.put_nowait(_CLOSED) # type: ignore[arg-type]
371
393
 
372
394
  async def _endpoint(self) -> tuple[asyncio.DatagramTransport, _Protocol]:
395
+ if self._transport is not None and self._endpoint_addr != self.host:
396
+ # The caller changed host; the connected socket points at the
397
+ # old address, so drop it.
398
+ await self.aclose()
373
399
  if self._transport is None or self._transport.is_closing():
374
400
  self._transport, self._protocol = await _open_endpoint(
375
401
  remote_addr=self.host
376
402
  )
403
+ self._endpoint_addr = self.host
377
404
  return self._transport, self._protocol # type: ignore[return-value]
378
405
 
379
406
  def _frame(self, packet_type: int, payload: bytes) -> bytes:
@@ -419,28 +446,53 @@ class Device:
419
446
  return resp
420
447
 
421
448
  async def _exchange(self, packet: bytes) -> bytes:
422
- """Send one frame and wait for one reply, resending on silence."""
449
+ """Send one frame and wait for its reply, resending on silence.
450
+
451
+ Replies carry the request's packet counter (offset 0x28), so a reply
452
+ is matched to the request by counter. A reply whose counter belongs
453
+ to a request that already timed out is dropped; one with a counter
454
+ this device has never sent is accepted, for firmware that may not
455
+ echo it.
456
+ """
423
457
  transport, protocol = await self._endpoint()
424
458
  protocol.drain()
425
459
  loop = asyncio.get_running_loop()
426
460
  start = loop.time()
427
461
  timeout = self.timeout
462
+ count = int.from_bytes(packet[0x28:0x2A], "little")
428
463
 
429
464
  while True:
430
465
  transport.sendto(packet)
431
- time_left = timeout - (loop.time() - start)
432
- wait = min(DEFAULT_RETRY_INTVL, time_left)
433
- try:
434
- resp, _ = await asyncio.wait_for(protocol.queue.get(), max(wait, 0))
435
- except asyncio.TimeoutError:
436
- if (loop.time() - start) >= timeout:
437
- raise e.NetworkTimeoutError(
438
- -4000,
439
- "Network timeout",
440
- f"No response received within {timeout}s",
441
- ) from None
442
- continue
443
- return self._validate(resp)
466
+ resend_at = loop.time() + DEFAULT_RETRY_INTVL
467
+ while True:
468
+ now = loop.time()
469
+ if now - start >= timeout:
470
+ break
471
+ wait = min(resend_at, start + timeout) - now
472
+ try:
473
+ resp, _ = await asyncio.wait_for(protocol.queue.get(), max(wait, 0))
474
+ except asyncio.TimeoutError:
475
+ if loop.time() - start >= timeout:
476
+ break
477
+ if loop.time() >= resend_at:
478
+ break # Resend.
479
+ continue
480
+ if resp is None:
481
+ raise e.ConnectionClosedError(
482
+ -4013, "Connection closed", "The device endpoint was closed"
483
+ )
484
+ resp = self._validate(resp)
485
+ reply_count = int.from_bytes(resp[0x28:0x2A], "little")
486
+ if reply_count == count or reply_count not in self._abandoned:
487
+ return resp
488
+ # A late answer to a request we gave up on: keep waiting.
489
+ if loop.time() - start >= timeout:
490
+ self._abandoned.append(count)
491
+ raise e.NetworkTimeoutError(
492
+ -4000,
493
+ "Network timeout",
494
+ f"No response received within {timeout}s",
495
+ ) from None
444
496
 
445
497
  async def send_packet(
446
498
  self, packet_type: int, payload: bytes, *, _reauth: bool = True
@@ -449,22 +501,24 @@ class Device:
449
501
 
450
502
  If the device answers that the session key is no longer valid, the
451
503
  session is re-authenticated once and the request is sent again.
504
+ Concurrent callers that hit the same expired key share one
505
+ re-authentication and each retry once.
452
506
  """
453
507
  if self._lock is None:
454
508
  self._lock = asyncio.Lock()
509
+ self._reauth_lock = asyncio.Lock()
510
+ generation = self._auth_generation
455
511
  async with self._lock:
456
512
  resp = await self._exchange(self._frame(packet_type, bytes(payload)))
457
513
 
458
- if _reauth and self._reauth_ok:
514
+ if _reauth:
459
515
  code = int.from_bytes(resp[0x22:0x24], "little", signed=True)
460
516
  if code in _REAUTH_CODES:
461
- self._reauth_ok = False
462
- try:
463
- await self.auth()
464
- async with self._lock:
465
- resp = await self._exchange(
466
- self._frame(packet_type, bytes(payload))
467
- )
468
- finally:
469
- self._reauth_ok = True
517
+ async with self._reauth_lock: # type: ignore[union-attr]
518
+ if self._auth_generation == generation:
519
+ await self.auth()
520
+ async with self._lock:
521
+ resp = await self._exchange(
522
+ self._frame(packet_type, bytes(payload))
523
+ )
470
524
  return resp
@@ -4,6 +4,7 @@ import asyncio
4
4
  import enum
5
5
  import struct
6
6
  import time
7
+ import weakref
7
8
  from dataclasses import dataclass, field
8
9
  from typing import AsyncIterator, Awaitable, Callable, List, Optional, Tuple
9
10
 
@@ -214,7 +215,40 @@ class rmmini(Device):
214
215
  # against the value it saw when it armed the device and re-arms
215
216
  # after any send, since the device has one front end for both.
216
217
  self._tx_generation = 0
217
- self._capture_open = False
218
+ # Weak reference to the async generator of the current capture
219
+ # window, if any. See _claim_window.
220
+ self._window: Optional[weakref.ReferenceType] = None
221
+
222
+ @property
223
+ def capture_active(self) -> bool:
224
+ """True while a capture window is open on this device."""
225
+ window = self._window() if self._window is not None else None
226
+ return window is not None and window.ag_frame is not None
227
+
228
+ def _check_window(self) -> Optional[weakref.ReferenceType]:
229
+ """Refuse a new window while another is being iterated; return the
230
+ reference to a previous window that the new one should close."""
231
+ old = self._window() if self._window is not None else None
232
+ if old is None or old.ag_frame is None:
233
+ return None
234
+ if old.ag_running:
235
+ raise e.CaptureInProgressError("A capture window is already open")
236
+ return self._window
237
+
238
+ async def _claim_window(self, prev: Optional[weakref.ReferenceType]) -> None:
239
+ """Close a previous window whose consumer walked away from it.
240
+
241
+ A window whose consumer is still iterating it is live and a new one
242
+ is refused at call time (``_check_window``). One the consumer broke
243
+ out of without closing the generator is not live; it is closed here
244
+ so that it cannot block the device forever.
245
+ """
246
+ old = prev() if prev is not None else None
247
+ if old is None or old.ag_frame is None:
248
+ return
249
+ if old.ag_running:
250
+ raise e.CaptureInProgressError("A capture window is already open")
251
+ await old.aclose()
218
252
 
219
253
  async def _send(self, command: int, data: bytes = b"") -> bytes:
220
254
  """Send a packet to the device."""
@@ -266,10 +300,13 @@ class rmmini(Device):
266
300
  and after every ``send_data`` on the same device. Closing the
267
301
  generator sends nothing further; the device times out by itself.
268
302
  Use ``contextlib.aclosing`` (or iterate to the end) so the window is
269
- released promptly. Only one capture window can be open per device;
270
- a second raises ``CaptureInProgressError``.
303
+ released promptly. Only one capture window can be open per device:
304
+ opening one while another is being iterated raises
305
+ ``CaptureInProgressError``, and opening one after breaking out of
306
+ another without closing it closes the old one.
271
307
  """
272
- return self._capture_loop(
308
+ prev = self._check_window()
309
+ gen = self._capture_loop(
273
310
  self.enter_learning,
274
311
  window,
275
312
  stop_after_first,
@@ -277,7 +314,10 @@ class rmmini(Device):
277
314
  rearm_interval,
278
315
  SignalKind.IR,
279
316
  None,
317
+ prev=prev,
280
318
  )
319
+ self._window = weakref.ref(gen)
320
+ return gen
281
321
 
282
322
  async def _capture_loop(
283
323
  self,
@@ -288,58 +328,60 @@ class rmmini(Device):
288
328
  rearm_interval: float,
289
329
  kind: SignalKind,
290
330
  frequency_mhz: Optional[float],
331
+ *,
332
+ prev: Optional[weakref.ReferenceType] = None,
333
+ claim: bool = True,
291
334
  ) -> AsyncIterator[CapturedSignal]:
292
335
  if window < 0:
293
336
  raise ValueError("window must be 0 (open-ended) or positive")
294
337
  if poll_interval <= 0 or rearm_interval <= 0:
295
338
  raise ValueError("poll_interval and rearm_interval must be positive")
296
- if self._capture_open:
297
- raise e.CaptureInProgressError("A capture window is already open")
339
+ if claim:
340
+ await self._claim_window(prev)
298
341
 
299
- self._capture_open = True
300
- try:
301
- loop = asyncio.get_running_loop()
302
- deadline = loop.time() + window if window else None
303
- timeouts = 0
342
+ loop = asyncio.get_running_loop()
343
+ deadline = loop.time() + window if window else None
344
+ timeouts = 0
345
+
346
+ await arm()
347
+ armed_at = loop.time()
348
+ generation = self._tx_generation
304
349
 
305
- await arm()
306
- armed_at = loop.time()
307
- generation = self._tx_generation
350
+ while True:
351
+ now = loop.time()
352
+ if deadline is not None and now >= deadline:
353
+ return
354
+ delay = poll_interval
355
+ if deadline is not None:
356
+ delay = min(delay, deadline - now)
357
+ await asyncio.sleep(delay)
308
358
 
309
- while True:
310
- now = loop.time()
311
- if deadline is not None and now >= deadline:
359
+ try:
360
+ data = await self.check_data()
361
+ except (e.StorageError, e.ReadError):
362
+ # "Nothing yet": -5 on the RM4 Pro, -10 on some older
363
+ # firmware (upstream's CLI and Home Assistant tolerate
364
+ # both).
365
+ data = b""
366
+ except e.NetworkTimeoutError:
367
+ timeouts += 1
368
+ if timeouts >= 3:
369
+ raise
370
+ generation = -1 # Re-arm; the device's state is unknown.
371
+ continue
372
+ timeouts = 0
373
+
374
+ if data:
375
+ yield CapturedSignal.from_packet(data, frequency_mhz, kind=kind)
376
+ if stop_after_first:
312
377
  return
313
- delay = poll_interval
314
- if deadline is not None:
315
- delay = min(delay, deadline - now)
316
- await asyncio.sleep(delay)
317
-
318
- try:
319
- data = await self.check_data()
320
- except e.StorageError:
321
- data = b"" # The device's answer for "nothing yet".
322
- except e.NetworkTimeoutError:
323
- timeouts += 1
324
- if timeouts >= 3:
325
- raise
326
- generation = -1 # Re-arm; the device's state is unknown.
327
- continue
328
- timeouts = 0
329
-
330
- if data:
331
- yield CapturedSignal.from_packet(data, frequency_mhz, kind=kind)
332
- if stop_after_first:
333
- return
334
- generation = -1 # One code per session: re-arm.
335
-
336
- now = loop.time()
337
- if generation != self._tx_generation or now - armed_at >= rearm_interval:
338
- await arm()
339
- armed_at = loop.time()
340
- generation = self._tx_generation
341
- finally:
342
- self._capture_open = False
378
+ generation = -1 # One code per session: re-arm.
379
+
380
+ now = loop.time()
381
+ if generation != self._tx_generation or now - armed_at >= rearm_interval:
382
+ await arm()
383
+ armed_at = loop.time()
384
+ generation = self._tx_generation
343
385
 
344
386
 
345
387
  class rmpro(rmmini):
@@ -369,7 +411,7 @@ class rmpro(rmmini):
369
411
  """Cancel sweep frequency."""
370
412
  await self._send(0x1E)
371
413
 
372
- async def capture_rf(
414
+ def capture_rf(
373
415
  self,
374
416
  window: float = 30.0,
375
417
  *,
@@ -389,24 +431,38 @@ class rmpro(rmmini):
389
431
 
390
432
  The window, polling, re-arm and stop-after-first semantics are those
391
433
  of ``capture``; the sweep counts against the same ``window``. A
392
- ``send_data`` during the sweep restarts it. Each ``CapturedSignal``
393
- carries the carrier in ``frequency_mhz``, which the packet itself
394
- does not record.
434
+ ``send_data`` during the sweep restarts it. Closing the generator
435
+ during a sweep sends nothing; the device ends the sweep on its own.
436
+ Each ``CapturedSignal`` carries the carrier in ``frequency_mhz``,
437
+ which the packet itself does not record.
395
438
  """
396
- if self._capture_open:
397
- raise e.CaptureInProgressError("A capture window is already open")
439
+ prev = self._check_window()
440
+ gen = self._capture_rf_loop(
441
+ window, frequency, stop_after_first, poll_interval, rearm_interval,
442
+ prev=prev,
443
+ )
444
+ self._window = weakref.ref(gen)
445
+ return gen
446
+
447
+ async def _capture_rf_loop(
448
+ self,
449
+ window: float,
450
+ frequency: Optional[float],
451
+ stop_after_first: bool,
452
+ poll_interval: float,
453
+ rearm_interval: float,
454
+ *,
455
+ prev: Optional[weakref.ReferenceType],
456
+ ) -> AsyncIterator[CapturedSignal]:
398
457
  if window < 0 or poll_interval <= 0:
399
458
  raise ValueError("window must be 0 or positive, poll_interval positive")
459
+ await self._claim_window(prev)
400
460
 
401
461
  loop = asyncio.get_running_loop()
402
462
  deadline = loop.time() + window if window else None
403
463
 
404
464
  if frequency is None:
405
- self._capture_open = True
406
- try:
407
- frequency = await self._sweep(deadline, poll_interval)
408
- finally:
409
- self._capture_open = False
465
+ frequency = await self._sweep(deadline, poll_interval)
410
466
  if frequency is None:
411
467
  return
412
468
  if deadline is not None:
@@ -418,10 +474,15 @@ class rmpro(rmmini):
418
474
  await self.find_rf_packet(frequency)
419
475
 
420
476
  kind = SignalKind.RF_315 if frequency < 400 else SignalKind.RF_433
421
- async for signal in self._capture_loop(
422
- arm, window, stop_after_first, poll_interval, rearm_interval, kind, frequency
423
- ):
424
- yield signal
477
+ inner = self._capture_loop(
478
+ arm, window, stop_after_first, poll_interval, rearm_interval, kind, frequency,
479
+ claim=False,
480
+ )
481
+ try:
482
+ async for signal in inner:
483
+ yield signal
484
+ finally:
485
+ await inner.aclose()
425
486
 
426
487
  async def _sweep(
427
488
  self, deadline: Optional[float], poll_interval: float
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-broadlink"
7
- version = "1.0.0"
7
+ version = "1.0.1"
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.0
3
+ Version: 1.0.1
4
4
  Summary: Python API for controlling Broadlink devices
5
5
  Author: DAB-LABS
6
6
  Author-email: Matthew Garrett <mjg59@srcf.ucam.org>
@@ -91,9 +91,16 @@ Use pip3 to install the latest version of this module.
91
91
  pip3 install python-broadlink
92
92
  ```
93
93
 
94
- If the original `broadlink` distribution is also installed in the same
95
- environment, remove it first (`pip3 uninstall broadlink`); both provide the
96
- `broadlink` package.
94
+ Both this distribution and the original `broadlink` install a package named
95
+ `broadlink`, so only one can be present in an environment at a time. Pip
96
+ does not warn about this: installing one on top of the other appears to
97
+ succeed, and whichever was installed last is the one that `import broadlink`
98
+ finds. If both were installed, uninstall both (`pip3 uninstall broadlink
99
+ python-broadlink`) and reinstall this one, since `pip3 uninstall broadlink`
100
+ alone removes the shared files and leaves `python-broadlink` registered but
101
+ unimportable. This matters most where another package pins `broadlink`:
102
+ installing it into the same environment silently replaces this async
103
+ library with the original synchronous one.
97
104
 
98
105
  ## Basic functions
99
106
 
@@ -50,6 +50,7 @@ class FakeRM:
50
50
  self.sweeping = False
51
51
  self.sweep_answers: list[tuple[bool, float]] = []
52
52
  self.timeouts_to_raise = 0
53
+ self.nothing_yet_code = -5 # -10 on some older firmware
53
54
  device.send_packet = self.send_packet # type: ignore[method-assign]
54
55
 
55
56
  # -- what the test does to the device
@@ -98,7 +99,7 @@ class FakeRM:
98
99
  self.timeouts_to_raise -= 1
99
100
  return b"", "timeout"
100
101
  if self.pending is None:
101
- return b"", -5
102
+ return b"", self.nothing_yet_code
102
103
  code, self.pending = self.pending, None
103
104
  return code, 0
104
105
  if command == CMD_SEND:
@@ -166,7 +167,7 @@ def test_capture_yields_first_signal_and_closes(cls_name, devtype):
166
167
  assert fake.commands[0][0] == CMD_LEARN
167
168
  assert fake.count(CMD_LEARN) == 1
168
169
  assert fake.count(CMD_CHECK) >= 2
169
- assert device._capture_open is False
170
+ assert device.capture_active is False
170
171
 
171
172
 
172
173
  def test_capture_window_elapses_with_nothing():
@@ -175,7 +176,7 @@ def test_capture_window_elapses_with_nothing():
175
176
  assert signals == []
176
177
  assert fake.count(CMD_LEARN) == 1
177
178
  assert fake.count(CMD_CHECK) >= 3
178
- assert device._capture_open is False
179
+ assert device.capture_active is False
179
180
 
180
181
 
181
182
  async def _collect(gen):
@@ -287,7 +288,7 @@ def test_open_ended_window_runs_until_closed():
287
288
 
288
289
  got = run(go())
289
290
  assert len(got) == 1
290
- assert device._capture_open is False
291
+ assert device.capture_active is False
291
292
  # Closing sends nothing further to the device.
292
293
  assert fake.commands[-1][0] in (CMD_CHECK, CMD_LEARN)
293
294
 
@@ -302,13 +303,77 @@ def test_second_window_is_refused():
302
303
  await asyncio.sleep(2 * UNIT)
303
304
  with pytest.raises(e.CaptureInProgressError):
304
305
  await _collect(device.capture(window=1, **FAST))
305
- assert device._capture_open is True
306
+ assert device.capture_active is True
306
307
  task.cancel()
307
308
  with pytest.raises(asyncio.CancelledError):
308
309
  await task
309
310
 
310
311
  run(go())
311
- assert device._capture_open is False
312
+ assert device.capture_active is False
313
+
314
+
315
+ def test_abandoned_window_is_closed_by_the_next_one():
316
+ """A consumer that breaks out of the loop without closing the generator
317
+ must not block the device; the next window closes the old one."""
318
+ device, fake = make()
319
+
320
+ async def go():
321
+ asyncio.get_running_loop().create_task(press_later(fake, IR, 2 * UNIT))
322
+ first = device.capture(window=1, stop_after_first=False, **FAST)
323
+ async for s in first:
324
+ got = s
325
+ break # Walk away without aclose().
326
+ assert device.capture_active is True # The old generator is suspended.
327
+ assert not first.ag_running
328
+ asyncio.get_running_loop().create_task(press_later(fake, RF, 2 * UNIT))
329
+ second = [s async for s in device.capture(window=1, **FAST)]
330
+ assert first.ag_frame is None # Closed by the second window.
331
+ return got, second
332
+
333
+ got, second = run(go())
334
+ assert got.packet == IR
335
+ assert [s.packet for s in second] == [RF]
336
+ assert device.capture_active is False
337
+
338
+
339
+ def test_unstarted_window_does_not_block():
340
+ device, fake = make()
341
+
342
+ async def go():
343
+ _unused = device.capture(window=1, **FAST) # never iterated
344
+ asyncio.get_running_loop().create_task(press_later(fake, IR, 2 * UNIT))
345
+ return [s async for s in device.capture(window=1, **FAST)]
346
+
347
+ assert len(run(go())) == 1
348
+
349
+
350
+ def test_older_firmware_read_error_means_nothing_yet():
351
+ device, fake = make()
352
+ fake.nothing_yet_code = -10 # ReadError
353
+
354
+ async def go():
355
+ asyncio.get_running_loop().create_task(press_later(fake, IR, 3 * UNIT))
356
+ return [s async for s in device.capture(window=1, **FAST)]
357
+
358
+ signals = run(go())
359
+ assert len(signals) == 1
360
+ assert fake.count(CMD_CHECK) >= 2
361
+
362
+
363
+ def test_capture_rf_abandoned_then_ir_window():
364
+ device, fake = make()
365
+
366
+ async def go():
367
+ asyncio.get_running_loop().create_task(press_later(fake, RF, 2 * UNIT))
368
+ rf = device.capture_rf(window=1, frequency=433.92, stop_after_first=False, **FAST)
369
+ async for _ in rf:
370
+ break
371
+ asyncio.get_running_loop().create_task(press_later(fake, IR, 2 * UNIT))
372
+ return [s async for s in device.capture(window=1, **FAST)]
373
+
374
+ signals = run(go())
375
+ assert [s.kind for s in signals] == [SignalKind.IR]
376
+ assert device.capture_active is False
312
377
 
313
378
 
314
379
  def test_transport_timeouts_rearm_then_give_up():
@@ -327,7 +392,7 @@ def test_transport_timeouts_rearm_then_give_up():
327
392
  fake.timeouts_to_raise = 3
328
393
  with pytest.raises(e.NetworkTimeoutError):
329
394
  run(_collect(device.capture(window=1, **FAST)))
330
- assert device._capture_open is False
395
+ assert device.capture_active is False
331
396
 
332
397
 
333
398
  def test_capture_rejects_bad_arguments():
@@ -417,7 +482,7 @@ def test_capture_rf_sweep_that_never_locks_is_cancelled():
417
482
  assert fake.count(CMD_CANCEL_SWEEP) == 1
418
483
  assert fake.count(CMD_FIND_RF) == 0
419
484
  assert fake.sweeping is False
420
- assert device._capture_open is False
485
+ assert device.capture_active is False
421
486
 
422
487
 
423
488
  def test_send_during_sweep_restarts_it():
@@ -457,7 +522,7 @@ def test_capture_rf_refused_while_ir_window_open():
457
522
  await task
458
523
 
459
524
  run(go())
460
- assert device._capture_open is False
525
+ assert device.capture_active is False
461
526
 
462
527
 
463
528
  def test_rf_capture_is_only_on_pro_classes():
@@ -223,6 +223,89 @@ def test_stale_reply_is_drained_before_a_request(net):
223
223
  assert run(go()) == 7
224
224
 
225
225
 
226
+ def stamped(dev: Device, payload: bytes, count: int, error: int = 0) -> bytes:
227
+ """A response frame that echoes a packet counter, as real firmware does."""
228
+ frame = bytearray(make_response(dev, payload, error))
229
+ frame[0x28:0x2A] = count.to_bytes(2, "little")
230
+ checksum = sum(frame, 0xBEAF) - sum(frame[0x20:0x22]) & 0xFFFF
231
+ frame[0x20:0x22] = checksum.to_bytes(2, "little")
232
+ return bytes(frame)
233
+
234
+
235
+ def test_late_reply_to_timed_out_request_is_not_taken_as_next_reply(net):
236
+ """The defect that 0.19.0 could not have because it threw its socket away
237
+ after every call: a slow answer to request 1 arriving after request 1
238
+ timed out must not be returned as the answer to request 2."""
239
+ dev = fixed_device()
240
+ dev.timeout = 0.02
241
+
242
+ async def go():
243
+ await dev._endpoint()
244
+ ep = net.endpoints[-1]
245
+ with pytest.raises(e.NetworkTimeoutError):
246
+ await dev.send_packet(0x6A, b"") # request 1, count 0x8001, no answer
247
+ # Its late reply lands while request 2 (count 0x8002) is waiting.
248
+ late = stamped(dev, bytes([1]) + bytes(15), 0x8001)
249
+ good = stamped(dev, bytes([2]) + bytes(15), 0x8002)
250
+ ep.replies = [late, good] and []
251
+ ep.protocol.queue.put_nowait((late, HOST))
252
+
253
+ async def answer_later():
254
+ await asyncio.sleep(0.005)
255
+ ep.protocol.queue.put_nowait((good, HOST))
256
+
257
+ asyncio.get_running_loop().create_task(answer_later())
258
+ dev.timeout = 1
259
+ resp = await dev.send_packet(0x6A, b"")
260
+ return dev.decrypt(resp[0x38:])[0]
261
+
262
+ assert run(go()) == 2
263
+
264
+
265
+ def test_reply_with_unknown_counter_is_accepted(net):
266
+ """Firmware that does not echo the counter must keep working."""
267
+ dev = fixed_device()
268
+
269
+ async def go():
270
+ net.replies = [(stamped(dev, bytes([5]) + bytes(15), 0x0000), HOST)]
271
+ resp = await dev.send_packet(0x6A, b"")
272
+ return dev.decrypt(resp[0x38:])[0]
273
+
274
+ assert run(go()) == 5
275
+
276
+
277
+ def test_aclose_fails_inflight_request_fast(net):
278
+ dev = fixed_device()
279
+ dev.timeout = 5
280
+ net.replies = []
281
+
282
+ async def go():
283
+ task = asyncio.get_running_loop().create_task(dev.send_packet(0x6A, b""))
284
+ await asyncio.sleep(0.01)
285
+ t0 = asyncio.get_running_loop().time()
286
+ await dev.aclose()
287
+ with pytest.raises(e.ConnectionClosedError):
288
+ await task
289
+ return asyncio.get_running_loop().time() - t0
290
+
291
+ assert run(go()) < 1.0
292
+
293
+
294
+ def test_host_change_reopens_endpoint(net):
295
+ dev = fixed_device()
296
+
297
+ async def go():
298
+ net.replies = [(make_response(dev, b""), HOST)]
299
+ await dev.send_packet(0x6A, b"")
300
+ dev.host = ("192.0.2.99", 80)
301
+ net.replies = [(make_response(dev, b""), ("192.0.2.99", 80))]
302
+ await dev.send_packet(0x6A, b"")
303
+
304
+ run(go())
305
+ assert [ep.remote_addr for ep in net.endpoints] == [HOST, ("192.0.2.99", 80)]
306
+ assert net.endpoints[0].closed
307
+
308
+
226
309
  # ------------------------------------------------------------------------- auth
227
310
 
228
311
 
@@ -311,6 +394,67 @@ def test_reauth_is_not_attempted_twice(net):
311
394
  run(go())
312
395
 
313
396
 
397
+ def test_concurrent_callers_share_one_reauth(net):
398
+ dev = fixed_device()
399
+ dev.id = 5
400
+ session_key = bytes.fromhex("00112233445566778899aabbccddeeff")
401
+ fresh = fixed_device()
402
+ auth_reply = make_response(fresh, (0x42).to_bytes(4, "little") + session_key)
403
+ renewed = fixed_device()
404
+ renewed.update_aes(session_key)
405
+ counters = {"value": 1}
406
+
407
+ async def go():
408
+ await dev._endpoint()
409
+ ep = net.endpoints[-1]
410
+ authed = {"done": False}
411
+
412
+ def sendto(data, addr=None):
413
+ ep.sent.append((bytes(data), addr or ep.remote_addr))
414
+ ptype = int.from_bytes(data[0x26:0x28], "little")
415
+ if ptype == 0x65:
416
+ authed["done"] = True
417
+ reply = auth_reply
418
+ elif not authed["done"]:
419
+ reply = make_response(dev, b"", error=0xFFF9) # -7 expired
420
+ else:
421
+ n = counters["value"]
422
+ counters["value"] += 1
423
+ reply = make_response(renewed, bytes([n]) + bytes(15))
424
+ ep.protocol.queue.put_nowait((reply, ep.remote_addr))
425
+
426
+ ep.sendto = sendto
427
+ a, b = await asyncio.gather(dev.send_packet(0x6A, b"a"), dev.send_packet(0x6A, b"b"))
428
+ return ep, {dev.decrypt(a[0x38:])[0], dev.decrypt(b[0x38:])[0]}
429
+
430
+ ep, values = run(go())
431
+ types = [int.from_bytes(f[0x26:0x28], "little") for f, _ in ep.sent]
432
+ assert types.count(0x65) == 1 # exactly one auth despite two expired requests
433
+ assert values == {1, 2}
434
+
435
+
436
+ def test_logged_out_code_triggers_reauth(net):
437
+ dev = fixed_device()
438
+ session_key = bytes.fromhex("00112233445566778899aabbccddeeff")
439
+ fresh = fixed_device()
440
+ auth_reply = make_response(fresh, (0x42).to_bytes(4, "little") + session_key)
441
+ renewed = fixed_device()
442
+ renewed.update_aes(session_key)
443
+
444
+ async def go():
445
+ await dev._endpoint()
446
+ ep = net.endpoints[-1]
447
+ ep.replies = [
448
+ (make_response(dev, b"", error=0xFFFE), HOST), # -2 logged out
449
+ (auth_reply, HOST),
450
+ (make_response(renewed, bytes([3]) + bytes(15)), HOST),
451
+ ]
452
+ resp = await dev.send_packet(0x6A, b"")
453
+ return dev.decrypt(resp[0x38:])[0]
454
+
455
+ assert run(go()) == 3
456
+
457
+
314
458
  # ------------------------------------------------------------------- discovery
315
459
 
316
460