pybls21 4.4.0__tar.gz → 5.1.0__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 (34) hide show
  1. pybls21-5.1.0/MANIFEST.in +1 -0
  2. pybls21-5.1.0/MIGRATION.md +132 -0
  3. pybls21-5.1.0/PKG-INFO +278 -0
  4. pybls21-5.1.0/README.md +254 -0
  5. pybls21-5.1.0/pybls21/__init__.py +26 -0
  6. pybls21-5.1.0/pybls21/_decoder.py +204 -0
  7. pybls21-5.1.0/pybls21/client.py +300 -0
  8. pybls21-5.1.0/pybls21/discovery.py +133 -0
  9. pybls21-5.1.0/pybls21/exceptions.py +20 -0
  10. pybls21-5.1.0/pybls21/models.py +180 -0
  11. pybls21-5.1.0/pybls21.egg-info/PKG-INFO +278 -0
  12. {pybls21-4.4.0 → pybls21-5.1.0}/pybls21.egg-info/SOURCES.txt +11 -2
  13. pybls21-5.1.0/pybls21.egg-info/requires.txt +9 -0
  14. pybls21-5.1.0/pyproject.toml +50 -0
  15. {pybls21-4.4.0 → pybls21-5.1.0}/tests/test_client.py +350 -262
  16. pybls21-5.1.0/tests/test_decoder.py +56 -0
  17. pybls21-5.1.0/tests/test_discovery.py +233 -0
  18. pybls21-5.1.0/tests/test_lifecycle.py +140 -0
  19. pybls21-5.1.0/tests/test_models.py +18 -0
  20. pybls21-4.4.0/PKG-INFO +0 -141
  21. pybls21-4.4.0/README.md +0 -115
  22. pybls21-4.4.0/pybls21/client.py +0 -412
  23. pybls21-4.4.0/pybls21/exceptions.py +0 -8
  24. pybls21-4.4.0/pybls21/models.py +0 -93
  25. pybls21-4.4.0/pybls21.egg-info/PKG-INFO +0 -141
  26. pybls21-4.4.0/pybls21.egg-info/requires.txt +0 -1
  27. pybls21-4.4.0/setup.py +0 -25
  28. {pybls21-4.4.0 → pybls21-5.1.0}/LICENSE +0 -0
  29. {pybls21-4.4.0 → pybls21-5.1.0}/THIRD_PARTY_NOTICES +0 -0
  30. {pybls21-4.4.0 → pybls21-5.1.0}/pybls21/constants.py +0 -0
  31. /pybls21-4.4.0/pybls21/__init__.py → /pybls21-5.1.0/pybls21/py.typed +0 -0
  32. {pybls21-4.4.0 → pybls21-5.1.0}/pybls21.egg-info/dependency_links.txt +0 -0
  33. {pybls21-4.4.0 → pybls21-5.1.0}/pybls21.egg-info/top_level.txt +0 -0
  34. {pybls21-4.4.0 → pybls21-5.1.0}/setup.cfg +0 -0
@@ -0,0 +1 @@
1
+ include MIGRATION.md
@@ -0,0 +1,132 @@
1
+ # Migrating from 4.4 to 5.0
2
+
3
+ Version 5 keeps the async control methods and Modbus mappings, but changes the
4
+ state model, error contract, and packaging. Update the Home Assistant component
5
+ before changing its pinned dependency to `pybls21==5.0.0`.
6
+
7
+ ## Python requirement
8
+
9
+ Version 5 requires Python 3.14.2 or newer, matching Home Assistant 2026.9.4.
10
+ Python 3.10–3.13 and Python 3.14.0–3.14.1 are no longer supported. Update the
11
+ runtime before upgrading; older Home Assistant installations may need an update.
12
+
13
+ ## Device snapshots
14
+
15
+ `ClimateDevice` is now a frozen, keyword-only dataclass with slots. Attribute
16
+ access is unchanged. Tuple indexing, unpacking, `_fields`, `_asdict()`, and
17
+ `_replace()` are removed. Use the standard dataclass helpers when needed:
18
+
19
+ ```python
20
+ from dataclasses import asdict, replace
21
+
22
+ values = asdict(snapshot)
23
+ updated = replace(snapshot, available=False)
24
+ ```
25
+
26
+ `asdict()` is a Python mapping, not a JSON serialization contract: enum and
27
+ `timedelta` values may need conversion for diagnostics or storage.
28
+
29
+ `hvac_modes`, `fan_modes`, and `alarm_codes` are tuples. `alarm_codes` is an empty
30
+ tuple when no alarms are reported. `hvac_mode` and `hvac_action` are annotated
31
+ with their enum types. Both enums now use `StrEnum`: string conversion and
32
+ formatting return the value, so `str(HVACMode.HEAT)` and `f"{HVACMode.HEAT}"`
33
+ produce `"heat"` instead of `"HVACMode.HEAT"`. String equality and value-based
34
+ construction remain supported. `BypassMode` and `BypassType` are now `IntEnum`
35
+ classes. Snapshot fields include Python 3.14 `field(doc=...)` documentation
36
+ for their meaning, units, and unavailable values.
37
+
38
+ `timer_countdown` is a `datetime.timedelta` rather than an `HH:MM:SS` string.
39
+ Use `snapshot.timer_countdown.total_seconds()` for a Home Assistant duration
40
+ sensor; handle `None` for an unknown value. A zero duration is a valid value.
41
+
42
+ ## Home Assistant identity and features
43
+
44
+ The IP-derived `unique_id` is removed. The protocol mapping implemented by this
45
+ library does not expose a stable hardware identifier. The integration must own
46
+ identity: use a device-provided identifier if available, or a config-entry ID
47
+ as a last resort. Do not rebuild entity IDs from the current host address.
48
+
49
+ For existing installations, explicitly migrate the entity/device registry
50
+ identifiers while preserving the registered entities and their user settings;
51
+ simply assigning new identifiers can create duplicates. Inspect the component's
52
+ existing identifier format before implementing that migration.
53
+
54
+ `ClimateEntityFeature` and `snapshot.supported_features` are removed. Set the
55
+ feature flags in the component using Home Assistant's own `ClimateEntityFeature`.
56
+ Other descriptive fields, including manufacturer, model, temperature unit, and
57
+ supported modes, remain available.
58
+
59
+ ## Optional percentage readings
60
+
61
+ `bypass_position`, `supply_fan_speed_percent`, and `extract_fan_speed_percent`
62
+ now report `None` for values outside 0–100, including the `65535` observed on
63
+ physical hardware. Valid readings remain usable even if another optional
64
+ percentage is unknown; the device is still available after a successful poll.
65
+
66
+ ## Errors and inputs
67
+
68
+ Catch `S21Error` for all library device/communication failures, or its subclasses:
69
+
70
+ - `UnsupportedDeviceException`: the device type is not S21.
71
+ - `ModbusCommunicationException`: connection failure, timeout, pymodbus failure,
72
+ error response, or invalid response data.
73
+
74
+ `OSError`, `TimeoutError`, and pymodbus transport exceptions are now wrapped in
75
+ `ModbusCommunicationException`, with the original error in `__cause__`.
76
+ Unexpected programming errors are not wrapped. Task cancellation propagates as
77
+ `asyncio.CancelledError`; cleanup still runs and cached state becomes unavailable.
78
+
79
+ Invalid control arguments raise `ValueError` before connecting. An unknown HVAC
80
+ mode no longer silently selects AUTO. Booleans are rejected for numeric controls.
81
+ An unknown operating mode in a powered-on device response is a communication
82
+ error rather than an assumed AUTO state.
83
+
84
+ ## Client ownership
85
+
86
+ `client.device` remains readable but is now a read-only property. Before the
87
+ first successful poll it is `None`. Successful writes do not optimistically
88
+ change the snapshot: poll to read confirmed state. A failed operation replaces
89
+ the cached snapshot with an unavailable one; previously returned snapshots remain
90
+ unchanged.
91
+
92
+ The underlying pymodbus client and lock are private implementation details;
93
+ `client.client` and `client.lock` are removed. All public control methods are
94
+ still async. No persistent connection or explicit public `close()` is needed:
95
+ each operation connects, performs its work, and closes under one shared lock.
96
+
97
+ Optional keyword arguments `timeout` (seconds per Modbus request) and `retries`
98
+ (request retry count) are now available. Defaults remain 3 seconds and 3 retries.
99
+ They are not a total poll deadline. Callers can bound an entire operation with
100
+ `asyncio.timeout()`, including time waiting for the client lock:
101
+
102
+ ```python
103
+ try:
104
+ async with asyncio.timeout(10):
105
+ snapshot = await client.poll()
106
+ except TimeoutError:
107
+ # The caller's total deadline expired; handle outside the timeout block.
108
+ ...
109
+ except S21Error:
110
+ # A device/transport failure occurred, including per-request timeouts.
111
+ ...
112
+ ```
113
+
114
+ An expired outer deadline cancels the operation and raises `TimeoutError` to
115
+ the caller. An operation that acquired the lock closes its connection and marks
116
+ cached state unavailable; one cancelled while waiting for the lock has not
117
+ started I/O and leaves the active operation and cached state untouched.
118
+
119
+ ## Development and releases
120
+
121
+ Metadata now lives in `pyproject.toml`; `setup.py` is removed. Install with
122
+ `pip install -e '.[dev]'` or `pip install -r requirements.txt` (the latter pins
123
+ pymodbus to the minimum supported version).
124
+
125
+ Pushes and pull requests run validation only. To publish, merge the version bump
126
+ and changes, then publish a GitHub release with the matching tag, such as
127
+ `v5.0.0`. A bare tag push or a draft release does not publish to PyPI. The release
128
+ workflow reruns tests, typing, lint, coverage, and packaging checks, verifies the
129
+ tag against the built package version, and uploads those same validated artifacts
130
+ using the existing `pypi` environment and Trusted Publisher configuration.
131
+ Published prereleases also trigger publishing; use a matching PEP 440 prerelease
132
+ version/tag (for example `5.1.0rc1` / `v5.1.0rc1`).
pybls21-5.1.0/PKG-INFO ADDED
@@ -0,0 +1,278 @@
1
+ Metadata-Version: 2.4
2
+ Name: pybls21
3
+ Version: 5.1.0
4
+ Summary: Async Modbus TCP client for Blauberg S21 ventilation devices
5
+ Author-email: Julius Vitkauskas <zadintuvas@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/jvitkauskas/pybls21
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: Typing :: Typed
11
+ Requires-Python: >=3.14.2
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ License-File: THIRD_PARTY_NOTICES
15
+ Requires-Dist: pymodbus<4.0,>=3.13.1
16
+ Provides-Extra: dev
17
+ Requires-Dist: pyModbusTCP==0.3.0; extra == "dev"
18
+ Requires-Dist: coverage[toml]>=7.6; extra == "dev"
19
+ Requires-Dist: mypy>=1.19; extra == "dev"
20
+ Requires-Dist: ruff>=0.14; extra == "dev"
21
+ Requires-Dist: build>=1.2; extra == "dev"
22
+ Requires-Dist: twine>=6; extra == "dev"
23
+ Dynamic: license-file
24
+
25
+ # Blauberg S21 Asynchronous Python API
26
+ An api allowing control of AC state (temperature, on/off, speed) of an Blauberg S21 device locally over TCP.
27
+
28
+ ## Usage
29
+
30
+ Requires Python 3.14.2+ and `pymodbus>=3.13.1,<4.0`.
31
+
32
+ ```python
33
+ import asyncio
34
+
35
+ from pybls21 import HVACMode, S21Client, S21Error
36
+
37
+
38
+ async def main():
39
+ client = S21Client("192.168.0.125", timeout=3.0, retries=3)
40
+ try:
41
+ snapshot = await client.poll()
42
+ print(snapshot.current_temperature)
43
+ await client.set_hvac_mode(HVACMode.AUTO)
44
+ await client.set_temperature(21)
45
+ except S21Error as error:
46
+ print(f"Device unavailable: {error}")
47
+
48
+
49
+ asyncio.run(main())
50
+ ```
51
+
52
+ Each operation opens and closes its connection. Calls on the same client are
53
+ serialized because the unit supports one connection. Reuse one client per unit.
54
+ `timeout` applies per Modbus request, not to the full poll. Writes do not update
55
+ cached readings; call `poll()` to get confirmed state.
56
+
57
+ To limit the whole poll, including waiting for the client lock, use an outer
58
+ `asyncio.timeout()` context. Handle its `TimeoutError` outside the block:
59
+
60
+ ```python
61
+ try:
62
+ async with asyncio.timeout(10):
63
+ snapshot = await client.poll()
64
+ except TimeoutError:
65
+ print("The total poll deadline expired")
66
+ except S21Error as error:
67
+ print(f"Device communication failed: {error}")
68
+ ```
69
+
70
+ The outer deadline also limits time spent on retries. A per-request timeout is
71
+ reported as `ModbusCommunicationException` (a subclass of `S21Error`); expiry of
72
+ the outer deadline raises `TimeoutError`. Cancellation cleans up an operation
73
+ that has started I/O. Cancelling while waiting for the lock leaves the operation
74
+ already using the connection unaffected.
75
+
76
+ Upgrading from v4? See [the migration guide](MIGRATION.md) for the changed model,
77
+ identity, exceptions, and Home Assistant component migration.
78
+
79
+ | Async method | Input / behavior |
80
+ | --- | --- |
81
+ | `poll()` | Return an immutable `ClimateDevice` snapshot |
82
+ | `turn_on()`, `turn_off()` | Enable/disable the unit |
83
+ | `set_hvac_mode(mode)` | `HVACMode` or its string value; invalid values raise `ValueError` |
84
+ | `set_fan_mode(mode)` | Level 1–5, or 255 for manual; use levels supported by the device |
85
+ | `set_manual_fan_speed_percent(value)` | Integer 0–100; does not select manual mode |
86
+ | `set_temperature(value)` | Integer 15–30 °C |
87
+ | `reset_filter_change_timer()` | Restart the configured filter interval |
88
+ | `reset_alarm()` | Request alarm reset |
89
+ | `boost_on()`, `boost_off()` | Enable/disable boost |
90
+ | `set_bypass_mode(mode)` | `BypassMode` or its integer value |
91
+ | `set_bypass_position(value)` | Integer 0–100; does not change bypass mode |
92
+ | `set_timer_on()`, `set_timer_off()` | Enable/disable the configured timer |
93
+ | `set_scheduler_mode_on()`, `set_scheduler_mode_off()` | Enable/disable the configured weekly schedule |
94
+
95
+ Booleans are not accepted as numeric control values. Bad arguments raise
96
+ `ValueError` before network I/O. `S21Error` is the base for
97
+ `UnsupportedDeviceException`, `DiscoveryError`, and `ModbusCommunicationException`; the latter
98
+ includes transport errors and timeouts, retaining the original cause.
99
+
100
+ ## Discovering devices
101
+
102
+ ```python
103
+ from pybls21 import discover, S21Client
104
+
105
+ # Broadcast one read-only UDP request and collect replies for three seconds.
106
+ devices = await discover()
107
+ for device in devices:
108
+ print(device.device_id, device.host)
109
+ snapshot = await S21Client(device.host).poll()
110
+
111
+ # Optional: target a subnet, choose a local interface, or query a known host.
112
+ devices = await discover(address="192.168.1.255", local_address="192.168.1.10")
113
+ devices = await discover(address="192.168.1.149", timeout=1.0)
114
+ ```
115
+
116
+ `discover()` returns a tuple of immutable `DiscoveredDevice` objects with `host`
117
+ and `device_id`. It collects replies for the whole timeout, deduplicates by
118
+ controller ID (keeping the first address received), and sorts results by ID.
119
+ No replies means an empty tuple. Invalid arguments raise `ValueError`; socket
120
+ failures raise `DiscoveryError`, a subclass of `S21Error`, retaining the cause.
121
+ Cancellation propagates and closes the socket. No background tasks are left running.
122
+
123
+ Discovery uses IPv4 UDP port **4000**, independently of Modbus TCP port **502**.
124
+ It sends a parameter-read request for controller ID and device type using an
125
+ empty password. Replies must have a valid checksum, matching controller IDs,
126
+ and S21 device type 1. This was verified by unicast and broadcast against an S21
127
+ running firmware `0.36 (2019-05-08)`; availability on other firmware is not yet
128
+ verified. The packet format follows the manufacturer's
129
+ [UDP protocol documentation](https://device.report/m/4ad7c8426973ad1157494cc5715f286e69d244ea3fa438d4b66851938bebf485);
130
+ the [S21 manual](https://blaubergdata.de/Daten/Manual_S21.pdf) describes the app's
131
+ network-search feature.
132
+
133
+ The default broadcast address is `255.255.255.255`; it normally reaches only the
134
+ local subnet. On a host with multiple network interfaces, call discovery for
135
+ each desired interface using `local_address` and, if necessary, its subnet's
136
+ broadcast address. Arguments accept IPv4 addresses, not hostnames or IPv6.
137
+ Firewalls and network isolation can prevent replies; manual host configuration
138
+ remains supported. Discovery does not automatically run when creating a client.
139
+
140
+ The controller ID is a device-provided identifier that applications can use
141
+ across IP changes. It is separate from `ClimateDevice` and does not change
142
+ existing polling or entity IDs. UDP replies are not authenticated; validate a
143
+ candidate with `S21Client.poll()` before setting up the device. No device settings
144
+ are written and no password is needed for discovery on the tested unit.
145
+
146
+ ## Additional readings
147
+
148
+ `await client.poll()` also returns extract and exhaust air temperatures
149
+ (`current_extract_temperature`, `current_exhaust_temperature`), supply and
150
+ extract duct pressure in Pa (`supply_pressure`, `extract_pressure`), and whole
151
+ days remaining until filter replacement (`filter_countdown_days`).
152
+
153
+ Missing or short-circuited temperature sensors are reported as `None`, including
154
+ `current_temperature` and `current_intake_temperature`. In AUTO mode,
155
+ `hvac_action` is also `None` when either temperature needed to infer the action
156
+ is unavailable. Other readings remain usable and `available` stays true after
157
+ a successful poll. Connection and communication failures mark
158
+ `client.device.available` false if a previous poll populated the device; a
159
+ successful subsequent poll restores it. Previously returned models are immutable
160
+ snapshots, so read `client.device` for the updated availability.
161
+
162
+ ## Bypass control
163
+
164
+ ```python
165
+ from pybls21.models import BypassMode
166
+
167
+ # AUTO lets the device control its bypass or rotary heat exchanger.
168
+ await client.set_bypass_mode(BypassMode.AUTO)
169
+
170
+ # For analogue control, set the manual percentage and select manual mode.
171
+ await client.set_bypass_position(60)
172
+ await client.set_bypass_mode(BypassMode.OPEN)
173
+ ```
174
+
175
+ `CLOSED` closes the bypass or starts the rotor. `OPEN` opens the bypass or stops
176
+ the rotor for discrete control; with analogue control it selects the percentage
177
+ set by `set_bypass_position()`. That setter alone does not change the mode.
178
+ A value of 0 means closed bypass / maximum rotor speed; 100 means open bypass /
179
+ stopped rotor. Choose controls appropriate to the reported `bypass_type`.
180
+
181
+ The model exposes `bypass_type`, `bypass_mode`, `manual_bypass_position`, and
182
+ `bypass_position`. Position is read separately from optional input register 51.
183
+ If the device rejects this address with Illegal Data Address, `bypass_position` is `None` and
184
+ polling the other readings still succeeds. Other communication errors are
185
+ propagated. When no bypass/rotor is fitted, its mode and positions are `None`
186
+ and the optional read is skipped.
187
+
188
+ Snapshots are frozen dataclasses; access readings by attribute. Collections of
189
+ modes and alarm codes are immutable tuples. The component owns entity identity
190
+ and Home Assistant feature flags; the library does not generate a unique ID
191
+ from the network address.
192
+
193
+ ## Timer, scheduler, and telemetry
194
+
195
+ `set_timer_on()` / `set_timer_off()` enable or disable the device's existing
196
+ main timer. `set_scheduler_mode_on()` / `set_scheduler_mode_off()` enable or
197
+ disable its existing weekly schedule. Configure the timer duration and weekly
198
+ schedule on the device; these methods only toggle their activation.
199
+
200
+ The following fields are available after `await client.poll()`:
201
+
202
+ | Field | Meaning |
203
+ | --- | --- |
204
+ | `is_timer` | Whether the main timer is active |
205
+ | `timer_countdown` | Remaining main timer time as `datetime.timedelta` |
206
+ | `is_schedule_mode` | Whether the weekly schedule is enabled |
207
+ | `fan_level_schedule_mode` | Current scheduled fan level; 0 means standby |
208
+ | `fan_level_timer_mode` | Configured timer fan level; 0 means standby |
209
+ | `alarm_codes` | Active numeric alarm codes (0–52); empty tuple when no alarm or warning is reported |
210
+ | `supply_airflow`, `extract_airflow` | Airflow in m³/h |
211
+ | `operating_time_minutes` | Total device operating time in minutes |
212
+ | `filter_countdown_hours`, `filter_countdown_minutes` | Remaining hours and minutes in addition to `filter_countdown_days` |
213
+ | `supply_fan_speed_percent`, `extract_fan_speed_percent` | Actual fan performance in percent, or `None` when unavailable |
214
+
215
+ `fan_mode` remains the configured normal fan level and `set_fan_mode(mode)`
216
+ still takes one argument. Timer and schedule levels are separate readings, not
217
+ an inferred effective fan level during overrides. The existing
218
+ `supply_fan_speed` and `extract_fan_speed` fields continue to report **RPM**.
219
+
220
+ The timer, schedule, airflow, operating time, and filter readings use the blocks
221
+ already fetched by polling. Detailed alarm codes add a discrete-input read only
222
+ when an alarm or warning is active. Fan percentages add a separate read of
223
+ IR52–53; an Illegal Data Address response leaves both percentages unknown without
224
+ interrupting other readings. Successful reads containing percentages outside
225
+ 0–100 (including `65535` / `0xFFFF`) are also reported as `None`, individually
226
+ for bypass position and each fan percentage. Timeouts and other errors still propagate and
227
+ invalidate cached availability.
228
+
229
+ There is no verified firmware-version cutoff for these optional readings. The
230
+ bundled protocol table ends at IR50; a physical device reporting firmware
231
+ `0.36 (2019-05-08)` accepted IR51–53 reads but returned `0xFFFF` for all three.
232
+ It also returned `0xFFFF` at IR54–55. Those replies are not usable measurements
233
+ and do not establish that the corresponding features are implemented. Its full
234
+ DI0–71 range, including the 53 alarm bits, was readable.
235
+
236
+ These additions are adapted from [marni-xyz's fork](https://github.com/marni-xyz/pybls21),
237
+ including its operating-time, airflow, and fan-performance work attributed to
238
+ [birdie1](https://github.com/birdie1).
239
+
240
+ The copyright and MIT terms for these ported portions are retained in
241
+ [THIRD_PARTY_NOTICES](THIRD_PARTY_NOTICES), included in both source and wheel
242
+ distributions.
243
+
244
+ ## Interpretation limits
245
+
246
+ `current_temperature` is the supply outlet temperature, not necessarily room
247
+ temperature. `hvac_action` is inferred: HEAT and COOL report their configured
248
+ action; AUTO compares supply temperatures before/after heating. It is not a
249
+ measurement of heater/compressor activity. Missing temperature sensors produce
250
+ `None`; zero remains a valid temperature. Optional unsupported registers also
251
+ produce `None`, while communication failures fail the poll.
252
+
253
+ ## Development
254
+
255
+ ```sh
256
+ python -m pip install -r requirements.txt
257
+ ruff check .
258
+ ruff format --check .
259
+ mypy
260
+ python -m coverage run -m unittest -v
261
+ python -m coverage report
262
+ python -m build
263
+ python -m twine check dist/*
264
+ ```
265
+
266
+ CI tests Python 3.14.2 with minimum pymodbus and the latest Python 3.14 patch
267
+ with the newest allowed pymodbus, and requires at least 95% combined
268
+ statement/branch coverage. The Python minimum matches
269
+ [Home Assistant 2026.9.4](https://github.com/home-assistant/core/blob/2026.9.4/pyproject.toml#L22). The package ships
270
+ `py.typed` for downstream type checking.
271
+
272
+ ## Releasing
273
+
274
+ Merges to `main` validate changes without publishing. Set the package version in
275
+ `pyproject.toml`, merge the change, and publish a GitHub release with its matching
276
+ `vVERSION` tag. The release workflow repeats validation, checks the tag/version
277
+ match, and publishes the validated wheel and source distribution to PyPI.
278
+ See [the release details](MIGRATION.md#development-and-releases).
@@ -0,0 +1,254 @@
1
+ # Blauberg S21 Asynchronous Python API
2
+ An api allowing control of AC state (temperature, on/off, speed) of an Blauberg S21 device locally over TCP.
3
+
4
+ ## Usage
5
+
6
+ Requires Python 3.14.2+ and `pymodbus>=3.13.1,<4.0`.
7
+
8
+ ```python
9
+ import asyncio
10
+
11
+ from pybls21 import HVACMode, S21Client, S21Error
12
+
13
+
14
+ async def main():
15
+ client = S21Client("192.168.0.125", timeout=3.0, retries=3)
16
+ try:
17
+ snapshot = await client.poll()
18
+ print(snapshot.current_temperature)
19
+ await client.set_hvac_mode(HVACMode.AUTO)
20
+ await client.set_temperature(21)
21
+ except S21Error as error:
22
+ print(f"Device unavailable: {error}")
23
+
24
+
25
+ asyncio.run(main())
26
+ ```
27
+
28
+ Each operation opens and closes its connection. Calls on the same client are
29
+ serialized because the unit supports one connection. Reuse one client per unit.
30
+ `timeout` applies per Modbus request, not to the full poll. Writes do not update
31
+ cached readings; call `poll()` to get confirmed state.
32
+
33
+ To limit the whole poll, including waiting for the client lock, use an outer
34
+ `asyncio.timeout()` context. Handle its `TimeoutError` outside the block:
35
+
36
+ ```python
37
+ try:
38
+ async with asyncio.timeout(10):
39
+ snapshot = await client.poll()
40
+ except TimeoutError:
41
+ print("The total poll deadline expired")
42
+ except S21Error as error:
43
+ print(f"Device communication failed: {error}")
44
+ ```
45
+
46
+ The outer deadline also limits time spent on retries. A per-request timeout is
47
+ reported as `ModbusCommunicationException` (a subclass of `S21Error`); expiry of
48
+ the outer deadline raises `TimeoutError`. Cancellation cleans up an operation
49
+ that has started I/O. Cancelling while waiting for the lock leaves the operation
50
+ already using the connection unaffected.
51
+
52
+ Upgrading from v4? See [the migration guide](MIGRATION.md) for the changed model,
53
+ identity, exceptions, and Home Assistant component migration.
54
+
55
+ | Async method | Input / behavior |
56
+ | --- | --- |
57
+ | `poll()` | Return an immutable `ClimateDevice` snapshot |
58
+ | `turn_on()`, `turn_off()` | Enable/disable the unit |
59
+ | `set_hvac_mode(mode)` | `HVACMode` or its string value; invalid values raise `ValueError` |
60
+ | `set_fan_mode(mode)` | Level 1–5, or 255 for manual; use levels supported by the device |
61
+ | `set_manual_fan_speed_percent(value)` | Integer 0–100; does not select manual mode |
62
+ | `set_temperature(value)` | Integer 15–30 °C |
63
+ | `reset_filter_change_timer()` | Restart the configured filter interval |
64
+ | `reset_alarm()` | Request alarm reset |
65
+ | `boost_on()`, `boost_off()` | Enable/disable boost |
66
+ | `set_bypass_mode(mode)` | `BypassMode` or its integer value |
67
+ | `set_bypass_position(value)` | Integer 0–100; does not change bypass mode |
68
+ | `set_timer_on()`, `set_timer_off()` | Enable/disable the configured timer |
69
+ | `set_scheduler_mode_on()`, `set_scheduler_mode_off()` | Enable/disable the configured weekly schedule |
70
+
71
+ Booleans are not accepted as numeric control values. Bad arguments raise
72
+ `ValueError` before network I/O. `S21Error` is the base for
73
+ `UnsupportedDeviceException`, `DiscoveryError`, and `ModbusCommunicationException`; the latter
74
+ includes transport errors and timeouts, retaining the original cause.
75
+
76
+ ## Discovering devices
77
+
78
+ ```python
79
+ from pybls21 import discover, S21Client
80
+
81
+ # Broadcast one read-only UDP request and collect replies for three seconds.
82
+ devices = await discover()
83
+ for device in devices:
84
+ print(device.device_id, device.host)
85
+ snapshot = await S21Client(device.host).poll()
86
+
87
+ # Optional: target a subnet, choose a local interface, or query a known host.
88
+ devices = await discover(address="192.168.1.255", local_address="192.168.1.10")
89
+ devices = await discover(address="192.168.1.149", timeout=1.0)
90
+ ```
91
+
92
+ `discover()` returns a tuple of immutable `DiscoveredDevice` objects with `host`
93
+ and `device_id`. It collects replies for the whole timeout, deduplicates by
94
+ controller ID (keeping the first address received), and sorts results by ID.
95
+ No replies means an empty tuple. Invalid arguments raise `ValueError`; socket
96
+ failures raise `DiscoveryError`, a subclass of `S21Error`, retaining the cause.
97
+ Cancellation propagates and closes the socket. No background tasks are left running.
98
+
99
+ Discovery uses IPv4 UDP port **4000**, independently of Modbus TCP port **502**.
100
+ It sends a parameter-read request for controller ID and device type using an
101
+ empty password. Replies must have a valid checksum, matching controller IDs,
102
+ and S21 device type 1. This was verified by unicast and broadcast against an S21
103
+ running firmware `0.36 (2019-05-08)`; availability on other firmware is not yet
104
+ verified. The packet format follows the manufacturer's
105
+ [UDP protocol documentation](https://device.report/m/4ad7c8426973ad1157494cc5715f286e69d244ea3fa438d4b66851938bebf485);
106
+ the [S21 manual](https://blaubergdata.de/Daten/Manual_S21.pdf) describes the app's
107
+ network-search feature.
108
+
109
+ The default broadcast address is `255.255.255.255`; it normally reaches only the
110
+ local subnet. On a host with multiple network interfaces, call discovery for
111
+ each desired interface using `local_address` and, if necessary, its subnet's
112
+ broadcast address. Arguments accept IPv4 addresses, not hostnames or IPv6.
113
+ Firewalls and network isolation can prevent replies; manual host configuration
114
+ remains supported. Discovery does not automatically run when creating a client.
115
+
116
+ The controller ID is a device-provided identifier that applications can use
117
+ across IP changes. It is separate from `ClimateDevice` and does not change
118
+ existing polling or entity IDs. UDP replies are not authenticated; validate a
119
+ candidate with `S21Client.poll()` before setting up the device. No device settings
120
+ are written and no password is needed for discovery on the tested unit.
121
+
122
+ ## Additional readings
123
+
124
+ `await client.poll()` also returns extract and exhaust air temperatures
125
+ (`current_extract_temperature`, `current_exhaust_temperature`), supply and
126
+ extract duct pressure in Pa (`supply_pressure`, `extract_pressure`), and whole
127
+ days remaining until filter replacement (`filter_countdown_days`).
128
+
129
+ Missing or short-circuited temperature sensors are reported as `None`, including
130
+ `current_temperature` and `current_intake_temperature`. In AUTO mode,
131
+ `hvac_action` is also `None` when either temperature needed to infer the action
132
+ is unavailable. Other readings remain usable and `available` stays true after
133
+ a successful poll. Connection and communication failures mark
134
+ `client.device.available` false if a previous poll populated the device; a
135
+ successful subsequent poll restores it. Previously returned models are immutable
136
+ snapshots, so read `client.device` for the updated availability.
137
+
138
+ ## Bypass control
139
+
140
+ ```python
141
+ from pybls21.models import BypassMode
142
+
143
+ # AUTO lets the device control its bypass or rotary heat exchanger.
144
+ await client.set_bypass_mode(BypassMode.AUTO)
145
+
146
+ # For analogue control, set the manual percentage and select manual mode.
147
+ await client.set_bypass_position(60)
148
+ await client.set_bypass_mode(BypassMode.OPEN)
149
+ ```
150
+
151
+ `CLOSED` closes the bypass or starts the rotor. `OPEN` opens the bypass or stops
152
+ the rotor for discrete control; with analogue control it selects the percentage
153
+ set by `set_bypass_position()`. That setter alone does not change the mode.
154
+ A value of 0 means closed bypass / maximum rotor speed; 100 means open bypass /
155
+ stopped rotor. Choose controls appropriate to the reported `bypass_type`.
156
+
157
+ The model exposes `bypass_type`, `bypass_mode`, `manual_bypass_position`, and
158
+ `bypass_position`. Position is read separately from optional input register 51.
159
+ If the device rejects this address with Illegal Data Address, `bypass_position` is `None` and
160
+ polling the other readings still succeeds. Other communication errors are
161
+ propagated. When no bypass/rotor is fitted, its mode and positions are `None`
162
+ and the optional read is skipped.
163
+
164
+ Snapshots are frozen dataclasses; access readings by attribute. Collections of
165
+ modes and alarm codes are immutable tuples. The component owns entity identity
166
+ and Home Assistant feature flags; the library does not generate a unique ID
167
+ from the network address.
168
+
169
+ ## Timer, scheduler, and telemetry
170
+
171
+ `set_timer_on()` / `set_timer_off()` enable or disable the device's existing
172
+ main timer. `set_scheduler_mode_on()` / `set_scheduler_mode_off()` enable or
173
+ disable its existing weekly schedule. Configure the timer duration and weekly
174
+ schedule on the device; these methods only toggle their activation.
175
+
176
+ The following fields are available after `await client.poll()`:
177
+
178
+ | Field | Meaning |
179
+ | --- | --- |
180
+ | `is_timer` | Whether the main timer is active |
181
+ | `timer_countdown` | Remaining main timer time as `datetime.timedelta` |
182
+ | `is_schedule_mode` | Whether the weekly schedule is enabled |
183
+ | `fan_level_schedule_mode` | Current scheduled fan level; 0 means standby |
184
+ | `fan_level_timer_mode` | Configured timer fan level; 0 means standby |
185
+ | `alarm_codes` | Active numeric alarm codes (0–52); empty tuple when no alarm or warning is reported |
186
+ | `supply_airflow`, `extract_airflow` | Airflow in m³/h |
187
+ | `operating_time_minutes` | Total device operating time in minutes |
188
+ | `filter_countdown_hours`, `filter_countdown_minutes` | Remaining hours and minutes in addition to `filter_countdown_days` |
189
+ | `supply_fan_speed_percent`, `extract_fan_speed_percent` | Actual fan performance in percent, or `None` when unavailable |
190
+
191
+ `fan_mode` remains the configured normal fan level and `set_fan_mode(mode)`
192
+ still takes one argument. Timer and schedule levels are separate readings, not
193
+ an inferred effective fan level during overrides. The existing
194
+ `supply_fan_speed` and `extract_fan_speed` fields continue to report **RPM**.
195
+
196
+ The timer, schedule, airflow, operating time, and filter readings use the blocks
197
+ already fetched by polling. Detailed alarm codes add a discrete-input read only
198
+ when an alarm or warning is active. Fan percentages add a separate read of
199
+ IR52–53; an Illegal Data Address response leaves both percentages unknown without
200
+ interrupting other readings. Successful reads containing percentages outside
201
+ 0–100 (including `65535` / `0xFFFF`) are also reported as `None`, individually
202
+ for bypass position and each fan percentage. Timeouts and other errors still propagate and
203
+ invalidate cached availability.
204
+
205
+ There is no verified firmware-version cutoff for these optional readings. The
206
+ bundled protocol table ends at IR50; a physical device reporting firmware
207
+ `0.36 (2019-05-08)` accepted IR51–53 reads but returned `0xFFFF` for all three.
208
+ It also returned `0xFFFF` at IR54–55. Those replies are not usable measurements
209
+ and do not establish that the corresponding features are implemented. Its full
210
+ DI0–71 range, including the 53 alarm bits, was readable.
211
+
212
+ These additions are adapted from [marni-xyz's fork](https://github.com/marni-xyz/pybls21),
213
+ including its operating-time, airflow, and fan-performance work attributed to
214
+ [birdie1](https://github.com/birdie1).
215
+
216
+ The copyright and MIT terms for these ported portions are retained in
217
+ [THIRD_PARTY_NOTICES](THIRD_PARTY_NOTICES), included in both source and wheel
218
+ distributions.
219
+
220
+ ## Interpretation limits
221
+
222
+ `current_temperature` is the supply outlet temperature, not necessarily room
223
+ temperature. `hvac_action` is inferred: HEAT and COOL report their configured
224
+ action; AUTO compares supply temperatures before/after heating. It is not a
225
+ measurement of heater/compressor activity. Missing temperature sensors produce
226
+ `None`; zero remains a valid temperature. Optional unsupported registers also
227
+ produce `None`, while communication failures fail the poll.
228
+
229
+ ## Development
230
+
231
+ ```sh
232
+ python -m pip install -r requirements.txt
233
+ ruff check .
234
+ ruff format --check .
235
+ mypy
236
+ python -m coverage run -m unittest -v
237
+ python -m coverage report
238
+ python -m build
239
+ python -m twine check dist/*
240
+ ```
241
+
242
+ CI tests Python 3.14.2 with minimum pymodbus and the latest Python 3.14 patch
243
+ with the newest allowed pymodbus, and requires at least 95% combined
244
+ statement/branch coverage. The Python minimum matches
245
+ [Home Assistant 2026.9.4](https://github.com/home-assistant/core/blob/2026.9.4/pyproject.toml#L22). The package ships
246
+ `py.typed` for downstream type checking.
247
+
248
+ ## Releasing
249
+
250
+ Merges to `main` validate changes without publishing. Set the package version in
251
+ `pyproject.toml`, merge the change, and publish a GitHub release with its matching
252
+ `vVERSION` tag. The release workflow repeats validation, checks the tag/version
253
+ match, and publishes the validated wheel and source distribution to PyPI.
254
+ See [the release details](MIGRATION.md#development-and-releases).