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 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.
@@ -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
- self.device.available = False
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=45)
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
- temp_before_heating_x10: int = _to_signed_16bit(
229
+ temp_before_heating = _parse_temperature(
171
230
  input_registers[IR_CurTEMP_SuAirIn]
172
231
  )
173
- temp_after_heating_x10: int = _to_signed_16bit(
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=temp_after_heating_x10 / 10,
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 temp_before_heating_x10 < temp_after_heating_x10
302
+ if temp_before_heating < temp_after_heating
215
303
  else HVACAction.COOLING
216
- if temp_before_heating_x10 > temp_after_heating_x10
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=temp_before_heating_x10 / 10,
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