python-broadlink 1.0.1__tar.gz → 1.0.3__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.1 → python_broadlink-1.0.3}/CHANGELOG.md +97 -0
- {python_broadlink-1.0.1/python_broadlink.egg-info → python_broadlink-1.0.3}/PKG-INFO +79 -14
- {python_broadlink-1.0.1 → python_broadlink-1.0.3}/README.md +78 -13
- {python_broadlink-1.0.1 → python_broadlink-1.0.3}/broadlink/__init__.py +18 -19
- {python_broadlink-1.0.1 → python_broadlink-1.0.3}/broadlink/alarm.py +1 -0
- {python_broadlink-1.0.1 → python_broadlink-1.0.3}/broadlink/climate.py +10 -27
- {python_broadlink-1.0.1 → python_broadlink-1.0.3}/broadlink/const.py +1 -0
- {python_broadlink-1.0.1 → python_broadlink-1.0.3}/broadlink/cover.py +2 -1
- {python_broadlink-1.0.1 → python_broadlink-1.0.3}/broadlink/device.py +128 -93
- {python_broadlink-1.0.1 → python_broadlink-1.0.3}/broadlink/exceptions.py +16 -4
- {python_broadlink-1.0.1 → python_broadlink-1.0.3}/broadlink/helpers.py +4 -3
- {python_broadlink-1.0.1 → python_broadlink-1.0.3}/broadlink/hub.py +8 -10
- {python_broadlink-1.0.1 → python_broadlink-1.0.3}/broadlink/light.py +31 -33
- {python_broadlink-1.0.1 → python_broadlink-1.0.3}/broadlink/protocol.py +1 -0
- {python_broadlink-1.0.1 → python_broadlink-1.0.3}/broadlink/remote.py +110 -63
- {python_broadlink-1.0.1 → python_broadlink-1.0.3}/broadlink/sensor.py +2 -1
- {python_broadlink-1.0.1 → python_broadlink-1.0.3}/broadlink/switch.py +32 -32
- {python_broadlink-1.0.1 → python_broadlink-1.0.3}/cli/broadlink_cli +8 -1
- {python_broadlink-1.0.1 → python_broadlink-1.0.3}/pyproject.toml +17 -4
- {python_broadlink-1.0.1 → python_broadlink-1.0.3/python_broadlink.egg-info}/PKG-INFO +79 -14
- {python_broadlink-1.0.1 → python_broadlink-1.0.3}/tests/oracle/cases.py +323 -72
- {python_broadlink-1.0.1 → python_broadlink-1.0.3}/tests/oracle/harness.py +3 -4
- {python_broadlink-1.0.1 → python_broadlink-1.0.3}/tests/test_capture.py +136 -27
- {python_broadlink-1.0.1 → python_broadlink-1.0.3}/tests/test_oracle.py +10 -2
- {python_broadlink-1.0.1 → python_broadlink-1.0.3}/tests/test_remote.py +2 -1
- {python_broadlink-1.0.1 → python_broadlink-1.0.3}/tests/test_transport.py +210 -8
- {python_broadlink-1.0.1 → python_broadlink-1.0.3}/LICENSE +0 -0
- {python_broadlink-1.0.1 → python_broadlink-1.0.3}/MANIFEST.in +0 -0
- {python_broadlink-1.0.1 → python_broadlink-1.0.3}/TROUBLESHOOTING.md +0 -0
- {python_broadlink-1.0.1 → python_broadlink-1.0.3}/cli/README.md +0 -0
- {python_broadlink-1.0.1 → python_broadlink-1.0.3}/cli/broadlink_discovery +0 -0
- {python_broadlink-1.0.1 → python_broadlink-1.0.3}/protocol.md +0 -0
- {python_broadlink-1.0.1 → python_broadlink-1.0.3}/python_broadlink.egg-info/SOURCES.txt +0 -0
- {python_broadlink-1.0.1 → python_broadlink-1.0.3}/python_broadlink.egg-info/dependency_links.txt +0 -0
- {python_broadlink-1.0.1 → python_broadlink-1.0.3}/python_broadlink.egg-info/requires.txt +0 -0
- {python_broadlink-1.0.1 → python_broadlink-1.0.3}/python_broadlink.egg-info/top_level.txt +0 -0
- {python_broadlink-1.0.1 → python_broadlink-1.0.3}/setup.cfg +0 -0
- {python_broadlink-1.0.1 → python_broadlink-1.0.3}/tests/__init__.py +0 -0
- {python_broadlink-1.0.1 → python_broadlink-1.0.3}/tests/oracle/__init__.py +0 -0
- {python_broadlink-1.0.1 → python_broadlink-1.0.3}/tests/oracle/fixtures.json +0 -0
- {python_broadlink-1.0.1 → python_broadlink-1.0.3}/tests/oracle/record.py +0 -0
- {python_broadlink-1.0.1 → python_broadlink-1.0.3}/tests/test_helpers.py +0 -0
|
@@ -3,6 +3,103 @@
|
|
|
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.3 - 2026-09-06
|
|
7
|
+
|
|
8
|
+
Fixes from a third review, this one of 1.0.2. No change to the wire
|
|
9
|
+
format. One small API change: `pulses` on a captured signal is a tuple.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- When the device answered that the session key had expired and the
|
|
14
|
+
re-authentication then failed (for example because the device had been
|
|
15
|
+
locked in the app), the call raised `AuthenticationError` from the
|
|
16
|
+
re-authentication instead of the error the device gave the request. The
|
|
17
|
+
original library never re-authenticated, so a program written against
|
|
18
|
+
it, Home Assistant's integration included, handles the request's own
|
|
19
|
+
error and never expected the other one. The failed re-authentication is
|
|
20
|
+
now logged and the request's original reply is returned, so the caller
|
|
21
|
+
sees the same `AuthorizationError` or `ConnectionClosedError` it always
|
|
22
|
+
did.
|
|
23
|
+
- The authentication generation was read before the request lock was
|
|
24
|
+
taken rather than under it, so a request queued behind an `auth()`
|
|
25
|
+
could observe a stale generation and skip a re-authentication it needed.
|
|
26
|
+
- `aclose()` racing an endpoint that was still being opened could leave
|
|
27
|
+
the new socket open and unreferenced. The open now notices the close
|
|
28
|
+
and fails with `EndpointClosedError`.
|
|
29
|
+
- After a new capture window gives the finalizer its turn, it re-checks
|
|
30
|
+
that no other window claimed the device in the meantime.
|
|
31
|
+
- `CapturedSignal` and `ParsedPacket` are frozen dataclasses, but they
|
|
32
|
+
held a list, so they could not be hashed or put in a set. `pulses` is
|
|
33
|
+
now a `tuple[int, ...]`.
|
|
34
|
+
- `check_error` unpacks the error code as little-endian explicitly
|
|
35
|
+
(`"<h"`), matching the rest of the code, instead of native order.
|
|
36
|
+
- The CLI closes the device it opens instead of leaving that to
|
|
37
|
+
`asyncio.run`, which warned under `python -X dev`.
|
|
38
|
+
- The locks are created in `__init__` rather than lazily in two places.
|
|
39
|
+
|
|
40
|
+
### Changed
|
|
41
|
+
|
|
42
|
+
- `send_packet` accepts a `bytearray` payload as well as `bytes`.
|
|
43
|
+
- `setup()` sends its provisioning packet through a new
|
|
44
|
+
`send_setup_packet()` helper in `broadlink.device` instead of reaching
|
|
45
|
+
into a private function.
|
|
46
|
+
- README: the re-authentication contract and its worst case (one call can
|
|
47
|
+
wait out up to three timeouts), the A2 sensor and the Hysen HY02/HY03
|
|
48
|
+
in the device list, and the hello response's `mac` being `bytes` in the
|
|
49
|
+
list of differences from 0.19.0.
|
|
50
|
+
|
|
51
|
+
## 1.0.2 - 2026-09-05
|
|
52
|
+
|
|
53
|
+
Fixes from a second, adversarial review of 1.0.1 and a re-test of the
|
|
54
|
+
first review's findings. No change to the wire format or the public API.
|
|
55
|
+
|
|
56
|
+
### Fixed
|
|
57
|
+
|
|
58
|
+
- 1.0.1's reply matching dropped a late reply to a request that had
|
|
59
|
+
timed out, but not the second reply to a request that was resent after a
|
|
60
|
+
silent second and then answered twice. That duplicate carries the counter
|
|
61
|
+
of a request that succeeded, and it could still be taken as the answer
|
|
62
|
+
to the next request. The library now remembers every recently used
|
|
63
|
+
counter and drops any reply carrying one other than the current
|
|
64
|
+
request's. A reply whose counter the device has not used recently is
|
|
65
|
+
still accepted, for firmware that may not echo it.
|
|
66
|
+
- `auth()` reset the session id and key before taking the request lock, so
|
|
67
|
+
a request already queued behind the lock could be framed with device id
|
|
68
|
+
0 and the initial key. The reset, the exchange and the install of the
|
|
69
|
+
new key now happen as one unit under the lock.
|
|
70
|
+
- 1.0.1 let a new capture window close one that a consumer had abandoned,
|
|
71
|
+
using "is the generator running right now" as the test. That cannot
|
|
72
|
+
tell an abandoned window from one whose consumer is awaiting something
|
|
73
|
+
between signals, which the README's own example does. A new window now
|
|
74
|
+
gives asyncio's finalizer one turn to close a genuinely dropped
|
|
75
|
+
generator and then refuses if the old window is still alive, rather
|
|
76
|
+
than taking it. A refused attempt no longer displaces the live window.
|
|
77
|
+
- A packet the device returned that cannot be decoded (a declared length
|
|
78
|
+
running into a truncated escape) no longer ends the capture window; it
|
|
79
|
+
is logged and the window re-arms.
|
|
80
|
+
- `aclose()` during a request now raises `EndpointClosedError`, a subclass
|
|
81
|
+
of `ConnectionClosedError` with code -4013 in the error table, so a
|
|
82
|
+
caller that closed the device on purpose can tell that apart from the
|
|
83
|
+
device's own "logged out" answer.
|
|
84
|
+
- `hello()` closes the discovery generator it breaks out of instead of
|
|
85
|
+
leaving the socket to the finalizer; `asyncio.TimeoutError` is spelled
|
|
86
|
+
`TimeoutError`; an unused future on the protocol object is gone.
|
|
87
|
+
|
|
88
|
+
### Added
|
|
89
|
+
|
|
90
|
+
- Debug logging on the `broadlink.device` and `broadlink.remote` loggers:
|
|
91
|
+
endpoint open and close, resends, dropped late replies, timeouts,
|
|
92
|
+
re-authentication, capture arm and re-arm, captured packets.
|
|
93
|
+
- README: a "Closing" section on the persistent socket, a "Timing" section
|
|
94
|
+
with the bench measurement of the tick fix (5.4 percent short before,
|
|
95
|
+
0.6 percent short after, on an RM4 Pro against an independent
|
|
96
|
+
receiver), a note that Python 3.13 is a support decision, and the short
|
|
97
|
+
list of return-value differences from 0.19.0.
|
|
98
|
+
|
|
99
|
+
### Changed
|
|
100
|
+
|
|
101
|
+
- The code is formatted with `ruff format` and CI checks it.
|
|
102
|
+
|
|
6
103
|
## 1.0.1 - 2026-09-05
|
|
7
104
|
|
|
8
105
|
Fixes from an independent review of 1.0.0, most of them in the transport.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-broadlink
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.3
|
|
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,26 @@ 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, `timeout`
|
|
55
|
+
parameters typed as floats, and the `mac` in a hello response typed as
|
|
56
|
+
`bytes`).
|
|
53
57
|
|
|
54
58
|
```python
|
|
55
59
|
import asyncio
|
|
56
60
|
import broadlink
|
|
57
61
|
|
|
62
|
+
|
|
58
63
|
async def main():
|
|
59
64
|
devices = await broadlink.discover(timeout=5)
|
|
60
65
|
device = devices[0]
|
|
61
66
|
await device.auth()
|
|
62
67
|
print(await device.check_sensors())
|
|
63
68
|
|
|
69
|
+
|
|
64
70
|
asyncio.run(main())
|
|
65
71
|
```
|
|
66
72
|
|
|
@@ -76,15 +82,39 @@ The following devices are supported:
|
|
|
76
82
|
- **Switches**: MCB1, SC1, SCB1E, SCB2
|
|
77
83
|
- **Outlets**: BG 800, BG 900
|
|
78
84
|
- **Power strips**: MP1-1K3S2U, MP1-1K4S, MP2
|
|
79
|
-
- **Environment sensors**: A1
|
|
85
|
+
- **Environment sensors**: A1, A2
|
|
80
86
|
- **Alarm kits**: S1C, S2KIT
|
|
81
87
|
- **Light bulbs**: LB1, LB26 R1, LB27 R1, SB800TD, LEDVANCE SMART+ WIFI CEILING TW 24W
|
|
82
88
|
- **Curtain motors**: Dooya DT360E-45/20
|
|
83
|
-
- **Thermostats**: Hysen
|
|
89
|
+
- **Thermostats**: Hysen HY02/HY03
|
|
84
90
|
- **Hubs**: S3
|
|
85
91
|
|
|
92
|
+
## Timing
|
|
93
|
+
|
|
94
|
+
The original library converted microseconds to the device's timing units
|
|
95
|
+
with the constant 32.84, which is the right ratio applied the wrong way
|
|
96
|
+
round, and it shortened every IR code built from microsecond timings by
|
|
97
|
+
about 7 percent. Codes learned from a remote and replayed through the same
|
|
98
|
+
device were never affected, which is why it went unnoticed for years.
|
|
99
|
+
Version 1.0 uses 8192/269 (about 30.45 us per unit), the value implied by
|
|
100
|
+
`protocol.md`, and rounds to the nearest unit instead of truncating.
|
|
101
|
+
|
|
102
|
+
Measured on an RM4 Pro against an independent receiver, the same NEC frame
|
|
103
|
+
packed with the old constant arrived 5.4 percent short of its intended
|
|
104
|
+
length; packed with the corrected constant it arrived 0.6 percent short,
|
|
105
|
+
twice, thirteen hours apart, within 22 us of itself. Packets learned by
|
|
106
|
+
the device and replayed by name are unchanged. Anything that stores
|
|
107
|
+
microsecond timings produced by the old `data_to_pulses` (which reported
|
|
108
|
+
them about 7.8 percent long) and re-encodes them with the new
|
|
109
|
+
`pulses_to_data` will lengthen by that amount; store the device packet
|
|
110
|
+
instead, as `CapturedSignal.packet` does.
|
|
111
|
+
|
|
86
112
|
## Installation
|
|
87
113
|
|
|
114
|
+
Python 3.13 or newer. That is a support decision rather than a technical
|
|
115
|
+
one: the code runs on 3.11, but the versions tested in CI are 3.13 and
|
|
116
|
+
3.14 and those are the ones Home Assistant ships.
|
|
117
|
+
|
|
88
118
|
Use pip3 to install the latest version of this module.
|
|
89
119
|
|
|
90
120
|
```
|
|
@@ -125,7 +155,7 @@ In order to control the device, you need to connect it to your local network. If
|
|
|
125
155
|
- Manually connect to the WiFi SSID named BroadlinkProv.
|
|
126
156
|
2. Connect the device to your local network with the setup function.
|
|
127
157
|
```python3
|
|
128
|
-
await broadlink.setup(
|
|
158
|
+
await broadlink.setup("myssid", "mynetworkpass", 3)
|
|
129
159
|
```
|
|
130
160
|
|
|
131
161
|
Security mode options are (0 = none, 1 = WEP, 2 = WPA1, 3 = WPA2, 4 = WPA1/2)
|
|
@@ -134,7 +164,7 @@ Security mode options are (0 = none, 1 = WEP, 2 = WPA1, 3 = WPA2, 4 = WPA1/2)
|
|
|
134
164
|
|
|
135
165
|
You may need to specify a broadcast address if setup is not working.
|
|
136
166
|
```python3
|
|
137
|
-
await broadlink.setup(
|
|
167
|
+
await broadlink.setup("myssid", "mynetworkpass", 3, ip_address="192.168.0.255")
|
|
138
168
|
```
|
|
139
169
|
|
|
140
170
|
### Discovery
|
|
@@ -150,17 +180,17 @@ You may need to specify `local_ip_address` or `discover_ip_address` if discovery
|
|
|
150
180
|
|
|
151
181
|
Using the IP address of your local machine:
|
|
152
182
|
```python3
|
|
153
|
-
devices = await broadlink.discover(local_ip_address=
|
|
183
|
+
devices = await broadlink.discover(local_ip_address="192.168.0.100")
|
|
154
184
|
```
|
|
155
185
|
|
|
156
186
|
Using the broadcast address of your subnet:
|
|
157
187
|
```python3
|
|
158
|
-
devices = await broadlink.discover(discover_ip_address=
|
|
188
|
+
devices = await broadlink.discover(discover_ip_address="192.168.0.255")
|
|
159
189
|
```
|
|
160
190
|
|
|
161
191
|
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:
|
|
162
192
|
```python3
|
|
163
|
-
device = await broadlink.hello(
|
|
193
|
+
device = await broadlink.hello("192.168.0.16")
|
|
164
194
|
```
|
|
165
195
|
|
|
166
196
|
If you are a perfomance freak, use `broadlink.xdiscover()` to create devices instantly:
|
|
@@ -175,6 +205,38 @@ After discovering the device, call the `auth()` method to obtain the authenticat
|
|
|
175
205
|
await device.auth()
|
|
176
206
|
```
|
|
177
207
|
|
|
208
|
+
The session key expires on the device after a while. When a request comes
|
|
209
|
+
back with an expired-key answer, the library authenticates again and
|
|
210
|
+
repeats the request once, so a long-running program does not need to
|
|
211
|
+
handle that itself. If the second authentication fails, for example
|
|
212
|
+
because the device was locked in the app in the meantime, the call raises
|
|
213
|
+
the error the device gave the first time, the same `AuthorizationError`
|
|
214
|
+
or `ConnectionClosedError` the original library raised, and it is up to
|
|
215
|
+
the caller to decide what to do. In the worst case one call can wait out
|
|
216
|
+
three timeouts (the request, the authentication, and the repeat), each
|
|
217
|
+
bounded by `device.timeout`.
|
|
218
|
+
|
|
219
|
+
### Closing
|
|
220
|
+
|
|
221
|
+
Each device keeps one UDP socket open for its lifetime (the original
|
|
222
|
+
library opened a new one for every call). Close it when you are done with
|
|
223
|
+
the device, either with the context manager or explicitly:
|
|
224
|
+
|
|
225
|
+
```python3
|
|
226
|
+
async with device:
|
|
227
|
+
await device.auth()
|
|
228
|
+
print(await device.check_sensors())
|
|
229
|
+
|
|
230
|
+
# or
|
|
231
|
+
await device.aclose()
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
The socket reopens by itself on the next call, so closing is cheap and
|
|
235
|
+
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.
|
|
239
|
+
|
|
178
240
|
The next steps depend on the type of device you want to control.
|
|
179
241
|
|
|
180
242
|
## Universal remotes
|
|
@@ -206,7 +268,7 @@ await device.sweep_frequency()
|
|
|
206
268
|
```python3
|
|
207
269
|
ok, frequency = await device.check_frequency()
|
|
208
270
|
if ok:
|
|
209
|
-
print(f
|
|
271
|
+
print(f"Frequency found: {frequency} MHz")
|
|
210
272
|
```
|
|
211
273
|
4. Enter learning mode:
|
|
212
274
|
```python3
|
|
@@ -248,10 +310,13 @@ By default the window closes after the first signal. Pass
|
|
|
248
310
|
`window=0` runs until the generator is closed), re-arming after each signal
|
|
249
311
|
because the device holds only one code per learning session. A universal
|
|
250
312
|
remote has a single receiver, so only one capture window can be open on a
|
|
251
|
-
device at a time
|
|
313
|
+
device at a time: opening a second one raises `CaptureInProgressError`
|
|
314
|
+
while the first is still held. Always close a window you leave early
|
|
315
|
+
(`aclosing` above does it), otherwise it stays open until Python collects
|
|
316
|
+
the generator.
|
|
252
317
|
|
|
253
318
|
`CapturedSignal` carries the device's own `packet` bytes (ready for
|
|
254
|
-
`send_data`), the decoded `pulses` in microseconds at the
|
|
319
|
+
`send_data`), the decoded `pulses` in microseconds at the corrected tick, the
|
|
255
320
|
`kind` (`SignalKind.IR`, `RF_433` or `RF_315`), the `repeat` count, and for
|
|
256
321
|
RF the `frequency_mhz` the packet itself does not record.
|
|
257
322
|
|
|
@@ -16,20 +16,26 @@ 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, `timeout`
|
|
24
|
+
parameters typed as floats, and the `mac` in a hello response typed as
|
|
25
|
+
`bytes`).
|
|
22
26
|
|
|
23
27
|
```python
|
|
24
28
|
import asyncio
|
|
25
29
|
import broadlink
|
|
26
30
|
|
|
31
|
+
|
|
27
32
|
async def main():
|
|
28
33
|
devices = await broadlink.discover(timeout=5)
|
|
29
34
|
device = devices[0]
|
|
30
35
|
await device.auth()
|
|
31
36
|
print(await device.check_sensors())
|
|
32
37
|
|
|
38
|
+
|
|
33
39
|
asyncio.run(main())
|
|
34
40
|
```
|
|
35
41
|
|
|
@@ -45,15 +51,39 @@ The following devices are supported:
|
|
|
45
51
|
- **Switches**: MCB1, SC1, SCB1E, SCB2
|
|
46
52
|
- **Outlets**: BG 800, BG 900
|
|
47
53
|
- **Power strips**: MP1-1K3S2U, MP1-1K4S, MP2
|
|
48
|
-
- **Environment sensors**: A1
|
|
54
|
+
- **Environment sensors**: A1, A2
|
|
49
55
|
- **Alarm kits**: S1C, S2KIT
|
|
50
56
|
- **Light bulbs**: LB1, LB26 R1, LB27 R1, SB800TD, LEDVANCE SMART+ WIFI CEILING TW 24W
|
|
51
57
|
- **Curtain motors**: Dooya DT360E-45/20
|
|
52
|
-
- **Thermostats**: Hysen
|
|
58
|
+
- **Thermostats**: Hysen HY02/HY03
|
|
53
59
|
- **Hubs**: S3
|
|
54
60
|
|
|
61
|
+
## Timing
|
|
62
|
+
|
|
63
|
+
The original library converted microseconds to the device's timing units
|
|
64
|
+
with the constant 32.84, which is the right ratio applied the wrong way
|
|
65
|
+
round, and it shortened every IR code built from microsecond timings by
|
|
66
|
+
about 7 percent. Codes learned from a remote and replayed through the same
|
|
67
|
+
device were never affected, which is why it went unnoticed for years.
|
|
68
|
+
Version 1.0 uses 8192/269 (about 30.45 us per unit), the value implied by
|
|
69
|
+
`protocol.md`, and rounds to the nearest unit instead of truncating.
|
|
70
|
+
|
|
71
|
+
Measured on an RM4 Pro against an independent receiver, the same NEC frame
|
|
72
|
+
packed with the old constant arrived 5.4 percent short of its intended
|
|
73
|
+
length; packed with the corrected constant it arrived 0.6 percent short,
|
|
74
|
+
twice, thirteen hours apart, within 22 us of itself. Packets learned by
|
|
75
|
+
the device and replayed by name are unchanged. Anything that stores
|
|
76
|
+
microsecond timings produced by the old `data_to_pulses` (which reported
|
|
77
|
+
them about 7.8 percent long) and re-encodes them with the new
|
|
78
|
+
`pulses_to_data` will lengthen by that amount; store the device packet
|
|
79
|
+
instead, as `CapturedSignal.packet` does.
|
|
80
|
+
|
|
55
81
|
## Installation
|
|
56
82
|
|
|
83
|
+
Python 3.13 or newer. That is a support decision rather than a technical
|
|
84
|
+
one: the code runs on 3.11, but the versions tested in CI are 3.13 and
|
|
85
|
+
3.14 and those are the ones Home Assistant ships.
|
|
86
|
+
|
|
57
87
|
Use pip3 to install the latest version of this module.
|
|
58
88
|
|
|
59
89
|
```
|
|
@@ -94,7 +124,7 @@ In order to control the device, you need to connect it to your local network. If
|
|
|
94
124
|
- Manually connect to the WiFi SSID named BroadlinkProv.
|
|
95
125
|
2. Connect the device to your local network with the setup function.
|
|
96
126
|
```python3
|
|
97
|
-
await broadlink.setup(
|
|
127
|
+
await broadlink.setup("myssid", "mynetworkpass", 3)
|
|
98
128
|
```
|
|
99
129
|
|
|
100
130
|
Security mode options are (0 = none, 1 = WEP, 2 = WPA1, 3 = WPA2, 4 = WPA1/2)
|
|
@@ -103,7 +133,7 @@ Security mode options are (0 = none, 1 = WEP, 2 = WPA1, 3 = WPA2, 4 = WPA1/2)
|
|
|
103
133
|
|
|
104
134
|
You may need to specify a broadcast address if setup is not working.
|
|
105
135
|
```python3
|
|
106
|
-
await broadlink.setup(
|
|
136
|
+
await broadlink.setup("myssid", "mynetworkpass", 3, ip_address="192.168.0.255")
|
|
107
137
|
```
|
|
108
138
|
|
|
109
139
|
### Discovery
|
|
@@ -119,17 +149,17 @@ You may need to specify `local_ip_address` or `discover_ip_address` if discovery
|
|
|
119
149
|
|
|
120
150
|
Using the IP address of your local machine:
|
|
121
151
|
```python3
|
|
122
|
-
devices = await broadlink.discover(local_ip_address=
|
|
152
|
+
devices = await broadlink.discover(local_ip_address="192.168.0.100")
|
|
123
153
|
```
|
|
124
154
|
|
|
125
155
|
Using the broadcast address of your subnet:
|
|
126
156
|
```python3
|
|
127
|
-
devices = await broadlink.discover(discover_ip_address=
|
|
157
|
+
devices = await broadlink.discover(discover_ip_address="192.168.0.255")
|
|
128
158
|
```
|
|
129
159
|
|
|
130
160
|
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:
|
|
131
161
|
```python3
|
|
132
|
-
device = await broadlink.hello(
|
|
162
|
+
device = await broadlink.hello("192.168.0.16")
|
|
133
163
|
```
|
|
134
164
|
|
|
135
165
|
If you are a perfomance freak, use `broadlink.xdiscover()` to create devices instantly:
|
|
@@ -144,6 +174,38 @@ After discovering the device, call the `auth()` method to obtain the authenticat
|
|
|
144
174
|
await device.auth()
|
|
145
175
|
```
|
|
146
176
|
|
|
177
|
+
The session key expires on the device after a while. When a request comes
|
|
178
|
+
back with an expired-key answer, the library authenticates again and
|
|
179
|
+
repeats the request once, so a long-running program does not need to
|
|
180
|
+
handle that itself. If the second authentication fails, for example
|
|
181
|
+
because the device was locked in the app in the meantime, the call raises
|
|
182
|
+
the error the device gave the first time, the same `AuthorizationError`
|
|
183
|
+
or `ConnectionClosedError` the original library raised, and it is up to
|
|
184
|
+
the caller to decide what to do. In the worst case one call can wait out
|
|
185
|
+
three timeouts (the request, the authentication, and the repeat), each
|
|
186
|
+
bounded by `device.timeout`.
|
|
187
|
+
|
|
188
|
+
### Closing
|
|
189
|
+
|
|
190
|
+
Each device keeps one UDP socket open for its lifetime (the original
|
|
191
|
+
library opened a new one for every call). Close it when you are done with
|
|
192
|
+
the device, either with the context manager or explicitly:
|
|
193
|
+
|
|
194
|
+
```python3
|
|
195
|
+
async with device:
|
|
196
|
+
await device.auth()
|
|
197
|
+
print(await device.check_sensors())
|
|
198
|
+
|
|
199
|
+
# or
|
|
200
|
+
await device.aclose()
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
The socket reopens by itself on the next call, so closing is cheap and
|
|
204
|
+
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.
|
|
208
|
+
|
|
147
209
|
The next steps depend on the type of device you want to control.
|
|
148
210
|
|
|
149
211
|
## Universal remotes
|
|
@@ -175,7 +237,7 @@ await device.sweep_frequency()
|
|
|
175
237
|
```python3
|
|
176
238
|
ok, frequency = await device.check_frequency()
|
|
177
239
|
if ok:
|
|
178
|
-
print(f
|
|
240
|
+
print(f"Frequency found: {frequency} MHz")
|
|
179
241
|
```
|
|
180
242
|
4. Enter learning mode:
|
|
181
243
|
```python3
|
|
@@ -217,10 +279,13 @@ By default the window closes after the first signal. Pass
|
|
|
217
279
|
`window=0` runs until the generator is closed), re-arming after each signal
|
|
218
280
|
because the device holds only one code per learning session. A universal
|
|
219
281
|
remote has a single receiver, so only one capture window can be open on a
|
|
220
|
-
device at a time
|
|
282
|
+
device at a time: opening a second one raises `CaptureInProgressError`
|
|
283
|
+
while the first is still held. Always close a window you leave early
|
|
284
|
+
(`aclosing` above does it), otherwise it stays open until Python collects
|
|
285
|
+
the generator.
|
|
221
286
|
|
|
222
287
|
`CapturedSignal` carries the device's own `packet` bytes (ready for
|
|
223
|
-
`send_data`), the decoded `pulses` in microseconds at the
|
|
288
|
+
`send_data`), the decoded `pulses` in microseconds at the corrected tick, the
|
|
224
289
|
`kind` (`SignalKind.IR`, `RF_433` or `RF_315`), the `repeat` count, and for
|
|
225
290
|
RF the `frequency_mhz` the packet itself does not record.
|
|
226
291
|
|
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
1
|
"""The python-broadlink library."""
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
3
4
|
from collections.abc import AsyncIterator
|
|
4
|
-
from typing import List, Optional, Tuple, Union
|
|
5
5
|
|
|
6
6
|
from . import exceptions as e
|
|
7
7
|
from .alarm import S1C
|
|
8
8
|
from .climate import hvac, hysen
|
|
9
9
|
from .const import DEFAULT_BCAST_ADDR, DEFAULT_PORT, DEFAULT_TIMEOUT
|
|
10
10
|
from .cover import dooya, dooya2, wser
|
|
11
|
-
from .device import Device,
|
|
11
|
+
from .device import Device, ping, scan, send_setup_packet
|
|
12
12
|
from .hub import s3
|
|
13
13
|
from .light import lb1, lb2
|
|
14
14
|
from .remote import rm, rm4, rm4mini, rm4pro, rm5plus, rmmini, rmminib, rmpro
|
|
@@ -223,8 +223,8 @@ SUPPORTED_TYPES = {
|
|
|
223
223
|
|
|
224
224
|
def gendevice(
|
|
225
225
|
dev_type: int,
|
|
226
|
-
host:
|
|
227
|
-
mac:
|
|
226
|
+
host: tuple[str, int],
|
|
227
|
+
mac: bytes | str,
|
|
228
228
|
name: str = "",
|
|
229
229
|
is_locked: bool = False,
|
|
230
230
|
) -> Device:
|
|
@@ -258,12 +258,15 @@ async def hello(
|
|
|
258
258
|
|
|
259
259
|
Useful if the device is locked.
|
|
260
260
|
"""
|
|
261
|
-
async
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
261
|
+
async with contextlib.aclosing(
|
|
262
|
+
xdiscover(
|
|
263
|
+
timeout=timeout,
|
|
264
|
+
discover_ip_address=ip_address,
|
|
265
|
+
discover_ip_port=port,
|
|
266
|
+
)
|
|
267
|
+
) as devices:
|
|
268
|
+
async for device in devices:
|
|
269
|
+
return device
|
|
267
270
|
raise e.NetworkTimeoutError(
|
|
268
271
|
-4000,
|
|
269
272
|
"Network timeout",
|
|
@@ -273,10 +276,10 @@ async def hello(
|
|
|
273
276
|
|
|
274
277
|
async def discover(
|
|
275
278
|
timeout: float = DEFAULT_TIMEOUT,
|
|
276
|
-
local_ip_address:
|
|
279
|
+
local_ip_address: str | None = None,
|
|
277
280
|
discover_ip_address: str = DEFAULT_BCAST_ADDR,
|
|
278
281
|
discover_ip_port: int = DEFAULT_PORT,
|
|
279
|
-
) ->
|
|
282
|
+
) -> list[Device]:
|
|
280
283
|
"""Discover devices connected to the local network."""
|
|
281
284
|
return [
|
|
282
285
|
device
|
|
@@ -288,7 +291,7 @@ async def discover(
|
|
|
288
291
|
|
|
289
292
|
async def xdiscover(
|
|
290
293
|
timeout: float = DEFAULT_TIMEOUT,
|
|
291
|
-
local_ip_address:
|
|
294
|
+
local_ip_address: str | None = None,
|
|
292
295
|
discover_ip_address: str = DEFAULT_BCAST_ADDR,
|
|
293
296
|
discover_ip_port: int = DEFAULT_PORT,
|
|
294
297
|
) -> AsyncIterator[Device]:
|
|
@@ -335,8 +338,4 @@ async def setup(
|
|
|
335
338
|
payload[0x20] = checksum & 0xFF # Checksum 1 position
|
|
336
339
|
payload[0x21] = checksum >> 8 # Checksum 2 position
|
|
337
340
|
|
|
338
|
-
|
|
339
|
-
try:
|
|
340
|
-
transport.sendto(payload, (ip_address, DEFAULT_PORT))
|
|
341
|
-
finally:
|
|
342
|
-
transport.close()
|
|
341
|
+
await send_setup_packet(bytes(payload), ip_address)
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"""Support for climate control."""
|
|
2
|
+
|
|
2
3
|
import enum
|
|
3
4
|
import struct
|
|
4
|
-
from
|
|
5
|
+
from collections.abc import Sequence
|
|
5
6
|
|
|
6
7
|
from . import exceptions as e
|
|
7
8
|
from .device import Device
|
|
@@ -33,7 +34,7 @@ class hysen(Device):
|
|
|
33
34
|
payload = self.decrypt(response[0x38:])
|
|
34
35
|
|
|
35
36
|
p_len = int.from_bytes(payload[:0x02], "little")
|
|
36
|
-
nom_crc = int.from_bytes(payload[p_len:p_len+2], "little")
|
|
37
|
+
nom_crc = int.from_bytes(payload[p_len : p_len + 2], "little")
|
|
37
38
|
real_crc = CRC16.calculate(payload[0x02:p_len])
|
|
38
39
|
|
|
39
40
|
if nom_crc != real_crc:
|
|
@@ -83,9 +84,7 @@ class hysen(Device):
|
|
|
83
84
|
data["dif"] = payload[10]
|
|
84
85
|
data["svh"] = payload[11]
|
|
85
86
|
data["svl"] = payload[12]
|
|
86
|
-
data["room_temp_adj"] = (
|
|
87
|
-
int.from_bytes(payload[13:15], "big", signed=True) / 10.0
|
|
88
|
-
)
|
|
87
|
+
data["room_temp_adj"] = int.from_bytes(payload[13:15], "big", signed=True) / 10.0
|
|
89
88
|
data["fre"] = payload[15]
|
|
90
89
|
data["poweron"] = payload[16]
|
|
91
90
|
data["unknown"] = payload[17]
|
|
@@ -127,9 +126,7 @@ class hysen(Device):
|
|
|
127
126
|
# E.g. loop_mode = 0 ("12345,67") means Saturday and Sunday (weekend schedule)
|
|
128
127
|
# loop_mode = 2 ("1234567") means every day, including Saturday and Sunday (weekday schedule)
|
|
129
128
|
# The sensor command is currently experimental
|
|
130
|
-
async def set_mode(
|
|
131
|
-
self, auto_mode: int, loop_mode: int, sensor: int = 0
|
|
132
|
-
) -> None:
|
|
129
|
+
async def set_mode(self, auto_mode: int, loop_mode: int, sensor: int = 0) -> None:
|
|
133
130
|
"""Set the mode of the device."""
|
|
134
131
|
mode_byte = ((loop_mode + 1) << 4) + auto_mode
|
|
135
132
|
await self.send_request([0x01, 0x06, 0x00, 0x02, mode_byte, sensor])
|
|
@@ -210,19 +207,7 @@ class hysen(Device):
|
|
|
210
207
|
async def set_time(self, hour: int, minute: int, second: int, day: int) -> None:
|
|
211
208
|
"""Set the time."""
|
|
212
209
|
await self.send_request(
|
|
213
|
-
[
|
|
214
|
-
0x01,
|
|
215
|
-
0x10,
|
|
216
|
-
0x00,
|
|
217
|
-
0x08,
|
|
218
|
-
0x00,
|
|
219
|
-
0x02,
|
|
220
|
-
0x04,
|
|
221
|
-
hour,
|
|
222
|
-
minute,
|
|
223
|
-
second,
|
|
224
|
-
day
|
|
225
|
-
]
|
|
210
|
+
[0x01, 0x10, 0x00, 0x08, 0x00, 0x02, 0x04, hour, minute, second, day]
|
|
226
211
|
)
|
|
227
212
|
|
|
228
213
|
# Set timer schedule
|
|
@@ -231,7 +216,7 @@ class hysen(Device):
|
|
|
231
216
|
# {'start_hour':17, 'start_minute':30, 'temp': 22 }
|
|
232
217
|
# Each one specifies the thermostat temp that will become effective at start_hour:start_minute
|
|
233
218
|
# weekend is similar but only has 2 (e.g. switch on in morning and off in afternoon)
|
|
234
|
-
async def set_schedule(self, weekday:
|
|
219
|
+
async def set_schedule(self, weekday: list[dict], weekend: list[dict]) -> None:
|
|
235
220
|
"""Set timer schedule."""
|
|
236
221
|
request = [0x01, 0x10, 0x00, 0x0A, 0x00, 0x0C, 0x18]
|
|
237
222
|
|
|
@@ -317,9 +302,7 @@ class hvac(Device):
|
|
|
317
302
|
"""Encode data for transport."""
|
|
318
303
|
packet = bytearray(10)
|
|
319
304
|
p_len = 10 + len(data)
|
|
320
|
-
struct.pack_into(
|
|
321
|
-
"<HHHHH", packet, 0, p_len, 0x00BB, 0x8006, 0, len(data)
|
|
322
|
-
)
|
|
305
|
+
struct.pack_into("<HHHHH", packet, 0, p_len, 0x00BB, 0x8006, 0, len(data))
|
|
323
306
|
packet += data
|
|
324
307
|
crc = CRC16.calculate(packet[0x02:], polynomial=0x9BE4)
|
|
325
308
|
packet += crc.to_bytes(2, "little")
|
|
@@ -330,7 +313,7 @@ class hvac(Device):
|
|
|
330
313
|
# payload[0x2:0x8] == bytes([0xbb, 0x00, 0x07, 0x00, 0x00, 0x00])
|
|
331
314
|
payload = self.decrypt(response[0x38:])
|
|
332
315
|
p_len = int.from_bytes(payload[:0x02], "little")
|
|
333
|
-
nom_crc = int.from_bytes(payload[p_len:p_len+2], "little")
|
|
316
|
+
nom_crc = int.from_bytes(payload[p_len : p_len + 2], "little")
|
|
334
317
|
real_crc = CRC16.calculate(payload[0x02:p_len], polynomial=0x9BE4)
|
|
335
318
|
|
|
336
319
|
if nom_crc != real_crc:
|
|
@@ -341,7 +324,7 @@ class hvac(Device):
|
|
|
341
324
|
)
|
|
342
325
|
|
|
343
326
|
d_len = int.from_bytes(payload[0x08:0x0A], "little")
|
|
344
|
-
return payload[0x0A:0x0A+d_len]
|
|
327
|
+
return payload[0x0A : 0x0A + d_len]
|
|
345
328
|
|
|
346
329
|
async def _send(self, command: int, data: bytes = b"") -> bytes:
|
|
347
330
|
"""Send a command to the unit."""
|