python-broadlink 1.0.3__py3-none-any.whl → 1.0.5__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/__init__.py CHANGED
@@ -1,7 +1,7 @@
1
1
  """The python-broadlink library."""
2
2
 
3
3
  import contextlib
4
- from collections.abc import AsyncIterator
4
+ from collections.abc import AsyncGenerator
5
5
 
6
6
  from . import exceptions as e
7
7
  from .alarm import S1C
@@ -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
+ # The RM Max answers the RM pro framing; the RM4 framing (length
120
+ # prefix) gets "device is locked" from it. Tested on hardware in
121
+ # upstream #838, whose text says rm4pro but whose diff says rmpro.
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(
@@ -294,15 +294,15 @@ async def xdiscover(
294
294
  local_ip_address: str | None = None,
295
295
  discover_ip_address: str = DEFAULT_BCAST_ADDR,
296
296
  discover_ip_port: int = DEFAULT_PORT,
297
- ) -> AsyncIterator[Device]:
297
+ ) -> AsyncGenerator[Device]:
298
298
  """Discover devices connected to the local network.
299
299
 
300
300
  Yields each device as soon as it answers.
301
301
  """
302
- async for resp in scan(
303
- timeout, local_ip_address, discover_ip_address, discover_ip_port
304
- ):
305
- yield gendevice(*resp)
302
+ responses = scan(timeout, local_ip_address, discover_ip_address, discover_ip_port)
303
+ async with contextlib.aclosing(responses):
304
+ async for resp in responses:
305
+ yield gendevice(*resp)
306
306
 
307
307
 
308
308
  # Setup a new Broadlink device via AP Mode. Review the README to see how to enter AP Mode.
broadlink/device.py CHANGED
@@ -12,10 +12,11 @@ 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
18
- from collections.abc import AsyncIterator
19
+ from collections.abc import AsyncGenerator
19
20
 
20
21
  from cryptography.hazmat.backends import default_backend
21
22
  from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
@@ -48,12 +49,36 @@ _RECENT_MAX = 64
48
49
  _CLOSED = (None, None)
49
50
  """Sentinel put on the receive queue when the endpoint is closed."""
50
51
 
52
+ _QueueItem = tuple[bytes | Exception | None, tuple[str, int] | None]
53
+ """What the receive queue carries: a datagram with its source address, an
54
+ error the socket reported (address ``None``), or ``_CLOSED``."""
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
+
51
72
 
52
73
  class _Protocol(asyncio.DatagramProtocol):
53
- """Datagram protocol that hands every received packet to a queue."""
74
+ """Datagram protocol that hands every received packet to a queue.
75
+
76
+ Errors the socket reports go on the same queue, so the request that is
77
+ waiting fails at once instead of waiting out its timeout.
78
+ """
54
79
 
55
80
  def __init__(self) -> None:
56
- self.queue: asyncio.Queue[tuple[bytes, tuple[str, int]]] = asyncio.Queue()
81
+ self.queue: asyncio.Queue[_QueueItem] = asyncio.Queue()
57
82
  self.transport: asyncio.DatagramTransport | None = None
58
83
 
59
84
  def connection_made(self, transport: asyncio.BaseTransport) -> None:
@@ -65,18 +90,30 @@ class _Protocol(asyncio.DatagramProtocol):
65
90
  self.queue.put_nowait((data, addr))
66
91
 
67
92
  def error_received(self, exc: Exception) -> None:
68
- # ICMP unreachable and the like. Surface it as a receive of nothing;
69
- # the retry loop will time out and raise NetworkTimeoutError.
70
- pass
93
+ """Queue a send failure or an ICMP error for the waiting request."""
94
+ self.queue.put_nowait((exc, None))
71
95
 
72
96
  def connection_lost(self, exc: Exception | None) -> None:
73
- """Nothing to do; a waiting request is told through the queue."""
97
+ """Wake the waiting request if asyncio closed the transport on us."""
98
+ self.queue.put_nowait((exc, None) if exc is not None else _CLOSED)
74
99
 
75
100
  def drain(self) -> None:
76
101
  """Drop anything that arrived before the current request."""
77
102
  while not self.queue.empty():
78
103
  self.queue.get_nowait()
79
104
 
105
+ def raise_if_error(self) -> None:
106
+ """Raise the error a send just reported, if it reported one.
107
+
108
+ asyncio delivers a failed ``sendto`` to ``error_received`` before
109
+ ``sendto`` returns, so a fire-and-forget sender can check right
110
+ after sending and raise the ``OSError`` the way a plain socket did.
111
+ """
112
+ while not self.queue.empty():
113
+ item, _ = self.queue.get_nowait()
114
+ if isinstance(item, Exception):
115
+ raise item
116
+
80
117
 
81
118
  async def _open_endpoint(
82
119
  local_addr: tuple[str, int] | None = None,
@@ -95,6 +132,21 @@ async def _open_endpoint(
95
132
  return transport, protocol # type: ignore[return-value]
96
133
 
97
134
 
135
+ async def _resolve(host: str, port: int) -> tuple[str, int]:
136
+ """Resolve a destination once, off the event loop.
137
+
138
+ Sending to a hostname through an unconnected datagram socket would
139
+ resolve it with a blocking call on the loop and hide the failure. A
140
+ name that does not resolve raises ``socket.gaierror`` here, as the
141
+ original library's ``sendto`` did.
142
+ """
143
+ loop = asyncio.get_running_loop()
144
+ info = await loop.getaddrinfo(
145
+ host, port, family=socket.AF_INET, type=socket.SOCK_DGRAM
146
+ )
147
+ return info[0][4][:2] # type: ignore[return-value]
148
+
149
+
98
150
  def _hello_packet(local_ip_address: str, port: int) -> bytearray:
99
151
  packet = bytearray(0x30)
100
152
  packet[0x08:0x14] = Datetime.pack(Datetime.now())
@@ -119,13 +171,14 @@ async def scan(
119
171
  local_ip_address: str | None = None,
120
172
  discover_ip_address: str = DEFAULT_BCAST_ADDR,
121
173
  discover_ip_port: int = DEFAULT_PORT,
122
- ) -> AsyncIterator[HelloResponse]:
174
+ ) -> AsyncGenerator[HelloResponse]:
123
175
  """Broadcast a hello message and yield responses as they arrive.
124
176
 
125
177
  The hello is repeated every ``DEFAULT_RETRY_INTVL`` seconds until
126
178
  ``timeout`` elapses. Each device is yielded once.
127
179
  """
128
180
  local_addr = (local_ip_address, 0) if local_ip_address else None
181
+ target = await _resolve(discover_ip_address, discover_ip_port)
129
182
  transport, protocol = await _open_endpoint(local_addr=local_addr, broadcast=True)
130
183
  try:
131
184
  if local_ip_address:
@@ -140,7 +193,7 @@ async def scan(
140
193
  discovered: set[tuple[tuple[str, int], bytes, int]] = set()
141
194
 
142
195
  while (loop.time() - start) < timeout:
143
- transport.sendto(packet, (discover_ip_address, discover_ip_port))
196
+ transport.sendto(packet, target)
144
197
  deadline = min(DEFAULT_RETRY_INTVL, timeout - (loop.time() - start))
145
198
  slot_end = loop.time() + deadline
146
199
  while True:
@@ -151,7 +204,11 @@ async def scan(
151
204
  resp, host = await asyncio.wait_for(protocol.queue.get(), remaining)
152
205
  except TimeoutError:
153
206
  break
154
- if len(resp) < 0x80:
207
+ if resp is None:
208
+ return # The transport was closed under us.
209
+ if isinstance(resp, Exception):
210
+ raise resp
211
+ if host is None or len(resp) < 0x80:
155
212
  continue
156
213
  entry = _parse_hello(resp, host)
157
214
  key = (entry[1], entry[2], entry[0])
@@ -167,9 +224,11 @@ async def send_setup_packet(
167
224
  payload: bytes, ip_address: str, port: int = DEFAULT_PORT
168
225
  ) -> None:
169
226
  """Broadcast one Wi-Fi provisioning packet to a device in AP mode."""
170
- transport, _ = await _open_endpoint(broadcast=True)
227
+ target = await _resolve(ip_address, port)
228
+ transport, protocol = await _open_endpoint(broadcast=True)
171
229
  try:
172
- transport.sendto(payload, (ip_address, port))
230
+ transport.sendto(payload, target)
231
+ protocol.raise_if_error()
173
232
  finally:
174
233
  transport.close()
175
234
 
@@ -181,11 +240,13 @@ async def ping(ip_address: str, port: int = DEFAULT_PORT) -> None:
181
240
  Useful to prevent reboots when the cloud cannot be reached.
182
241
  It must be sent every 2 minutes in such cases.
183
242
  """
184
- transport, _ = await _open_endpoint(broadcast=True)
243
+ target = await _resolve(ip_address, port)
244
+ transport, protocol = await _open_endpoint(broadcast=True)
185
245
  try:
186
246
  packet = bytearray(0x30)
187
247
  packet[0x26] = 1
188
- transport.sendto(packet, (ip_address, port))
248
+ transport.sendto(packet, target)
249
+ protocol.raise_if_error()
189
250
  finally:
190
251
  transport.close()
191
252
 
@@ -407,6 +468,22 @@ class Device:
407
468
  if protocol is not None:
408
469
  protocol.queue.put_nowait(_CLOSED) # type: ignore[arg-type]
409
470
 
471
+ def _drop_endpoint(self) -> None:
472
+ """Throw the endpoint away after a failure; the next call reopens it.
473
+
474
+ A connected datagram socket can go bad for good (the interface
475
+ bounced, the host's address changed), and the original library
476
+ never noticed because it opened a socket per call. Dropping the
477
+ endpoint whenever a request fails restores that self-healing.
478
+ """
479
+ transport = self._transport
480
+ self._transport = None
481
+ self._protocol = None
482
+ self._endpoint_addr = None
483
+ if transport is not None:
484
+ transport.close()
485
+ _LOGGER.debug("%s: endpoint dropped after a failure", self.host[0])
486
+
410
487
  async def _endpoint(self) -> tuple[asyncio.DatagramTransport, _Protocol]:
411
488
  if self._transport is not None and self._endpoint_addr != self.host:
412
489
  # The caller changed host; the connected socket points at the
@@ -510,6 +587,22 @@ class Device:
510
587
  raise e.EndpointClosedError(
511
588
  -4013, "Endpoint closed", "The device endpoint was closed"
512
589
  )
590
+ if _is_silence(resp):
591
+ # ICMP unreachable of one kind or another: the host is up
592
+ # with nothing listening, the device is off or rebooting,
593
+ # or a router answered for it. The original library's
594
+ # unconnected socket never saw these, so keep waiting and
595
+ # let the timeout decide, as it did.
596
+ _LOGGER.debug(
597
+ "%s: unreachable (%s), still waiting", self.host[0], resp
598
+ )
599
+ continue
600
+ if isinstance(resp, Exception):
601
+ # A send failure (no route, address gone) or a fatal
602
+ # transport error: fail now and throw the socket away.
603
+ _LOGGER.debug("%s: socket error: %s", self.host[0], resp)
604
+ self._drop_endpoint()
605
+ raise resp
513
606
  resp = self._validate(resp)
514
607
  reply_count = int.from_bytes(resp[0x28:0x2A], "little")
515
608
  if reply_count == count or reply_count not in self._recent:
@@ -521,6 +614,7 @@ class Device:
521
614
  )
522
615
  if loop.time() - start >= timeout:
523
616
  _LOGGER.debug("%s: no reply within %ss", self.host[0], timeout)
617
+ self._drop_endpoint()
524
618
  raise e.NetworkTimeoutError(
525
619
  -4000,
526
620
  "Network timeout",
broadlink/remote.py CHANGED
@@ -1,12 +1,12 @@
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
- from collections.abc import AsyncIterator, Awaitable, Callable
9
+ from collections.abc import AsyncGenerator, Awaitable, Callable
10
10
  from dataclasses import dataclass, field
11
11
  from typing import Self
12
12
 
@@ -18,9 +18,10 @@ _LOGGER = logging.getLogger(__name__)
18
18
  TICK = 8192 / 269
19
19
  """Duration of one Broadlink timing unit in microseconds (about 30.45 us).
20
20
 
21
- The RM firmware counts pulses on a 32768 Hz clock (protocol.md: us * 269 / 8192).
22
- Earlier releases used 32.84, the inverse of the right ratio applied the wrong
23
- way round, which compressed externally sourced IR codes by about 7 percent
21
+ The value comes from protocol.md, whose conversion "us * 269 / 8192 works
22
+ very well" was measured against real firmware; 8192/269 is its inverse.
23
+ Earlier releases used 32.84, the right ratio applied the wrong way round,
24
+ which compressed externally sourced IR codes by about 7 percent
24
25
  (mjg59/python-broadlink#839). Codes learned and replayed through the same
25
26
  device were unaffected because both directions shared the constant.
26
27
  """
@@ -176,7 +177,7 @@ class CapturedSignal:
176
177
  repeat: int = 0
177
178
  frequency_mhz: float | None = None
178
179
  type_byte: int | None = None
179
- captured_at: float = field(default_factory=time.time, repr=False)
180
+ captured_at: float = field(default_factory=time.time, repr=False, compare=False)
180
181
 
181
182
  @classmethod
182
183
  def from_packet(
@@ -220,51 +221,39 @@ class rmmini(Device):
220
221
  # against the value it saw when it armed the device and re-arms
221
222
  # after any send, since the device has one front end for both.
222
223
  self._tx_generation = 0
223
- # Weak reference to the async generator of the current capture
224
- # window, if any. See _claim_window.
225
- 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
226
228
 
227
229
  @property
228
230
  def capture_active(self) -> bool:
229
231
  """True while a capture window is open on this device."""
230
- window = self._window() if self._window is not None else None
231
- return window is not None and window.ag_frame is not None
232
+ return self._capturing
232
233
 
233
- def _check_window(self) -> None:
234
- """Fail fast at call time if another window is being iterated now."""
235
- old = self._window() if self._window is not None else None
236
- if old is not None and old.ag_frame is not None and old.ag_running:
237
- raise e.CaptureInProgressError("A capture window is already open")
238
-
239
- async def _claim_window(self, new: weakref.ReferenceType) -> None:
240
- """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.
241
236
 
242
237
  A consumer that walked away from a window without closing it (for
243
238
  example ``break`` out of ``async for`` with no ``aclosing``) leaves
244
239
  the generator to asyncio's finalizer, which closes it on the next
245
- loop iteration once nothing references it. Give that a turn. If the
246
- window is still alive after that, someone still holds it, whether
247
- they are inside ``__anext__`` or paused between signals, and the new
248
- 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.
249
245
  """
250
- prev = self._window
251
- if prev is not None:
252
- old = prev()
253
- if old is not None and old.ag_frame is not None:
254
- if old.ag_running:
255
- raise e.CaptureInProgressError("A capture window is already open")
256
- del old # Hold no reference while the finalizer gets its turn.
257
- await asyncio.sleep(0)
258
- await asyncio.sleep(0)
259
- old = prev()
260
- if old is not None and old.ag_frame is not None:
261
- raise e.CaptureInProgressError(
262
- "A capture window is already open; close it with aclose() first"
263
- )
264
- if self._window is not prev:
265
- # Another claimant got in during the two turns above.
266
- raise e.CaptureInProgressError("A capture window is already open")
267
- 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
268
257
 
269
258
  async def _send(self, command: int, data: bytes = b"") -> bytes:
270
259
  """Send a packet to the device."""
@@ -300,7 +289,7 @@ class rmmini(Device):
300
289
  stop_after_first: bool = True,
301
290
  poll_interval: float = DEFAULT_POLL_INTERVAL,
302
291
  rearm_interval: float = DEFAULT_REARM_INTERVAL,
303
- ) -> AsyncIterator[CapturedSignal]:
292
+ ) -> AsyncGenerator[CapturedSignal]:
304
293
  """Open an infrared capture window and yield what the device hears.
305
294
 
306
295
  The device is put into learning mode and polled every
@@ -318,13 +307,11 @@ class rmmini(Device):
318
307
  Use ``contextlib.aclosing`` (or iterate to the end) so the window is
319
308
  released promptly. Only one capture window can be open per device:
320
309
  opening one while another is still held raises
321
- ``CaptureInProgressError``. A window whose generator was dropped
322
- without being closed is finalized by asyncio on the next loop
323
- 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.
324
313
  """
325
- self._check_window()
326
- holder: list = []
327
- gen = self._capture_loop(
314
+ return self._capture_loop(
328
315
  self.enter_learning,
329
316
  window,
330
317
  stop_after_first,
@@ -332,10 +319,8 @@ class rmmini(Device):
332
319
  rearm_interval,
333
320
  SignalKind.IR,
334
321
  None,
335
- claim=holder,
322
+ claim=True,
336
323
  )
337
- holder.append(weakref.ref(gen))
338
- return gen
339
324
 
340
325
  async def _capture_loop(
341
326
  self,
@@ -347,18 +332,43 @@ class rmmini(Device):
347
332
  kind: SignalKind,
348
333
  frequency_mhz: float | None,
349
334
  *,
350
- claim: list | None = None,
351
- ) -> AsyncIterator[CapturedSignal]:
352
- # ``claim`` carries a weak reference to this generator (filled in by
353
- # the caller after creating it); None means the caller owns the
354
- # window claim, as capture_rf does for its inner loop.
335
+ claim: bool,
336
+ ) -> AsyncGenerator[CapturedSignal]:
337
+ # ``claim`` is False when the caller already holds the window, as
338
+ # capture_rf does for its inner loop.
355
339
  if window < 0:
356
340
  raise ValueError("window must be 0 (open-ended) or positive")
357
341
  if poll_interval <= 0 or rearm_interval <= 0:
358
342
  raise ValueError("poll_interval and rearm_interval must be positive")
359
343
  if claim:
360
- 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()
361
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]:
362
372
  loop = asyncio.get_running_loop()
363
373
  deadline = loop.time() + window if window else None
364
374
  timeouts = 0
@@ -457,7 +467,7 @@ class rmpro(rmmini):
457
467
  stop_after_first: bool = True,
458
468
  poll_interval: float = DEFAULT_POLL_INTERVAL,
459
469
  rearm_interval: float = DEFAULT_REARM_INTERVAL,
460
- ) -> AsyncIterator[CapturedSignal]:
470
+ ) -> AsyncGenerator[CapturedSignal]:
461
471
  """Open a radio frequency capture window and yield what the device hears.
462
472
 
463
473
  With ``frequency`` (in MHz, for example 433.92) the device goes
@@ -474,18 +484,9 @@ class rmpro(rmmini):
474
484
  Each ``CapturedSignal`` carries the carrier in ``frequency_mhz``,
475
485
  which the packet itself does not record.
476
486
  """
477
- self._check_window()
478
- holder: list = []
479
- gen = self._capture_rf_loop(
480
- window,
481
- frequency,
482
- stop_after_first,
483
- poll_interval,
484
- rearm_interval,
485
- claim=holder,
487
+ return self._capture_rf_loop(
488
+ window, frequency, stop_after_first, poll_interval, rearm_interval
486
489
  )
487
- holder.append(weakref.ref(gen))
488
- return gen
489
490
 
490
491
  async def _capture_rf_loop(
491
492
  self,
@@ -494,13 +495,26 @@ class rmpro(rmmini):
494
495
  stop_after_first: bool,
495
496
  poll_interval: float,
496
497
  rearm_interval: float,
497
- *,
498
- claim: list,
499
- ) -> AsyncIterator[CapturedSignal]:
498
+ ) -> AsyncGenerator[CapturedSignal]:
500
499
  if window < 0 or poll_interval <= 0:
501
500
  raise ValueError("window must be 0 or positive, poll_interval positive")
502
- await self._claim_window(claim[0])
501
+ await self._claim_window()
502
+ try:
503
+ async for signal in self._capture_rf_body(
504
+ window, frequency, stop_after_first, poll_interval, rearm_interval
505
+ ):
506
+ yield signal
507
+ finally:
508
+ self._release_window()
503
509
 
510
+ async def _capture_rf_body(
511
+ self,
512
+ window: float,
513
+ frequency: float | None,
514
+ stop_after_first: bool,
515
+ poll_interval: float,
516
+ rearm_interval: float,
517
+ ) -> AsyncGenerator[CapturedSignal]:
504
518
  loop = asyncio.get_running_loop()
505
519
  deadline = loop.time() + window if window else None
506
520
 
@@ -525,13 +539,11 @@ class rmpro(rmmini):
525
539
  rearm_interval,
526
540
  kind,
527
541
  frequency,
528
- claim=None,
542
+ claim=False,
529
543
  )
530
- try:
544
+ async with contextlib.aclosing(inner):
531
545
  async for signal in inner:
532
546
  yield signal
533
- finally:
534
- await inner.aclose()
535
547
 
536
548
  async def _sweep(self, deadline: float | None, poll_interval: float) -> float | None:
537
549
  """Sweep for the remote's carrier; return it in MHz, or None if the
broadlink/sensor.py CHANGED
@@ -1,6 +1,6 @@
1
1
  """Support for sensors."""
2
2
 
3
- from collections.abc import Sequence
3
+ import struct
4
4
 
5
5
  from . import exceptions as e
6
6
  from .device import Device
@@ -48,35 +48,35 @@ class a2(Device):
48
48
 
49
49
  TYPE = "A2"
50
50
 
51
- async def _send(self, operation: int, data: Sequence = b""):
52
- """Send a command to the device."""
53
- packet = bytearray(12)
54
- packet[0x02] = 0xA5
55
- packet[0x03] = 0xA5
56
- packet[0x04] = 0x5A
57
- packet[0x05] = 0x5A
58
- packet[0x08] = operation
59
- packet[0x09] = 0x0B
60
-
61
- if data:
62
- data_len = len(data)
63
- packet[0x0A] = data_len & 0xFF
64
- packet[0x0B] = data_len >> 8
65
- packet += bytes(2)
66
- packet.extend(data)
67
-
68
- checksum = sum(packet, 0xBEAF) & 0xFFFF
69
- packet[0x06] = checksum & 0xFF
70
- packet[0x07] = checksum >> 8
71
-
72
- packet_len = len(packet) - 2
73
- packet[0x00] = packet_len & 0xFF
74
- packet[0x01] = packet_len >> 8
51
+ async def _send(self, operation: int, data: bytes = b"") -> bytes:
52
+ """Send a command to the device.
53
+
54
+ The frame is the one the SP4 and LB1 families use: a two-byte
55
+ length, the A5A5 5A5A marker, a checksum, the operation, 0x0B and
56
+ a four-byte data length. The 0.19.0 code wrote a two-byte data
57
+ length and a length field two short, and real A2 units answered
58
+ every request with error -5 (mjg59/python-broadlink#826).
59
+ """
60
+ packet = bytearray(14)
61
+ struct.pack_into(
62
+ "<HHHHBBI",
63
+ packet,
64
+ 0,
65
+ 12 + len(data),
66
+ 0xA5A5,
67
+ 0x5A5A,
68
+ 0,
69
+ operation,
70
+ 0x0B,
71
+ len(data),
72
+ )
73
+ packet.extend(data)
74
+ checksum = sum(packet[0x02:], 0xBEAF) & 0xFFFF
75
+ packet[0x06:0x08] = checksum.to_bytes(2, "little")
75
76
 
76
77
  resp = await self.send_packet(0x6A, packet)
77
78
  e.check_error(resp[0x22:0x24])
78
- payload = self.decrypt(resp[0x38:])
79
- return payload
79
+ return self.decrypt(resp[0x38:])
80
80
 
81
81
  async def check_sensors_raw(self) -> dict:
82
82
  """Return the state of the sensors in raw format."""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-broadlink
3
- Version: 1.0.3
3
+ Version: 1.0.5
4
4
  Summary: Python API for controlling Broadlink devices
5
5
  Author: DAB-LABS
6
6
  Author-email: Matthew Garrett <mjg59@srcf.ucam.org>
@@ -193,6 +193,11 @@ If the device is locked, it may not be discoverable with broadcast. In such case
193
193
  device = await broadlink.hello("192.168.0.16")
194
194
  ```
195
195
 
196
+ `discover()` and `hello()` raise `NetworkTimeoutError` when nothing answers
197
+ within the timeout, `socket.gaierror` when a hostname does not resolve, and
198
+ `OSError` when the socket cannot be opened or the send fails (no route, for
199
+ example), the same errors the original library raised from its socket.
200
+
196
201
  If you are a perfomance freak, use `broadlink.xdiscover()` to create devices instantly:
197
202
  ```python3
198
203
  async for device in broadlink.xdiscover():
@@ -231,11 +236,21 @@ async with device:
231
236
  await device.aclose()
232
237
  ```
233
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
+
234
244
  The socket reopens by itself on the next call, so closing is cheap and
235
245
  safe to do at any time. A request that is in flight when `aclose()` runs
236
- fails with `EndpointClosedError`. An integration that creates devices
237
- should close them when it unloads; a device that is never closed holds
238
- its socket until it is garbage collected.
246
+ fails with `EndpointClosedError`. A request that fails for a network
247
+ reason (a timeout, or an `OSError` from the socket such as "network is
248
+ unreachable" after an interface change, raised at once) also drops the socket, so the
249
+ next call starts fresh rather than reusing one that has gone bad, which
250
+ is how the original library behaved by opening a socket per call. An
251
+ integration that creates devices should close them when it unloads; a
252
+ device that is never closed holds its socket until it is garbage
253
+ collected.
239
254
 
240
255
  The next steps depend on the type of device you want to control.
241
256
 
@@ -311,7 +326,8 @@ By default the window closes after the first signal. Pass
311
326
  because the device holds only one code per learning session. A universal
312
327
  remote has a single receiver, so only one capture window can be open on a
313
328
  device at a time: opening a second one raises `CaptureInProgressError`
314
- while the first is still held. 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
315
331
  (`aclosing` above does it), otherwise it stays open until Python collects
316
332
  the generator.
317
333
 
@@ -1,19 +1,19 @@
1
- broadlink/__init__.py,sha256=wXL3gDxcmE563q4Kjsx9vzWn8Jgopj9Ls7kl4sC0qNQ,10821
1
+ broadlink/__init__.py,sha256=OJCaOs_IOgLqdaVe4bH3ALQEs-IVFV3lGFd5da7o2w4,11138
2
2
  broadlink/alarm.py,sha256=tmeNI-AaPna5nbgoTqz_AGmuQVItGTmt7zyNiRLsSlQ,1260
3
3
  broadlink/climate.py,sha256=fciXwQ0P2_kOaJiXAprfXuDyhLdHU--mE6pCYkVWdF8,15592
4
4
  broadlink/const.py,sha256=Y0XSxYr1k27FKqHWBirJsUB0HGxQ1riIZ9rfVEJaIL4,120
5
5
  broadlink/cover.py,sha256=-wW1BPmf5Z8oeLuqDVEpUyU-PgId5CJ91M3g3RjgVAU,5448
6
- broadlink/device.py,sha256=dP2RbnxZve_m9bJsis51KfDwPNxxJuGapPltYuWF50M,21507
6
+ broadlink/device.py,sha256=Y3uzlrZ0CaAIhpNO7ap4DvKlLpqNQVKp9kXjcKxTqRI,25786
7
7
  broadlink/exceptions.py,sha256=S8xOGdzyFGd6ULXUlblDwE-MADoJIR49itEo0j_fZNU,5226
8
8
  broadlink/helpers.py,sha256=Zbw7lNRMB08P5FPAtKMJLExBjts_Gq64tJbR7-JwxX8,1294
9
9
  broadlink/hub.py,sha256=e78lDKcdtDNqSOZkQRqLMPMFR0PgT2g3E7VgULCq-QM,2869
10
10
  broadlink/light.py,sha256=fWCoaf6JmDfcc7ggRmehss9D3cp66evUDlcB50Iu2LI,7660
11
11
  broadlink/protocol.py,sha256=eRCm0fGdApVp5ko_fOP79lazQklda8N6x30ty6P0TiA,1848
12
- broadlink/remote.py,sha256=1TO3xo_rhp9_Zt8yFRL3Im_IUH9Nbvt9pwyn9gstk18,22418
13
- broadlink/sensor.py,sha256=dhetprwgv45gkyvHgS2jJrYVuSQ2z-IXJynykgXo_2k,2701
12
+ broadlink/remote.py,sha256=sXDvBneN5dppAyMi3-d1ysVPu6-tF1ugHMzpBEMr-_E,22382
13
+ broadlink/sensor.py,sha256=Pa4fJnG28wPFIf7HSN4JQ3cIOfAdTCu5I0spnBG12Qg,2791
14
14
  broadlink/switch.py,sha256=0VurdoGf3CTrYBSQU4ahVSLC_FYHqIRWr3dLkBwIatI,14985
15
- python_broadlink-1.0.3.dist-info/licenses/LICENSE,sha256=h0N_LWq34pY0W-Ky3ayO6Re5ejG0XvutPlW-GL8eG5I,1111
16
- python_broadlink-1.0.3.dist-info/METADATA,sha256=YQblobXPiUvcd2rGgxclX3M_JuFMp77kB4d6uSAuJNs,15046
17
- python_broadlink-1.0.3.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
18
- python_broadlink-1.0.3.dist-info/top_level.txt,sha256=_Qx7d8oNTOLeDkAN-HHDoq1FlRNlsiaMZlyMWgHXAVc,10
19
- python_broadlink-1.0.3.dist-info/RECORD,,
15
+ python_broadlink-1.0.5.dist-info/licenses/LICENSE,sha256=h0N_LWq34pY0W-Ky3ayO6Re5ejG0XvutPlW-GL8eG5I,1111
16
+ python_broadlink-1.0.5.dist-info/METADATA,sha256=h7AZlQhasZUDjRbtIEB_JmUAsRPIkf7gsUXl7fb3QvA,15948
17
+ python_broadlink-1.0.5.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
18
+ python_broadlink-1.0.5.dist-info/top_level.txt,sha256=_Qx7d8oNTOLeDkAN-HHDoq1FlRNlsiaMZlyMWgHXAVc,10
19
+ python_broadlink-1.0.5.dist-info/RECORD,,