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
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ jobs:
pip install -e ".[dev]"
- name: Lint
run: ruff check .
- name: Format check
run: ruff format --check .
- name: Test
run: pytest

Expand Down
52 changes: 52 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,58 @@
All notable changes to this project are recorded here. The format follows
Keep a Changelog; versions follow Semantic Versioning.

## 1.0.2 - 2026-09-05

Fixes from a second, adversarial review of 1.0.1 and a re-test of the
first review's findings. No change to the wire format or the public API.

### Fixed

- 1.0.1's reply matching dropped a late reply to a request that had
timed out, but not the second reply to a request that was resent after a
silent second and then answered twice. That duplicate carries the counter
of a request that succeeded, and it could still be taken as the answer
to the next request. The library now remembers every recently used
counter and drops any reply carrying one other than the current
request's. A reply whose counter the device has not used recently is
still accepted, for firmware that may not echo it.
- `auth()` reset the session id and key before taking the request lock, so
a request already queued behind the lock could be framed with device id
0 and the initial key. The reset, the exchange and the install of the
new key now happen as one unit under the lock.
- 1.0.1 let a new capture window close one that a consumer had abandoned,
using "is the generator running right now" as the test. That cannot
tell an abandoned window from one whose consumer is awaiting something
between signals, which the README's own example does. A new window now
gives asyncio's finalizer one turn to close a genuinely dropped
generator and then refuses if the old window is still alive, rather
than taking it. A refused attempt no longer displaces the live window.
- A packet the device returned that cannot be decoded (a declared length
running into a truncated escape) no longer ends the capture window; it
is logged and the window re-arms.
- `aclose()` during a request now raises `EndpointClosedError`, a subclass
of `ConnectionClosedError` with code -4013 in the error table, so a
caller that closed the device on purpose can tell that apart from the
device's own "logged out" answer.
- `hello()` closes the discovery generator it breaks out of instead of
leaving the socket to the finalizer; `asyncio.TimeoutError` is spelled
`TimeoutError`; an unused future on the protocol object is gone.

### Added

- Debug logging on the `broadlink.device` and `broadlink.remote` loggers:
endpoint open and close, resends, dropped late replies, timeouts,
re-authentication, capture arm and re-arm, captured packets.
- README: a "Closing" section on the persistent socket, a "Timing" section
with the bench measurement of the tick fix (5.4 percent short before,
0.6 percent short after, on an RM4 Pro against an independent
receiver), a note that Python 3.13 is a support decision, and the short
list of return-value differences from 0.19.0.

### Changed

- The code is formatted with `ruff format` and CI checks it.

## 1.0.1 - 2026-09-05

Fixes from an independent review of 1.0.0, most of them in the transport.
Expand Down
75 changes: 64 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,25 @@ A Python module and CLI for controlling Broadlink devices locally.

## Version 1.0 is asynchronous

Every call that reaches a device is a coroutine and must be awaited. This
is the whole change from the original library's API; method names,
arguments and return values are the same.
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).

```python
import asyncio
import broadlink


async def main():
devices = await broadlink.discover(timeout=5)
device = devices[0]
await device.auth()
print(await device.check_sensors())


asyncio.run(main())
```

Expand All @@ -52,8 +57,32 @@ The following devices are supported:
- **Thermostats**: Hysen HY02B05H
- **Hubs**: S3

## Timing

The original library converted microseconds to the device's timing units
with the constant 32.84, which is the right ratio applied the wrong way
round, and it shortened every IR code built from microsecond timings by
about 7 percent. Codes learned from a remote and replayed through the same
device were never affected, which is why it went unnoticed for years.
Version 1.0 uses 8192/269 (about 30.45 us per unit), the value implied by
`protocol.md`, and rounds to the nearest unit instead of truncating.

Measured on an RM4 Pro against an independent receiver, the same NEC frame
packed with the old constant arrived 5.4 percent short of its intended
length; packed with the corrected constant it arrived 0.6 percent short,
twice, thirteen hours apart, within 22 us of itself. Packets learned by
the device and replayed by name are unchanged. Anything that stores
microsecond timings produced by the old `data_to_pulses` (which reported
them about 7.8 percent long) and re-encodes them with the new
`pulses_to_data` will lengthen by that amount; store the device packet
instead, as `CapturedSignal.packet` does.

## Installation

Python 3.13 or newer. That is a support decision rather than a technical
one: the code runs on 3.11, but the versions tested in CI are 3.13 and
3.14 and those are the ones Home Assistant ships.

Use pip3 to install the latest version of this module.

```
Expand Down Expand Up @@ -94,7 +123,7 @@ In order to control the device, you need to connect it to your local network. If
- Manually connect to the WiFi SSID named BroadlinkProv.
2. Connect the device to your local network with the setup function.
```python3
await broadlink.setup('myssid', 'mynetworkpass', 3)
await broadlink.setup("myssid", "mynetworkpass", 3)
```

Security mode options are (0 = none, 1 = WEP, 2 = WPA1, 3 = WPA2, 4 = WPA1/2)
Expand All @@ -103,7 +132,7 @@ Security mode options are (0 = none, 1 = WEP, 2 = WPA1, 3 = WPA2, 4 = WPA1/2)

You may need to specify a broadcast address if setup is not working.
```python3
await broadlink.setup('myssid', 'mynetworkpass', 3, ip_address='192.168.0.255')
await broadlink.setup("myssid", "mynetworkpass", 3, ip_address="192.168.0.255")
```

### Discovery
Expand All @@ -119,17 +148,17 @@ You may need to specify `local_ip_address` or `discover_ip_address` if discovery

Using the IP address of your local machine:
```python3
devices = await broadlink.discover(local_ip_address='192.168.0.100')
devices = await broadlink.discover(local_ip_address="192.168.0.100")
```

Using the broadcast address of your subnet:
```python3
devices = await broadlink.discover(discover_ip_address='192.168.0.255')
devices = await broadlink.discover(discover_ip_address="192.168.0.255")
```

If the device is locked, it may not be discoverable with broadcast. In such cases, you can use the unicast version `broadlink.hello()` for direct discovery:
```python3
device = await broadlink.hello('192.168.0.16')
device = await broadlink.hello("192.168.0.16")
```

If you are a perfomance freak, use `broadlink.xdiscover()` to create devices instantly:
Expand All @@ -144,6 +173,27 @@ After discovering the device, call the `auth()` method to obtain the authenticat
await device.auth()
```

### Closing

Each device keeps one UDP socket open for its lifetime (the original
library opened a new one for every call). Close it when you are done with
the device, either with the context manager or explicitly:

```python3
async with device:
await device.auth()
print(await device.check_sensors())

# or
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.

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

## Universal remotes
Expand Down Expand Up @@ -175,7 +225,7 @@ await device.sweep_frequency()
```python3
ok, frequency = await device.check_frequency()
if ok:
print(f'Frequency found: {frequency} MHz')
print(f"Frequency found: {frequency} MHz")
```
4. Enter learning mode:
```python3
Expand Down Expand Up @@ -217,10 +267,13 @@ By default the window closes after the first signal. Pass
`window=0` runs until the generator is closed), re-arming after each signal
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.
device at a time: opening a second one raises `CaptureInProgressError`
while the first is still held. Always close a window you leave early
(`aclosing` above does it), otherwise it stays open until Python collects
the generator.

`CapturedSignal` carries the device's own `packet` bytes (ready for
`send_data`), the decoded `pulses` in microseconds at the correct tick, the
`send_data`), the decoded `pulses` in microseconds at the corrected tick, the
`kind` (`SignalKind.IR`, `RF_433` or `RF_315`), the `repeat` count, and for
RF the `frequency_mhz` the packet itself does not record.

Expand Down
29 changes: 17 additions & 12 deletions broadlink/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
#!/usr/bin/env python3
"""The python-broadlink library."""

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

from . import exceptions as e
from .alarm import S1C
Expand Down Expand Up @@ -223,8 +225,8 @@

def gendevice(
dev_type: int,
host: Tuple[str, int],
mac: Union[bytes, str],
host: tuple[str, int],
mac: bytes | str,
name: str = "",
is_locked: bool = False,
) -> Device:
Expand Down Expand Up @@ -258,12 +260,15 @@ async def hello(

Useful if the device is locked.
"""
async for device in xdiscover(
timeout=timeout,
discover_ip_address=ip_address,
discover_ip_port=port,
):
return device
async with contextlib.aclosing(
xdiscover(
timeout=timeout,
discover_ip_address=ip_address,
discover_ip_port=port,
)
) as devices:
async for device in devices:
return device
raise e.NetworkTimeoutError(
-4000,
"Network timeout",
Expand All @@ -273,10 +278,10 @@ async def hello(

async def discover(
timeout: float = DEFAULT_TIMEOUT,
local_ip_address: Optional[str] = None,
local_ip_address: str | None = None,
discover_ip_address: str = DEFAULT_BCAST_ADDR,
discover_ip_port: int = DEFAULT_PORT,
) -> List[Device]:
) -> list[Device]:
"""Discover devices connected to the local network."""
return [
device
Expand All @@ -288,7 +293,7 @@ async def discover(

async def xdiscover(
timeout: float = DEFAULT_TIMEOUT,
local_ip_address: Optional[str] = None,
local_ip_address: str | None = None,
discover_ip_address: str = DEFAULT_BCAST_ADDR,
discover_ip_port: int = DEFAULT_PORT,
) -> AsyncIterator[Device]:
Expand Down
1 change: 1 addition & 0 deletions broadlink/alarm.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Support for alarm kits."""

from . import exceptions as e
from .device import Device

Expand Down
37 changes: 10 additions & 27 deletions broadlink/climate.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
"""Support for climate control."""

import enum
import struct
from typing import List, Sequence
from collections.abc import Sequence

from . import exceptions as e
from .device import Device
Expand Down Expand Up @@ -33,7 +34,7 @@ async def send_request(self, request: Sequence[int]) -> bytes:
payload = self.decrypt(response[0x38:])

p_len = int.from_bytes(payload[:0x02], "little")
nom_crc = int.from_bytes(payload[p_len:p_len+2], "little")
nom_crc = int.from_bytes(payload[p_len : p_len + 2], "little")
real_crc = CRC16.calculate(payload[0x02:p_len])

if nom_crc != real_crc:
Expand Down Expand Up @@ -83,9 +84,7 @@ async def get_full_status(self) -> dict:
data["dif"] = payload[10]
data["svh"] = payload[11]
data["svl"] = payload[12]
data["room_temp_adj"] = (
int.from_bytes(payload[13:15], "big", signed=True) / 10.0
)
data["room_temp_adj"] = int.from_bytes(payload[13:15], "big", signed=True) / 10.0
data["fre"] = payload[15]
data["poweron"] = payload[16]
data["unknown"] = payload[17]
Expand Down Expand Up @@ -127,9 +126,7 @@ async def get_full_status(self) -> dict:
# E.g. loop_mode = 0 ("12345,67") means Saturday and Sunday (weekend schedule)
# loop_mode = 2 ("1234567") means every day, including Saturday and Sunday (weekday schedule)
# The sensor command is currently experimental
async def set_mode(
self, auto_mode: int, loop_mode: int, sensor: int = 0
) -> None:
async def set_mode(self, auto_mode: int, loop_mode: int, sensor: int = 0) -> None:
"""Set the mode of the device."""
mode_byte = ((loop_mode + 1) << 4) + auto_mode
await self.send_request([0x01, 0x06, 0x00, 0x02, mode_byte, sensor])
Expand Down Expand Up @@ -210,19 +207,7 @@ async def set_power(
async def set_time(self, hour: int, minute: int, second: int, day: int) -> None:
"""Set the time."""
await self.send_request(
[
0x01,
0x10,
0x00,
0x08,
0x00,
0x02,
0x04,
hour,
minute,
second,
day
]
[0x01, 0x10, 0x00, 0x08, 0x00, 0x02, 0x04, hour, minute, second, day]
)

# Set timer schedule
Expand All @@ -231,7 +216,7 @@ async def set_time(self, hour: int, minute: int, second: int, day: int) -> None:
# {'start_hour':17, 'start_minute':30, 'temp': 22 }
# Each one specifies the thermostat temp that will become effective at start_hour:start_minute
# weekend is similar but only has 2 (e.g. switch on in morning and off in afternoon)
async def set_schedule(self, weekday: List[dict], weekend: List[dict]) -> None:
async def set_schedule(self, weekday: list[dict], weekend: list[dict]) -> None:
"""Set timer schedule."""
request = [0x01, 0x10, 0x00, 0x0A, 0x00, 0x0C, 0x18]

Expand Down Expand Up @@ -317,9 +302,7 @@ def _encode(self, data: bytes) -> bytes:
"""Encode data for transport."""
packet = bytearray(10)
p_len = 10 + len(data)
struct.pack_into(
"<HHHHH", packet, 0, p_len, 0x00BB, 0x8006, 0, len(data)
)
struct.pack_into("<HHHHH", packet, 0, p_len, 0x00BB, 0x8006, 0, len(data))
packet += data
crc = CRC16.calculate(packet[0x02:], polynomial=0x9BE4)
packet += crc.to_bytes(2, "little")
Expand All @@ -330,7 +313,7 @@ def _decode(self, response: bytes) -> bytes:
# payload[0x2:0x8] == bytes([0xbb, 0x00, 0x07, 0x00, 0x00, 0x00])
payload = self.decrypt(response[0x38:])
p_len = int.from_bytes(payload[:0x02], "little")
nom_crc = int.from_bytes(payload[p_len:p_len+2], "little")
nom_crc = int.from_bytes(payload[p_len : p_len + 2], "little")
real_crc = CRC16.calculate(payload[0x02:p_len], polynomial=0x9BE4)

if nom_crc != real_crc:
Expand All @@ -341,7 +324,7 @@ def _decode(self, response: bytes) -> bytes:
)

d_len = int.from_bytes(payload[0x08:0x0A], "little")
return payload[0x0A:0x0A+d_len]
return payload[0x0A : 0x0A + d_len]

async def _send(self, command: int, data: bytes = b"") -> bytes:
"""Send a command to the unit."""
Expand Down
1 change: 1 addition & 0 deletions broadlink/const.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Constants."""

DEFAULT_BCAST_ADDR = "255.255.255.255"
DEFAULT_PORT = 80
DEFAULT_RETRY_INTVL = 1
Expand Down
Loading
Loading