python-broadlink 1.0.1__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 (42) hide show
  1. {python_broadlink-1.0.1 → python_broadlink-1.0.2}/CHANGELOG.md +52 -0
  2. {python_broadlink-1.0.1/python_broadlink.egg-info → python_broadlink-1.0.2}/PKG-INFO +65 -12
  3. {python_broadlink-1.0.1 → python_broadlink-1.0.2}/README.md +64 -11
  4. {python_broadlink-1.0.1 → python_broadlink-1.0.2}/broadlink/__init__.py +17 -12
  5. {python_broadlink-1.0.1 → python_broadlink-1.0.2}/broadlink/alarm.py +1 -0
  6. {python_broadlink-1.0.1 → python_broadlink-1.0.2}/broadlink/climate.py +10 -27
  7. {python_broadlink-1.0.1 → python_broadlink-1.0.2}/broadlink/const.py +1 -0
  8. {python_broadlink-1.0.1 → python_broadlink-1.0.2}/broadlink/cover.py +2 -1
  9. {python_broadlink-1.0.1 → python_broadlink-1.0.2}/broadlink/device.py +95 -86
  10. {python_broadlink-1.0.1 → python_broadlink-1.0.2}/broadlink/exceptions.py +15 -3
  11. {python_broadlink-1.0.1 → python_broadlink-1.0.2}/broadlink/helpers.py +4 -3
  12. {python_broadlink-1.0.1 → python_broadlink-1.0.2}/broadlink/hub.py +8 -10
  13. {python_broadlink-1.0.1 → python_broadlink-1.0.2}/broadlink/light.py +31 -33
  14. {python_broadlink-1.0.1 → python_broadlink-1.0.2}/broadlink/protocol.py +1 -0
  15. {python_broadlink-1.0.1 → python_broadlink-1.0.2}/broadlink/remote.py +101 -59
  16. {python_broadlink-1.0.1 → python_broadlink-1.0.2}/broadlink/sensor.py +2 -1
  17. {python_broadlink-1.0.1 → python_broadlink-1.0.2}/broadlink/switch.py +32 -32
  18. {python_broadlink-1.0.1 → python_broadlink-1.0.2}/pyproject.toml +17 -4
  19. {python_broadlink-1.0.1 → python_broadlink-1.0.2/python_broadlink.egg-info}/PKG-INFO +65 -12
  20. {python_broadlink-1.0.1 → python_broadlink-1.0.2}/tests/oracle/cases.py +323 -72
  21. {python_broadlink-1.0.1 → python_broadlink-1.0.2}/tests/oracle/harness.py +3 -4
  22. {python_broadlink-1.0.1 → python_broadlink-1.0.2}/tests/test_capture.py +127 -24
  23. {python_broadlink-1.0.1 → python_broadlink-1.0.2}/tests/test_oracle.py +10 -2
  24. {python_broadlink-1.0.1 → python_broadlink-1.0.2}/tests/test_remote.py +2 -1
  25. {python_broadlink-1.0.1 → python_broadlink-1.0.2}/tests/test_transport.py +100 -3
  26. {python_broadlink-1.0.1 → python_broadlink-1.0.2}/LICENSE +0 -0
  27. {python_broadlink-1.0.1 → python_broadlink-1.0.2}/MANIFEST.in +0 -0
  28. {python_broadlink-1.0.1 → python_broadlink-1.0.2}/TROUBLESHOOTING.md +0 -0
  29. {python_broadlink-1.0.1 → python_broadlink-1.0.2}/cli/README.md +0 -0
  30. {python_broadlink-1.0.1 → python_broadlink-1.0.2}/cli/broadlink_cli +0 -0
  31. {python_broadlink-1.0.1 → python_broadlink-1.0.2}/cli/broadlink_discovery +0 -0
  32. {python_broadlink-1.0.1 → python_broadlink-1.0.2}/protocol.md +0 -0
  33. {python_broadlink-1.0.1 → python_broadlink-1.0.2}/python_broadlink.egg-info/SOURCES.txt +0 -0
  34. {python_broadlink-1.0.1 → python_broadlink-1.0.2}/python_broadlink.egg-info/dependency_links.txt +0 -0
  35. {python_broadlink-1.0.1 → python_broadlink-1.0.2}/python_broadlink.egg-info/requires.txt +0 -0
  36. {python_broadlink-1.0.1 → python_broadlink-1.0.2}/python_broadlink.egg-info/top_level.txt +0 -0
  37. {python_broadlink-1.0.1 → python_broadlink-1.0.2}/setup.cfg +0 -0
  38. {python_broadlink-1.0.1 → python_broadlink-1.0.2}/tests/__init__.py +0 -0
  39. {python_broadlink-1.0.1 → python_broadlink-1.0.2}/tests/oracle/__init__.py +0 -0
  40. {python_broadlink-1.0.1 → python_broadlink-1.0.2}/tests/oracle/fixtures.json +0 -0
  41. {python_broadlink-1.0.1 → python_broadlink-1.0.2}/tests/oracle/record.py +0 -0
  42. {python_broadlink-1.0.1 → python_broadlink-1.0.2}/tests/test_helpers.py +0 -0
@@ -3,6 +3,58 @@
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.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
+
6
58
  ## 1.0.1 - 2026-09-05
7
59
 
8
60
  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.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,8 +88,32 @@ 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
  ```
@@ -125,7 +154,7 @@ In order to control the device, you need to connect it to your local network. If
125
154
  - Manually connect to the WiFi SSID named BroadlinkProv.
126
155
  2. Connect the device to your local network with the setup function.
127
156
  ```python3
128
- await broadlink.setup('myssid', 'mynetworkpass', 3)
157
+ await broadlink.setup("myssid", "mynetworkpass", 3)
129
158
  ```
130
159
 
131
160
  Security mode options are (0 = none, 1 = WEP, 2 = WPA1, 3 = WPA2, 4 = WPA1/2)
@@ -134,7 +163,7 @@ Security mode options are (0 = none, 1 = WEP, 2 = WPA1, 3 = WPA2, 4 = WPA1/2)
134
163
 
135
164
  You may need to specify a broadcast address if setup is not working.
136
165
  ```python3
137
- 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")
138
167
  ```
139
168
 
140
169
  ### Discovery
@@ -150,17 +179,17 @@ You may need to specify `local_ip_address` or `discover_ip_address` if discovery
150
179
 
151
180
  Using the IP address of your local machine:
152
181
  ```python3
153
- devices = await broadlink.discover(local_ip_address='192.168.0.100')
182
+ devices = await broadlink.discover(local_ip_address="192.168.0.100")
154
183
  ```
155
184
 
156
185
  Using the broadcast address of your subnet:
157
186
  ```python3
158
- devices = await broadlink.discover(discover_ip_address='192.168.0.255')
187
+ devices = await broadlink.discover(discover_ip_address="192.168.0.255")
159
188
  ```
160
189
 
161
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:
162
191
  ```python3
163
- device = await broadlink.hello('192.168.0.16')
192
+ device = await broadlink.hello("192.168.0.16")
164
193
  ```
165
194
 
166
195
  If you are a perfomance freak, use `broadlink.xdiscover()` to create devices instantly:
@@ -175,6 +204,27 @@ After discovering the device, call the `auth()` method to obtain the authenticat
175
204
  await device.auth()
176
205
  ```
177
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
+
178
228
  The next steps depend on the type of device you want to control.
179
229
 
180
230
  ## Universal remotes
@@ -206,7 +256,7 @@ await device.sweep_frequency()
206
256
  ```python3
207
257
  ok, frequency = await device.check_frequency()
208
258
  if ok:
209
- print(f'Frequency found: {frequency} MHz')
259
+ print(f"Frequency found: {frequency} MHz")
210
260
  ```
211
261
  4. Enter learning mode:
212
262
  ```python3
@@ -248,10 +298,13 @@ By default the window closes after the first signal. Pass
248
298
  `window=0` runs until the generator is closed), re-arming after each signal
249
299
  because the device holds only one code per learning session. A universal
250
300
  remote has a single receiver, so only one capture window can be open on a
251
- 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.
252
305
 
253
306
  `CapturedSignal` carries the device's own `packet` bytes (ready for
254
- `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
255
308
  `kind` (`SignalKind.IR`, `RF_433` or `RF_315`), the `repeat` count, and for
256
309
  RF the `frequency_mhz` the packet itself does not record.
257
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,8 +57,32 @@ 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
  ```
@@ -94,7 +123,7 @@ In order to control the device, you need to connect it to your local network. If
94
123
  - Manually connect to the WiFi SSID named BroadlinkProv.
95
124
  2. Connect the device to your local network with the setup function.
96
125
  ```python3
97
- await broadlink.setup('myssid', 'mynetworkpass', 3)
126
+ await broadlink.setup("myssid", "mynetworkpass", 3)
98
127
  ```
99
128
 
100
129
  Security mode options are (0 = none, 1 = WEP, 2 = WPA1, 3 = WPA2, 4 = WPA1/2)
@@ -103,7 +132,7 @@ Security mode options are (0 = none, 1 = WEP, 2 = WPA1, 3 = WPA2, 4 = WPA1/2)
103
132
 
104
133
  You may need to specify a broadcast address if setup is not working.
105
134
  ```python3
106
- 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")
107
136
  ```
108
137
 
109
138
  ### Discovery
@@ -119,17 +148,17 @@ You may need to specify `local_ip_address` or `discover_ip_address` if discovery
119
148
 
120
149
  Using the IP address of your local machine:
121
150
  ```python3
122
- devices = await broadlink.discover(local_ip_address='192.168.0.100')
151
+ devices = await broadlink.discover(local_ip_address="192.168.0.100")
123
152
  ```
124
153
 
125
154
  Using the broadcast address of your subnet:
126
155
  ```python3
127
- devices = await broadlink.discover(discover_ip_address='192.168.0.255')
156
+ devices = await broadlink.discover(discover_ip_address="192.168.0.255")
128
157
  ```
129
158
 
130
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:
131
160
  ```python3
132
- device = await broadlink.hello('192.168.0.16')
161
+ device = await broadlink.hello("192.168.0.16")
133
162
  ```
134
163
 
135
164
  If you are a perfomance freak, use `broadlink.xdiscover()` to create devices instantly:
@@ -144,6 +173,27 @@ After discovering the device, call the `auth()` method to obtain the authenticat
144
173
  await device.auth()
145
174
  ```
146
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
+
147
197
  The next steps depend on the type of device you want to control.
148
198
 
149
199
  ## Universal remotes
@@ -175,7 +225,7 @@ await device.sweep_frequency()
175
225
  ```python3
176
226
  ok, frequency = await device.check_frequency()
177
227
  if ok:
178
- print(f'Frequency found: {frequency} MHz')
228
+ print(f"Frequency found: {frequency} MHz")
179
229
  ```
180
230
  4. Enter learning mode:
181
231
  ```python3
@@ -217,10 +267,13 @@ By default the window closes after the first signal. Pass
217
267
  `window=0` runs until the generator is closed), re-arming after each signal
218
268
  because the device holds only one code per learning session. A universal
219
269
  remote has a single receiver, so only one capture window can be open on a
220
- 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.
221
274
 
222
275
  `CapturedSignal` carries the device's own `packet` bytes (ready for
223
- `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
224
277
  `kind` (`SignalKind.IR`, `RF_433` or `RF_315`), the `repeat` count, and for
225
278
  RF the `frequency_mhz` the packet itself does not record.
226
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
 
@@ -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