python-broadlink 1.0.0__py3-none-any.whl → 1.0.1__py3-none-any.whl

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.
broadlink/device.py CHANGED
@@ -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
broadlink/remote.py CHANGED
@@ -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
@@ -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
 
@@ -3,17 +3,17 @@ broadlink/alarm.py,sha256=ZsPKG4xibM9mxuuiWJcv_XPOOwFogGMfuP-3qOvyuA0,1259
3
3
  broadlink/climate.py,sha256=YJUi6oVrvY941R5AqToADObZbjuQd7NZ7E2u0UbVrMM,15826
4
4
  broadlink/const.py,sha256=MDzkcoaWN_4zXPTiG2DnYZgr6w97zX7tsnDfImYgA-Q,119
5
5
  broadlink/cover.py,sha256=bnUmQYUucxOogmYjmk57vwDsIvMjDIL_WKvMb-IdjJc,5438
6
- broadlink/device.py,sha256=aBu9zC5O_SDK-lv1L-Qy-_C74Cwzwy_TUMbIjFj5Dfk,16721
6
+ broadlink/device.py,sha256=_3Ia-SXjeywcCi99-81zPhpotOIj0r-IENOr0sM1fLU,19248
7
7
  broadlink/exceptions.py,sha256=0uooFfEd34id60fddcsXvU7uQ_4lFt07iDYDE5NkCSA,4756
8
8
  broadlink/helpers.py,sha256=Kpm7GUSXmwhNMJGFWfVNJ_1idcO1D-k5rgzHbO8vDlM,1296
9
9
  broadlink/hub.py,sha256=unQ45HIzIUmixqXrb7RvuWO5y7wvGAHFEPfR8OVWsjA,2929
10
10
  broadlink/light.py,sha256=2t7Jumm4DHP81RzeLFUQQDhXK5VcvgmH3osrkufCaIs,7782
11
11
  broadlink/protocol.py,sha256=9pNT6HrsCzHyeqpzhxKOkR58GjHhfVz8QdyefceI4tE,1847
12
- broadlink/remote.py,sha256=19tw-H58tDJHJipCyxee9hUUFErG2ao6_NRXiMg5ofw,18057
12
+ broadlink/remote.py,sha256=pqs7NWE8m21U9V3F44KA-Gdk4Ai5oj5nLLj81WS9_xo,20405
13
13
  broadlink/sensor.py,sha256=K76lAF-addjaq3-l6pl-buaqZelRd62sDE21cEz-lnI,2691
14
14
  broadlink/switch.py,sha256=WzCi-UbWSlIEytGc9E0-oAje2Z8E1453eUYmFq_hjf8,15060
15
- python_broadlink-1.0.0.dist-info/licenses/LICENSE,sha256=h0N_LWq34pY0W-Ky3ayO6Re5ejG0XvutPlW-GL8eG5I,1111
16
- python_broadlink-1.0.0.dist-info/METADATA,sha256=hGLTSEsgJJQIsoam0j7AjMFvLymcl-dDXX5PDVTL9ZU,11369
17
- python_broadlink-1.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
18
- python_broadlink-1.0.0.dist-info/top_level.txt,sha256=_Qx7d8oNTOLeDkAN-HHDoq1FlRNlsiaMZlyMWgHXAVc,10
19
- python_broadlink-1.0.0.dist-info/RECORD,,
15
+ python_broadlink-1.0.1.dist-info/licenses/LICENSE,sha256=h0N_LWq34pY0W-Ky3ayO6Re5ejG0XvutPlW-GL8eG5I,1111
16
+ python_broadlink-1.0.1.dist-info/METADATA,sha256=1KVEvHEbXlFG_dZYBLf6cMl2RihqDyo841FzjC_xog8,11908
17
+ python_broadlink-1.0.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
18
+ python_broadlink-1.0.1.dist-info/top_level.txt,sha256=_Qx7d8oNTOLeDkAN-HHDoq1FlRNlsiaMZlyMWgHXAVc,10
19
+ python_broadlink-1.0.1.dist-info/RECORD,,