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.
- pybls21-5.0.0/MANIFEST.in +1 -0
- pybls21-5.0.0/MIGRATION.md +132 -0
- pybls21-5.0.0/PKG-INFO +232 -0
- pybls21-5.0.0/README.md +208 -0
- pybls21-5.0.0/THIRD_PARTY_NOTICES +41 -0
- pybls21-5.0.0/pybls21/__init__.py +21 -0
- pybls21-5.0.0/pybls21/_decoder.py +204 -0
- pybls21-5.0.0/pybls21/client.py +300 -0
- {pybls21-4.3.0 → pybls21-5.0.0}/pybls21/constants.py +16 -0
- pybls21-5.0.0/pybls21/exceptions.py +16 -0
- pybls21-5.0.0/pybls21/models.py +180 -0
- pybls21-5.0.0/pybls21.egg-info/PKG-INFO +232 -0
- {pybls21-4.3.0 → pybls21-5.0.0}/pybls21.egg-info/SOURCES.txt +10 -2
- pybls21-5.0.0/pybls21.egg-info/requires.txt +9 -0
- pybls21-5.0.0/pyproject.toml +50 -0
- pybls21-5.0.0/tests/test_client.py +995 -0
- pybls21-5.0.0/tests/test_decoder.py +56 -0
- pybls21-5.0.0/tests/test_lifecycle.py +140 -0
- pybls21-5.0.0/tests/test_models.py +18 -0
- pybls21-4.3.0/PKG-INFO +0 -96
- pybls21-4.3.0/README.md +0 -71
- pybls21-4.3.0/pybls21/client.py +0 -358
- pybls21-4.3.0/pybls21/exceptions.py +0 -8
- pybls21-4.3.0/pybls21/models.py +0 -80
- pybls21-4.3.0/pybls21.egg-info/PKG-INFO +0 -96
- pybls21-4.3.0/pybls21.egg-info/requires.txt +0 -1
- pybls21-4.3.0/setup.py +0 -24
- pybls21-4.3.0/tests/test_client.py +0 -761
- {pybls21-4.3.0 → pybls21-5.0.0}/LICENSE +0 -0
- /pybls21-4.3.0/pybls21/__init__.py → /pybls21-5.0.0/pybls21/py.typed +0 -0
- {pybls21-4.3.0 → pybls21-5.0.0}/pybls21.egg-info/dependency_links.txt +0 -0
- {pybls21-4.3.0 → pybls21-5.0.0}/pybls21.egg-info/top_level.txt +0 -0
- {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).
|
pybls21-5.0.0/README.md
ADDED
|
@@ -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
|
+
]
|