pybls21 4.3.0__tar.gz → 5.0.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 (33) hide show
  1. pybls21-5.0.0/MANIFEST.in +1 -0
  2. pybls21-5.0.0/MIGRATION.md +132 -0
  3. pybls21-5.0.0/PKG-INFO +232 -0
  4. pybls21-5.0.0/README.md +208 -0
  5. pybls21-5.0.0/THIRD_PARTY_NOTICES +41 -0
  6. pybls21-5.0.0/pybls21/__init__.py +21 -0
  7. pybls21-5.0.0/pybls21/_decoder.py +204 -0
  8. pybls21-5.0.0/pybls21/client.py +300 -0
  9. {pybls21-4.3.0 → pybls21-5.0.0}/pybls21/constants.py +16 -0
  10. pybls21-5.0.0/pybls21/exceptions.py +16 -0
  11. pybls21-5.0.0/pybls21/models.py +180 -0
  12. pybls21-5.0.0/pybls21.egg-info/PKG-INFO +232 -0
  13. {pybls21-4.3.0 → pybls21-5.0.0}/pybls21.egg-info/SOURCES.txt +10 -2
  14. pybls21-5.0.0/pybls21.egg-info/requires.txt +9 -0
  15. pybls21-5.0.0/pyproject.toml +50 -0
  16. pybls21-5.0.0/tests/test_client.py +995 -0
  17. pybls21-5.0.0/tests/test_decoder.py +56 -0
  18. pybls21-5.0.0/tests/test_lifecycle.py +140 -0
  19. pybls21-5.0.0/tests/test_models.py +18 -0
  20. pybls21-4.3.0/PKG-INFO +0 -96
  21. pybls21-4.3.0/README.md +0 -71
  22. pybls21-4.3.0/pybls21/client.py +0 -358
  23. pybls21-4.3.0/pybls21/exceptions.py +0 -8
  24. pybls21-4.3.0/pybls21/models.py +0 -80
  25. pybls21-4.3.0/pybls21.egg-info/PKG-INFO +0 -96
  26. pybls21-4.3.0/pybls21.egg-info/requires.txt +0 -1
  27. pybls21-4.3.0/setup.py +0 -24
  28. pybls21-4.3.0/tests/test_client.py +0 -761
  29. {pybls21-4.3.0 → pybls21-5.0.0}/LICENSE +0 -0
  30. /pybls21-4.3.0/pybls21/__init__.py → /pybls21-5.0.0/pybls21/py.typed +0 -0
  31. {pybls21-4.3.0 → pybls21-5.0.0}/pybls21.egg-info/dependency_links.txt +0 -0
  32. {pybls21-4.3.0 → pybls21-5.0.0}/pybls21.egg-info/top_level.txt +0 -0
  33. {pybls21-4.3.0 → pybls21-5.0.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.0.0/PKG-INFO ADDED
@@ -0,0 +1,232 @@
1
+ Metadata-Version: 2.4
2
+ Name: pybls21
3
+ Version: 5.0.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` and `ModbusCommunicationException`; the latter
98
+ includes transport errors and timeouts, retaining the original cause.
99
+
100
+ ## Additional readings
101
+
102
+ `await client.poll()` also returns extract and exhaust air temperatures
103
+ (`current_extract_temperature`, `current_exhaust_temperature`), supply and
104
+ extract duct pressure in Pa (`supply_pressure`, `extract_pressure`), and whole
105
+ days remaining until filter replacement (`filter_countdown_days`).
106
+
107
+ Missing or short-circuited temperature sensors are reported as `None`, including
108
+ `current_temperature` and `current_intake_temperature`. In AUTO mode,
109
+ `hvac_action` is also `None` when either temperature needed to infer the action
110
+ is unavailable. Other readings remain usable and `available` stays true after
111
+ a successful poll. Connection and communication failures mark
112
+ `client.device.available` false if a previous poll populated the device; a
113
+ successful subsequent poll restores it. Previously returned models are immutable
114
+ snapshots, so read `client.device` for the updated availability.
115
+
116
+ ## Bypass control
117
+
118
+ ```python
119
+ from pybls21.models import BypassMode
120
+
121
+ # AUTO lets the device control its bypass or rotary heat exchanger.
122
+ await client.set_bypass_mode(BypassMode.AUTO)
123
+
124
+ # For analogue control, set the manual percentage and select manual mode.
125
+ await client.set_bypass_position(60)
126
+ await client.set_bypass_mode(BypassMode.OPEN)
127
+ ```
128
+
129
+ `CLOSED` closes the bypass or starts the rotor. `OPEN` opens the bypass or stops
130
+ the rotor for discrete control; with analogue control it selects the percentage
131
+ set by `set_bypass_position()`. That setter alone does not change the mode.
132
+ A value of 0 means closed bypass / maximum rotor speed; 100 means open bypass /
133
+ stopped rotor. Choose controls appropriate to the reported `bypass_type`.
134
+
135
+ The model exposes `bypass_type`, `bypass_mode`, `manual_bypass_position`, and
136
+ `bypass_position`. Position is read separately from optional input register 51.
137
+ If the device rejects this address with Illegal Data Address, `bypass_position` is `None` and
138
+ polling the other readings still succeeds. Other communication errors are
139
+ propagated. When no bypass/rotor is fitted, its mode and positions are `None`
140
+ and the optional read is skipped.
141
+
142
+ Snapshots are frozen dataclasses; access readings by attribute. Collections of
143
+ modes and alarm codes are immutable tuples. The component owns entity identity
144
+ and Home Assistant feature flags; the library does not generate a unique ID
145
+ from the network address.
146
+
147
+ ## Timer, scheduler, and telemetry
148
+
149
+ `set_timer_on()` / `set_timer_off()` enable or disable the device's existing
150
+ main timer. `set_scheduler_mode_on()` / `set_scheduler_mode_off()` enable or
151
+ disable its existing weekly schedule. Configure the timer duration and weekly
152
+ schedule on the device; these methods only toggle their activation.
153
+
154
+ The following fields are available after `await client.poll()`:
155
+
156
+ | Field | Meaning |
157
+ | --- | --- |
158
+ | `is_timer` | Whether the main timer is active |
159
+ | `timer_countdown` | Remaining main timer time as `datetime.timedelta` |
160
+ | `is_schedule_mode` | Whether the weekly schedule is enabled |
161
+ | `fan_level_schedule_mode` | Current scheduled fan level; 0 means standby |
162
+ | `fan_level_timer_mode` | Configured timer fan level; 0 means standby |
163
+ | `alarm_codes` | Active numeric alarm codes (0–52); empty tuple when no alarm or warning is reported |
164
+ | `supply_airflow`, `extract_airflow` | Airflow in m³/h |
165
+ | `operating_time_minutes` | Total device operating time in minutes |
166
+ | `filter_countdown_hours`, `filter_countdown_minutes` | Remaining hours and minutes in addition to `filter_countdown_days` |
167
+ | `supply_fan_speed_percent`, `extract_fan_speed_percent` | Actual fan performance in percent, or `None` when unavailable |
168
+
169
+ `fan_mode` remains the configured normal fan level and `set_fan_mode(mode)`
170
+ still takes one argument. Timer and schedule levels are separate readings, not
171
+ an inferred effective fan level during overrides. The existing
172
+ `supply_fan_speed` and `extract_fan_speed` fields continue to report **RPM**.
173
+
174
+ The timer, schedule, airflow, operating time, and filter readings use the blocks
175
+ already fetched by polling. Detailed alarm codes add a discrete-input read only
176
+ when an alarm or warning is active. Fan percentages add a separate read of
177
+ IR52–53; an Illegal Data Address response leaves both percentages unknown without
178
+ interrupting other readings. Successful reads containing percentages outside
179
+ 0–100 (including `65535` / `0xFFFF`) are also reported as `None`, individually
180
+ for bypass position and each fan percentage. Timeouts and other errors still propagate and
181
+ invalidate cached availability.
182
+
183
+ There is no verified firmware-version cutoff for these optional readings. The
184
+ bundled protocol table ends at IR50; a physical device reporting firmware
185
+ `0.36 (2019-05-08)` accepted IR51–53 reads but returned `0xFFFF` for all three.
186
+ It also returned `0xFFFF` at IR54–55. Those replies are not usable measurements
187
+ and do not establish that the corresponding features are implemented. Its full
188
+ DI0–71 range, including the 53 alarm bits, was readable.
189
+
190
+ These additions are adapted from [marni-xyz's fork](https://github.com/marni-xyz/pybls21),
191
+ including its operating-time, airflow, and fan-performance work attributed to
192
+ [birdie1](https://github.com/birdie1).
193
+
194
+ The copyright and MIT terms for these ported portions are retained in
195
+ [THIRD_PARTY_NOTICES](THIRD_PARTY_NOTICES), included in both source and wheel
196
+ distributions.
197
+
198
+ ## Interpretation limits
199
+
200
+ `current_temperature` is the supply outlet temperature, not necessarily room
201
+ temperature. `hvac_action` is inferred: HEAT and COOL report their configured
202
+ action; AUTO compares supply temperatures before/after heating. It is not a
203
+ measurement of heater/compressor activity. Missing temperature sensors produce
204
+ `None`; zero remains a valid temperature. Optional unsupported registers also
205
+ produce `None`, while communication failures fail the poll.
206
+
207
+ ## Development
208
+
209
+ ```sh
210
+ python -m pip install -r requirements.txt
211
+ ruff check .
212
+ ruff format --check .
213
+ mypy
214
+ python -m coverage run -m unittest -v
215
+ python -m coverage report
216
+ python -m build
217
+ python -m twine check dist/*
218
+ ```
219
+
220
+ CI tests Python 3.14.2 with minimum pymodbus and the latest Python 3.14 patch
221
+ with the newest allowed pymodbus, and requires at least 95% combined
222
+ statement/branch coverage. The Python minimum matches
223
+ [Home Assistant 2026.9.4](https://github.com/home-assistant/core/blob/2026.9.4/pyproject.toml#L22). The package ships
224
+ `py.typed` for downstream type checking.
225
+
226
+ ## Releasing
227
+
228
+ Merges to `main` validate changes without publishing. Set the package version in
229
+ `pyproject.toml`, merge the change, and publish a GitHub release with its matching
230
+ `vVERSION` tag. The release workflow repeats validation, checks the tag/version
231
+ match, and publishes the validated wheel and source distribution to PyPI.
232
+ See [the release details](MIGRATION.md#development-and-releases).
@@ -0,0 +1,208 @@
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` and `ModbusCommunicationException`; the latter
74
+ includes transport errors and timeouts, retaining the original cause.
75
+
76
+ ## Additional readings
77
+
78
+ `await client.poll()` also returns extract and exhaust air temperatures
79
+ (`current_extract_temperature`, `current_exhaust_temperature`), supply and
80
+ extract duct pressure in Pa (`supply_pressure`, `extract_pressure`), and whole
81
+ days remaining until filter replacement (`filter_countdown_days`).
82
+
83
+ Missing or short-circuited temperature sensors are reported as `None`, including
84
+ `current_temperature` and `current_intake_temperature`. In AUTO mode,
85
+ `hvac_action` is also `None` when either temperature needed to infer the action
86
+ is unavailable. Other readings remain usable and `available` stays true after
87
+ a successful poll. Connection and communication failures mark
88
+ `client.device.available` false if a previous poll populated the device; a
89
+ successful subsequent poll restores it. Previously returned models are immutable
90
+ snapshots, so read `client.device` for the updated availability.
91
+
92
+ ## Bypass control
93
+
94
+ ```python
95
+ from pybls21.models import BypassMode
96
+
97
+ # AUTO lets the device control its bypass or rotary heat exchanger.
98
+ await client.set_bypass_mode(BypassMode.AUTO)
99
+
100
+ # For analogue control, set the manual percentage and select manual mode.
101
+ await client.set_bypass_position(60)
102
+ await client.set_bypass_mode(BypassMode.OPEN)
103
+ ```
104
+
105
+ `CLOSED` closes the bypass or starts the rotor. `OPEN` opens the bypass or stops
106
+ the rotor for discrete control; with analogue control it selects the percentage
107
+ set by `set_bypass_position()`. That setter alone does not change the mode.
108
+ A value of 0 means closed bypass / maximum rotor speed; 100 means open bypass /
109
+ stopped rotor. Choose controls appropriate to the reported `bypass_type`.
110
+
111
+ The model exposes `bypass_type`, `bypass_mode`, `manual_bypass_position`, and
112
+ `bypass_position`. Position is read separately from optional input register 51.
113
+ If the device rejects this address with Illegal Data Address, `bypass_position` is `None` and
114
+ polling the other readings still succeeds. Other communication errors are
115
+ propagated. When no bypass/rotor is fitted, its mode and positions are `None`
116
+ and the optional read is skipped.
117
+
118
+ Snapshots are frozen dataclasses; access readings by attribute. Collections of
119
+ modes and alarm codes are immutable tuples. The component owns entity identity
120
+ and Home Assistant feature flags; the library does not generate a unique ID
121
+ from the network address.
122
+
123
+ ## Timer, scheduler, and telemetry
124
+
125
+ `set_timer_on()` / `set_timer_off()` enable or disable the device's existing
126
+ main timer. `set_scheduler_mode_on()` / `set_scheduler_mode_off()` enable or
127
+ disable its existing weekly schedule. Configure the timer duration and weekly
128
+ schedule on the device; these methods only toggle their activation.
129
+
130
+ The following fields are available after `await client.poll()`:
131
+
132
+ | Field | Meaning |
133
+ | --- | --- |
134
+ | `is_timer` | Whether the main timer is active |
135
+ | `timer_countdown` | Remaining main timer time as `datetime.timedelta` |
136
+ | `is_schedule_mode` | Whether the weekly schedule is enabled |
137
+ | `fan_level_schedule_mode` | Current scheduled fan level; 0 means standby |
138
+ | `fan_level_timer_mode` | Configured timer fan level; 0 means standby |
139
+ | `alarm_codes` | Active numeric alarm codes (0–52); empty tuple when no alarm or warning is reported |
140
+ | `supply_airflow`, `extract_airflow` | Airflow in m³/h |
141
+ | `operating_time_minutes` | Total device operating time in minutes |
142
+ | `filter_countdown_hours`, `filter_countdown_minutes` | Remaining hours and minutes in addition to `filter_countdown_days` |
143
+ | `supply_fan_speed_percent`, `extract_fan_speed_percent` | Actual fan performance in percent, or `None` when unavailable |
144
+
145
+ `fan_mode` remains the configured normal fan level and `set_fan_mode(mode)`
146
+ still takes one argument. Timer and schedule levels are separate readings, not
147
+ an inferred effective fan level during overrides. The existing
148
+ `supply_fan_speed` and `extract_fan_speed` fields continue to report **RPM**.
149
+
150
+ The timer, schedule, airflow, operating time, and filter readings use the blocks
151
+ already fetched by polling. Detailed alarm codes add a discrete-input read only
152
+ when an alarm or warning is active. Fan percentages add a separate read of
153
+ IR52–53; an Illegal Data Address response leaves both percentages unknown without
154
+ interrupting other readings. Successful reads containing percentages outside
155
+ 0–100 (including `65535` / `0xFFFF`) are also reported as `None`, individually
156
+ for bypass position and each fan percentage. Timeouts and other errors still propagate and
157
+ invalidate cached availability.
158
+
159
+ There is no verified firmware-version cutoff for these optional readings. The
160
+ bundled protocol table ends at IR50; a physical device reporting firmware
161
+ `0.36 (2019-05-08)` accepted IR51–53 reads but returned `0xFFFF` for all three.
162
+ It also returned `0xFFFF` at IR54–55. Those replies are not usable measurements
163
+ and do not establish that the corresponding features are implemented. Its full
164
+ DI0–71 range, including the 53 alarm bits, was readable.
165
+
166
+ These additions are adapted from [marni-xyz's fork](https://github.com/marni-xyz/pybls21),
167
+ including its operating-time, airflow, and fan-performance work attributed to
168
+ [birdie1](https://github.com/birdie1).
169
+
170
+ The copyright and MIT terms for these ported portions are retained in
171
+ [THIRD_PARTY_NOTICES](THIRD_PARTY_NOTICES), included in both source and wheel
172
+ distributions.
173
+
174
+ ## Interpretation limits
175
+
176
+ `current_temperature` is the supply outlet temperature, not necessarily room
177
+ temperature. `hvac_action` is inferred: HEAT and COOL report their configured
178
+ action; AUTO compares supply temperatures before/after heating. It is not a
179
+ measurement of heater/compressor activity. Missing temperature sensors produce
180
+ `None`; zero remains a valid temperature. Optional unsupported registers also
181
+ produce `None`, while communication failures fail the poll.
182
+
183
+ ## Development
184
+
185
+ ```sh
186
+ python -m pip install -r requirements.txt
187
+ ruff check .
188
+ ruff format --check .
189
+ mypy
190
+ python -m coverage run -m unittest -v
191
+ python -m coverage report
192
+ python -m build
193
+ python -m twine check dist/*
194
+ ```
195
+
196
+ CI tests Python 3.14.2 with minimum pymodbus and the latest Python 3.14 patch
197
+ with the newest allowed pymodbus, and requires at least 95% combined
198
+ statement/branch coverage. The Python minimum matches
199
+ [Home Assistant 2026.9.4](https://github.com/home-assistant/core/blob/2026.9.4/pyproject.toml#L22). The package ships
200
+ `py.typed` for downstream type checking.
201
+
202
+ ## Releasing
203
+
204
+ Merges to `main` validate changes without publishing. Set the package version in
205
+ `pyproject.toml`, merge the change, and publish a GitHub release with its matching
206
+ `vVERSION` tag. The release workflow repeats validation, checks the tag/version
207
+ match, and publishes the validated wheel and source distribution to PyPI.
208
+ See [the release details](MIGRATION.md#development-and-releases).
@@ -0,0 +1,41 @@
1
+ Third-party notices
2
+ ===================
3
+
4
+ Ported S21 controls and telemetry
5
+ --------------------------------
6
+
7
+ This project includes code adapted from marni-xyz/pybls21 for timer and
8
+ weekly-schedule activation, alarm details, timer and filter countdowns,
9
+ airflow, operating time, and fan-performance readings. This notice applies
10
+ to those incorporated portions and their adaptations.
11
+
12
+ Source: https://github.com/marni-xyz/pybls21
13
+ Revision: 4bbd1189811d7e96ec501a632aac7824e2ad907d
14
+
15
+ The upstream fork attributes the operating-time, airflow, and fan-performance
16
+ additions to birdie1. Contributor acknowledgements are also recorded in the
17
+ README and Git commit history.
18
+
19
+ The source project's copyright notice and MIT license follow:
20
+
21
+ MIT License
22
+
23
+ Copyright (c) 2026 marni-xyz / 2021 Julius Vitkauskas
24
+
25
+ Permission is hereby granted, free of charge, to any person obtaining a copy
26
+ of this software and associated documentation files (the "Software"), to deal
27
+ in the Software without restriction, including without limitation the rights
28
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
29
+ copies of the Software, and to permit persons to whom the Software is
30
+ furnished to do so, subject to the following conditions:
31
+
32
+ The above copyright notice and this permission notice shall be included in all
33
+ copies or substantial portions of the Software.
34
+
35
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
36
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
37
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
38
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
39
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
40
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
41
+ SOFTWARE.
@@ -0,0 +1,21 @@
1
+ """Public API for the asynchronous Blauberg S21 client."""
2
+
3
+ from .client import S21Client
4
+ from .exceptions import (
5
+ ModbusCommunicationException,
6
+ S21Error,
7
+ UnsupportedDeviceException,
8
+ )
9
+ from .models import BypassMode, BypassType, ClimateDevice, HVACAction, HVACMode
10
+
11
+ __all__ = [
12
+ "S21Client",
13
+ "S21Error",
14
+ "ModbusCommunicationException",
15
+ "UnsupportedDeviceException",
16
+ "BypassMode",
17
+ "BypassType",
18
+ "ClimateDevice",
19
+ "HVACAction",
20
+ "HVACMode",
21
+ ]