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.
Files changed (42) hide show
  1. {python_broadlink-1.0.1 → python_broadlink-1.0.3}/CHANGELOG.md +97 -0
  2. {python_broadlink-1.0.1/python_broadlink.egg-info → python_broadlink-1.0.3}/PKG-INFO +79 -14
  3. {python_broadlink-1.0.1 → python_broadlink-1.0.3}/README.md +78 -13
  4. {python_broadlink-1.0.1 → python_broadlink-1.0.3}/broadlink/__init__.py +18 -19
  5. {python_broadlink-1.0.1 → python_broadlink-1.0.3}/broadlink/alarm.py +1 -0
  6. {python_broadlink-1.0.1 → python_broadlink-1.0.3}/broadlink/climate.py +10 -27
  7. {python_broadlink-1.0.1 → python_broadlink-1.0.3}/broadlink/const.py +1 -0
  8. {python_broadlink-1.0.1 → python_broadlink-1.0.3}/broadlink/cover.py +2 -1
  9. {python_broadlink-1.0.1 → python_broadlink-1.0.3}/broadlink/device.py +128 -93
  10. {python_broadlink-1.0.1 → python_broadlink-1.0.3}/broadlink/exceptions.py +16 -4
  11. {python_broadlink-1.0.1 → python_broadlink-1.0.3}/broadlink/helpers.py +4 -3
  12. {python_broadlink-1.0.1 → python_broadlink-1.0.3}/broadlink/hub.py +8 -10
  13. {python_broadlink-1.0.1 → python_broadlink-1.0.3}/broadlink/light.py +31 -33
  14. {python_broadlink-1.0.1 → python_broadlink-1.0.3}/broadlink/protocol.py +1 -0
  15. {python_broadlink-1.0.1 → python_broadlink-1.0.3}/broadlink/remote.py +110 -63
  16. {python_broadlink-1.0.1 → python_broadlink-1.0.3}/broadlink/sensor.py +2 -1
  17. {python_broadlink-1.0.1 → python_broadlink-1.0.3}/broadlink/switch.py +32 -32
  18. {python_broadlink-1.0.1 → python_broadlink-1.0.3}/cli/broadlink_cli +8 -1
  19. {python_broadlink-1.0.1 → python_broadlink-1.0.3}/pyproject.toml +17 -4
  20. {python_broadlink-1.0.1 → python_broadlink-1.0.3/python_broadlink.egg-info}/PKG-INFO +79 -14
  21. {python_broadlink-1.0.1 → python_broadlink-1.0.3}/tests/oracle/cases.py +323 -72
  22. {python_broadlink-1.0.1 → python_broadlink-1.0.3}/tests/oracle/harness.py +3 -4
  23. {python_broadlink-1.0.1 → python_broadlink-1.0.3}/tests/test_capture.py +136 -27
  24. {python_broadlink-1.0.1 → python_broadlink-1.0.3}/tests/test_oracle.py +10 -2
  25. {python_broadlink-1.0.1 → python_broadlink-1.0.3}/tests/test_remote.py +2 -1
  26. {python_broadlink-1.0.1 → python_broadlink-1.0.3}/tests/test_transport.py +210 -8
  27. {python_broadlink-1.0.1 → python_broadlink-1.0.3}/LICENSE +0 -0
  28. {python_broadlink-1.0.1 → python_broadlink-1.0.3}/MANIFEST.in +0 -0
  29. {python_broadlink-1.0.1 → python_broadlink-1.0.3}/TROUBLESHOOTING.md +0 -0
  30. {python_broadlink-1.0.1 → python_broadlink-1.0.3}/cli/README.md +0 -0
  31. {python_broadlink-1.0.1 → python_broadlink-1.0.3}/cli/broadlink_discovery +0 -0
  32. {python_broadlink-1.0.1 → python_broadlink-1.0.3}/protocol.md +0 -0
  33. {python_broadlink-1.0.1 → python_broadlink-1.0.3}/python_broadlink.egg-info/SOURCES.txt +0 -0
  34. {python_broadlink-1.0.1 → python_broadlink-1.0.3}/python_broadlink.egg-info/dependency_links.txt +0 -0
  35. {python_broadlink-1.0.1 → python_broadlink-1.0.3}/python_broadlink.egg-info/requires.txt +0 -0
  36. {python_broadlink-1.0.1 → python_broadlink-1.0.3}/python_broadlink.egg-info/top_level.txt +0 -0
  37. {python_broadlink-1.0.1 → python_broadlink-1.0.3}/setup.cfg +0 -0
  38. {python_broadlink-1.0.1 → python_broadlink-1.0.3}/tests/__init__.py +0 -0
  39. {python_broadlink-1.0.1 → python_broadlink-1.0.3}/tests/oracle/__init__.py +0 -0
  40. {python_broadlink-1.0.1 → python_broadlink-1.0.3}/tests/oracle/fixtures.json +0 -0
  41. {python_broadlink-1.0.1 → python_broadlink-1.0.3}/tests/oracle/record.py +0 -0
  42. {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.1
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. This
51
- is the whole change from the original library's API; method names,
52
- arguments and return values are the same.
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 HY02B05H
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('myssid', 'mynetworkpass', 3)
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('myssid', 'mynetworkpass', 3, ip_address='192.168.0.255')
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='192.168.0.100')
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='192.168.0.255')
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('192.168.0.16')
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'Frequency found: {frequency} MHz')
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 correct tick, 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. This
20
- is the whole change from the original library's API; method names,
21
- arguments and return values are the same.
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 HY02B05H
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('myssid', 'mynetworkpass', 3)
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('myssid', 'mynetworkpass', 3, ip_address='192.168.0.255')
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='192.168.0.100')
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='192.168.0.255')
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('192.168.0.16')
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'Frequency found: {frequency} MHz')
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 correct tick, 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, _open_endpoint, ping, scan
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: Tuple[str, int],
227
- mac: Union[bytes, str],
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 for device in xdiscover(
262
- timeout=timeout,
263
- discover_ip_address=ip_address,
264
- discover_ip_port=port,
265
- ):
266
- return device
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: Optional[str] = None,
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
- ) -> List[Device]:
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: Optional[str] = None,
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
- transport, _ = await _open_endpoint(broadcast=True)
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,4 +1,5 @@
1
1
  """Support for alarm kits."""
2
+
2
3
  from . import exceptions as e
3
4
  from .device import Device
4
5
 
@@ -1,7 +1,8 @@
1
1
  """Support for climate control."""
2
+
2
3
  import enum
3
4
  import struct
4
- from typing import List, Sequence
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: List[dict], weekend: List[dict]) -> None:
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."""
@@ -1,4 +1,5 @@
1
1
  """Constants."""
2
+
2
3
  DEFAULT_BCAST_ADDR = "255.255.255.255"
3
4
  DEFAULT_PORT = 80
4
5
  DEFAULT_RETRY_INTVL = 1
@@ -1,6 +1,7 @@
1
1
  """Support for covers."""
2
+
2
3
  import asyncio
3
- from typing import Sequence
4
+ from collections.abc import Sequence
4
5
 
5
6
  from . import exceptions as e
6
7
  from .device import Device