python-broadlink 1.0.0__tar.gz → 1.0.2__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.
- python_broadlink-1.0.2/CHANGELOG.md +200 -0
- {python_broadlink-1.0.0/python_broadlink.egg-info → python_broadlink-1.0.2}/PKG-INFO +75 -15
- {python_broadlink-1.0.0 → python_broadlink-1.0.2}/README.md +74 -14
- {python_broadlink-1.0.0 → python_broadlink-1.0.2}/broadlink/__init__.py +17 -12
- {python_broadlink-1.0.0 → python_broadlink-1.0.2}/broadlink/alarm.py +1 -0
- {python_broadlink-1.0.0 → python_broadlink-1.0.2}/broadlink/climate.py +10 -27
- {python_broadlink-1.0.0 → python_broadlink-1.0.2}/broadlink/const.py +1 -0
- {python_broadlink-1.0.0 → python_broadlink-1.0.2}/broadlink/cover.py +2 -1
- {python_broadlink-1.0.0 → python_broadlink-1.0.2}/broadlink/device.py +157 -94
- {python_broadlink-1.0.0 → python_broadlink-1.0.2}/broadlink/exceptions.py +15 -3
- {python_broadlink-1.0.0 → python_broadlink-1.0.2}/broadlink/helpers.py +4 -3
- {python_broadlink-1.0.0 → python_broadlink-1.0.2}/broadlink/hub.py +8 -10
- {python_broadlink-1.0.0 → python_broadlink-1.0.2}/broadlink/light.py +31 -33
- {python_broadlink-1.0.0 → python_broadlink-1.0.2}/broadlink/protocol.py +1 -0
- {python_broadlink-1.0.0 → python_broadlink-1.0.2}/broadlink/remote.py +178 -75
- {python_broadlink-1.0.0 → python_broadlink-1.0.2}/broadlink/sensor.py +2 -1
- {python_broadlink-1.0.0 → python_broadlink-1.0.2}/broadlink/switch.py +32 -32
- {python_broadlink-1.0.0 → python_broadlink-1.0.2}/pyproject.toml +17 -4
- {python_broadlink-1.0.0 → python_broadlink-1.0.2/python_broadlink.egg-info}/PKG-INFO +75 -15
- {python_broadlink-1.0.0 → python_broadlink-1.0.2}/tests/oracle/cases.py +323 -72
- {python_broadlink-1.0.0 → python_broadlink-1.0.2}/tests/oracle/harness.py +3 -4
- {python_broadlink-1.0.0 → python_broadlink-1.0.2}/tests/test_capture.py +187 -19
- {python_broadlink-1.0.0 → python_broadlink-1.0.2}/tests/test_oracle.py +10 -2
- {python_broadlink-1.0.0 → python_broadlink-1.0.2}/tests/test_remote.py +2 -1
- {python_broadlink-1.0.0 → python_broadlink-1.0.2}/tests/test_transport.py +242 -1
- python_broadlink-1.0.0/CHANGELOG.md +0 -98
- {python_broadlink-1.0.0 → python_broadlink-1.0.2}/LICENSE +0 -0
- {python_broadlink-1.0.0 → python_broadlink-1.0.2}/MANIFEST.in +0 -0
- {python_broadlink-1.0.0 → python_broadlink-1.0.2}/TROUBLESHOOTING.md +0 -0
- {python_broadlink-1.0.0 → python_broadlink-1.0.2}/cli/README.md +0 -0
- {python_broadlink-1.0.0 → python_broadlink-1.0.2}/cli/broadlink_cli +0 -0
- {python_broadlink-1.0.0 → python_broadlink-1.0.2}/cli/broadlink_discovery +0 -0
- {python_broadlink-1.0.0 → python_broadlink-1.0.2}/protocol.md +0 -0
- {python_broadlink-1.0.0 → python_broadlink-1.0.2}/python_broadlink.egg-info/SOURCES.txt +0 -0
- {python_broadlink-1.0.0 → python_broadlink-1.0.2}/python_broadlink.egg-info/dependency_links.txt +0 -0
- {python_broadlink-1.0.0 → python_broadlink-1.0.2}/python_broadlink.egg-info/requires.txt +0 -0
- {python_broadlink-1.0.0 → python_broadlink-1.0.2}/python_broadlink.egg-info/top_level.txt +0 -0
- {python_broadlink-1.0.0 → python_broadlink-1.0.2}/setup.cfg +0 -0
- {python_broadlink-1.0.0 → python_broadlink-1.0.2}/tests/__init__.py +0 -0
- {python_broadlink-1.0.0 → python_broadlink-1.0.2}/tests/oracle/__init__.py +0 -0
- {python_broadlink-1.0.0 → python_broadlink-1.0.2}/tests/oracle/fixtures.json +0 -0
- {python_broadlink-1.0.0 → python_broadlink-1.0.2}/tests/oracle/record.py +0 -0
- {python_broadlink-1.0.0 → python_broadlink-1.0.2}/tests/test_helpers.py +0 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are recorded here. The format follows
|
|
4
|
+
Keep a Changelog; versions follow Semantic Versioning.
|
|
5
|
+
|
|
6
|
+
## 1.0.2 - 2026-09-05
|
|
7
|
+
|
|
8
|
+
Fixes from a second, adversarial review of 1.0.1 and a re-test of the
|
|
9
|
+
first review's findings. No change to the wire format or the public API.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- 1.0.1's reply matching dropped a late reply to a request that had
|
|
14
|
+
timed out, but not the second reply to a request that was resent after a
|
|
15
|
+
silent second and then answered twice. That duplicate carries the counter
|
|
16
|
+
of a request that succeeded, and it could still be taken as the answer
|
|
17
|
+
to the next request. The library now remembers every recently used
|
|
18
|
+
counter and drops any reply carrying one other than the current
|
|
19
|
+
request's. A reply whose counter the device has not used recently is
|
|
20
|
+
still accepted, for firmware that may not echo it.
|
|
21
|
+
- `auth()` reset the session id and key before taking the request lock, so
|
|
22
|
+
a request already queued behind the lock could be framed with device id
|
|
23
|
+
0 and the initial key. The reset, the exchange and the install of the
|
|
24
|
+
new key now happen as one unit under the lock.
|
|
25
|
+
- 1.0.1 let a new capture window close one that a consumer had abandoned,
|
|
26
|
+
using "is the generator running right now" as the test. That cannot
|
|
27
|
+
tell an abandoned window from one whose consumer is awaiting something
|
|
28
|
+
between signals, which the README's own example does. A new window now
|
|
29
|
+
gives asyncio's finalizer one turn to close a genuinely dropped
|
|
30
|
+
generator and then refuses if the old window is still alive, rather
|
|
31
|
+
than taking it. A refused attempt no longer displaces the live window.
|
|
32
|
+
- A packet the device returned that cannot be decoded (a declared length
|
|
33
|
+
running into a truncated escape) no longer ends the capture window; it
|
|
34
|
+
is logged and the window re-arms.
|
|
35
|
+
- `aclose()` during a request now raises `EndpointClosedError`, a subclass
|
|
36
|
+
of `ConnectionClosedError` with code -4013 in the error table, so a
|
|
37
|
+
caller that closed the device on purpose can tell that apart from the
|
|
38
|
+
device's own "logged out" answer.
|
|
39
|
+
- `hello()` closes the discovery generator it breaks out of instead of
|
|
40
|
+
leaving the socket to the finalizer; `asyncio.TimeoutError` is spelled
|
|
41
|
+
`TimeoutError`; an unused future on the protocol object is gone.
|
|
42
|
+
|
|
43
|
+
### Added
|
|
44
|
+
|
|
45
|
+
- Debug logging on the `broadlink.device` and `broadlink.remote` loggers:
|
|
46
|
+
endpoint open and close, resends, dropped late replies, timeouts,
|
|
47
|
+
re-authentication, capture arm and re-arm, captured packets.
|
|
48
|
+
- README: a "Closing" section on the persistent socket, a "Timing" section
|
|
49
|
+
with the bench measurement of the tick fix (5.4 percent short before,
|
|
50
|
+
0.6 percent short after, on an RM4 Pro against an independent
|
|
51
|
+
receiver), a note that Python 3.13 is a support decision, and the short
|
|
52
|
+
list of return-value differences from 0.19.0.
|
|
53
|
+
|
|
54
|
+
### Changed
|
|
55
|
+
|
|
56
|
+
- The code is formatted with `ruff format` and CI checks it.
|
|
57
|
+
|
|
58
|
+
## 1.0.1 - 2026-09-05
|
|
59
|
+
|
|
60
|
+
Fixes from an independent review of 1.0.0, most of them in the transport.
|
|
61
|
+
None changes the wire format or the public API.
|
|
62
|
+
|
|
63
|
+
### Fixed
|
|
64
|
+
|
|
65
|
+
- A reply to a request that had already timed out could be delivered as the
|
|
66
|
+
reply to the next request on the same device, because the persistent
|
|
67
|
+
endpoint (new in 1.0.0) is not thrown away between calls the way the old
|
|
68
|
+
per-call socket was. Replies are now matched to their request by the
|
|
69
|
+
packet counter the device echoes at offset 0x28; a reply carrying the
|
|
70
|
+
counter of a request that already timed out is discarded, and a reply
|
|
71
|
+
whose counter matches nothing the device sent is still accepted, so
|
|
72
|
+
firmware that does not echo the counter is unaffected. Confirmed on an
|
|
73
|
+
RM4 Pro, which echoes it.
|
|
74
|
+
- `capture()` treated only `StorageError` (-5) as "nothing captured yet".
|
|
75
|
+
Some firmware answers `ReadError` (-10); both are now treated as "nothing
|
|
76
|
+
yet", matching what the original CLI and Home Assistant do while polling.
|
|
77
|
+
The CLI's `--learn` and `--rflearn` inherit the fix.
|
|
78
|
+
- Abandoning a capture generator without closing it (for example `break`
|
|
79
|
+
out of `async for` to take one code) no longer blocks the next
|
|
80
|
+
`capture()` on the same device: opening a new window closes an abandoned
|
|
81
|
+
one. Opening a window while another is actively being iterated still
|
|
82
|
+
raises `CaptureInProgressError`. A new read-only `Device.capture_active`
|
|
83
|
+
property reports whether a window is open.
|
|
84
|
+
- Re-authentication is now shared between concurrent callers: when several
|
|
85
|
+
requests hit an expired session key at once, the library authenticates
|
|
86
|
+
once and every caller retries, instead of one caller re-authenticating
|
|
87
|
+
and the others surfacing the raw error. The logged-out code (-2) now
|
|
88
|
+
triggers re-authentication as well, matching Home Assistant's own retry.
|
|
89
|
+
- Changing `device.host` after the endpoint is open now reopens it against
|
|
90
|
+
the new address instead of continuing to talk to the old one.
|
|
91
|
+
- `aclose()` while a request is in flight fails that request at once with
|
|
92
|
+
`ConnectionClosedError` instead of waiting out the timeout.
|
|
93
|
+
|
|
94
|
+
### Documentation
|
|
95
|
+
|
|
96
|
+
- The README explains that `broadlink` and `python-broadlink` install the
|
|
97
|
+
same package name and cannot coexist, and how to recover if both were
|
|
98
|
+
installed.
|
|
99
|
+
- The changelog no longer describes the carried-over device commits as
|
|
100
|
+
"intact" (they were squash-merged with `Co-authored-by` credit) and no
|
|
101
|
+
longer overstates what the oracle records.
|
|
102
|
+
|
|
103
|
+
## 1.0.0 - 2026-09-05
|
|
104
|
+
|
|
105
|
+
This is the first release of `python-broadlink`, a maintained fork of
|
|
106
|
+
`mjg59/python-broadlink` (PyPI `broadlink`, last released as 0.19.0). The
|
|
107
|
+
history below starts at that fork point.
|
|
108
|
+
|
|
109
|
+
### Changed
|
|
110
|
+
|
|
111
|
+
- **The library is asynchronous.** Every method that talks to a device is
|
|
112
|
+
now a coroutine: `await device.auth()`, `await device.send_data(...)`,
|
|
113
|
+
`await device.check_sensors()`, and so on. Discovery is
|
|
114
|
+
`await broadlink.discover(...)`, `broadlink.hello(...)` and `setup(...)`
|
|
115
|
+
are coroutines, and `xdiscover(...)` is an async generator. The packet
|
|
116
|
+
helpers (`pulses_to_data`, `data_to_pulses`), CRC and datetime helpers
|
|
117
|
+
stay synchronous. There is no synchronous compatibility layer: a call
|
|
118
|
+
without `await` returns a coroutine and does nothing.
|
|
119
|
+
- Each device keeps one UDP endpoint for its lifetime (the previous
|
|
120
|
+
version opened a socket per call) and serializes requests on it with an
|
|
121
|
+
`asyncio.Lock`. The old code declared a lock but never acquired it.
|
|
122
|
+
`async with device:` or `await device.aclose()` releases the endpoint;
|
|
123
|
+
it reopens on the next call.
|
|
124
|
+
- When a device reports that the session key has expired, the library
|
|
125
|
+
re-authenticates once and repeats the request. Callers no longer need
|
|
126
|
+
their own re-auth loop.
|
|
127
|
+
- Retry and timeout behaviour is unchanged: a request is repeated every
|
|
128
|
+
second until `timeout` elapses, then `NetworkTimeoutError` is raised.
|
|
129
|
+
- `dooya.set_percentage_and_wait` sleeps with `asyncio.sleep`.
|
|
130
|
+
- The CLI tools run their body under `asyncio.run`. `broadlink_cli
|
|
131
|
+
--learn` and `--rflearn` use `capture()` / `capture_rf()`, so a learning
|
|
132
|
+
session no longer goes deaf when the device times out partway through;
|
|
133
|
+
`--window` sets how long to listen, `--keep` prints every code heard, and
|
|
134
|
+
`--send --durations --repeat N` sets the repeat count. The CLI README's
|
|
135
|
+
`--rfscanlearn` was a typo for `--rflearn` (mjg59/python-broadlink#803,
|
|
136
|
+
#830).
|
|
137
|
+
- `pulses_to_data` returns `bytes` (it returned a `bytearray`, against its
|
|
138
|
+
own annotation).
|
|
139
|
+
- The device's request lock is now a private `_lock` that is actually
|
|
140
|
+
acquired; the unused public `Device.lock` attribute is gone.
|
|
141
|
+
- Packaging moved to `pyproject.toml`; `setup.py` and the stale
|
|
142
|
+
`requirements.txt` pin are gone. The distribution name is now
|
|
143
|
+
`python-broadlink`; the import name stays `broadlink`. Python 3.13 or
|
|
144
|
+
newer is required.
|
|
145
|
+
- Continuous integration now runs `ruff` and `pytest` on Python 3.13 and
|
|
146
|
+
3.14, and builds the sdist and wheel on every pull request. Releases are
|
|
147
|
+
published to PyPI from version tags using trusted publishing.
|
|
148
|
+
|
|
149
|
+
### Fixed
|
|
150
|
+
|
|
151
|
+
- The IR tick constant used by `pulses_to_data` and `data_to_pulses` is now
|
|
152
|
+
`TICK = 8192 / 269` (about 30.45 us), matching the device's 32768 Hz
|
|
153
|
+
timebase as documented in `protocol.md`. The previous value, 32.84, was
|
|
154
|
+
the inverse ratio applied the wrong way round and compressed IR codes
|
|
155
|
+
built from true microsecond timings by about 7 percent. Codes learned and
|
|
156
|
+
replayed through the same device were unaffected. Verified on an RM4 Pro
|
|
157
|
+
against an independent receiver in both directions.
|
|
158
|
+
(mjg59/python-broadlink#839, #841)
|
|
159
|
+
- `pulses_to_data` rounds each duration to the nearest tick instead of
|
|
160
|
+
truncating, which removes up to one tick of systematic shortening per
|
|
161
|
+
pulse.
|
|
162
|
+
|
|
163
|
+
### Added
|
|
164
|
+
|
|
165
|
+
- `capture()` and `capture_rf()`, async generators that own the arm, poll,
|
|
166
|
+
timeout and re-arm loop of a learning session and yield each signal as a
|
|
167
|
+
`CapturedSignal` (device packet, decoded pulses at the correct tick,
|
|
168
|
+
kind, repeat count, and for RF the carrier frequency). They re-arm on a
|
|
169
|
+
timer, because the device leaves learning mode silently, and after any
|
|
170
|
+
`send_data`, because a transmission ends the session; both intervals and
|
|
171
|
+
the poll cadence were set from a bench on an RM4 Pro. Only one window can
|
|
172
|
+
be open per device. `capture_rf()` (Pro models only) takes the carrier
|
|
173
|
+
frequency directly and falls back to the on-device sweep when it is not
|
|
174
|
+
given.
|
|
175
|
+
- Packet helpers: `pulses_to_data` takes `kind` and `repeat`, `parse_packet`
|
|
176
|
+
is its inverse, and `SignalKind` names the IR, 433 MHz and 315 MHz bands.
|
|
177
|
+
A device's returned RF packet does not always use the canonical type byte
|
|
178
|
+
(an RM4 Pro answers a 433 MHz capture with 0xB1, not 0xB2), so the kind is
|
|
179
|
+
read by band and a capture is tagged from what it armed rather than the
|
|
180
|
+
byte.
|
|
181
|
+
- Devices, carried over from pull requests against the original repository
|
|
182
|
+
with their authors credited (the changes were squash-merged with
|
|
183
|
+
`Co-authored-by` trailers naming each author): RM Max 0xAF8B (#838, Alexey Masolov);
|
|
184
|
+
RM5 plus 0x5224 with a new `rm5plus` class (#831, Anil Daoud); RM mini 3
|
|
185
|
+
OEM 0xA544 (#823, Bartłomiej Nogaś); RM mini 3 CMCC 0x27C8 (#802,
|
|
186
|
+
shuxin); LB26 R1 0xA517 (#812, techitapart); SP mini 3-AL 0x7D15 (#805,
|
|
187
|
+
bbcbbk); LEDVANCE SMART+ WIFI CEILING TW 24W 0x6498 (#799, Felipe Martins
|
|
188
|
+
Diel).
|
|
189
|
+
- Devices reported in issues against the original repository, added by
|
|
190
|
+
model name to the existing class for that family and not yet confirmed on
|
|
191
|
+
hardware: MP1-1K3S2U 0x4EDA (#816) and SP4 0xA57A (#758). Please open an
|
|
192
|
+
issue if either does not behave.
|
|
193
|
+
- `cryptography` 43 or newer is required, the first release with wheels for
|
|
194
|
+
Python 3.13 (supersedes mjg59/python-broadlink#749).
|
|
195
|
+
- A test suite. The `tests/oracle` package records, for every public method
|
|
196
|
+
of every device class, the request each one hands to the transport (its
|
|
197
|
+
packet type and plaintext payload) and the result it decodes from a canned
|
|
198
|
+
response, so that a later reimplementation can be checked against the
|
|
199
|
+
original method by method; the framing, encryption and checksum layer is
|
|
200
|
+
covered separately by `tests/test_transport.py`.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-broadlink
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.2
|
|
4
4
|
Summary: Python API for controlling Broadlink devices
|
|
5
5
|
Author: DAB-LABS
|
|
6
6
|
Author-email: Matthew Garrett <mjg59@srcf.ucam.org>
|
|
@@ -47,20 +47,25 @@ A Python module and CLI for controlling Broadlink devices locally.
|
|
|
47
47
|
|
|
48
48
|
## Version 1.0 is asynchronous
|
|
49
49
|
|
|
50
|
-
Every call that reaches a device is a coroutine and must be awaited.
|
|
51
|
-
is the
|
|
52
|
-
arguments and return values
|
|
50
|
+
Every call that reaches a device is a coroutine and must be awaited. That
|
|
51
|
+
is the main change from the original library's API: method names and
|
|
52
|
+
arguments are the same, and so are return values, with the small
|
|
53
|
+
exceptions listed in `CHANGELOG.md` (the IR tick constant, `pulses_to_data`
|
|
54
|
+
returning `bytes`, the unused `Device.lock` attribute removed, and
|
|
55
|
+
`timeout` parameters typed as floats).
|
|
53
56
|
|
|
54
57
|
```python
|
|
55
58
|
import asyncio
|
|
56
59
|
import broadlink
|
|
57
60
|
|
|
61
|
+
|
|
58
62
|
async def main():
|
|
59
63
|
devices = await broadlink.discover(timeout=5)
|
|
60
64
|
device = devices[0]
|
|
61
65
|
await device.auth()
|
|
62
66
|
print(await device.check_sensors())
|
|
63
67
|
|
|
68
|
+
|
|
64
69
|
asyncio.run(main())
|
|
65
70
|
```
|
|
66
71
|
|
|
@@ -83,17 +88,48 @@ The following devices are supported:
|
|
|
83
88
|
- **Thermostats**: Hysen HY02B05H
|
|
84
89
|
- **Hubs**: S3
|
|
85
90
|
|
|
91
|
+
## Timing
|
|
92
|
+
|
|
93
|
+
The original library converted microseconds to the device's timing units
|
|
94
|
+
with the constant 32.84, which is the right ratio applied the wrong way
|
|
95
|
+
round, and it shortened every IR code built from microsecond timings by
|
|
96
|
+
about 7 percent. Codes learned from a remote and replayed through the same
|
|
97
|
+
device were never affected, which is why it went unnoticed for years.
|
|
98
|
+
Version 1.0 uses 8192/269 (about 30.45 us per unit), the value implied by
|
|
99
|
+
`protocol.md`, and rounds to the nearest unit instead of truncating.
|
|
100
|
+
|
|
101
|
+
Measured on an RM4 Pro against an independent receiver, the same NEC frame
|
|
102
|
+
packed with the old constant arrived 5.4 percent short of its intended
|
|
103
|
+
length; packed with the corrected constant it arrived 0.6 percent short,
|
|
104
|
+
twice, thirteen hours apart, within 22 us of itself. Packets learned by
|
|
105
|
+
the device and replayed by name are unchanged. Anything that stores
|
|
106
|
+
microsecond timings produced by the old `data_to_pulses` (which reported
|
|
107
|
+
them about 7.8 percent long) and re-encodes them with the new
|
|
108
|
+
`pulses_to_data` will lengthen by that amount; store the device packet
|
|
109
|
+
instead, as `CapturedSignal.packet` does.
|
|
110
|
+
|
|
86
111
|
## Installation
|
|
87
112
|
|
|
113
|
+
Python 3.13 or newer. That is a support decision rather than a technical
|
|
114
|
+
one: the code runs on 3.11, but the versions tested in CI are 3.13 and
|
|
115
|
+
3.14 and those are the ones Home Assistant ships.
|
|
116
|
+
|
|
88
117
|
Use pip3 to install the latest version of this module.
|
|
89
118
|
|
|
90
119
|
```
|
|
91
120
|
pip3 install python-broadlink
|
|
92
121
|
```
|
|
93
122
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
123
|
+
Both this distribution and the original `broadlink` install a package named
|
|
124
|
+
`broadlink`, so only one can be present in an environment at a time. Pip
|
|
125
|
+
does not warn about this: installing one on top of the other appears to
|
|
126
|
+
succeed, and whichever was installed last is the one that `import broadlink`
|
|
127
|
+
finds. If both were installed, uninstall both (`pip3 uninstall broadlink
|
|
128
|
+
python-broadlink`) and reinstall this one, since `pip3 uninstall broadlink`
|
|
129
|
+
alone removes the shared files and leaves `python-broadlink` registered but
|
|
130
|
+
unimportable. This matters most where another package pins `broadlink`:
|
|
131
|
+
installing it into the same environment silently replaces this async
|
|
132
|
+
library with the original synchronous one.
|
|
97
133
|
|
|
98
134
|
## Basic functions
|
|
99
135
|
|
|
@@ -118,7 +154,7 @@ In order to control the device, you need to connect it to your local network. If
|
|
|
118
154
|
- Manually connect to the WiFi SSID named BroadlinkProv.
|
|
119
155
|
2. Connect the device to your local network with the setup function.
|
|
120
156
|
```python3
|
|
121
|
-
await broadlink.setup(
|
|
157
|
+
await broadlink.setup("myssid", "mynetworkpass", 3)
|
|
122
158
|
```
|
|
123
159
|
|
|
124
160
|
Security mode options are (0 = none, 1 = WEP, 2 = WPA1, 3 = WPA2, 4 = WPA1/2)
|
|
@@ -127,7 +163,7 @@ Security mode options are (0 = none, 1 = WEP, 2 = WPA1, 3 = WPA2, 4 = WPA1/2)
|
|
|
127
163
|
|
|
128
164
|
You may need to specify a broadcast address if setup is not working.
|
|
129
165
|
```python3
|
|
130
|
-
await broadlink.setup(
|
|
166
|
+
await broadlink.setup("myssid", "mynetworkpass", 3, ip_address="192.168.0.255")
|
|
131
167
|
```
|
|
132
168
|
|
|
133
169
|
### Discovery
|
|
@@ -143,17 +179,17 @@ You may need to specify `local_ip_address` or `discover_ip_address` if discovery
|
|
|
143
179
|
|
|
144
180
|
Using the IP address of your local machine:
|
|
145
181
|
```python3
|
|
146
|
-
devices = await broadlink.discover(local_ip_address=
|
|
182
|
+
devices = await broadlink.discover(local_ip_address="192.168.0.100")
|
|
147
183
|
```
|
|
148
184
|
|
|
149
185
|
Using the broadcast address of your subnet:
|
|
150
186
|
```python3
|
|
151
|
-
devices = await broadlink.discover(discover_ip_address=
|
|
187
|
+
devices = await broadlink.discover(discover_ip_address="192.168.0.255")
|
|
152
188
|
```
|
|
153
189
|
|
|
154
190
|
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:
|
|
155
191
|
```python3
|
|
156
|
-
device = await broadlink.hello(
|
|
192
|
+
device = await broadlink.hello("192.168.0.16")
|
|
157
193
|
```
|
|
158
194
|
|
|
159
195
|
If you are a perfomance freak, use `broadlink.xdiscover()` to create devices instantly:
|
|
@@ -168,6 +204,27 @@ After discovering the device, call the `auth()` method to obtain the authenticat
|
|
|
168
204
|
await device.auth()
|
|
169
205
|
```
|
|
170
206
|
|
|
207
|
+
### Closing
|
|
208
|
+
|
|
209
|
+
Each device keeps one UDP socket open for its lifetime (the original
|
|
210
|
+
library opened a new one for every call). Close it when you are done with
|
|
211
|
+
the device, either with the context manager or explicitly:
|
|
212
|
+
|
|
213
|
+
```python3
|
|
214
|
+
async with device:
|
|
215
|
+
await device.auth()
|
|
216
|
+
print(await device.check_sensors())
|
|
217
|
+
|
|
218
|
+
# or
|
|
219
|
+
await device.aclose()
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
The socket reopens by itself on the next call, so closing is cheap and
|
|
223
|
+
safe to do at any time. A request that is in flight when `aclose()` runs
|
|
224
|
+
fails with `EndpointClosedError`. An integration that creates devices
|
|
225
|
+
should close them when it unloads; a device that is never closed holds
|
|
226
|
+
its socket until it is garbage collected.
|
|
227
|
+
|
|
171
228
|
The next steps depend on the type of device you want to control.
|
|
172
229
|
|
|
173
230
|
## Universal remotes
|
|
@@ -199,7 +256,7 @@ await device.sweep_frequency()
|
|
|
199
256
|
```python3
|
|
200
257
|
ok, frequency = await device.check_frequency()
|
|
201
258
|
if ok:
|
|
202
|
-
print(f
|
|
259
|
+
print(f"Frequency found: {frequency} MHz")
|
|
203
260
|
```
|
|
204
261
|
4. Enter learning mode:
|
|
205
262
|
```python3
|
|
@@ -241,10 +298,13 @@ By default the window closes after the first signal. Pass
|
|
|
241
298
|
`window=0` runs until the generator is closed), re-arming after each signal
|
|
242
299
|
because the device holds only one code per learning session. A universal
|
|
243
300
|
remote has a single receiver, so only one capture window can be open on a
|
|
244
|
-
device at a time
|
|
301
|
+
device at a time: opening a second one raises `CaptureInProgressError`
|
|
302
|
+
while the first is still held. Always close a window you leave early
|
|
303
|
+
(`aclosing` above does it), otherwise it stays open until Python collects
|
|
304
|
+
the generator.
|
|
245
305
|
|
|
246
306
|
`CapturedSignal` carries the device's own `packet` bytes (ready for
|
|
247
|
-
`send_data`), the decoded `pulses` in microseconds at the
|
|
307
|
+
`send_data`), the decoded `pulses` in microseconds at the corrected tick, the
|
|
248
308
|
`kind` (`SignalKind.IR`, `RF_433` or `RF_315`), the `repeat` count, and for
|
|
249
309
|
RF the `frequency_mhz` the packet itself does not record.
|
|
250
310
|
|
|
@@ -16,20 +16,25 @@ A Python module and CLI for controlling Broadlink devices locally.
|
|
|
16
16
|
|
|
17
17
|
## Version 1.0 is asynchronous
|
|
18
18
|
|
|
19
|
-
Every call that reaches a device is a coroutine and must be awaited.
|
|
20
|
-
is the
|
|
21
|
-
arguments and return values
|
|
19
|
+
Every call that reaches a device is a coroutine and must be awaited. That
|
|
20
|
+
is the main change from the original library's API: method names and
|
|
21
|
+
arguments are the same, and so are return values, with the small
|
|
22
|
+
exceptions listed in `CHANGELOG.md` (the IR tick constant, `pulses_to_data`
|
|
23
|
+
returning `bytes`, the unused `Device.lock` attribute removed, and
|
|
24
|
+
`timeout` parameters typed as floats).
|
|
22
25
|
|
|
23
26
|
```python
|
|
24
27
|
import asyncio
|
|
25
28
|
import broadlink
|
|
26
29
|
|
|
30
|
+
|
|
27
31
|
async def main():
|
|
28
32
|
devices = await broadlink.discover(timeout=5)
|
|
29
33
|
device = devices[0]
|
|
30
34
|
await device.auth()
|
|
31
35
|
print(await device.check_sensors())
|
|
32
36
|
|
|
37
|
+
|
|
33
38
|
asyncio.run(main())
|
|
34
39
|
```
|
|
35
40
|
|
|
@@ -52,17 +57,48 @@ The following devices are supported:
|
|
|
52
57
|
- **Thermostats**: Hysen HY02B05H
|
|
53
58
|
- **Hubs**: S3
|
|
54
59
|
|
|
60
|
+
## Timing
|
|
61
|
+
|
|
62
|
+
The original library converted microseconds to the device's timing units
|
|
63
|
+
with the constant 32.84, which is the right ratio applied the wrong way
|
|
64
|
+
round, and it shortened every IR code built from microsecond timings by
|
|
65
|
+
about 7 percent. Codes learned from a remote and replayed through the same
|
|
66
|
+
device were never affected, which is why it went unnoticed for years.
|
|
67
|
+
Version 1.0 uses 8192/269 (about 30.45 us per unit), the value implied by
|
|
68
|
+
`protocol.md`, and rounds to the nearest unit instead of truncating.
|
|
69
|
+
|
|
70
|
+
Measured on an RM4 Pro against an independent receiver, the same NEC frame
|
|
71
|
+
packed with the old constant arrived 5.4 percent short of its intended
|
|
72
|
+
length; packed with the corrected constant it arrived 0.6 percent short,
|
|
73
|
+
twice, thirteen hours apart, within 22 us of itself. Packets learned by
|
|
74
|
+
the device and replayed by name are unchanged. Anything that stores
|
|
75
|
+
microsecond timings produced by the old `data_to_pulses` (which reported
|
|
76
|
+
them about 7.8 percent long) and re-encodes them with the new
|
|
77
|
+
`pulses_to_data` will lengthen by that amount; store the device packet
|
|
78
|
+
instead, as `CapturedSignal.packet` does.
|
|
79
|
+
|
|
55
80
|
## Installation
|
|
56
81
|
|
|
82
|
+
Python 3.13 or newer. That is a support decision rather than a technical
|
|
83
|
+
one: the code runs on 3.11, but the versions tested in CI are 3.13 and
|
|
84
|
+
3.14 and those are the ones Home Assistant ships.
|
|
85
|
+
|
|
57
86
|
Use pip3 to install the latest version of this module.
|
|
58
87
|
|
|
59
88
|
```
|
|
60
89
|
pip3 install python-broadlink
|
|
61
90
|
```
|
|
62
91
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
92
|
+
Both this distribution and the original `broadlink` install a package named
|
|
93
|
+
`broadlink`, so only one can be present in an environment at a time. Pip
|
|
94
|
+
does not warn about this: installing one on top of the other appears to
|
|
95
|
+
succeed, and whichever was installed last is the one that `import broadlink`
|
|
96
|
+
finds. If both were installed, uninstall both (`pip3 uninstall broadlink
|
|
97
|
+
python-broadlink`) and reinstall this one, since `pip3 uninstall broadlink`
|
|
98
|
+
alone removes the shared files and leaves `python-broadlink` registered but
|
|
99
|
+
unimportable. This matters most where another package pins `broadlink`:
|
|
100
|
+
installing it into the same environment silently replaces this async
|
|
101
|
+
library with the original synchronous one.
|
|
66
102
|
|
|
67
103
|
## Basic functions
|
|
68
104
|
|
|
@@ -87,7 +123,7 @@ In order to control the device, you need to connect it to your local network. If
|
|
|
87
123
|
- Manually connect to the WiFi SSID named BroadlinkProv.
|
|
88
124
|
2. Connect the device to your local network with the setup function.
|
|
89
125
|
```python3
|
|
90
|
-
await broadlink.setup(
|
|
126
|
+
await broadlink.setup("myssid", "mynetworkpass", 3)
|
|
91
127
|
```
|
|
92
128
|
|
|
93
129
|
Security mode options are (0 = none, 1 = WEP, 2 = WPA1, 3 = WPA2, 4 = WPA1/2)
|
|
@@ -96,7 +132,7 @@ Security mode options are (0 = none, 1 = WEP, 2 = WPA1, 3 = WPA2, 4 = WPA1/2)
|
|
|
96
132
|
|
|
97
133
|
You may need to specify a broadcast address if setup is not working.
|
|
98
134
|
```python3
|
|
99
|
-
await broadlink.setup(
|
|
135
|
+
await broadlink.setup("myssid", "mynetworkpass", 3, ip_address="192.168.0.255")
|
|
100
136
|
```
|
|
101
137
|
|
|
102
138
|
### Discovery
|
|
@@ -112,17 +148,17 @@ You may need to specify `local_ip_address` or `discover_ip_address` if discovery
|
|
|
112
148
|
|
|
113
149
|
Using the IP address of your local machine:
|
|
114
150
|
```python3
|
|
115
|
-
devices = await broadlink.discover(local_ip_address=
|
|
151
|
+
devices = await broadlink.discover(local_ip_address="192.168.0.100")
|
|
116
152
|
```
|
|
117
153
|
|
|
118
154
|
Using the broadcast address of your subnet:
|
|
119
155
|
```python3
|
|
120
|
-
devices = await broadlink.discover(discover_ip_address=
|
|
156
|
+
devices = await broadlink.discover(discover_ip_address="192.168.0.255")
|
|
121
157
|
```
|
|
122
158
|
|
|
123
159
|
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:
|
|
124
160
|
```python3
|
|
125
|
-
device = await broadlink.hello(
|
|
161
|
+
device = await broadlink.hello("192.168.0.16")
|
|
126
162
|
```
|
|
127
163
|
|
|
128
164
|
If you are a perfomance freak, use `broadlink.xdiscover()` to create devices instantly:
|
|
@@ -137,6 +173,27 @@ After discovering the device, call the `auth()` method to obtain the authenticat
|
|
|
137
173
|
await device.auth()
|
|
138
174
|
```
|
|
139
175
|
|
|
176
|
+
### Closing
|
|
177
|
+
|
|
178
|
+
Each device keeps one UDP socket open for its lifetime (the original
|
|
179
|
+
library opened a new one for every call). Close it when you are done with
|
|
180
|
+
the device, either with the context manager or explicitly:
|
|
181
|
+
|
|
182
|
+
```python3
|
|
183
|
+
async with device:
|
|
184
|
+
await device.auth()
|
|
185
|
+
print(await device.check_sensors())
|
|
186
|
+
|
|
187
|
+
# or
|
|
188
|
+
await device.aclose()
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
The socket reopens by itself on the next call, so closing is cheap and
|
|
192
|
+
safe to do at any time. A request that is in flight when `aclose()` runs
|
|
193
|
+
fails with `EndpointClosedError`. An integration that creates devices
|
|
194
|
+
should close them when it unloads; a device that is never closed holds
|
|
195
|
+
its socket until it is garbage collected.
|
|
196
|
+
|
|
140
197
|
The next steps depend on the type of device you want to control.
|
|
141
198
|
|
|
142
199
|
## Universal remotes
|
|
@@ -168,7 +225,7 @@ await device.sweep_frequency()
|
|
|
168
225
|
```python3
|
|
169
226
|
ok, frequency = await device.check_frequency()
|
|
170
227
|
if ok:
|
|
171
|
-
print(f
|
|
228
|
+
print(f"Frequency found: {frequency} MHz")
|
|
172
229
|
```
|
|
173
230
|
4. Enter learning mode:
|
|
174
231
|
```python3
|
|
@@ -210,10 +267,13 @@ By default the window closes after the first signal. Pass
|
|
|
210
267
|
`window=0` runs until the generator is closed), re-arming after each signal
|
|
211
268
|
because the device holds only one code per learning session. A universal
|
|
212
269
|
remote has a single receiver, so only one capture window can be open on a
|
|
213
|
-
device at a time
|
|
270
|
+
device at a time: opening a second one raises `CaptureInProgressError`
|
|
271
|
+
while the first is still held. Always close a window you leave early
|
|
272
|
+
(`aclosing` above does it), otherwise it stays open until Python collects
|
|
273
|
+
the generator.
|
|
214
274
|
|
|
215
275
|
`CapturedSignal` carries the device's own `packet` bytes (ready for
|
|
216
|
-
`send_data`), the decoded `pulses` in microseconds at the
|
|
276
|
+
`send_data`), the decoded `pulses` in microseconds at the corrected tick, the
|
|
217
277
|
`kind` (`SignalKind.IR`, `RF_433` or `RF_315`), the `repeat` count, and for
|
|
218
278
|
RF the `frequency_mhz` the packet itself does not record.
|
|
219
279
|
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""The python-broadlink library."""
|
|
3
|
+
|
|
4
|
+
import contextlib
|
|
3
5
|
from collections.abc import AsyncIterator
|
|
4
|
-
from typing import
|
|
6
|
+
from typing import Optional, Union
|
|
5
7
|
|
|
6
8
|
from . import exceptions as e
|
|
7
9
|
from .alarm import S1C
|
|
@@ -223,8 +225,8 @@ SUPPORTED_TYPES = {
|
|
|
223
225
|
|
|
224
226
|
def gendevice(
|
|
225
227
|
dev_type: int,
|
|
226
|
-
host:
|
|
227
|
-
mac:
|
|
228
|
+
host: tuple[str, int],
|
|
229
|
+
mac: bytes | str,
|
|
228
230
|
name: str = "",
|
|
229
231
|
is_locked: bool = False,
|
|
230
232
|
) -> Device:
|
|
@@ -258,12 +260,15 @@ async def hello(
|
|
|
258
260
|
|
|
259
261
|
Useful if the device is locked.
|
|
260
262
|
"""
|
|
261
|
-
async
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
263
|
+
async with contextlib.aclosing(
|
|
264
|
+
xdiscover(
|
|
265
|
+
timeout=timeout,
|
|
266
|
+
discover_ip_address=ip_address,
|
|
267
|
+
discover_ip_port=port,
|
|
268
|
+
)
|
|
269
|
+
) as devices:
|
|
270
|
+
async for device in devices:
|
|
271
|
+
return device
|
|
267
272
|
raise e.NetworkTimeoutError(
|
|
268
273
|
-4000,
|
|
269
274
|
"Network timeout",
|
|
@@ -273,10 +278,10 @@ async def hello(
|
|
|
273
278
|
|
|
274
279
|
async def discover(
|
|
275
280
|
timeout: float = DEFAULT_TIMEOUT,
|
|
276
|
-
local_ip_address:
|
|
281
|
+
local_ip_address: str | None = None,
|
|
277
282
|
discover_ip_address: str = DEFAULT_BCAST_ADDR,
|
|
278
283
|
discover_ip_port: int = DEFAULT_PORT,
|
|
279
|
-
) ->
|
|
284
|
+
) -> list[Device]:
|
|
280
285
|
"""Discover devices connected to the local network."""
|
|
281
286
|
return [
|
|
282
287
|
device
|
|
@@ -288,7 +293,7 @@ async def discover(
|
|
|
288
293
|
|
|
289
294
|
async def xdiscover(
|
|
290
295
|
timeout: float = DEFAULT_TIMEOUT,
|
|
291
|
-
local_ip_address:
|
|
296
|
+
local_ip_address: str | None = None,
|
|
292
297
|
discover_ip_address: str = DEFAULT_BCAST_ADDR,
|
|
293
298
|
discover_ip_port: int = DEFAULT_PORT,
|
|
294
299
|
) -> AsyncIterator[Device]:
|