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
45 changes: 45 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,51 @@
All notable changes to this project are recorded here. The format follows
Keep a Changelog; versions follow Semantic Versioning.

## 1.0.3 - 2026-09-06

Fixes from a third review, this one of 1.0.2. No change to the wire
format. One small API change: `pulses` on a captured signal is a tuple.

### Fixed

- When the device answered that the session key had expired and the
re-authentication then failed (for example because the device had been
locked in the app), the call raised `AuthenticationError` from the
re-authentication instead of the error the device gave the request. The
original library never re-authenticated, so a program written against
it, Home Assistant's integration included, handles the request's own
error and never expected the other one. The failed re-authentication is
now logged and the request's original reply is returned, so the caller
sees the same `AuthorizationError` or `ConnectionClosedError` it always
did.
- The authentication generation was read before the request lock was
taken rather than under it, so a request queued behind an `auth()`
could observe a stale generation and skip a re-authentication it needed.
- `aclose()` racing an endpoint that was still being opened could leave
the new socket open and unreferenced. The open now notices the close
and fails with `EndpointClosedError`.
- After a new capture window gives the finalizer its turn, it re-checks
that no other window claimed the device in the meantime.
- `CapturedSignal` and `ParsedPacket` are frozen dataclasses, but they
held a list, so they could not be hashed or put in a set. `pulses` is
now a `tuple[int, ...]`.
- `check_error` unpacks the error code as little-endian explicitly
(`"<h"`), matching the rest of the code, instead of native order.
- The CLI closes the device it opens instead of leaving that to
`asyncio.run`, which warned under `python -X dev`.
- The locks are created in `__init__` rather than lazily in two places.

### Changed

- `send_packet` accepts a `bytearray` payload as well as `bytes`.
- `setup()` sends its provisioning packet through a new
`send_setup_packet()` helper in `broadlink.device` instead of reaching
into a private function.
- README: the re-authentication contract and its worst case (one call can
wait out up to three timeouts), the A2 sensor and the Hysen HY02/HY03
in the device list, and the hello response's `mac` being `bytes` in the
list of differences from 0.19.0.

## 1.0.2 - 2026-09-05

Fixes from a second, adversarial review of 1.0.1 and a re-test of the
Expand Down
20 changes: 16 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@ Every call that reaches a device is a coroutine and must be awaited. That
is the main change from the original library's API: method names and
arguments are the same, and so are return values, with the small
exceptions listed in `CHANGELOG.md` (the IR tick constant, `pulses_to_data`
returning `bytes`, the unused `Device.lock` attribute removed, and
`timeout` parameters typed as floats).
returning `bytes`, the unused `Device.lock` attribute removed, `timeout`
parameters typed as floats, and the `mac` in a hello response typed as
`bytes`).

```python
import asyncio
Expand Down Expand Up @@ -50,11 +51,11 @@ The following devices are supported:
- **Switches**: MCB1, SC1, SCB1E, SCB2
- **Outlets**: BG 800, BG 900
- **Power strips**: MP1-1K3S2U, MP1-1K4S, MP2
- **Environment sensors**: A1
- **Environment sensors**: A1, A2
- **Alarm kits**: S1C, S2KIT
- **Light bulbs**: LB1, LB26 R1, LB27 R1, SB800TD, LEDVANCE SMART+ WIFI CEILING TW 24W
- **Curtain motors**: Dooya DT360E-45/20
- **Thermostats**: Hysen HY02B05H
- **Thermostats**: Hysen HY02/HY03
- **Hubs**: S3

## Timing
Expand Down Expand Up @@ -173,6 +174,17 @@ After discovering the device, call the `auth()` method to obtain the authenticat
await device.auth()
```

The session key expires on the device after a while. When a request comes
back with an expired-key answer, the library authenticates again and
repeats the request once, so a long-running program does not need to
handle that itself. If the second authentication fails, for example
because the device was locked in the app in the meantime, the call raises
the error the device gave the first time, the same `AuthorizationError`
or `ConnectionClosedError` the original library raised, and it is up to
the caller to decide what to do. In the worst case one call can wait out
three timeouts (the request, the authentication, and the repeat), each
bounded by `device.timeout`.

### Closing

Each device keeps one UDP socket open for its lifetime (the original
Expand Down
10 changes: 2 additions & 8 deletions broadlink/__init__.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@
#!/usr/bin/env python3
"""The python-broadlink library."""

import contextlib
from collections.abc import AsyncIterator
from typing import Optional, Union

from . import exceptions as e
from .alarm import S1C
from .climate import hvac, hysen
from .const import DEFAULT_BCAST_ADDR, DEFAULT_PORT, DEFAULT_TIMEOUT
from .cover import dooya, dooya2, wser
from .device import Device, _open_endpoint, ping, scan
from .device import Device, ping, scan, send_setup_packet
from .hub import s3
from .light import lb1, lb2
from .remote import rm, rm4, rm4mini, rm4pro, rm5plus, rmmini, rmminib, rmpro
Expand Down Expand Up @@ -340,8 +338,4 @@ async def setup(
payload[0x20] = checksum & 0xFF # Checksum 1 position
payload[0x21] = checksum >> 8 # Checksum 2 position

transport, _ = await _open_endpoint(broadcast=True)
try:
transport.sendto(payload, (ip_address, DEFAULT_PORT))
finally:
transport.close()
await send_setup_packet(bytes(payload), ip_address)
62 changes: 44 additions & 18 deletions broadlink/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,12 @@ def __init__(self) -> None:
self.queue: asyncio.Queue[tuple[bytes, tuple[str, int]]] = asyncio.Queue()
self.transport: asyncio.DatagramTransport | None = None

def connection_made(self, transport) -> None: # type: ignore[override]
self.transport = transport
def connection_made(self, transport: asyncio.BaseTransport) -> None:
"""Keep the transport; the endpoint sends through it."""
self.transport = transport # type: ignore[assignment]

def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None:
"""Queue every datagram for the request that is waiting."""
self.queue.put_nowait((data, addr))

def error_received(self, exc: Exception) -> None:
Expand All @@ -68,7 +70,7 @@ def error_received(self, exc: Exception) -> None:
pass

def connection_lost(self, exc: Exception | None) -> None:
pass
"""Nothing to do; a waiting request is told through the queue."""

def drain(self) -> None:
"""Drop anything that arrived before the current request."""
Expand Down Expand Up @@ -161,6 +163,17 @@ async def scan(
transport.close()


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)
try:
transport.sendto(payload, (ip_address, port))
finally:
transport.close()


async def ping(ip_address: str, port: int = DEFAULT_PORT) -> None:
"""Send a ping packet to an address.

Expand Down Expand Up @@ -213,12 +226,13 @@ def __init__(
self.aes = None
self.update_aes(bytes.fromhex(self.__INIT_KEY))

self._lock: asyncio.Lock | None = None
self._lock = asyncio.Lock()
self._transport: asyncio.DatagramTransport | None = None
self._protocol: _Protocol | None = None
self._endpoint_addr: tuple[str, int] | None = None
self._recent: collections.deque[int] = collections.deque(maxlen=_RECENT_MAX)
self._reauth_lock: asyncio.Lock | None = None
self._reauth_lock = asyncio.Lock()
self._closes = 0 # Bumped by aclose(); guards an open racing a close.
self._auth_generation = 0

def __repr__(self) -> str:
Expand Down Expand Up @@ -277,9 +291,6 @@ async def auth(self) -> bool:
packet[0x2D] = 0x01
packet[0x30:0x36] = b"Test 1"

if self._lock is None:
self._lock = asyncio.Lock()
self._reauth_lock = asyncio.Lock()
async with self._lock:
self.id = 0
self.update_aes(bytes.fromhex(self.__INIT_KEY))
Expand All @@ -292,7 +303,7 @@ async def auth(self) -> bool:
_LOGGER.debug("%s: authenticated, session id %d", self.host[0], self.id)
return True

async def hello(self, local_ip_address=None) -> bool:
async def hello(self, local_ip_address: str | None = None) -> bool:
"""Send a hello message to the device.

Device information is checked before updating name and lock status.
Expand Down Expand Up @@ -385,6 +396,7 @@ async def aclose(self) -> None:
A request in flight fails at once with ``ConnectionClosedError``
rather than waiting out its timeout.
"""
self._closes += 1
transport, protocol = self._transport, self._protocol
self._transport = None
self._protocol = None
Expand All @@ -401,7 +413,15 @@ async def _endpoint(self) -> tuple[asyncio.DatagramTransport, _Protocol]:
# old address, so drop it.
await self.aclose()
if self._transport is None or self._transport.is_closing():
self._transport, self._protocol = await _open_endpoint(remote_addr=self.host)
closes = self._closes
transport, protocol = await _open_endpoint(remote_addr=self.host)
if self._closes != closes:
# aclose() ran while the socket was being opened.
transport.close()
raise e.EndpointClosedError(
-4013, "Endpoint closed", "The device endpoint was closed"
)
self._transport, self._protocol = transport, protocol
self._endpoint_addr = self.host
_LOGGER.debug("%s: endpoint opened", self.host[0])
return self._transport, self._protocol # type: ignore[return-value]
Expand Down Expand Up @@ -507,27 +527,33 @@ async def _exchange(self, packet: bytes) -> bytes:
f"No response received within {timeout}s",
) from None

async def send_packet(self, packet_type: int, payload: bytes) -> bytes:
async def send_packet(self, packet_type: int, payload: bytes | bytearray) -> bytes:
"""Send a packet to the device and return the raw response frame.

If the device answers that the session key is no longer valid, the
session is re-authenticated once and the request is sent again.
Concurrent callers that hit the same expired key share one
re-authentication and each retry once.
re-authentication and each retry once. If that re-authentication
fails (for example the device has been locked in the app), the
original reply is returned unchanged, so the caller sees the same
error the original library raised and can run its own recovery.
"""
if self._lock is None:
self._lock = asyncio.Lock()
self._reauth_lock = asyncio.Lock()
generation = self._auth_generation
async with self._lock:
generation = self._auth_generation
resp = await self._exchange(self._frame(packet_type, bytes(payload)))

code = int.from_bytes(resp[0x22:0x24], "little", signed=True)
if code in _REAUTH_CODES:
_LOGGER.debug("%s: device answered %d, re-authenticating", self.host[0], code)
async with self._reauth_lock: # type: ignore[union-attr]
async with self._reauth_lock:
if self._auth_generation == generation:
await self.auth()
try:
await self.auth()
except e.BroadlinkException as err:
_LOGGER.debug(
"%s: re-authentication failed: %s", self.host[0], err
)
return resp
async with self._lock:
resp = await self._exchange(self._frame(packet_type, bytes(payload)))
return resp
2 changes: 1 addition & 1 deletion broadlink/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,6 @@ def exception(err_code: int) -> BroadlinkException:

def check_error(error: bytes) -> None:
"""Raise exception if an error occurred."""
error_code = struct.unpack("h", error)[0]
error_code = struct.unpack("<h", error)[0]
if error_code:
raise exception(error_code)
17 changes: 11 additions & 6 deletions broadlink/remote.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import weakref
from collections.abc import AsyncIterator, Awaitable, Callable
from dataclasses import dataclass, field
from typing import Self

from . import exceptions as e
from .device import Device
Expand Down Expand Up @@ -53,10 +54,11 @@ class SignalKind(enum.IntEnum):

@property
def is_rf(self) -> bool:
"""True for the radio bands."""
return self is not SignalKind.IR

@classmethod
def classify(cls, type_byte: int) -> "SignalKind":
def classify(cls, type_byte: int) -> Self:
"""Map a packet's raw first byte to a kind, tolerantly.

The RF learn path returns bytes in the 0xB_ (433 MHz) and 0xD_
Expand Down Expand Up @@ -139,7 +141,7 @@ class ParsedPacket:

kind: SignalKind
repeat: int
pulses: list[int]
pulses: tuple[int, ...]
type_byte: int


Expand All @@ -152,7 +154,7 @@ def parse_packet(data: bytes, tick: float = TICK) -> ParsedPacket:
if len(data) < 4:
raise ValueError("Malformed data.")
kind = SignalKind.classify(data[0x00])
return ParsedPacket(kind, data[0x01], data_to_pulses(data, tick), data[0x00])
return ParsedPacket(kind, data[0x01], tuple(data_to_pulses(data, tick)), data[0x00])


@dataclass(frozen=True)
Expand All @@ -170,7 +172,7 @@ class CapturedSignal:

packet: bytes
kind: SignalKind
pulses: list[int] = field(repr=False)
pulses: tuple[int, ...] = field(repr=False)
repeat: int = 0
frequency_mhz: float | None = None
type_byte: int | None = None
Expand All @@ -183,7 +185,7 @@ def from_packet(
frequency_mhz: float | None = None,
*,
kind: SignalKind | None = None,
) -> "CapturedSignal":
) -> Self:
"""Build a signal from a device-returned packet.

``kind`` overrides the band read from the packet's type byte. A
Expand All @@ -200,7 +202,7 @@ def from_packet(
return cls(
bytes(packet),
kind,
data_to_pulses(packet),
tuple(data_to_pulses(packet)),
packet[0x01],
frequency_mhz,
type_byte,
Expand Down Expand Up @@ -259,6 +261,9 @@ async def _claim_window(self, new: weakref.ReferenceType) -> None:
raise e.CaptureInProgressError(
"A capture window is already open; close it with aclose() first"
)
if self._window is not prev:
# Another claimant got in during the two turns above.
raise e.CaptureInProgressError("A capture window is already open")
self._window = new

async def _send(self, command: int, data: bytes = b"") -> bytes:
Expand Down
9 changes: 8 additions & 1 deletion cli/broadlink_cli
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import asyncio
import base64
import sys
import time
from contextlib import aclosing
from contextlib import AsyncExitStack, aclosing
from typing import List

import broadlink
Expand Down Expand Up @@ -96,6 +96,12 @@ args = parser.parse_args()


async def main():
async with AsyncExitStack() as stack:
await run_commands(stack)


async def run_commands(stack: AsyncExitStack):
"""Run the requested commands; the device is closed with the stack."""
dev = None

if args.device:
Expand All @@ -110,6 +116,7 @@ async def main():

if args.host or args.device:
dev = broadlink.gendevice(devtype, (host, DEFAULT_PORT), mac)
await stack.enter_async_context(dev)
await dev.auth()

if args.joinwifi:
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "python-broadlink"
version = "1.0.2"
version = "1.0.3"
description = "Python API for controlling Broadlink devices"
readme = "README.md"
license = "MIT"
Expand Down
Loading
Loading