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.
Files changed (43) hide show
  1. python_broadlink-1.0.2/CHANGELOG.md +200 -0
  2. {python_broadlink-1.0.0/python_broadlink.egg-info → python_broadlink-1.0.2}/PKG-INFO +75 -15
  3. {python_broadlink-1.0.0 → python_broadlink-1.0.2}/README.md +74 -14
  4. {python_broadlink-1.0.0 → python_broadlink-1.0.2}/broadlink/__init__.py +17 -12
  5. {python_broadlink-1.0.0 → python_broadlink-1.0.2}/broadlink/alarm.py +1 -0
  6. {python_broadlink-1.0.0 → python_broadlink-1.0.2}/broadlink/climate.py +10 -27
  7. {python_broadlink-1.0.0 → python_broadlink-1.0.2}/broadlink/const.py +1 -0
  8. {python_broadlink-1.0.0 → python_broadlink-1.0.2}/broadlink/cover.py +2 -1
  9. {python_broadlink-1.0.0 → python_broadlink-1.0.2}/broadlink/device.py +157 -94
  10. {python_broadlink-1.0.0 → python_broadlink-1.0.2}/broadlink/exceptions.py +15 -3
  11. {python_broadlink-1.0.0 → python_broadlink-1.0.2}/broadlink/helpers.py +4 -3
  12. {python_broadlink-1.0.0 → python_broadlink-1.0.2}/broadlink/hub.py +8 -10
  13. {python_broadlink-1.0.0 → python_broadlink-1.0.2}/broadlink/light.py +31 -33
  14. {python_broadlink-1.0.0 → python_broadlink-1.0.2}/broadlink/protocol.py +1 -0
  15. {python_broadlink-1.0.0 → python_broadlink-1.0.2}/broadlink/remote.py +178 -75
  16. {python_broadlink-1.0.0 → python_broadlink-1.0.2}/broadlink/sensor.py +2 -1
  17. {python_broadlink-1.0.0 → python_broadlink-1.0.2}/broadlink/switch.py +32 -32
  18. {python_broadlink-1.0.0 → python_broadlink-1.0.2}/pyproject.toml +17 -4
  19. {python_broadlink-1.0.0 → python_broadlink-1.0.2/python_broadlink.egg-info}/PKG-INFO +75 -15
  20. {python_broadlink-1.0.0 → python_broadlink-1.0.2}/tests/oracle/cases.py +323 -72
  21. {python_broadlink-1.0.0 → python_broadlink-1.0.2}/tests/oracle/harness.py +3 -4
  22. {python_broadlink-1.0.0 → python_broadlink-1.0.2}/tests/test_capture.py +187 -19
  23. {python_broadlink-1.0.0 → python_broadlink-1.0.2}/tests/test_oracle.py +10 -2
  24. {python_broadlink-1.0.0 → python_broadlink-1.0.2}/tests/test_remote.py +2 -1
  25. {python_broadlink-1.0.0 → python_broadlink-1.0.2}/tests/test_transport.py +242 -1
  26. python_broadlink-1.0.0/CHANGELOG.md +0 -98
  27. {python_broadlink-1.0.0 → python_broadlink-1.0.2}/LICENSE +0 -0
  28. {python_broadlink-1.0.0 → python_broadlink-1.0.2}/MANIFEST.in +0 -0
  29. {python_broadlink-1.0.0 → python_broadlink-1.0.2}/TROUBLESHOOTING.md +0 -0
  30. {python_broadlink-1.0.0 → python_broadlink-1.0.2}/cli/README.md +0 -0
  31. {python_broadlink-1.0.0 → python_broadlink-1.0.2}/cli/broadlink_cli +0 -0
  32. {python_broadlink-1.0.0 → python_broadlink-1.0.2}/cli/broadlink_discovery +0 -0
  33. {python_broadlink-1.0.0 → python_broadlink-1.0.2}/protocol.md +0 -0
  34. {python_broadlink-1.0.0 → python_broadlink-1.0.2}/python_broadlink.egg-info/SOURCES.txt +0 -0
  35. {python_broadlink-1.0.0 → python_broadlink-1.0.2}/python_broadlink.egg-info/dependency_links.txt +0 -0
  36. {python_broadlink-1.0.0 → python_broadlink-1.0.2}/python_broadlink.egg-info/requires.txt +0 -0
  37. {python_broadlink-1.0.0 → python_broadlink-1.0.2}/python_broadlink.egg-info/top_level.txt +0 -0
  38. {python_broadlink-1.0.0 → python_broadlink-1.0.2}/setup.cfg +0 -0
  39. {python_broadlink-1.0.0 → python_broadlink-1.0.2}/tests/__init__.py +0 -0
  40. {python_broadlink-1.0.0 → python_broadlink-1.0.2}/tests/oracle/__init__.py +0 -0
  41. {python_broadlink-1.0.0 → python_broadlink-1.0.2}/tests/oracle/fixtures.json +0 -0
  42. {python_broadlink-1.0.0 → python_broadlink-1.0.2}/tests/oracle/record.py +0 -0
  43. {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.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. 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, 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
- If the original `broadlink` distribution is also installed in the same
95
- environment, remove it first (`pip3 uninstall broadlink`); both provide the
96
- `broadlink` package.
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('myssid', 'mynetworkpass', 3)
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('myssid', 'mynetworkpass', 3, ip_address='192.168.0.255')
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='192.168.0.100')
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='192.168.0.255')
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.168.0.16')
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'Frequency found: {frequency} MHz')
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 correct tick, 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. 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, 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
- If the original `broadlink` distribution is also installed in the same
64
- environment, remove it first (`pip3 uninstall broadlink`); both provide the
65
- `broadlink` package.
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('myssid', 'mynetworkpass', 3)
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('myssid', 'mynetworkpass', 3, ip_address='192.168.0.255')
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='192.168.0.100')
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='192.168.0.255')
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('192.168.0.16')
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'Frequency found: {frequency} MHz')
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 correct tick, 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 List, Optional, Tuple, Union
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: Tuple[str, int],
227
- mac: Union[bytes, str],
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 for device in xdiscover(
262
- timeout=timeout,
263
- discover_ip_address=ip_address,
264
- discover_ip_port=port,
265
- ):
266
- return device
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: Optional[str] = None,
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
- ) -> List[Device]:
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: Optional[str] = None,
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]:
@@ -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