python-broadlink 1.0.3__tar.gz → 1.0.5__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.5}/CHANGELOG.md +89 -0
  2. {python_broadlink-1.0.3/python_broadlink.egg-info → python_broadlink-1.0.5}/PKG-INFO +21 -5
  3. {python_broadlink-1.0.3 → python_broadlink-1.0.5}/README.md +20 -4
  4. {python_broadlink-1.0.3 → python_broadlink-1.0.5}/broadlink/__init__.py +12 -12
  5. {python_broadlink-1.0.3 → python_broadlink-1.0.5}/broadlink/device.py +108 -14
  6. {python_broadlink-1.0.3 → python_broadlink-1.0.5}/broadlink/remote.py +89 -77
  7. {python_broadlink-1.0.3 → python_broadlink-1.0.5}/broadlink/sensor.py +27 -27
  8. {python_broadlink-1.0.3 → python_broadlink-1.0.5}/pyproject.toml +1 -1
  9. {python_broadlink-1.0.3 → python_broadlink-1.0.5/python_broadlink.egg-info}/PKG-INFO +21 -5
  10. {python_broadlink-1.0.3 → python_broadlink-1.0.5}/python_broadlink.egg-info/SOURCES.txt +1 -0
  11. {python_broadlink-1.0.3 → python_broadlink-1.0.5}/tests/oracle/fixtures.json +1 -1
  12. {python_broadlink-1.0.3 → python_broadlink-1.0.5}/tests/oracle/harness.py +7 -0
  13. python_broadlink-1.0.5/tests/test_loopback.py +110 -0
  14. {python_broadlink-1.0.3 → python_broadlink-1.0.5}/tests/test_transport.py +176 -8
  15. {python_broadlink-1.0.3 → python_broadlink-1.0.5}/LICENSE +0 -0
  16. {python_broadlink-1.0.3 → python_broadlink-1.0.5}/MANIFEST.in +0 -0
  17. {python_broadlink-1.0.3 → python_broadlink-1.0.5}/TROUBLESHOOTING.md +0 -0
  18. {python_broadlink-1.0.3 → python_broadlink-1.0.5}/broadlink/alarm.py +0 -0
  19. {python_broadlink-1.0.3 → python_broadlink-1.0.5}/broadlink/climate.py +0 -0
  20. {python_broadlink-1.0.3 → python_broadlink-1.0.5}/broadlink/const.py +0 -0
  21. {python_broadlink-1.0.3 → python_broadlink-1.0.5}/broadlink/cover.py +0 -0
  22. {python_broadlink-1.0.3 → python_broadlink-1.0.5}/broadlink/exceptions.py +0 -0
  23. {python_broadlink-1.0.3 → python_broadlink-1.0.5}/broadlink/helpers.py +0 -0
  24. {python_broadlink-1.0.3 → python_broadlink-1.0.5}/broadlink/hub.py +0 -0
  25. {python_broadlink-1.0.3 → python_broadlink-1.0.5}/broadlink/light.py +0 -0
  26. {python_broadlink-1.0.3 → python_broadlink-1.0.5}/broadlink/protocol.py +0 -0
  27. {python_broadlink-1.0.3 → python_broadlink-1.0.5}/broadlink/switch.py +0 -0
  28. {python_broadlink-1.0.3 → python_broadlink-1.0.5}/cli/README.md +0 -0
  29. {python_broadlink-1.0.3 → python_broadlink-1.0.5}/cli/broadlink_cli +0 -0
  30. {python_broadlink-1.0.3 → python_broadlink-1.0.5}/cli/broadlink_discovery +0 -0
  31. {python_broadlink-1.0.3 → python_broadlink-1.0.5}/protocol.md +0 -0
  32. {python_broadlink-1.0.3 → python_broadlink-1.0.5}/python_broadlink.egg-info/dependency_links.txt +0 -0
  33. {python_broadlink-1.0.3 → python_broadlink-1.0.5}/python_broadlink.egg-info/requires.txt +0 -0
  34. {python_broadlink-1.0.3 → python_broadlink-1.0.5}/python_broadlink.egg-info/top_level.txt +0 -0
  35. {python_broadlink-1.0.3 → python_broadlink-1.0.5}/setup.cfg +0 -0
  36. {python_broadlink-1.0.3 → python_broadlink-1.0.5}/tests/__init__.py +0 -0
  37. {python_broadlink-1.0.3 → python_broadlink-1.0.5}/tests/oracle/__init__.py +0 -0
  38. {python_broadlink-1.0.3 → python_broadlink-1.0.5}/tests/oracle/cases.py +0 -0
  39. {python_broadlink-1.0.3 → python_broadlink-1.0.5}/tests/oracle/record.py +0 -0
  40. {python_broadlink-1.0.3 → python_broadlink-1.0.5}/tests/test_capture.py +0 -0
  41. {python_broadlink-1.0.3 → python_broadlink-1.0.5}/tests/test_helpers.py +0 -0
  42. {python_broadlink-1.0.3 → python_broadlink-1.0.5}/tests/test_oracle.py +0 -0
  43. {python_broadlink-1.0.3 → python_broadlink-1.0.5}/tests/test_remote.py +0 -0
@@ -3,6 +3,95 @@
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.5 - 2026-09-06
7
+
8
+ Fixes from a fifth review, of 1.0.4. No change to the wire format or the
9
+ public API.
10
+
11
+ ### Fixed
12
+
13
+ - A connected socket can learn from ICMP that a host cannot be reached
14
+ (`EHOSTUNREACH`, typically a router answering for a device that is off),
15
+ and 1.0.4 raised that as an `OSError` at once. The original library's
16
+ unconnected socket never saw it and simply timed out, and Home
17
+ Assistant tolerates a timeout for a few polls where it marks a device
18
+ unavailable on the first `OSError`. Host unreachable, and Windows's
19
+ `ConnectionResetError` for port unreachable, are now treated as silence
20
+ like port unreachable already was: logged, the timeout decides, and the
21
+ socket is still dropped afterwards. Measured on the bench first: on the
22
+ test network neither an on-link address with no host behind it nor an
23
+ off-subnet one produced the ICMP, so this is insurance for networks
24
+ that do, not a fix for one that reproduced.
25
+ - `discover()` closes the `xdiscover()` generator it drains, like
26
+ `hello()` and `xdiscover()` itself.
27
+
28
+ ### Changed
29
+
30
+ - The capture window claim is a flag set on the window's first iteration
31
+ and cleared when its generator finishes or is closed, including by
32
+ asyncio's finalizer, in place of the weak reference and frame
33
+ inspection used since 1.0.0. Same behaviour, pinned by the same tests:
34
+ a dropped window gets one turn to be finalized and then blocks nobody,
35
+ a held or paused window is refused to a newcomer. One visible
36
+ difference: `CaptureInProgressError` now always comes from the new
37
+ window's first iteration, never from the `capture()` call itself.
38
+ - A comment next to the RM Max entry says why it sits in `rmpro`
39
+ (upstream #838's text says rm4pro, its tested diff says rmpro).
40
+ - README: a device belongs to the event loop it first talks on.
41
+
42
+ ## 1.0.4 - 2026-09-06
43
+
44
+ Fixes from a fourth review, of 1.0.3, which drove the real socket path the
45
+ test suite fakes and found two behaviours the original library had and
46
+ this one had lost. One device fix carried from upstream.
47
+
48
+ ### Fixed
49
+
50
+ - A connected socket that went bad (interface bounce, host address change,
51
+ container network restart) was never replaced: the request waited out
52
+ its timeout and every later request did the same until `aclose()`. The
53
+ original opened a socket per call, so it healed on the next one. Now a
54
+ send failure the socket reports (no route, address gone) fails the
55
+ waiting request at once with that `OSError`, and a request that fails
56
+ for a network reason (that, or a timeout) drops the socket so the next
57
+ call opens a fresh one. A transport asyncio closes from its side also
58
+ wakes the waiting request instead of leaving it to time out. An ICMP
59
+ "port unreachable" (a host that is up with nothing listening, or a
60
+ device mid-reboot) is logged and treated as silence, since the
61
+ original's unconnected socket never saw those, so the timeout decides
62
+ as before.
63
+ - `discover()`, `hello()`, `ping()` and `setup()` passed hostnames straight
64
+ to `sendto`, which resolved them with a blocking call on the event loop
65
+ and swallowed the failure: a name that did not resolve made `hello()`
66
+ wait out its timeout and `ping()` return without sending. The
67
+ destination is now resolved once, off the loop, and `socket.gaierror`
68
+ propagates as it did from the original's socket. A send failure in
69
+ `ping()` and `setup()` is raised too.
70
+ - The A2 air quality sensor's request frame was two bytes short and
71
+ declared the wrong length, and real units answered every read with
72
+ error -5. The frame now follows the SP4/LB1 layout, which is byte for
73
+ byte the packet upstream pull request #826 tested on an A2. That is the
74
+ one oracle case re-recorded on purpose; the fix is carried on the
75
+ strength of that report, not of hardware we have.
76
+ - `xdiscover()` closes the `scan()` generator it wraps, so the discovery
77
+ socket is closed when the caller stops iterating rather than by the
78
+ finalizer a few turns later (1.0.2 claimed this and only `Device.hello()`
79
+ did it).
80
+ - Two identical captures compare equal: `CapturedSignal.captured_at` no
81
+ longer takes part in equality or hashing.
82
+ - Async generator functions are annotated `AsyncGenerator`, which has the
83
+ `aclose()` the library and the README call; `AsyncIterator` does not.
84
+ - The `TICK` docstring tells the same story as the README: 8192/269 from
85
+ protocol.md's measured conversion, not a 32768 Hz clock.
86
+
87
+ ### Added
88
+
89
+ - A loopback test module that drives the real datagram endpoint, including
90
+ the socket-error path, since every other transport test fakes it.
91
+ - README: which errors `discover()` and `hello()` raise, that a failed
92
+ request drops its socket, and that `CaptureInProgressError` can come
93
+ from the `capture()` call or from the first iteration.
94
+
6
95
  ## 1.0.3 - 2026-09-06
7
96
 
8
97
  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.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
 
@@ -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():
@@ -200,11 +205,21 @@ async with device:
200
205
  await device.aclose()
201
206
  ```
202
207
 
208
+ A device belongs to the event loop it first talks on: its socket and its
209
+ locks are bound to that loop, so a script that runs several
210
+ `asyncio.run(...)` calls should create the device inside each one rather
211
+ than reuse it across them.
212
+
203
213
  The socket reopens by itself on the next call, so closing is cheap and
204
214
  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.
215
+ fails with `EndpointClosedError`. A request that fails for a network
216
+ reason (a timeout, or an `OSError` from the socket such as "network is
217
+ unreachable" after an interface change, raised at once) also drops the socket, so the
218
+ next call starts fresh rather than reusing one that has gone bad, which
219
+ is how the original library behaved by opening a socket per call. An
220
+ integration that creates devices should close them when it unloads; a
221
+ device that is never closed holds its socket until it is garbage
222
+ collected.
208
223
 
209
224
  The next steps depend on the type of device you want to control.
210
225
 
@@ -280,7 +295,8 @@ By default the window closes after the first signal. Pass
280
295
  because the device holds only one code per learning session. A universal
281
296
  remote has a single receiver, so only one capture window can be open on a
282
297
  device at a time: opening a second one raises `CaptureInProgressError`
283
- while the first is still held. Always close a window you leave early
298
+ from the new window's first iteration while the first is still held.
299
+ Always close a window you leave early
284
300
  (`aclosing` above does it), otherwise it stays open until Python collects
285
301
  the generator.
286
302
 
@@ -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.
@@ -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",