Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,59 @@
All notable changes to this project are recorded here. The format follows
Keep a Changelog; versions follow Semantic Versioning.

## 1.0.4 - 2026-09-06

Fixes from a fourth review, of 1.0.3, which drove the real socket path the
test suite fakes and found two behaviours the original library had and
this one had lost. One device fix carried from upstream.

### Fixed

- A connected socket that went bad (interface bounce, host address change,
container network restart) was never replaced: the request waited out
its timeout and every later request did the same until `aclose()`. The
original opened a socket per call, so it healed on the next one. Now a
send failure the socket reports (no route, address gone) fails the
waiting request at once with that `OSError`, and a request that fails
for a network reason (that, or a timeout) drops the socket so the next
call opens a fresh one. A transport asyncio closes from its side also
wakes the waiting request instead of leaving it to time out. An ICMP
"port unreachable" (a host that is up with nothing listening, or a
device mid-reboot) is logged and treated as silence, since the
original's unconnected socket never saw those, so the timeout decides
as before.
- `discover()`, `hello()`, `ping()` and `setup()` passed hostnames straight
to `sendto`, which resolved them with a blocking call on the event loop
and swallowed the failure: a name that did not resolve made `hello()`
wait out its timeout and `ping()` return without sending. The
destination is now resolved once, off the loop, and `socket.gaierror`
propagates as it did from the original's socket. A send failure in
`ping()` and `setup()` is raised too.
- The A2 air quality sensor's request frame was two bytes short and
declared the wrong length, and real units answered every read with
error -5. The frame now follows the SP4/LB1 layout, which is byte for
byte the packet upstream pull request #826 tested on an A2. That is the
one oracle case re-recorded on purpose; the fix is carried on the
strength of that report, not of hardware we have.
- `xdiscover()` closes the `scan()` generator it wraps, so the discovery
socket is closed when the caller stops iterating rather than by the
finalizer a few turns later (1.0.2 claimed this and only `Device.hello()`
did it).
- Two identical captures compare equal: `CapturedSignal.captured_at` no
longer takes part in equality or hashing.
- Async generator functions are annotated `AsyncGenerator`, which has the
`aclose()` the library and the README call; `AsyncIterator` does not.
- The `TICK` docstring tells the same story as the README: 8192/269 from
protocol.md's measured conversion, not a 32768 Hz clock.

### Added

- A loopback test module that drives the real datagram endpoint, including
the socket-error path, since every other transport test fakes it.
- README: which errors `discover()` and `hello()` raise, that a failed
request drops its socket, and that `CaptureInProgressError` can come
from the `capture()` call or from the first iteration.

## 1.0.3 - 2026-09-06

Fixes from a third review, this one of 1.0.2. No change to the wire
Expand Down
20 changes: 16 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,11 @@ If the device is locked, it may not be discoverable with broadcast. In such case
device = await broadlink.hello("192.168.0.16")
```

`discover()` and `hello()` raise `NetworkTimeoutError` when nothing answers
within the timeout, `socket.gaierror` when a hostname does not resolve, and
`OSError` when the socket cannot be opened or the send fails (no route, for
example), the same errors the original library raised from its socket.

If you are a perfomance freak, use `broadlink.xdiscover()` to create devices instantly:
```python3
async for device in broadlink.xdiscover():
Expand Down Expand Up @@ -202,9 +207,14 @@ await device.aclose()

The socket reopens by itself on the next call, so closing is cheap and
safe to do at any time. A request that is in flight when `aclose()` runs
fails with `EndpointClosedError`. An integration that creates devices
should close them when it unloads; a device that is never closed holds
its socket until it is garbage collected.
fails with `EndpointClosedError`. A request that fails for a network
reason (a timeout, or an `OSError` from the socket such as "network is
unreachable" after an interface change, raised at once) also drops the socket, so the
next call starts fresh rather than reusing one that has gone bad, which
is how the original library behaved by opening a socket per call. An
integration that creates devices should close them when it unloads; a
device that is never closed holds its socket until it is garbage
collected.

The next steps depend on the type of device you want to control.

Expand Down Expand Up @@ -280,7 +290,9 @@ By default the window closes after the first signal. Pass
because the device holds only one code per learning session. A universal
remote has a single receiver, so only one capture window can be open on a
device at a time: opening a second one raises `CaptureInProgressError`
while the first is still held. Always close a window you leave early
while the first is still held, either from the `capture()` call itself or
from the new window's first iteration, depending on what the first window
was doing at that moment. Always close a window you leave early
(`aclosing` above does it), otherwise it stays open until Python collects
the generator.

Expand Down
12 changes: 6 additions & 6 deletions broadlink/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""The python-broadlink library."""

import contextlib
from collections.abc import AsyncIterator
from collections.abc import AsyncGenerator

from . import exceptions as e
from .alarm import S1C
Expand Down Expand Up @@ -294,15 +294,15 @@ async def xdiscover(
local_ip_address: str | None = None,
discover_ip_address: str = DEFAULT_BCAST_ADDR,
discover_ip_port: int = DEFAULT_PORT,
) -> AsyncIterator[Device]:
) -> AsyncGenerator[Device]:
"""Discover devices connected to the local network.

Yields each device as soon as it answers.
"""
async for resp in scan(
timeout, local_ip_address, discover_ip_address, discover_ip_port
):
yield gendevice(*resp)
responses = scan(timeout, local_ip_address, discover_ip_address, discover_ip_port)
async with contextlib.aclosing(responses):
async for resp in responses:
yield gendevice(*resp)


# Setup a new Broadlink device via AP Mode. Review the README to see how to enter AP Mode.
Expand Down
102 changes: 88 additions & 14 deletions broadlink/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
import logging
import random
import socket
from collections.abc import AsyncIterator
from collections.abc import AsyncGenerator

from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
Expand Down Expand Up @@ -48,12 +48,20 @@
_CLOSED = (None, None)
"""Sentinel put on the receive queue when the endpoint is closed."""

_QueueItem = tuple[bytes | Exception | None, tuple[str, int] | None]
"""What the receive queue carries: a datagram with its source address, an
error the socket reported (address ``None``), or ``_CLOSED``."""


class _Protocol(asyncio.DatagramProtocol):
"""Datagram protocol that hands every received packet to a queue."""
"""Datagram protocol that hands every received packet to a queue.

Errors the socket reports go on the same queue, so the request that is
waiting fails at once instead of waiting out its timeout.
"""

def __init__(self) -> None:
self.queue: asyncio.Queue[tuple[bytes, tuple[str, int]]] = asyncio.Queue()
self.queue: asyncio.Queue[_QueueItem] = asyncio.Queue()
self.transport: asyncio.DatagramTransport | None = None

def connection_made(self, transport: asyncio.BaseTransport) -> None:
Expand All @@ -65,18 +73,30 @@ def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None:
self.queue.put_nowait((data, addr))

def error_received(self, exc: Exception) -> None:
# ICMP unreachable and the like. Surface it as a receive of nothing;
# the retry loop will time out and raise NetworkTimeoutError.
pass
"""Queue a send failure or an ICMP error for the waiting request."""
self.queue.put_nowait((exc, None))

def connection_lost(self, exc: Exception | None) -> None:
"""Nothing to do; a waiting request is told through the queue."""
"""Wake the waiting request if asyncio closed the transport on us."""
self.queue.put_nowait((exc, None) if exc is not None else _CLOSED)

def drain(self) -> None:
"""Drop anything that arrived before the current request."""
while not self.queue.empty():
self.queue.get_nowait()

def raise_if_error(self) -> None:
"""Raise the error a send just reported, if it reported one.

asyncio delivers a failed ``sendto`` to ``error_received`` before
``sendto`` returns, so a fire-and-forget sender can check right
after sending and raise the ``OSError`` the way a plain socket did.
"""
while not self.queue.empty():
item, _ = self.queue.get_nowait()
if isinstance(item, Exception):
raise item


async def _open_endpoint(
local_addr: tuple[str, int] | None = None,
Expand All @@ -95,6 +115,21 @@ async def _open_endpoint(
return transport, protocol # type: ignore[return-value]


async def _resolve(host: str, port: int) -> tuple[str, int]:
"""Resolve a destination once, off the event loop.

Sending to a hostname through an unconnected datagram socket would
resolve it with a blocking call on the loop and hide the failure. A
name that does not resolve raises ``socket.gaierror`` here, as the
original library's ``sendto`` did.
"""
loop = asyncio.get_running_loop()
info = await loop.getaddrinfo(
host, port, family=socket.AF_INET, type=socket.SOCK_DGRAM
)
return info[0][4][:2] # type: ignore[return-value]


def _hello_packet(local_ip_address: str, port: int) -> bytearray:
packet = bytearray(0x30)
packet[0x08:0x14] = Datetime.pack(Datetime.now())
Expand All @@ -119,13 +154,14 @@ async def scan(
local_ip_address: str | None = None,
discover_ip_address: str = DEFAULT_BCAST_ADDR,
discover_ip_port: int = DEFAULT_PORT,
) -> AsyncIterator[HelloResponse]:
) -> AsyncGenerator[HelloResponse]:
"""Broadcast a hello message and yield responses as they arrive.

The hello is repeated every ``DEFAULT_RETRY_INTVL`` seconds until
``timeout`` elapses. Each device is yielded once.
"""
local_addr = (local_ip_address, 0) if local_ip_address else None
target = await _resolve(discover_ip_address, discover_ip_port)
transport, protocol = await _open_endpoint(local_addr=local_addr, broadcast=True)
try:
if local_ip_address:
Expand All @@ -140,7 +176,7 @@ async def scan(
discovered: set[tuple[tuple[str, int], bytes, int]] = set()

while (loop.time() - start) < timeout:
transport.sendto(packet, (discover_ip_address, discover_ip_port))
transport.sendto(packet, target)
deadline = min(DEFAULT_RETRY_INTVL, timeout - (loop.time() - start))
slot_end = loop.time() + deadline
while True:
Expand All @@ -151,7 +187,11 @@ async def scan(
resp, host = await asyncio.wait_for(protocol.queue.get(), remaining)
except TimeoutError:
break
if len(resp) < 0x80:
if resp is None:
return # The transport was closed under us.
if isinstance(resp, Exception):
raise resp
if host is None or len(resp) < 0x80:
continue
entry = _parse_hello(resp, host)
key = (entry[1], entry[2], entry[0])
Expand All @@ -167,9 +207,11 @@ async def send_setup_packet(
payload: bytes, ip_address: str, port: int = DEFAULT_PORT
) -> None:
"""Broadcast one Wi-Fi provisioning packet to a device in AP mode."""
transport, _ = await _open_endpoint(broadcast=True)
target = await _resolve(ip_address, port)
transport, protocol = await _open_endpoint(broadcast=True)
try:
transport.sendto(payload, (ip_address, port))
transport.sendto(payload, target)
protocol.raise_if_error()
finally:
transport.close()

Expand All @@ -181,11 +223,13 @@ async def ping(ip_address: str, port: int = DEFAULT_PORT) -> None:
Useful to prevent reboots when the cloud cannot be reached.
It must be sent every 2 minutes in such cases.
"""
transport, _ = await _open_endpoint(broadcast=True)
target = await _resolve(ip_address, port)
transport, protocol = await _open_endpoint(broadcast=True)
try:
packet = bytearray(0x30)
packet[0x26] = 1
transport.sendto(packet, (ip_address, port))
transport.sendto(packet, target)
protocol.raise_if_error()
finally:
transport.close()

Expand Down Expand Up @@ -407,6 +451,22 @@ async def aclose(self) -> None:
if protocol is not None:
protocol.queue.put_nowait(_CLOSED) # type: ignore[arg-type]

def _drop_endpoint(self) -> None:
"""Throw the endpoint away after a failure; the next call reopens it.

A connected datagram socket can go bad for good (the interface
bounced, the host's address changed), and the original library
never noticed because it opened a socket per call. Dropping the
endpoint whenever a request fails restores that self-healing.
"""
transport = self._transport
self._transport = None
self._protocol = None
self._endpoint_addr = None
if transport is not None:
transport.close()
_LOGGER.debug("%s: endpoint dropped after a failure", self.host[0])

async def _endpoint(self) -> tuple[asyncio.DatagramTransport, _Protocol]:
if self._transport is not None and self._endpoint_addr != self.host:
# The caller changed host; the connected socket points at the
Expand Down Expand Up @@ -510,6 +570,19 @@ async def _exchange(self, packet: bytes) -> bytes:
raise e.EndpointClosedError(
-4013, "Endpoint closed", "The device endpoint was closed"
)
if isinstance(resp, ConnectionRefusedError):
# ICMP port unreachable: the host is up and nothing is
# listening, or the device is rebooting. The original
# library's unconnected socket never saw these, so keep
# waiting and let the timeout decide, as it did.
_LOGGER.debug("%s: port unreachable, still waiting", self.host[0])
continue
if isinstance(resp, Exception):
# A send failure (no route, address gone) or a fatal
# transport error: fail now and throw the socket away.
_LOGGER.debug("%s: socket error: %s", self.host[0], resp)
self._drop_endpoint()
raise resp
resp = self._validate(resp)
reply_count = int.from_bytes(resp[0x28:0x2A], "little")
if reply_count == count or reply_count not in self._recent:
Expand All @@ -521,6 +594,7 @@ async def _exchange(self, packet: bytes) -> bytes:
)
if loop.time() - start >= timeout:
_LOGGER.debug("%s: no reply within %ss", self.host[0], timeout)
self._drop_endpoint()
raise e.NetworkTimeoutError(
-4000,
"Network timeout",
Expand Down
Loading
Loading