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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. {python_broadlink-1.0.3 → python_broadlink-1.0.4}/CHANGELOG.md +53 -0
  2. {python_broadlink-1.0.3/python_broadlink.egg-info → python_broadlink-1.0.4}/PKG-INFO +17 -5
  3. {python_broadlink-1.0.3 → python_broadlink-1.0.4}/README.md +16 -4
  4. {python_broadlink-1.0.3 → python_broadlink-1.0.4}/broadlink/__init__.py +6 -6
  5. {python_broadlink-1.0.3 → python_broadlink-1.0.4}/broadlink/device.py +88 -14
  6. {python_broadlink-1.0.3 → python_broadlink-1.0.4}/broadlink/remote.py +10 -9
  7. {python_broadlink-1.0.3 → python_broadlink-1.0.4}/broadlink/sensor.py +27 -27
  8. {python_broadlink-1.0.3 → python_broadlink-1.0.4}/pyproject.toml +1 -1
  9. {python_broadlink-1.0.3 → python_broadlink-1.0.4/python_broadlink.egg-info}/PKG-INFO +17 -5
  10. {python_broadlink-1.0.3 → python_broadlink-1.0.4}/python_broadlink.egg-info/SOURCES.txt +1 -0
  11. {python_broadlink-1.0.3 → python_broadlink-1.0.4}/tests/oracle/fixtures.json +1 -1
  12. {python_broadlink-1.0.3 → python_broadlink-1.0.4}/tests/oracle/harness.py +7 -0
  13. python_broadlink-1.0.4/tests/test_loopback.py +110 -0
  14. {python_broadlink-1.0.3 → python_broadlink-1.0.4}/tests/test_transport.py +142 -8
  15. {python_broadlink-1.0.3 → python_broadlink-1.0.4}/LICENSE +0 -0
  16. {python_broadlink-1.0.3 → python_broadlink-1.0.4}/MANIFEST.in +0 -0
  17. {python_broadlink-1.0.3 → python_broadlink-1.0.4}/TROUBLESHOOTING.md +0 -0
  18. {python_broadlink-1.0.3 → python_broadlink-1.0.4}/broadlink/alarm.py +0 -0
  19. {python_broadlink-1.0.3 → python_broadlink-1.0.4}/broadlink/climate.py +0 -0
  20. {python_broadlink-1.0.3 → python_broadlink-1.0.4}/broadlink/const.py +0 -0
  21. {python_broadlink-1.0.3 → python_broadlink-1.0.4}/broadlink/cover.py +0 -0
  22. {python_broadlink-1.0.3 → python_broadlink-1.0.4}/broadlink/exceptions.py +0 -0
  23. {python_broadlink-1.0.3 → python_broadlink-1.0.4}/broadlink/helpers.py +0 -0
  24. {python_broadlink-1.0.3 → python_broadlink-1.0.4}/broadlink/hub.py +0 -0
  25. {python_broadlink-1.0.3 → python_broadlink-1.0.4}/broadlink/light.py +0 -0
  26. {python_broadlink-1.0.3 → python_broadlink-1.0.4}/broadlink/protocol.py +0 -0
  27. {python_broadlink-1.0.3 → python_broadlink-1.0.4}/broadlink/switch.py +0 -0
  28. {python_broadlink-1.0.3 → python_broadlink-1.0.4}/cli/README.md +0 -0
  29. {python_broadlink-1.0.3 → python_broadlink-1.0.4}/cli/broadlink_cli +0 -0
  30. {python_broadlink-1.0.3 → python_broadlink-1.0.4}/cli/broadlink_discovery +0 -0
  31. {python_broadlink-1.0.3 → python_broadlink-1.0.4}/protocol.md +0 -0
  32. {python_broadlink-1.0.3 → python_broadlink-1.0.4}/python_broadlink.egg-info/dependency_links.txt +0 -0
  33. {python_broadlink-1.0.3 → python_broadlink-1.0.4}/python_broadlink.egg-info/requires.txt +0 -0
  34. {python_broadlink-1.0.3 → python_broadlink-1.0.4}/python_broadlink.egg-info/top_level.txt +0 -0
  35. {python_broadlink-1.0.3 → python_broadlink-1.0.4}/setup.cfg +0 -0
  36. {python_broadlink-1.0.3 → python_broadlink-1.0.4}/tests/__init__.py +0 -0
  37. {python_broadlink-1.0.3 → python_broadlink-1.0.4}/tests/oracle/__init__.py +0 -0
  38. {python_broadlink-1.0.3 → python_broadlink-1.0.4}/tests/oracle/cases.py +0 -0
  39. {python_broadlink-1.0.3 → python_broadlink-1.0.4}/tests/oracle/record.py +0 -0
  40. {python_broadlink-1.0.3 → python_broadlink-1.0.4}/tests/test_capture.py +0 -0
  41. {python_broadlink-1.0.3 → python_broadlink-1.0.4}/tests/test_helpers.py +0 -0
  42. {python_broadlink-1.0.3 → python_broadlink-1.0.4}/tests/test_oracle.py +0 -0
  43. {python_broadlink-1.0.3 → python_broadlink-1.0.4}/tests/test_remote.py +0 -0
@@ -3,6 +3,59 @@
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.4 - 2026-09-06
7
+
8
+ Fixes from a fourth review, of 1.0.3, which drove the real socket path the
9
+ test suite fakes and found two behaviours the original library had and
10
+ this one had lost. One device fix carried from upstream.
11
+
12
+ ### Fixed
13
+
14
+ - A connected socket that went bad (interface bounce, host address change,
15
+ container network restart) was never replaced: the request waited out
16
+ its timeout and every later request did the same until `aclose()`. The
17
+ original opened a socket per call, so it healed on the next one. Now a
18
+ send failure the socket reports (no route, address gone) fails the
19
+ waiting request at once with that `OSError`, and a request that fails
20
+ for a network reason (that, or a timeout) drops the socket so the next
21
+ call opens a fresh one. A transport asyncio closes from its side also
22
+ wakes the waiting request instead of leaving it to time out. An ICMP
23
+ "port unreachable" (a host that is up with nothing listening, or a
24
+ device mid-reboot) is logged and treated as silence, since the
25
+ original's unconnected socket never saw those, so the timeout decides
26
+ as before.
27
+ - `discover()`, `hello()`, `ping()` and `setup()` passed hostnames straight
28
+ to `sendto`, which resolved them with a blocking call on the event loop
29
+ and swallowed the failure: a name that did not resolve made `hello()`
30
+ wait out its timeout and `ping()` return without sending. The
31
+ destination is now resolved once, off the loop, and `socket.gaierror`
32
+ propagates as it did from the original's socket. A send failure in
33
+ `ping()` and `setup()` is raised too.
34
+ - The A2 air quality sensor's request frame was two bytes short and
35
+ declared the wrong length, and real units answered every read with
36
+ error -5. The frame now follows the SP4/LB1 layout, which is byte for
37
+ byte the packet upstream pull request #826 tested on an A2. That is the
38
+ one oracle case re-recorded on purpose; the fix is carried on the
39
+ strength of that report, not of hardware we have.
40
+ - `xdiscover()` closes the `scan()` generator it wraps, so the discovery
41
+ socket is closed when the caller stops iterating rather than by the
42
+ finalizer a few turns later (1.0.2 claimed this and only `Device.hello()`
43
+ did it).
44
+ - Two identical captures compare equal: `CapturedSignal.captured_at` no
45
+ longer takes part in equality or hashing.
46
+ - Async generator functions are annotated `AsyncGenerator`, which has the
47
+ `aclose()` the library and the README call; `AsyncIterator` does not.
48
+ - The `TICK` docstring tells the same story as the README: 8192/269 from
49
+ protocol.md's measured conversion, not a 32768 Hz clock.
50
+
51
+ ### Added
52
+
53
+ - A loopback test module that drives the real datagram endpoint, including
54
+ the socket-error path, since every other transport test fakes it.
55
+ - README: which errors `discover()` and `hello()` raise, that a failed
56
+ request drops its socket, and that `CaptureInProgressError` can come
57
+ from the `capture()` call or from the first iteration.
58
+
6
59
  ## 1.0.3 - 2026-09-06
7
60
 
8
61
  Fixes from a third review, this one of 1.0.2. No change to the wire
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-broadlink
3
- Version: 1.0.3
3
+ Version: 1.0.4
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():
@@ -233,9 +238,14 @@ await device.aclose()
233
238
 
234
239
  The socket reopens by itself on the next call, so closing is cheap and
235
240
  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.
241
+ fails with `EndpointClosedError`. A request that fails for a network
242
+ reason (a timeout, or an `OSError` from the socket such as "network is
243
+ unreachable" after an interface change, raised at once) also drops the socket, so the
244
+ next call starts fresh rather than reusing one that has gone bad, which
245
+ is how the original library behaved by opening a socket per call. An
246
+ integration that creates devices should close them when it unloads; a
247
+ device that is never closed holds its socket until it is garbage
248
+ collected.
239
249
 
240
250
  The next steps depend on the type of device you want to control.
241
251
 
@@ -311,7 +321,9 @@ By default the window closes after the first signal. Pass
311
321
  because the device holds only one code per learning session. A universal
312
322
  remote has a single receiver, so only one capture window can be open on a
313
323
  device at a time: opening a second one raises `CaptureInProgressError`
314
- while the first is still held. Always close a window you leave early
324
+ while the first is still held, either from the `capture()` call itself or
325
+ from the new window's first iteration, depending on what the first window
326
+ was doing at that moment. Always close a window you leave early
315
327
  (`aclosing` above does it), otherwise it stays open until Python collects
316
328
  the generator.
317
329
 
@@ -162,6 +162,11 @@ If the device is locked, it may not be discoverable with broadcast. In such case
162
162
  device = await broadlink.hello("192.168.0.16")
163
163
  ```
164
164
 
165
+ `discover()` and `hello()` raise `NetworkTimeoutError` when nothing answers
166
+ within the timeout, `socket.gaierror` when a hostname does not resolve, and
167
+ `OSError` when the socket cannot be opened or the send fails (no route, for
168
+ example), the same errors the original library raised from its socket.
169
+
165
170
  If you are a perfomance freak, use `broadlink.xdiscover()` to create devices instantly:
166
171
  ```python3
167
172
  async for device in broadlink.xdiscover():
@@ -202,9 +207,14 @@ await device.aclose()
202
207
 
203
208
  The socket reopens by itself on the next call, so closing is cheap and
204
209
  safe to do at any time. A request that is in flight when `aclose()` runs
205
- fails with `EndpointClosedError`. An integration that creates devices
206
- should close them when it unloads; a device that is never closed holds
207
- its socket until it is garbage collected.
210
+ fails with `EndpointClosedError`. A request that fails for a network
211
+ reason (a timeout, or an `OSError` from the socket such as "network is
212
+ unreachable" after an interface change, raised at once) also drops the socket, so the
213
+ next call starts fresh rather than reusing one that has gone bad, which
214
+ is how the original library behaved by opening a socket per call. An
215
+ integration that creates devices should close them when it unloads; a
216
+ device that is never closed holds its socket until it is garbage
217
+ collected.
208
218
 
209
219
  The next steps depend on the type of device you want to control.
210
220
 
@@ -280,7 +290,9 @@ By default the window closes after the first signal. Pass
280
290
  because the device holds only one code per learning session. A universal
281
291
  remote has a single receiver, so only one capture window can be open on a
282
292
  device at a time: opening a second one raises `CaptureInProgressError`
283
- while the first is still held. Always close a window you leave early
293
+ while the first is still held, either from the `capture()` call itself or
294
+ from the new window's first iteration, depending on what the first window
295
+ was doing at that moment. Always close a window you leave early
284
296
  (`aclosing` above does it), otherwise it stays open until Python collects
285
297
  the generator.
286
298
 
@@ -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
@@ -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.
@@ -15,7 +15,7 @@ import contextlib
15
15
  import logging
16
16
  import random
17
17
  import socket
18
- from collections.abc import AsyncIterator
18
+ from collections.abc import AsyncGenerator
19
19
 
20
20
  from cryptography.hazmat.backends import default_backend
21
21
  from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
@@ -48,12 +48,20 @@ _RECENT_MAX = 64
48
48
  _CLOSED = (None, None)
49
49
  """Sentinel put on the receive queue when the endpoint is closed."""
50
50
 
51
+ _QueueItem = tuple[bytes | Exception | None, tuple[str, int] | None]
52
+ """What the receive queue carries: a datagram with its source address, an
53
+ error the socket reported (address ``None``), or ``_CLOSED``."""
54
+
51
55
 
52
56
  class _Protocol(asyncio.DatagramProtocol):
53
- """Datagram protocol that hands every received packet to a queue."""
57
+ """Datagram protocol that hands every received packet to a queue.
58
+
59
+ Errors the socket reports go on the same queue, so the request that is
60
+ waiting fails at once instead of waiting out its timeout.
61
+ """
54
62
 
55
63
  def __init__(self) -> None:
56
- self.queue: asyncio.Queue[tuple[bytes, tuple[str, int]]] = asyncio.Queue()
64
+ self.queue: asyncio.Queue[_QueueItem] = asyncio.Queue()
57
65
  self.transport: asyncio.DatagramTransport | None = None
58
66
 
59
67
  def connection_made(self, transport: asyncio.BaseTransport) -> None:
@@ -65,18 +73,30 @@ class _Protocol(asyncio.DatagramProtocol):
65
73
  self.queue.put_nowait((data, addr))
66
74
 
67
75
  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
76
+ """Queue a send failure or an ICMP error for the waiting request."""
77
+ self.queue.put_nowait((exc, None))
71
78
 
72
79
  def connection_lost(self, exc: Exception | None) -> None:
73
- """Nothing to do; a waiting request is told through the queue."""
80
+ """Wake the waiting request if asyncio closed the transport on us."""
81
+ self.queue.put_nowait((exc, None) if exc is not None else _CLOSED)
74
82
 
75
83
  def drain(self) -> None:
76
84
  """Drop anything that arrived before the current request."""
77
85
  while not self.queue.empty():
78
86
  self.queue.get_nowait()
79
87
 
88
+ def raise_if_error(self) -> None:
89
+ """Raise the error a send just reported, if it reported one.
90
+
91
+ asyncio delivers a failed ``sendto`` to ``error_received`` before
92
+ ``sendto`` returns, so a fire-and-forget sender can check right
93
+ after sending and raise the ``OSError`` the way a plain socket did.
94
+ """
95
+ while not self.queue.empty():
96
+ item, _ = self.queue.get_nowait()
97
+ if isinstance(item, Exception):
98
+ raise item
99
+
80
100
 
81
101
  async def _open_endpoint(
82
102
  local_addr: tuple[str, int] | None = None,
@@ -95,6 +115,21 @@ async def _open_endpoint(
95
115
  return transport, protocol # type: ignore[return-value]
96
116
 
97
117
 
118
+ async def _resolve(host: str, port: int) -> tuple[str, int]:
119
+ """Resolve a destination once, off the event loop.
120
+
121
+ Sending to a hostname through an unconnected datagram socket would
122
+ resolve it with a blocking call on the loop and hide the failure. A
123
+ name that does not resolve raises ``socket.gaierror`` here, as the
124
+ original library's ``sendto`` did.
125
+ """
126
+ loop = asyncio.get_running_loop()
127
+ info = await loop.getaddrinfo(
128
+ host, port, family=socket.AF_INET, type=socket.SOCK_DGRAM
129
+ )
130
+ return info[0][4][:2] # type: ignore[return-value]
131
+
132
+
98
133
  def _hello_packet(local_ip_address: str, port: int) -> bytearray:
99
134
  packet = bytearray(0x30)
100
135
  packet[0x08:0x14] = Datetime.pack(Datetime.now())
@@ -119,13 +154,14 @@ async def scan(
119
154
  local_ip_address: str | None = None,
120
155
  discover_ip_address: str = DEFAULT_BCAST_ADDR,
121
156
  discover_ip_port: int = DEFAULT_PORT,
122
- ) -> AsyncIterator[HelloResponse]:
157
+ ) -> AsyncGenerator[HelloResponse]:
123
158
  """Broadcast a hello message and yield responses as they arrive.
124
159
 
125
160
  The hello is repeated every ``DEFAULT_RETRY_INTVL`` seconds until
126
161
  ``timeout`` elapses. Each device is yielded once.
127
162
  """
128
163
  local_addr = (local_ip_address, 0) if local_ip_address else None
164
+ target = await _resolve(discover_ip_address, discover_ip_port)
129
165
  transport, protocol = await _open_endpoint(local_addr=local_addr, broadcast=True)
130
166
  try:
131
167
  if local_ip_address:
@@ -140,7 +176,7 @@ async def scan(
140
176
  discovered: set[tuple[tuple[str, int], bytes, int]] = set()
141
177
 
142
178
  while (loop.time() - start) < timeout:
143
- transport.sendto(packet, (discover_ip_address, discover_ip_port))
179
+ transport.sendto(packet, target)
144
180
  deadline = min(DEFAULT_RETRY_INTVL, timeout - (loop.time() - start))
145
181
  slot_end = loop.time() + deadline
146
182
  while True:
@@ -151,7 +187,11 @@ async def scan(
151
187
  resp, host = await asyncio.wait_for(protocol.queue.get(), remaining)
152
188
  except TimeoutError:
153
189
  break
154
- if len(resp) < 0x80:
190
+ if resp is None:
191
+ return # The transport was closed under us.
192
+ if isinstance(resp, Exception):
193
+ raise resp
194
+ if host is None or len(resp) < 0x80:
155
195
  continue
156
196
  entry = _parse_hello(resp, host)
157
197
  key = (entry[1], entry[2], entry[0])
@@ -167,9 +207,11 @@ async def send_setup_packet(
167
207
  payload: bytes, ip_address: str, port: int = DEFAULT_PORT
168
208
  ) -> None:
169
209
  """Broadcast one Wi-Fi provisioning packet to a device in AP mode."""
170
- transport, _ = await _open_endpoint(broadcast=True)
210
+ target = await _resolve(ip_address, port)
211
+ transport, protocol = await _open_endpoint(broadcast=True)
171
212
  try:
172
- transport.sendto(payload, (ip_address, port))
213
+ transport.sendto(payload, target)
214
+ protocol.raise_if_error()
173
215
  finally:
174
216
  transport.close()
175
217
 
@@ -181,11 +223,13 @@ async def ping(ip_address: str, port: int = DEFAULT_PORT) -> None:
181
223
  Useful to prevent reboots when the cloud cannot be reached.
182
224
  It must be sent every 2 minutes in such cases.
183
225
  """
184
- transport, _ = await _open_endpoint(broadcast=True)
226
+ target = await _resolve(ip_address, port)
227
+ transport, protocol = await _open_endpoint(broadcast=True)
185
228
  try:
186
229
  packet = bytearray(0x30)
187
230
  packet[0x26] = 1
188
- transport.sendto(packet, (ip_address, port))
231
+ transport.sendto(packet, target)
232
+ protocol.raise_if_error()
189
233
  finally:
190
234
  transport.close()
191
235
 
@@ -407,6 +451,22 @@ class Device:
407
451
  if protocol is not None:
408
452
  protocol.queue.put_nowait(_CLOSED) # type: ignore[arg-type]
409
453
 
454
+ def _drop_endpoint(self) -> None:
455
+ """Throw the endpoint away after a failure; the next call reopens it.
456
+
457
+ A connected datagram socket can go bad for good (the interface
458
+ bounced, the host's address changed), and the original library
459
+ never noticed because it opened a socket per call. Dropping the
460
+ endpoint whenever a request fails restores that self-healing.
461
+ """
462
+ transport = self._transport
463
+ self._transport = None
464
+ self._protocol = None
465
+ self._endpoint_addr = None
466
+ if transport is not None:
467
+ transport.close()
468
+ _LOGGER.debug("%s: endpoint dropped after a failure", self.host[0])
469
+
410
470
  async def _endpoint(self) -> tuple[asyncio.DatagramTransport, _Protocol]:
411
471
  if self._transport is not None and self._endpoint_addr != self.host:
412
472
  # The caller changed host; the connected socket points at the
@@ -510,6 +570,19 @@ class Device:
510
570
  raise e.EndpointClosedError(
511
571
  -4013, "Endpoint closed", "The device endpoint was closed"
512
572
  )
573
+ if isinstance(resp, ConnectionRefusedError):
574
+ # ICMP port unreachable: the host is up and nothing is
575
+ # listening, or the device is rebooting. The original
576
+ # library's unconnected socket never saw these, so keep
577
+ # waiting and let the timeout decide, as it did.
578
+ _LOGGER.debug("%s: port unreachable, still waiting", self.host[0])
579
+ continue
580
+ if isinstance(resp, Exception):
581
+ # A send failure (no route, address gone) or a fatal
582
+ # transport error: fail now and throw the socket away.
583
+ _LOGGER.debug("%s: socket error: %s", self.host[0], resp)
584
+ self._drop_endpoint()
585
+ raise resp
513
586
  resp = self._validate(resp)
514
587
  reply_count = int.from_bytes(resp[0x28:0x2A], "little")
515
588
  if reply_count == count or reply_count not in self._recent:
@@ -521,6 +594,7 @@ class Device:
521
594
  )
522
595
  if loop.time() - start >= timeout:
523
596
  _LOGGER.debug("%s: no reply within %ss", self.host[0], timeout)
597
+ self._drop_endpoint()
524
598
  raise e.NetworkTimeoutError(
525
599
  -4000,
526
600
  "Network timeout",
@@ -6,7 +6,7 @@ import logging
6
6
  import struct
7
7
  import time
8
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(
@@ -300,7 +301,7 @@ class rmmini(Device):
300
301
  stop_after_first: bool = True,
301
302
  poll_interval: float = DEFAULT_POLL_INTERVAL,
302
303
  rearm_interval: float = DEFAULT_REARM_INTERVAL,
303
- ) -> AsyncIterator[CapturedSignal]:
304
+ ) -> AsyncGenerator[CapturedSignal]:
304
305
  """Open an infrared capture window and yield what the device hears.
305
306
 
306
307
  The device is put into learning mode and polled every
@@ -348,7 +349,7 @@ class rmmini(Device):
348
349
  frequency_mhz: float | None,
349
350
  *,
350
351
  claim: list | None = None,
351
- ) -> AsyncIterator[CapturedSignal]:
352
+ ) -> AsyncGenerator[CapturedSignal]:
352
353
  # ``claim`` carries a weak reference to this generator (filled in by
353
354
  # the caller after creating it); None means the caller owns the
354
355
  # window claim, as capture_rf does for its inner loop.
@@ -457,7 +458,7 @@ class rmpro(rmmini):
457
458
  stop_after_first: bool = True,
458
459
  poll_interval: float = DEFAULT_POLL_INTERVAL,
459
460
  rearm_interval: float = DEFAULT_REARM_INTERVAL,
460
- ) -> AsyncIterator[CapturedSignal]:
461
+ ) -> AsyncGenerator[CapturedSignal]:
461
462
  """Open a radio frequency capture window and yield what the device hears.
462
463
 
463
464
  With ``frequency`` (in MHz, for example 433.92) the device goes
@@ -496,7 +497,7 @@ class rmpro(rmmini):
496
497
  rearm_interval: float,
497
498
  *,
498
499
  claim: list,
499
- ) -> AsyncIterator[CapturedSignal]:
500
+ ) -> AsyncGenerator[CapturedSignal]:
500
501
  if window < 0 or poll_interval <= 0:
501
502
  raise ValueError("window must be 0 or positive, poll_interval positive")
502
503
  await self._claim_window(claim[0])
@@ -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."""
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-broadlink"
7
- version = "1.0.3"
7
+ version = "1.0.4"
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.3
3
+ Version: 1.0.4
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():
@@ -233,9 +238,14 @@ await device.aclose()
233
238
 
234
239
  The socket reopens by itself on the next call, so closing is cheap and
235
240
  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.
241
+ fails with `EndpointClosedError`. A request that fails for a network
242
+ reason (a timeout, or an `OSError` from the socket such as "network is
243
+ unreachable" after an interface change, raised at once) also drops the socket, so the
244
+ next call starts fresh rather than reusing one that has gone bad, which
245
+ is how the original library behaved by opening a socket per call. An
246
+ integration that creates devices should close them when it unloads; a
247
+ device that is never closed holds its socket until it is garbage
248
+ collected.
239
249
 
240
250
  The next steps depend on the type of device you want to control.
241
251
 
@@ -311,7 +321,9 @@ By default the window closes after the first signal. Pass
311
321
  because the device holds only one code per learning session. A universal
312
322
  remote has a single receiver, so only one capture window can be open on a
313
323
  device at a time: opening a second one raises `CaptureInProgressError`
314
- while the first is still held. Always close a window you leave early
324
+ while the first is still held, either from the `capture()` call itself or
325
+ from the new window's first iteration, depending on what the first window
326
+ was doing at that moment. Always close a window you leave early
315
327
  (`aclosing` above does it), otherwise it stays open until Python collects
316
328
  the generator.
317
329
 
@@ -30,6 +30,7 @@ python_broadlink.egg-info/top_level.txt
30
30
  tests/__init__.py
31
31
  tests/test_capture.py
32
32
  tests/test_helpers.py
33
+ tests/test_loopback.py
33
34
  tests/test_oracle.py
34
35
  tests/test_remote.py
35
36
  tests/test_transport.py
@@ -2688,7 +2688,7 @@
2688
2688
  "sent": [
2689
2689
  [
2690
2690
  106,
2691
- "0a00a5a55a5ab9c0010b0000"
2691
+ "0c00a5a55a5ab9c0010b00000000"
2692
2692
  ]
2693
2693
  ],
2694
2694
  "unused_responses": 0
@@ -18,6 +18,13 @@ responses. Results are normalized to plain JSON so they can be stored.
18
18
 
19
19
  The runner accepts awaitables so the same cases can drive an asynchronous
20
20
  ``send_packet`` later without changing the cases.
21
+
22
+ Deliberate departures from 0.19.0, re-recorded on purpose and reviewed in
23
+ the pull request that made them:
24
+
25
+ - ``a2.check_sensors_raw`` (1.0.4): the request frame follows the SP4/LB1
26
+ layout (length 12, four-byte data length) instead of the 0.19.0 frame
27
+ the device rejected with error -5. Upstream #826.
21
28
  """
22
29
 
23
30
  from __future__ import annotations
@@ -0,0 +1,110 @@
1
+ """The real datagram path, on loopback.
2
+
3
+ Every other transport test replaces ``_open_endpoint`` with a fake, so the
4
+ lines that talk to asyncio's real datagram transport (``_Protocol``,
5
+ ``_open_endpoint``) are only exercised here. A small fake device answers on
6
+ 127.0.0.1 with real sockets.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import socket
13
+ import sys
14
+
15
+ import pytest
16
+
17
+ from broadlink import device as device_module
18
+ from broadlink import exceptions as e
19
+ from broadlink.device import Device
20
+ from tests.oracle.harness import MAC, make_response
21
+
22
+
23
+ class FakeDevice(asyncio.DatagramProtocol):
24
+ """Answers every request frame with a canned payload, counter echoed."""
25
+
26
+ def __init__(self, dev: Device, payload: bytes) -> None:
27
+ self.dev = dev
28
+ self.payload = payload
29
+ self.received: list[bytes] = []
30
+ self.transport: asyncio.DatagramTransport | None = None
31
+
32
+ def connection_made(self, transport) -> None:
33
+ self.transport = transport
34
+
35
+ def datagram_received(self, data: bytes, addr) -> None:
36
+ self.received.append(data)
37
+ frame = bytearray(make_response(self.dev, self.payload))
38
+ frame[0x28:0x2A] = data[0x28:0x2A]
39
+ checksum = sum(frame, 0xBEAF) - sum(frame[0x20:0x22]) & 0xFFFF
40
+ frame[0x20:0x22] = checksum.to_bytes(2, "little")
41
+ assert self.transport is not None
42
+ self.transport.sendto(bytes(frame), addr)
43
+
44
+
45
+ async def start_fake(dev: Device, payload: bytes):
46
+ loop = asyncio.get_running_loop()
47
+ transport, protocol = await loop.create_datagram_endpoint(
48
+ lambda: FakeDevice(dev, payload), local_addr=("127.0.0.1", 0)
49
+ )
50
+ return transport, protocol, transport.get_extra_info("sockname")[:2]
51
+
52
+
53
+ def unused_udp_port() -> int:
54
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
55
+ sock.bind(("127.0.0.1", 0))
56
+ return sock.getsockname()[1]
57
+
58
+
59
+ def test_request_and_concurrent_requests_over_real_sockets():
60
+ async def go():
61
+ dev = Device(("127.0.0.1", 1), MAC, 0x2737, name="Loopback")
62
+ transport, fake, addr = await start_fake(dev, bytes([7]) + bytes(15))
63
+ dev.host = addr
64
+ try:
65
+ async with dev:
66
+ resp = await dev.send_packet(0x6A, b"")
67
+ assert dev.decrypt(resp[0x38:])[0] == 7
68
+ results = await asyncio.gather(
69
+ *(dev.send_packet(0x6A, bytes([i])) for i in range(20))
70
+ )
71
+ assert all(dev.decrypt(r[0x38:])[0] == 7 for r in results)
72
+ assert isinstance(dev._protocol, device_module._Protocol)
73
+ assert dev._transport is None
74
+ return len(fake.received)
75
+ finally:
76
+ transport.close()
77
+
78
+ assert asyncio.run(go()) == 21
79
+
80
+
81
+ @pytest.mark.skipif(sys.platform == "win32", reason="ICMP errors surface differently")
82
+ def test_endpoint_heals_after_a_socket_error_on_loopback(caplog):
83
+ """A request to a loopback port nobody listens on draws an ICMP port
84
+ unreachable, which the connected socket reports on its next read. That
85
+ one is treated like silence (the original's unconnected socket never
86
+ saw it) so the request times out as before, but it must have reached
87
+ the protocol, and the device must reopen its socket for the next call
88
+ instead of reusing the dead one."""
89
+ caplog.set_level("DEBUG", logger="broadlink.device")
90
+
91
+ async def go():
92
+ dev = Device(("127.0.0.1", unused_udp_port()), MAC, 0x2737, name="Loopback")
93
+ dev.timeout = 0.3
94
+ try:
95
+ with pytest.raises(e.NetworkTimeoutError):
96
+ await dev.send_packet(0x6A, b"")
97
+ assert dev._transport is None
98
+ # Point it at a live fake device: the next call opens a new socket.
99
+ transport, _, addr = await start_fake(dev, bytes([9]) + bytes(15))
100
+ try:
101
+ dev.host = addr
102
+ resp = await dev.send_packet(0x6A, b"")
103
+ return dev.decrypt(resp[0x38:])[0]
104
+ finally:
105
+ transport.close()
106
+ finally:
107
+ await dev.aclose()
108
+
109
+ assert asyncio.run(go()) == 9
110
+ assert "port unreachable" in caplog.text
@@ -233,22 +233,52 @@ def stamped(dev: Device, payload: bytes, count: int, error: int = 0) -> bytes:
233
233
  return bytes(frame)
234
234
 
235
235
 
236
- def test_late_reply_to_timed_out_request_is_not_taken_as_next_reply(net):
237
- """The defect that 0.19.0 could not have because it threw its socket away
238
- after every call: a slow answer to request 1 arriving after request 1
239
- timed out must not be returned as the answer to request 2."""
236
+ def test_timed_out_request_drops_its_endpoint(net):
237
+ """A request that times out throws its socket away, as 0.19.0 did by
238
+ opening one per call, so a socket that has gone bad heals on the next
239
+ call and a late reply to the timed-out request lands on a port nobody
240
+ listens to any more."""
240
241
  dev = fixed_device()
241
242
  dev.timeout = 0.02
242
243
 
243
244
  async def go():
244
245
  await dev._endpoint()
245
- ep = net.endpoints[-1]
246
+ first = net.endpoints[-1]
246
247
  with pytest.raises(e.NetworkTimeoutError):
247
- await dev.send_packet(0x6A, b"") # request 1, count 0x8001, no answer
248
+ await dev.send_packet(0x6A, b"")
249
+ assert first.closed
250
+ assert dev._transport is None
251
+ # The late answer arrives on the old socket; the next request opens
252
+ # a new one and only sees its own reply.
253
+ late = stamped(dev, bytes([1]) + bytes(15), 0x8001)
254
+ first.protocol.queue.put_nowait((late, HOST))
255
+ dev.timeout = 1
256
+ net.replies = [(stamped(dev, bytes([2]) + bytes(15), 0x8002), HOST)]
257
+ resp = await dev.send_packet(0x6A, b"")
258
+ assert net.endpoints[-1] is not first
259
+ return dev.decrypt(resp[0x38:])[0]
260
+
261
+ assert run(go()) == 2
262
+
263
+
264
+ def test_late_reply_to_cancelled_request_is_not_taken_as_next_reply(net):
265
+ """The endpoint survives a cancelled request, so a slow answer to it can
266
+ arrive while the next request is waiting on the same socket. It must
267
+ not be returned as the answer to that request."""
268
+ dev = fixed_device()
269
+
270
+ async def go():
271
+ await dev._endpoint()
272
+ ep = net.endpoints[-1]
273
+ task = asyncio.get_running_loop().create_task(dev.send_packet(0x6A, b""))
274
+ await asyncio.sleep(0.005) # request 1 (count 0x8001) is on the wire
275
+ task.cancel()
276
+ with pytest.raises(asyncio.CancelledError):
277
+ await task
278
+ assert dev._transport is not None
248
279
  # Its late reply lands while request 2 (count 0x8002) is waiting.
249
280
  late = stamped(dev, bytes([1]) + bytes(15), 0x8001)
250
281
  good = stamped(dev, bytes([2]) + bytes(15), 0x8002)
251
- ep.replies = [late, good] and []
252
282
  ep.protocol.queue.put_nowait((late, HOST))
253
283
 
254
284
  async def answer_later():
@@ -256,7 +286,6 @@ def test_late_reply_to_timed_out_request_is_not_taken_as_next_reply(net):
256
286
  ep.protocol.queue.put_nowait((good, HOST))
257
287
 
258
288
  asyncio.get_running_loop().create_task(answer_later())
259
- dev.timeout = 1
260
289
  resp = await dev.send_packet(0x6A, b"")
261
290
  return dev.decrypt(resp[0x38:])[0]
262
291
 
@@ -424,6 +453,53 @@ def test_host_change_reopens_endpoint(net):
424
453
  assert net.endpoints[0].closed
425
454
 
426
455
 
456
+ def test_send_failure_fails_the_request_fast_and_heals(net):
457
+ """A connected socket whose send fails (route gone, address changed)
458
+ reports it through error_received. The request must fail with that
459
+ OSError at once, not after the timeout, and the next call must get a
460
+ fresh socket rather than the dead one."""
461
+ dev = fixed_device()
462
+ dev.timeout = 5
463
+
464
+ async def go():
465
+ await dev._endpoint()
466
+ bad = net.endpoints[-1]
467
+
468
+ def failing_sendto(data, addr=None):
469
+ bad.protocol.error_received(OSError(101, "Network is unreachable"))
470
+
471
+ bad.sendto = failing_sendto
472
+ t0 = asyncio.get_running_loop().time()
473
+ with pytest.raises(OSError) as err:
474
+ await dev.send_packet(0x6A, b"")
475
+ assert err.value.errno == 101
476
+ assert asyncio.get_running_loop().time() - t0 < 1.0
477
+ assert bad.closed
478
+ assert dev._transport is None
479
+ net.replies = [(make_response(dev, b""), HOST)]
480
+ await dev.send_packet(0x6A, b"")
481
+ return net.endpoints[-1] is not bad
482
+
483
+ assert run(go())
484
+
485
+
486
+ def test_transport_lost_with_error_wakes_the_request(net):
487
+ """If asyncio closes the transport from its side, the waiting request
488
+ is told instead of waiting out its timeout."""
489
+ dev = fixed_device()
490
+ dev.timeout = 5
491
+
492
+ async def go():
493
+ task = asyncio.get_running_loop().create_task(dev.send_packet(0x6A, b""))
494
+ await asyncio.sleep(0.005)
495
+ net.endpoints[-1].protocol.connection_lost(OSError(22, "Invalid argument"))
496
+ with pytest.raises(OSError) as err:
497
+ await task
498
+ return err.value.errno
499
+
500
+ assert run(go()) == 22
501
+
502
+
427
503
  # ------------------------------------------------------------------------- auth
428
504
 
429
505
 
@@ -768,6 +844,64 @@ def test_ping_packet(net):
768
844
  assert net.endpoints[-1].closed
769
845
 
770
846
 
847
+ def test_unresolvable_hostname_raises_at_once(net, monkeypatch):
848
+ """0.19.0 raised socket.gaierror from sendto for a bad hostname. The
849
+ async version resolves the name off the loop first and lets the same
850
+ error through, instead of waiting out the timeout (hello) or sending
851
+ nothing and returning (ping)."""
852
+
853
+ async def no_such_host(host, port):
854
+ raise socket.gaierror(socket.EAI_NONAME, "Name or service not known")
855
+
856
+ monkeypatch.setattr(device_module, "_resolve", no_such_host)
857
+
858
+ async def go():
859
+ loop = asyncio.get_running_loop()
860
+ t0 = loop.time()
861
+ with pytest.raises(socket.gaierror):
862
+ await broadlink.hello("nonexistent.invalid", timeout=5)
863
+ with pytest.raises(socket.gaierror):
864
+ await broadlink.ping("nonexistent.invalid")
865
+ with pytest.raises(socket.gaierror):
866
+ await broadlink.setup("ssid", "pass", 3, ip_address="nonexistent.invalid")
867
+ return loop.time() - t0
868
+
869
+ assert run(go()) < 1.0
870
+ assert net.endpoints == [] # nothing was opened for a name that failed
871
+
872
+
873
+ def test_resolve_returns_a_numeric_address():
874
+ assert run(device_module._resolve("127.0.0.1", 80)) == ("127.0.0.1", 80)
875
+
876
+
877
+ def test_send_failure_on_ping_and_setup_is_raised(net, monkeypatch):
878
+ """ping and setup fire one datagram and do not wait for a reply; a send
879
+ failure still has to reach the caller, as it did from a plain socket."""
880
+ ep_holder = []
881
+ original = net.__call__
882
+
883
+ async def go():
884
+
885
+ async def open_and_break(**kwargs):
886
+ transport, protocol = await original(**kwargs)
887
+
888
+ def failing_sendto(data, addr=None):
889
+ protocol.error_received(OSError(101, "Network is unreachable"))
890
+
891
+ transport.sendto = failing_sendto
892
+ ep_holder.append(transport)
893
+ return transport, protocol
894
+
895
+ monkeypatch.setattr(device_module, "_open_endpoint", open_and_break)
896
+ with pytest.raises(OSError):
897
+ await broadlink.ping("192.0.2.1")
898
+ with pytest.raises(OSError):
899
+ await broadlink.setup("ssid", "pass", 3, ip_address="192.0.2.255")
900
+ return all(ep.closed for ep in ep_holder)
901
+
902
+ assert run(go())
903
+
904
+
771
905
  # ------------------------------------------------------------------ gendevice
772
906
 
773
907