pybls21 4.2.2__tar.gz → 4.4.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-4.4.0/PKG-INFO +141 -0
- pybls21-4.4.0/README.md +115 -0
- pybls21-4.4.0/THIRD_PARTY_NOTICES +41 -0
- {pybls21-4.2.2 → pybls21-4.4.0}/pybls21/client.py +138 -11
- pybls21-4.4.0/pybls21/constants.py +52 -0
- pybls21-4.4.0/pybls21/models.py +93 -0
- pybls21-4.4.0/pybls21.egg-info/PKG-INFO +141 -0
- {pybls21-4.2.2 → pybls21-4.4.0}/pybls21.egg-info/SOURCES.txt +1 -0
- pybls21-4.4.0/pybls21.egg-info/requires.txt +1 -0
- {pybls21-4.2.2 → pybls21-4.4.0}/setup.py +3 -2
- {pybls21-4.2.2 → pybls21-4.4.0}/tests/test_client.py +396 -3
- pybls21-4.2.2/PKG-INFO +0 -43
- pybls21-4.2.2/README.md +0 -18
- pybls21-4.2.2/pybls21/constants.py +0 -25
- pybls21-4.2.2/pybls21/models.py +0 -56
- pybls21-4.2.2/pybls21.egg-info/PKG-INFO +0 -43
- pybls21-4.2.2/pybls21.egg-info/requires.txt +0 -1
- {pybls21-4.2.2 → pybls21-4.4.0}/LICENSE +0 -0
- {pybls21-4.2.2 → pybls21-4.4.0}/pybls21/__init__.py +0 -0
- {pybls21-4.2.2 → pybls21-4.4.0}/pybls21/exceptions.py +0 -0
- {pybls21-4.2.2 → pybls21-4.4.0}/pybls21.egg-info/dependency_links.txt +0 -0
- {pybls21-4.2.2 → pybls21-4.4.0}/pybls21.egg-info/top_level.txt +0 -0
- {pybls21-4.2.2 → pybls21-4.4.0}/setup.cfg +0 -0
pybls21-4.4.0/PKG-INFO
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pybls21
|
|
3
|
+
Version: 4.4.0
|
|
4
|
+
Summary: An api allowing control of AC state (temperature, on/off, speed) of an Blauberg S21 device locally over TCP
|
|
5
|
+
Home-page: https://github.com/jvitkauskas/pybls21
|
|
6
|
+
Author: Julius Vitkauskas
|
|
7
|
+
Author-email: zadintuvas@gmail.com
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Requires-Python: >=3.10
|
|
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
|
+
Dynamic: author
|
|
17
|
+
Dynamic: author-email
|
|
18
|
+
Dynamic: classifier
|
|
19
|
+
Dynamic: description
|
|
20
|
+
Dynamic: description-content-type
|
|
21
|
+
Dynamic: home-page
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
Dynamic: requires-dist
|
|
24
|
+
Dynamic: requires-python
|
|
25
|
+
Dynamic: summary
|
|
26
|
+
|
|
27
|
+
# Blauberg S21 Asynchronous Python API
|
|
28
|
+
An api allowing control of AC state (temperature, on/off, speed) of an Blauberg S21 device locally over TCP.
|
|
29
|
+
|
|
30
|
+
## Usage
|
|
31
|
+
To initialize:
|
|
32
|
+
`client = S21Client("192.168.0.125")`
|
|
33
|
+
|
|
34
|
+
To load:
|
|
35
|
+
`await client.poll()`
|
|
36
|
+
|
|
37
|
+
The following functions are available:
|
|
38
|
+
`turn_on()`
|
|
39
|
+
`turn_off()`
|
|
40
|
+
`set_hvac_mode(hvac_mode: HVACMode)`
|
|
41
|
+
`set_fan_mode(mode: int)`
|
|
42
|
+
`set_manual_fan_speed_percent(speed_percent: int)`
|
|
43
|
+
`set_temperature(temp_celsius: int)`
|
|
44
|
+
`reset_filter_change_timer()`
|
|
45
|
+
`reset_alarm()`
|
|
46
|
+
`boost_on()`
|
|
47
|
+
`boost_off()`
|
|
48
|
+
`set_bypass_mode(mode: BypassMode)`
|
|
49
|
+
`set_bypass_position(position_percent: int)`
|
|
50
|
+
`set_timer_on()` / `set_timer_off()`
|
|
51
|
+
`set_scheduler_mode_on()` / `set_scheduler_mode_off()`
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
## Additional readings
|
|
55
|
+
|
|
56
|
+
`await client.poll()` also returns extract and exhaust air temperatures
|
|
57
|
+
(`current_extract_temperature`, `current_exhaust_temperature`), supply and
|
|
58
|
+
extract duct pressure in Pa (`supply_pressure`, `extract_pressure`), and whole
|
|
59
|
+
days remaining until filter replacement (`filter_countdown_days`).
|
|
60
|
+
|
|
61
|
+
Missing or short-circuited temperature sensors are reported as `None`, including
|
|
62
|
+
`current_temperature` and `current_intake_temperature`. In AUTO mode,
|
|
63
|
+
`hvac_action` is also `None` when either temperature needed to infer the action
|
|
64
|
+
is unavailable. Other readings remain usable and `available` stays true after
|
|
65
|
+
a successful poll. Connection and communication failures mark
|
|
66
|
+
`client.device.available` false if a previous poll populated the device; a
|
|
67
|
+
successful subsequent poll restores it. Previously returned models are immutable
|
|
68
|
+
snapshots, so read `client.device` for the updated availability.
|
|
69
|
+
|
|
70
|
+
## Bypass control
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from pybls21.models import BypassMode
|
|
74
|
+
|
|
75
|
+
# AUTO lets the device control its bypass or rotary heat exchanger.
|
|
76
|
+
await client.set_bypass_mode(BypassMode.AUTO)
|
|
77
|
+
|
|
78
|
+
# For analogue control, set the manual percentage and select manual mode.
|
|
79
|
+
await client.set_bypass_position(60)
|
|
80
|
+
await client.set_bypass_mode(BypassMode.OPEN)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`CLOSED` closes the bypass or starts the rotor. `OPEN` opens the bypass or stops
|
|
84
|
+
the rotor for discrete control; with analogue control it selects the percentage
|
|
85
|
+
set by `set_bypass_position()`. That setter alone does not change the mode.
|
|
86
|
+
A value of 0 means closed bypass / maximum rotor speed; 100 means open bypass /
|
|
87
|
+
stopped rotor. Choose controls appropriate to the reported `bypass_type`.
|
|
88
|
+
|
|
89
|
+
The model exposes `bypass_type`, `bypass_mode`, `manual_bypass_position`, and
|
|
90
|
+
`bypass_position`. Position is read separately from optional input register 51.
|
|
91
|
+
On older firmware that rejects this address, `bypass_position` is `None` and
|
|
92
|
+
polling the other readings still succeeds. Other communication errors are
|
|
93
|
+
propagated. When no bypass/rotor is fitted, its mode and positions are `None`
|
|
94
|
+
and the optional read is skipped.
|
|
95
|
+
|
|
96
|
+
New model fields have defaults, preserving construction with the original
|
|
97
|
+
positional or keyword arguments. The tuple now contains additional fields;
|
|
98
|
+
consumers should access readings by attribute rather than unpacking a fixed
|
|
99
|
+
number of values.
|
|
100
|
+
|
|
101
|
+
## Timer, scheduler, and telemetry
|
|
102
|
+
|
|
103
|
+
`set_timer_on()` / `set_timer_off()` enable or disable the device's existing
|
|
104
|
+
main timer. `set_scheduler_mode_on()` / `set_scheduler_mode_off()` enable or
|
|
105
|
+
disable its existing weekly schedule. Configure the timer duration and weekly
|
|
106
|
+
schedule on the device; these methods only toggle their activation.
|
|
107
|
+
|
|
108
|
+
The following fields are available after `await client.poll()`:
|
|
109
|
+
|
|
110
|
+
| Field | Meaning |
|
|
111
|
+
| --- | --- |
|
|
112
|
+
| `is_timer` | Whether the main timer is active |
|
|
113
|
+
| `timer_countdown` | Remaining main timer time as `HH:MM:SS` |
|
|
114
|
+
| `is_schedule_mode` | Whether the weekly schedule is enabled |
|
|
115
|
+
| `fan_level_schedule_mode` | Current scheduled fan level; 0 means standby |
|
|
116
|
+
| `fan_level_timer_mode` | Configured timer fan level; 0 means standby |
|
|
117
|
+
| `alarm_codes` | Active numeric alarm codes (0–52); empty list when no alarm or warning is reported |
|
|
118
|
+
| `supply_airflow`, `extract_airflow` | Airflow in m³/h |
|
|
119
|
+
| `operating_time_minutes` | Total device operating time in minutes |
|
|
120
|
+
| `filter_countdown_hours`, `filter_countdown_minutes` | Remaining hours and minutes in addition to `filter_countdown_days` |
|
|
121
|
+
| `supply_fan_speed_percent`, `extract_fan_speed_percent` | Actual fan performance in percent, or `None` on older firmware |
|
|
122
|
+
|
|
123
|
+
`fan_mode` remains the configured normal fan level and `set_fan_mode(mode)`
|
|
124
|
+
still takes one argument. Timer and schedule levels are separate readings, not
|
|
125
|
+
an inferred effective fan level during overrides. The existing
|
|
126
|
+
`supply_fan_speed` and `extract_fan_speed` fields continue to report **RPM**.
|
|
127
|
+
|
|
128
|
+
The timer, schedule, airflow, operating time, and filter readings use the blocks
|
|
129
|
+
already fetched by polling. Detailed alarm codes add a discrete-input read only
|
|
130
|
+
when an alarm or warning is active. Fan percentages add a separate read of
|
|
131
|
+
IR52–53; an Illegal Data Address response leaves both percentages unknown without
|
|
132
|
+
interrupting other readings. Timeouts and other errors still propagate and
|
|
133
|
+
invalidate cached availability.
|
|
134
|
+
|
|
135
|
+
These additions are adapted from [marni-xyz's fork](https://github.com/marni-xyz/pybls21),
|
|
136
|
+
including its operating-time, airflow, and fan-performance work attributed to
|
|
137
|
+
[birdie1](https://github.com/birdie1).
|
|
138
|
+
|
|
139
|
+
The copyright and MIT terms for these ported portions are retained in
|
|
140
|
+
[THIRD_PARTY_NOTICES](THIRD_PARTY_NOTICES), included in both source and wheel
|
|
141
|
+
distributions.
|
pybls21-4.4.0/README.md
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
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
|
+
To initialize:
|
|
6
|
+
`client = S21Client("192.168.0.125")`
|
|
7
|
+
|
|
8
|
+
To load:
|
|
9
|
+
`await client.poll()`
|
|
10
|
+
|
|
11
|
+
The following functions are available:
|
|
12
|
+
`turn_on()`
|
|
13
|
+
`turn_off()`
|
|
14
|
+
`set_hvac_mode(hvac_mode: HVACMode)`
|
|
15
|
+
`set_fan_mode(mode: int)`
|
|
16
|
+
`set_manual_fan_speed_percent(speed_percent: int)`
|
|
17
|
+
`set_temperature(temp_celsius: int)`
|
|
18
|
+
`reset_filter_change_timer()`
|
|
19
|
+
`reset_alarm()`
|
|
20
|
+
`boost_on()`
|
|
21
|
+
`boost_off()`
|
|
22
|
+
`set_bypass_mode(mode: BypassMode)`
|
|
23
|
+
`set_bypass_position(position_percent: int)`
|
|
24
|
+
`set_timer_on()` / `set_timer_off()`
|
|
25
|
+
`set_scheduler_mode_on()` / `set_scheduler_mode_off()`
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
## Additional readings
|
|
29
|
+
|
|
30
|
+
`await client.poll()` also returns extract and exhaust air temperatures
|
|
31
|
+
(`current_extract_temperature`, `current_exhaust_temperature`), supply and
|
|
32
|
+
extract duct pressure in Pa (`supply_pressure`, `extract_pressure`), and whole
|
|
33
|
+
days remaining until filter replacement (`filter_countdown_days`).
|
|
34
|
+
|
|
35
|
+
Missing or short-circuited temperature sensors are reported as `None`, including
|
|
36
|
+
`current_temperature` and `current_intake_temperature`. In AUTO mode,
|
|
37
|
+
`hvac_action` is also `None` when either temperature needed to infer the action
|
|
38
|
+
is unavailable. Other readings remain usable and `available` stays true after
|
|
39
|
+
a successful poll. Connection and communication failures mark
|
|
40
|
+
`client.device.available` false if a previous poll populated the device; a
|
|
41
|
+
successful subsequent poll restores it. Previously returned models are immutable
|
|
42
|
+
snapshots, so read `client.device` for the updated availability.
|
|
43
|
+
|
|
44
|
+
## Bypass control
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from pybls21.models import BypassMode
|
|
48
|
+
|
|
49
|
+
# AUTO lets the device control its bypass or rotary heat exchanger.
|
|
50
|
+
await client.set_bypass_mode(BypassMode.AUTO)
|
|
51
|
+
|
|
52
|
+
# For analogue control, set the manual percentage and select manual mode.
|
|
53
|
+
await client.set_bypass_position(60)
|
|
54
|
+
await client.set_bypass_mode(BypassMode.OPEN)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`CLOSED` closes the bypass or starts the rotor. `OPEN` opens the bypass or stops
|
|
58
|
+
the rotor for discrete control; with analogue control it selects the percentage
|
|
59
|
+
set by `set_bypass_position()`. That setter alone does not change the mode.
|
|
60
|
+
A value of 0 means closed bypass / maximum rotor speed; 100 means open bypass /
|
|
61
|
+
stopped rotor. Choose controls appropriate to the reported `bypass_type`.
|
|
62
|
+
|
|
63
|
+
The model exposes `bypass_type`, `bypass_mode`, `manual_bypass_position`, and
|
|
64
|
+
`bypass_position`. Position is read separately from optional input register 51.
|
|
65
|
+
On older firmware that rejects this address, `bypass_position` is `None` and
|
|
66
|
+
polling the other readings still succeeds. Other communication errors are
|
|
67
|
+
propagated. When no bypass/rotor is fitted, its mode and positions are `None`
|
|
68
|
+
and the optional read is skipped.
|
|
69
|
+
|
|
70
|
+
New model fields have defaults, preserving construction with the original
|
|
71
|
+
positional or keyword arguments. The tuple now contains additional fields;
|
|
72
|
+
consumers should access readings by attribute rather than unpacking a fixed
|
|
73
|
+
number of values.
|
|
74
|
+
|
|
75
|
+
## Timer, scheduler, and telemetry
|
|
76
|
+
|
|
77
|
+
`set_timer_on()` / `set_timer_off()` enable or disable the device's existing
|
|
78
|
+
main timer. `set_scheduler_mode_on()` / `set_scheduler_mode_off()` enable or
|
|
79
|
+
disable its existing weekly schedule. Configure the timer duration and weekly
|
|
80
|
+
schedule on the device; these methods only toggle their activation.
|
|
81
|
+
|
|
82
|
+
The following fields are available after `await client.poll()`:
|
|
83
|
+
|
|
84
|
+
| Field | Meaning |
|
|
85
|
+
| --- | --- |
|
|
86
|
+
| `is_timer` | Whether the main timer is active |
|
|
87
|
+
| `timer_countdown` | Remaining main timer time as `HH:MM:SS` |
|
|
88
|
+
| `is_schedule_mode` | Whether the weekly schedule is enabled |
|
|
89
|
+
| `fan_level_schedule_mode` | Current scheduled fan level; 0 means standby |
|
|
90
|
+
| `fan_level_timer_mode` | Configured timer fan level; 0 means standby |
|
|
91
|
+
| `alarm_codes` | Active numeric alarm codes (0–52); empty list when no alarm or warning is reported |
|
|
92
|
+
| `supply_airflow`, `extract_airflow` | Airflow in m³/h |
|
|
93
|
+
| `operating_time_minutes` | Total device operating time in minutes |
|
|
94
|
+
| `filter_countdown_hours`, `filter_countdown_minutes` | Remaining hours and minutes in addition to `filter_countdown_days` |
|
|
95
|
+
| `supply_fan_speed_percent`, `extract_fan_speed_percent` | Actual fan performance in percent, or `None` on older firmware |
|
|
96
|
+
|
|
97
|
+
`fan_mode` remains the configured normal fan level and `set_fan_mode(mode)`
|
|
98
|
+
still takes one argument. Timer and schedule levels are separate readings, not
|
|
99
|
+
an inferred effective fan level during overrides. The existing
|
|
100
|
+
`supply_fan_speed` and `extract_fan_speed` fields continue to report **RPM**.
|
|
101
|
+
|
|
102
|
+
The timer, schedule, airflow, operating time, and filter readings use the blocks
|
|
103
|
+
already fetched by polling. Detailed alarm codes add a discrete-input read only
|
|
104
|
+
when an alarm or warning is active. Fan percentages add a separate read of
|
|
105
|
+
IR52–53; an Illegal Data Address response leaves both percentages unknown without
|
|
106
|
+
interrupting other readings. Timeouts and other errors still propagate and
|
|
107
|
+
invalidate cached availability.
|
|
108
|
+
|
|
109
|
+
These additions are adapted from [marni-xyz's fork](https://github.com/marni-xyz/pybls21),
|
|
110
|
+
including its operating-time, airflow, and fan-performance work attributed to
|
|
111
|
+
[birdie1](https://github.com/birdie1).
|
|
112
|
+
|
|
113
|
+
The copyright and MIT terms for these ported portions are retained in
|
|
114
|
+
[THIRD_PARTY_NOTICES](THIRD_PARTY_NOTICES), included in both source and wheel
|
|
115
|
+
distributions.
|
|
@@ -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.
|
|
@@ -2,11 +2,14 @@ import asyncio
|
|
|
2
2
|
from typing import Any, Awaitable, Callable, List, Optional
|
|
3
3
|
|
|
4
4
|
from pymodbus.client import AsyncModbusTcpClient
|
|
5
|
+
from pymodbus.pdu import ExceptionResponse
|
|
5
6
|
|
|
6
7
|
from .constants import *
|
|
7
8
|
from .exceptions import *
|
|
8
9
|
from .models import (
|
|
9
10
|
TEMP_CELSIUS,
|
|
11
|
+
BypassMode,
|
|
12
|
+
BypassType,
|
|
10
13
|
ClimateDevice,
|
|
11
14
|
ClimateEntityFeature,
|
|
12
15
|
HVACAction,
|
|
@@ -27,6 +30,13 @@ def _to_signed_16bit(value: int) -> int:
|
|
|
27
30
|
return value - 0x10000 if value > 0x7FFF else value
|
|
28
31
|
|
|
29
32
|
|
|
33
|
+
def _parse_temperature(value: int) -> Optional[float]:
|
|
34
|
+
# The S21 reserves these values for a missing or short-circuited sensor.
|
|
35
|
+
if value in (0x8000, 0x7FFF):
|
|
36
|
+
return None
|
|
37
|
+
return _to_signed_16bit(value) / 10
|
|
38
|
+
|
|
39
|
+
|
|
30
40
|
class S21Client:
|
|
31
41
|
def __init__(self, host: str, port: int = 502):
|
|
32
42
|
self.host = host
|
|
@@ -73,6 +83,28 @@ class S21Client:
|
|
|
73
83
|
async def boost_off(self) -> None:
|
|
74
84
|
await self._do_with_connection(self._set_boost_off)
|
|
75
85
|
|
|
86
|
+
async def set_timer_on(self) -> None:
|
|
87
|
+
await self._do_with_connection(lambda: self._write_coil(CL_TIMER, True))
|
|
88
|
+
|
|
89
|
+
async def set_timer_off(self) -> None:
|
|
90
|
+
await self._do_with_connection(lambda: self._write_coil(CL_TIMER, False))
|
|
91
|
+
|
|
92
|
+
async def set_scheduler_mode_on(self) -> None:
|
|
93
|
+
await self._do_with_connection(lambda: self._write_coil(CL_WEEK, True))
|
|
94
|
+
|
|
95
|
+
async def set_scheduler_mode_off(self) -> None:
|
|
96
|
+
await self._do_with_connection(lambda: self._write_coil(CL_WEEK, False))
|
|
97
|
+
|
|
98
|
+
async def set_bypass_mode(self, mode: BypassMode) -> None:
|
|
99
|
+
mode = BypassMode(mode)
|
|
100
|
+
await self._do_with_connection(lambda: self._set_bypass_mode(mode))
|
|
101
|
+
|
|
102
|
+
async def set_bypass_position(self, position_percent: int) -> None:
|
|
103
|
+
self._validate_bypass_position(position_percent)
|
|
104
|
+
await self._do_with_connection(
|
|
105
|
+
lambda: self._set_bypass_position(position_percent)
|
|
106
|
+
)
|
|
107
|
+
|
|
76
108
|
@staticmethod
|
|
77
109
|
def _validate_modbus_response(response: Any, operation: str) -> Any:
|
|
78
110
|
if response is None:
|
|
@@ -117,10 +149,33 @@ class S21Client:
|
|
|
117
149
|
if not isinstance(temp_celsius, int) or not 15 <= temp_celsius <= 30:
|
|
118
150
|
raise ValueError("Temperature must be between 15 and 30 °C")
|
|
119
151
|
|
|
152
|
+
@staticmethod
|
|
153
|
+
def _validate_bypass_position(position_percent: int) -> None:
|
|
154
|
+
if not isinstance(position_percent, int) or not 0 <= position_percent <= 100:
|
|
155
|
+
raise ValueError("Bypass position percent must be between 0 and 100")
|
|
156
|
+
|
|
120
157
|
async def _read_input_registers(self, address: int, count: int) -> List[int]:
|
|
121
158
|
response = await self.client.read_input_registers(address, count=count)
|
|
122
159
|
return self._get_registers(response, count, f"read input registers at {address}")
|
|
123
160
|
|
|
161
|
+
async def _read_optional_input_registers(
|
|
162
|
+
self, address: int, count: int
|
|
163
|
+
) -> Optional[List[int]]:
|
|
164
|
+
response = await self.client.read_input_registers(address, count=count)
|
|
165
|
+
# Older firmware lacks IR51-53. Only Illegal Data Address is optional;
|
|
166
|
+
# timeouts, malformed replies and other device errors must still surface.
|
|
167
|
+
if isinstance(response, ExceptionResponse) and response.exception_code == 2:
|
|
168
|
+
return None
|
|
169
|
+
return self._get_registers(response, count, f"read input registers at {address}")
|
|
170
|
+
|
|
171
|
+
async def _read_alarm_codes(self) -> List[int]:
|
|
172
|
+
response = await self.client.read_discrete_inputs(
|
|
173
|
+
DI_ALARM_START, count=DI_ALARM_COUNT
|
|
174
|
+
)
|
|
175
|
+
bits = self._get_bits(response, DI_ALARM_COUNT, "read alarm codes")
|
|
176
|
+
# Modbus pads bit responses to whole bytes; ignore bits beyond code 52.
|
|
177
|
+
return [code for code in range(DI_ALARM_COUNT) if bits[code]]
|
|
178
|
+
|
|
124
179
|
async def _read_holding_registers(self, address: int, count: int) -> List[int]:
|
|
125
180
|
response = await self.client.read_holding_registers(address, count=count)
|
|
126
181
|
return self._get_registers(response, count, f"read holding registers at {address}")
|
|
@@ -139,14 +194,17 @@ class S21Client:
|
|
|
139
194
|
|
|
140
195
|
async def _do_with_connection(self, func: Callable[[], Awaitable[Any]]) -> Any:
|
|
141
196
|
async with self.lock: # Device does not support multiple connections
|
|
142
|
-
if not await self.client.connect():
|
|
143
|
-
raise ModbusCommunicationException("Failed to open Modbus TCP connection")
|
|
144
|
-
|
|
145
197
|
try:
|
|
198
|
+
if not await self.client.connect():
|
|
199
|
+
raise ModbusCommunicationException(
|
|
200
|
+
"Failed to open Modbus TCP connection"
|
|
201
|
+
)
|
|
146
202
|
return await func()
|
|
147
203
|
except Exception:
|
|
148
204
|
if isinstance(self.device, ClimateDevice):
|
|
149
|
-
|
|
205
|
+
# ClimateDevice is a NamedTuple and therefore immutable,
|
|
206
|
+
# so the flag has to be replaced instead of assigned.
|
|
207
|
+
self.device = self.device._replace(available=False)
|
|
150
208
|
raise
|
|
151
209
|
finally:
|
|
152
210
|
self.client.close() # Also, long connections break over time and become unusable
|
|
@@ -156,7 +214,7 @@ class S21Client:
|
|
|
156
214
|
raise UnsupportedDeviceException("Unsupported device (IR_DeviceTYPE != 1)")
|
|
157
215
|
|
|
158
216
|
coils = await self._read_coils(0, count=4)
|
|
159
|
-
holding_registers = await self._read_holding_registers(0, count=
|
|
217
|
+
holding_registers = await self._read_holding_registers(0, count=76)
|
|
160
218
|
input_registers = await self._read_input_registers(0, count=39)
|
|
161
219
|
|
|
162
220
|
is_on: bool = coils[CL_POWER]
|
|
@@ -165,12 +223,13 @@ class S21Client:
|
|
|
165
223
|
current_humidity: int = input_registers[IR_CurRH_Int]
|
|
166
224
|
filter_state: int = input_registers[IR_StateFILTER]
|
|
167
225
|
alarm_state: int = input_registers[IR_ALARM]
|
|
226
|
+
alarm_codes = await self._read_alarm_codes() if alarm_state else []
|
|
168
227
|
max_fan_level: int = holding_registers[HR_MaxSPEED_MODE]
|
|
169
228
|
current_fan_level: int = holding_registers[HR_SPEED_MODE] # 255 - manual
|
|
170
|
-
|
|
229
|
+
temp_before_heating = _parse_temperature(
|
|
171
230
|
input_registers[IR_CurTEMP_SuAirIn]
|
|
172
231
|
)
|
|
173
|
-
|
|
232
|
+
temp_after_heating = _parse_temperature(
|
|
174
233
|
input_registers[IR_CurTEMP_SuAirOut]
|
|
175
234
|
)
|
|
176
235
|
supply_fan_speed: int = input_registers[IR_SuRPM]
|
|
@@ -180,6 +239,33 @@ class S21Client:
|
|
|
180
239
|
]
|
|
181
240
|
operation_mode: int = holding_registers[HR_OPERATION_MODE]
|
|
182
241
|
manual_fan_speed_percent: int = holding_registers[HR_ManualSPEED]
|
|
242
|
+
bypass_type: BypassType = BypassType(holding_registers[HR_BPS_ROTOR_TYPE])
|
|
243
|
+
bypass_mode = (
|
|
244
|
+
BypassMode(holding_registers[HR_BPS_ROTOR_MODE])
|
|
245
|
+
if bypass_type != BypassType.NOT_AVAILABLE
|
|
246
|
+
else None
|
|
247
|
+
)
|
|
248
|
+
manual_bypass_position = (
|
|
249
|
+
holding_registers[HR_SetBpsRotorMANUAL]
|
|
250
|
+
if bypass_type != BypassType.NOT_AVAILABLE
|
|
251
|
+
else None
|
|
252
|
+
)
|
|
253
|
+
bypass_registers = (
|
|
254
|
+
await self._read_optional_input_registers(IR_StatusBpsRotor, count=1)
|
|
255
|
+
if bypass_type != BypassType.NOT_AVAILABLE
|
|
256
|
+
else None
|
|
257
|
+
)
|
|
258
|
+
fan_percentages = await self._read_optional_input_registers(
|
|
259
|
+
IR_CurSuFanSpeed, count=2
|
|
260
|
+
)
|
|
261
|
+
timer_minutes, timer_seconds = divmod(input_registers[IR_CurTIMER_TIME], 256)
|
|
262
|
+
timer_hours = input_registers[IR_CurTIMER_TIME_HOURS] & 0xFF
|
|
263
|
+
filter_hours, filter_minutes = divmod(
|
|
264
|
+
input_registers[IR_CurFILTER_TIMER_HOURS_MINUTES], 256
|
|
265
|
+
)
|
|
266
|
+
operating_hours, operating_minutes = divmod(
|
|
267
|
+
input_registers[IR_TotalWorkingTime_HOURS_MINUTES], 256
|
|
268
|
+
)
|
|
183
269
|
|
|
184
270
|
self.device = ClimateDevice(
|
|
185
271
|
available=True,
|
|
@@ -187,7 +273,7 @@ class S21Client:
|
|
|
187
273
|
unique_id=f"S21_{self.host}_{self.port}",
|
|
188
274
|
temperature_unit=TEMP_CELSIUS, # Seems like no Fahrenheit option is available
|
|
189
275
|
precision=1,
|
|
190
|
-
current_temperature=
|
|
276
|
+
current_temperature=temp_after_heating,
|
|
191
277
|
target_temperature=set_temperature,
|
|
192
278
|
target_temperature_step=1,
|
|
193
279
|
min_temp=15,
|
|
@@ -210,10 +296,12 @@ class S21Client:
|
|
|
210
296
|
if operation_mode == 1
|
|
211
297
|
else HVACAction.COOLING
|
|
212
298
|
if operation_mode == 2
|
|
299
|
+
else None
|
|
300
|
+
if temp_before_heating is None or temp_after_heating is None
|
|
213
301
|
else HVACAction.HEATING
|
|
214
|
-
if
|
|
302
|
+
if temp_before_heating < temp_after_heating
|
|
215
303
|
else HVACAction.COOLING
|
|
216
|
-
if
|
|
304
|
+
if temp_before_heating > temp_after_heating
|
|
217
305
|
else HVACAction.IDLE,
|
|
218
306
|
hvac_modes=[
|
|
219
307
|
HVACMode.OFF,
|
|
@@ -230,13 +318,46 @@ class S21Client:
|
|
|
230
318
|
model="S21",
|
|
231
319
|
sw_version=_parse_firmware_version(firmware_info),
|
|
232
320
|
is_boosting=is_boosting,
|
|
233
|
-
current_intake_temperature=
|
|
321
|
+
current_intake_temperature=temp_before_heating,
|
|
234
322
|
manual_fan_speed_percent=manual_fan_speed_percent,
|
|
235
323
|
max_fan_level=max_fan_level,
|
|
236
324
|
filter_state=filter_state,
|
|
237
325
|
alarm_state=alarm_state,
|
|
238
326
|
supply_fan_speed=supply_fan_speed,
|
|
239
327
|
extract_fan_speed=extract_fan_speed,
|
|
328
|
+
current_extract_temperature=_parse_temperature(
|
|
329
|
+
input_registers[IR_CurTEMP_ExAirIn]
|
|
330
|
+
),
|
|
331
|
+
current_exhaust_temperature=_parse_temperature(
|
|
332
|
+
input_registers[IR_CurTEMP_ExAirOut]
|
|
333
|
+
),
|
|
334
|
+
supply_pressure=input_registers[IR_CurSuPRESS],
|
|
335
|
+
extract_pressure=input_registers[IR_CurExPRESS],
|
|
336
|
+
filter_countdown_days=input_registers[IR_CurFILTER_TIMER_DAYS],
|
|
337
|
+
bypass_type=bypass_type,
|
|
338
|
+
bypass_mode=bypass_mode,
|
|
339
|
+
bypass_position=bypass_registers[0] if bypass_registers is not None else None,
|
|
340
|
+
manual_bypass_position=manual_bypass_position,
|
|
341
|
+
is_timer=coils[CL_TIMER],
|
|
342
|
+
timer_countdown=f"{timer_hours:02d}:{timer_minutes:02d}:{timer_seconds:02d}",
|
|
343
|
+
is_schedule_mode=coils[CL_WEEK],
|
|
344
|
+
fan_level_schedule_mode=input_registers[IR_CurWeekSpeed],
|
|
345
|
+
fan_level_timer_mode=holding_registers[HR_TIMER_MODE],
|
|
346
|
+
alarm_codes=alarm_codes,
|
|
347
|
+
supply_airflow=input_registers[IR_CurSuAirFLOW],
|
|
348
|
+
extract_airflow=input_registers[IR_CurExAirFLOW],
|
|
349
|
+
operating_time_minutes=(
|
|
350
|
+
input_registers[IR_TotalWorkingTime_DAYS] * 1440
|
|
351
|
+
+ operating_hours * 60 + operating_minutes
|
|
352
|
+
),
|
|
353
|
+
filter_countdown_hours=filter_hours,
|
|
354
|
+
filter_countdown_minutes=filter_minutes,
|
|
355
|
+
supply_fan_speed_percent=(
|
|
356
|
+
fan_percentages[0] if fan_percentages is not None else None
|
|
357
|
+
),
|
|
358
|
+
extract_fan_speed_percent=(
|
|
359
|
+
fan_percentages[1] if fan_percentages is not None else None
|
|
360
|
+
),
|
|
240
361
|
)
|
|
241
362
|
|
|
242
363
|
return self.device
|
|
@@ -283,3 +404,9 @@ class S21Client:
|
|
|
283
404
|
|
|
284
405
|
async def _set_boost_off(self) -> None:
|
|
285
406
|
await self._write_coil(CL_BoostSWITCH_CTRL, False)
|
|
407
|
+
|
|
408
|
+
async def _set_bypass_mode(self, mode: BypassMode) -> None:
|
|
409
|
+
await self._write_register(HR_BPS_ROTOR_MODE, int(mode))
|
|
410
|
+
|
|
411
|
+
async def _set_bypass_position(self, position_percent: int) -> None:
|
|
412
|
+
await self._write_register(HR_SetBpsRotorMANUAL, position_percent)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Coils
|
|
2
|
+
CL_POWER = 0
|
|
3
|
+
CL_TIMER = 1
|
|
4
|
+
CL_WEEK = 2
|
|
5
|
+
CL_Boost_MODE = 3
|
|
6
|
+
CL_BoostSWITCH_CTRL = 13
|
|
7
|
+
CL_RESET_FILTER_TIMER = 17
|
|
8
|
+
CL_RESET_ALARM = 18
|
|
9
|
+
|
|
10
|
+
# Holding registers
|
|
11
|
+
HR_MaxSPEED_MODE = 1
|
|
12
|
+
HR_SPEED_MODE = 2
|
|
13
|
+
HR_ManualSPEED = 17
|
|
14
|
+
HR_OPERATION_MODE = 43
|
|
15
|
+
HR_SetTEMP = 44
|
|
16
|
+
HR_TIMER_MODE = 49
|
|
17
|
+
HR_BPS_ROTOR_TYPE = 57
|
|
18
|
+
HR_BPS_ROTOR_MODE = 74
|
|
19
|
+
HR_SetBpsRotorMANUAL = 75
|
|
20
|
+
|
|
21
|
+
# Input registers
|
|
22
|
+
IR_CurTEMP_SuAirIn = 1
|
|
23
|
+
IR_CurTEMP_SuAirOut = 2
|
|
24
|
+
IR_CurTEMP_ExAirIn = 3 # Extract air from the rooms, at the unit inlet
|
|
25
|
+
IR_CurTEMP_ExAirOut = 4 # Exhaust air to the outside, at the unit outlet
|
|
26
|
+
IR_CurRH_Int = 10
|
|
27
|
+
IR_CurSuAirFLOW = 19 # Supply airflow, m³/h
|
|
28
|
+
IR_CurExAirFLOW = 20 # Extract airflow, m³/h
|
|
29
|
+
IR_CurSuPRESS = 21 # Supply duct pressure, Pa
|
|
30
|
+
IR_CurExPRESS = 22 # Extract duct pressure, Pa
|
|
31
|
+
IR_SuRPM = 23
|
|
32
|
+
IR_ExRPM = 24
|
|
33
|
+
IR_CurTIMER_TIME = 25 # High byte: minutes, low byte: seconds
|
|
34
|
+
IR_CurTIMER_TIME_HOURS = 26 # Low byte: hours
|
|
35
|
+
IR_CurFILTER_TIMER_HOURS_MINUTES = 27 # High byte: hours, low byte: minutes
|
|
36
|
+
IR_CurFILTER_TIMER_DAYS = 28
|
|
37
|
+
IR_TotalWorkingTime_HOURS_MINUTES = 29 # High byte: hours, low byte: minutes
|
|
38
|
+
IR_TotalWorkingTime_DAYS = 30
|
|
39
|
+
IR_StateFILTER = 31
|
|
40
|
+
IR_CurWeekSpeed = 32 # 0: standby, 1-5: scheduled speed
|
|
41
|
+
IR_VerMAIN_FMW_start = 34
|
|
42
|
+
IR_VerMAIN_FMW_end = 36
|
|
43
|
+
IR_DeviceTYPE = 37
|
|
44
|
+
IR_ALARM = 38
|
|
45
|
+
IR_BPS_ROTOR_U = 45
|
|
46
|
+
IR_StatusBpsRotor = 51
|
|
47
|
+
IR_CurSuFanSpeed = 52 # Actual supply fan performance, percent
|
|
48
|
+
IR_CurExFanSpeed = 53 # Actual extract fan performance, percent
|
|
49
|
+
|
|
50
|
+
# Discrete inputs: alarm codes 0 through 52
|
|
51
|
+
DI_ALARM_START = 19
|
|
52
|
+
DI_ALARM_COUNT = 53
|