pybls21 4.3.0__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pybls21
3
- Version: 4.3.0
3
+ Version: 4.4.0
4
4
  Summary: An api allowing control of AC state (temperature, on/off, speed) of an Blauberg S21 device locally over TCP
5
5
  Home-page: https://github.com/jvitkauskas/pybls21
6
6
  Author: Julius Vitkauskas
@@ -11,6 +11,7 @@ Classifier: Operating System :: OS Independent
11
11
  Requires-Python: >=3.10
12
12
  Description-Content-Type: text/markdown
13
13
  License-File: LICENSE
14
+ License-File: THIRD_PARTY_NOTICES
14
15
  Requires-Dist: pymodbus<4.0,>=3.13.1
15
16
  Dynamic: author
16
17
  Dynamic: author-email
@@ -46,6 +47,8 @@ The following functions are available:
46
47
  `boost_off()`
47
48
  `set_bypass_mode(mode: BypassMode)`
48
49
  `set_bypass_position(position_percent: int)`
50
+ `set_timer_on()` / `set_timer_off()`
51
+ `set_scheduler_mode_on()` / `set_scheduler_mode_off()`
49
52
 
50
53
 
51
54
  ## Additional readings
@@ -94,3 +97,45 @@ New model fields have defaults, preserving construction with the original
94
97
  positional or keyword arguments. The tuple now contains additional fields;
95
98
  consumers should access readings by attribute rather than unpacking a fixed
96
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.
@@ -21,6 +21,8 @@ The following functions are available:
21
21
  `boost_off()`
22
22
  `set_bypass_mode(mode: BypassMode)`
23
23
  `set_bypass_position(position_percent: int)`
24
+ `set_timer_on()` / `set_timer_off()`
25
+ `set_scheduler_mode_on()` / `set_scheduler_mode_off()`
24
26
 
25
27
 
26
28
  ## Additional readings
@@ -69,3 +71,45 @@ New model fields have defaults, preserving construction with the original
69
71
  positional or keyword arguments. The tuple now contains additional fields;
70
72
  consumers should access readings by attribute rather than unpacking a fixed
71
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.
@@ -83,6 +83,18 @@ class S21Client:
83
83
  async def boost_off(self) -> None:
84
84
  await self._do_with_connection(self._set_boost_off)
85
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
+
86
98
  async def set_bypass_mode(self, mode: BypassMode) -> None:
87
99
  mode = BypassMode(mode)
88
100
  await self._do_with_connection(lambda: self._set_bypass_mode(mode))
@@ -146,13 +158,23 @@ class S21Client:
146
158
  response = await self.client.read_input_registers(address, count=count)
147
159
  return self._get_registers(response, count, f"read input registers at {address}")
148
160
 
149
- async def _read_bypass_position(self) -> Optional[int]:
150
- response = await self.client.read_input_registers(IR_StatusBpsRotor, count=1)
151
- # Older firmware has no IR51. Only Illegal Data Address is optional;
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;
152
166
  # timeouts, malformed replies and other device errors must still surface.
153
167
  if isinstance(response, ExceptionResponse) and response.exception_code == 2:
154
168
  return None
155
- return self._get_registers(response, 1, "read bypass position")[0]
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]]
156
178
 
157
179
  async def _read_holding_registers(self, address: int, count: int) -> List[int]:
158
180
  response = await self.client.read_holding_registers(address, count=count)
@@ -201,6 +223,7 @@ class S21Client:
201
223
  current_humidity: int = input_registers[IR_CurRH_Int]
202
224
  filter_state: int = input_registers[IR_StateFILTER]
203
225
  alarm_state: int = input_registers[IR_ALARM]
226
+ alarm_codes = await self._read_alarm_codes() if alarm_state else []
204
227
  max_fan_level: int = holding_registers[HR_MaxSPEED_MODE]
205
228
  current_fan_level: int = holding_registers[HR_SPEED_MODE] # 255 - manual
206
229
  temp_before_heating = _parse_temperature(
@@ -227,11 +250,22 @@ class S21Client:
227
250
  if bypass_type != BypassType.NOT_AVAILABLE
228
251
  else None
229
252
  )
230
- bypass_position = (
231
- await self._read_bypass_position()
253
+ bypass_registers = (
254
+ await self._read_optional_input_registers(IR_StatusBpsRotor, count=1)
232
255
  if bypass_type != BypassType.NOT_AVAILABLE
233
256
  else None
234
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
+ )
235
269
 
236
270
  self.device = ClimateDevice(
237
271
  available=True,
@@ -302,8 +336,28 @@ class S21Client:
302
336
  filter_countdown_days=input_registers[IR_CurFILTER_TIMER_DAYS],
303
337
  bypass_type=bypass_type,
304
338
  bypass_mode=bypass_mode,
305
- bypass_position=bypass_position,
339
+ bypass_position=bypass_registers[0] if bypass_registers is not None else None,
306
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
+ ),
307
361
  )
308
362
 
309
363
  return self.device
@@ -1,5 +1,7 @@
1
1
  # Coils
2
2
  CL_POWER = 0
3
+ CL_TIMER = 1
4
+ CL_WEEK = 2
3
5
  CL_Boost_MODE = 3
4
6
  CL_BoostSWITCH_CTRL = 13
5
7
  CL_RESET_FILTER_TIMER = 17
@@ -11,6 +13,7 @@ HR_SPEED_MODE = 2
11
13
  HR_ManualSPEED = 17
12
14
  HR_OPERATION_MODE = 43
13
15
  HR_SetTEMP = 44
16
+ HR_TIMER_MODE = 49
14
17
  HR_BPS_ROTOR_TYPE = 57
15
18
  HR_BPS_ROTOR_MODE = 74
16
19
  HR_SetBpsRotorMANUAL = 75
@@ -21,16 +24,29 @@ IR_CurTEMP_SuAirOut = 2
21
24
  IR_CurTEMP_ExAirIn = 3 # Extract air from the rooms, at the unit inlet
22
25
  IR_CurTEMP_ExAirOut = 4 # Exhaust air to the outside, at the unit outlet
23
26
  IR_CurRH_Int = 10
27
+ IR_CurSuAirFLOW = 19 # Supply airflow, m³/h
28
+ IR_CurExAirFLOW = 20 # Extract airflow, m³/h
24
29
  IR_CurSuPRESS = 21 # Supply duct pressure, Pa
25
30
  IR_CurExPRESS = 22 # Extract duct pressure, Pa
26
31
  IR_SuRPM = 23
27
32
  IR_ExRPM = 24
33
+ IR_CurTIMER_TIME = 25 # High byte: minutes, low byte: seconds
34
+ IR_CurTIMER_TIME_HOURS = 26 # Low byte: hours
28
35
  IR_CurFILTER_TIMER_HOURS_MINUTES = 27 # High byte: hours, low byte: minutes
29
36
  IR_CurFILTER_TIMER_DAYS = 28
37
+ IR_TotalWorkingTime_HOURS_MINUTES = 29 # High byte: hours, low byte: minutes
38
+ IR_TotalWorkingTime_DAYS = 30
30
39
  IR_StateFILTER = 31
40
+ IR_CurWeekSpeed = 32 # 0: standby, 1-5: scheduled speed
31
41
  IR_VerMAIN_FMW_start = 34
32
42
  IR_VerMAIN_FMW_end = 36
33
43
  IR_DeviceTYPE = 37
34
44
  IR_ALARM = 38
35
45
  IR_BPS_ROTOR_U = 45
36
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
@@ -78,3 +78,16 @@ class ClimateDevice(NamedTuple):
78
78
  bypass_mode: Optional[BypassMode] = None
79
79
  bypass_position: Optional[int] = None
80
80
  manual_bypass_position: Optional[int] = None
81
+ is_timer: bool = False
82
+ timer_countdown: Optional[str] = None
83
+ is_schedule_mode: bool = False
84
+ fan_level_schedule_mode: Optional[int] = None
85
+ fan_level_timer_mode: Optional[int] = None
86
+ alarm_codes: Optional[List[int]] = None
87
+ supply_airflow: Optional[int] = None
88
+ extract_airflow: Optional[int] = None
89
+ operating_time_minutes: Optional[int] = None
90
+ filter_countdown_hours: Optional[int] = None
91
+ filter_countdown_minutes: Optional[int] = None
92
+ supply_fan_speed_percent: Optional[int] = None
93
+ extract_fan_speed_percent: Optional[int] = None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pybls21
3
- Version: 4.3.0
3
+ Version: 4.4.0
4
4
  Summary: An api allowing control of AC state (temperature, on/off, speed) of an Blauberg S21 device locally over TCP
5
5
  Home-page: https://github.com/jvitkauskas/pybls21
6
6
  Author: Julius Vitkauskas
@@ -11,6 +11,7 @@ Classifier: Operating System :: OS Independent
11
11
  Requires-Python: >=3.10
12
12
  Description-Content-Type: text/markdown
13
13
  License-File: LICENSE
14
+ License-File: THIRD_PARTY_NOTICES
14
15
  Requires-Dist: pymodbus<4.0,>=3.13.1
15
16
  Dynamic: author
16
17
  Dynamic: author-email
@@ -46,6 +47,8 @@ The following functions are available:
46
47
  `boost_off()`
47
48
  `set_bypass_mode(mode: BypassMode)`
48
49
  `set_bypass_position(position_percent: int)`
50
+ `set_timer_on()` / `set_timer_off()`
51
+ `set_scheduler_mode_on()` / `set_scheduler_mode_off()`
49
52
 
50
53
 
51
54
  ## Additional readings
@@ -94,3 +97,45 @@ New model fields have defaults, preserving construction with the original
94
97
  positional or keyword arguments. The tuple now contains additional fields;
95
98
  consumers should access readings by attribute rather than unpacking a fixed
96
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.
@@ -1,5 +1,6 @@
1
1
  LICENSE
2
2
  README.md
3
+ THIRD_PARTY_NOTICES
3
4
  setup.py
4
5
  pybls21/__init__.py
5
6
  pybls21/client.py
@@ -5,7 +5,7 @@ with open("README.md", "r") as fh:
5
5
 
6
6
  setuptools.setup(
7
7
  name="pybls21",
8
- version="4.3.0",
8
+ version="4.4.0",
9
9
  author="Julius Vitkauskas",
10
10
  author_email="zadintuvas@gmail.com",
11
11
  description="An api allowing control of AC state (temperature, on/off, speed) of an Blauberg S21 device locally over TCP",
@@ -13,6 +13,7 @@ setuptools.setup(
13
13
  long_description_content_type="text/markdown",
14
14
  url="https://github.com/jvitkauskas/pybls21",
15
15
  packages=setuptools.find_packages(exclude=["tests"]),
16
+ license_files=["LICENSE", "THIRD_PARTY_NOTICES"],
16
17
  install_requires=["pymodbus>=3.13.1,<4.0"],
17
18
  classifiers=[
18
19
  "Programming Language :: Python :: 3",
@@ -179,6 +179,14 @@ class TestClient(unittest.IsolatedAsyncioTestCase):
179
179
  self.server.data_bank.set_input_registers(IR_CurSuPRESS, [45])
180
180
  self.server.data_bank.set_input_registers(IR_CurExPRESS, [50])
181
181
  self.server.data_bank.set_input_registers(IR_CurFILTER_TIMER_DAYS, [69])
182
+ self.server.data_bank.set_input_registers(IR_CurFILTER_TIMER_HOURS_MINUTES, [0x0809])
183
+ self.server.data_bank.set_input_registers(IR_CurTIMER_TIME, [0x112B])
184
+ self.server.data_bank.set_input_registers(IR_CurTIMER_TIME_HOURS, [0xAB02])
185
+ self.server.data_bank.set_input_registers(IR_TotalWorkingTime_HOURS_MINUTES, [0x0304])
186
+ self.server.data_bank.set_input_registers(IR_TotalWorkingTime_DAYS, [2])
187
+ self.server.data_bank.set_input_registers(IR_CurSuAirFLOW, [123])
188
+ self.server.data_bank.set_input_registers(IR_CurExAirFLOW, [456])
189
+ self.server.data_bank.set_input_registers(IR_CurSuFanSpeed, [35, 45])
182
190
  self.server.data_bank.set_input_registers(
183
191
  IR_VerMAIN_FMW_start, [36, 2053, 2019]
184
192
  )
@@ -233,6 +241,19 @@ class TestClient(unittest.IsolatedAsyncioTestCase):
233
241
  bypass_mode=None,
234
242
  bypass_position=None,
235
243
  manual_bypass_position=None,
244
+ is_timer=False,
245
+ timer_countdown="02:17:43",
246
+ is_schedule_mode=False,
247
+ fan_level_schedule_mode=0,
248
+ fan_level_timer_mode=0,
249
+ alarm_codes=[],
250
+ supply_airflow=123,
251
+ extract_airflow=456,
252
+ operating_time_minutes=3064,
253
+ filter_countdown_hours=8,
254
+ filter_countdown_minutes=9,
255
+ supply_fan_speed_percent=35,
256
+ extract_fan_speed_percent=45,
236
257
  ),
237
258
  )
238
259
 
@@ -279,6 +300,95 @@ class TestClient(unittest.IsolatedAsyncioTestCase):
279
300
  self.server.data_bank.set_input_registers(address, [100])
280
301
  self.assertEqual(getattr(await client.poll(), field), 10.0)
281
302
 
303
+ async def test_version_43_model_constructor_remains_supported(self):
304
+ device = await S21Client(self.server.host, self.server.port).poll()
305
+ # Version 4.3.0 had 37 fields, including its sensor and bypass additions.
306
+ positional = ClimateDevice(*device[:37])
307
+ keyword = ClimateDevice(**dict(zip(device._fields[:37], device[:37])))
308
+ self.assertEqual(positional, keyword)
309
+ self.assertIsInstance(positional, tuple)
310
+ self.assertEqual(positional[:37], device[:37])
311
+ self.assertIsNone(positional.supply_fan_speed_percent)
312
+ self.assertIsNone(positional.alarm_codes)
313
+
314
+ async def test_timer_and_schedule_controls(self):
315
+ controls = (
316
+ ("set_timer_on", CL_TIMER, True, "is_timer"),
317
+ ("set_timer_off", CL_TIMER, False, "is_timer"),
318
+ ("set_scheduler_mode_on", CL_WEEK, True, "is_schedule_mode"),
319
+ ("set_scheduler_mode_off", CL_WEEK, False, "is_schedule_mode"),
320
+ )
321
+ client = S21Client(self.server.host, self.server.port)
322
+ for method, address, enabled, field in controls:
323
+ with self.subTest(method=method):
324
+ self.server.data_bank.set_coils(address, [not enabled])
325
+ await getattr(client, method)()
326
+ self.assertEqual(self.server.data_bank.get_coils(address, 1), [enabled])
327
+ self.assertEqual(getattr(await client.poll(), field), enabled)
328
+
329
+ async def test_timer_and_schedule_preserve_configured_fan_mode(self):
330
+ bank = self.server.data_bank
331
+ bank.set_holding_registers(HR_MaxSPEED_MODE, [3])
332
+ bank.set_holding_registers(HR_SPEED_MODE, [2])
333
+ bank.set_holding_registers(HR_TIMER_MODE, [1])
334
+ bank.set_input_registers(IR_CurWeekSpeed, [0]) # Scheduled standby
335
+ bank.set_coils(CL_TIMER, [True])
336
+ bank.set_coils(CL_WEEK, [True])
337
+ bank.set_coils(CL_Boost_MODE, [True])
338
+ device = await S21Client(self.server.host, self.server.port).poll()
339
+ self.assertTrue(device.is_timer)
340
+ self.assertTrue(device.is_schedule_mode)
341
+ self.assertEqual(device.fan_mode, 2)
342
+ self.assertEqual(device.fan_level_timer_mode, 1)
343
+ self.assertEqual(device.fan_level_schedule_mode, 0)
344
+
345
+ async def test_alarm_codes_are_read_only_for_active_alarms_or_warnings(self):
346
+ client = S21Client(self.server.host, self.server.port)
347
+ client.client.read_discrete_inputs = Mock(wraps=client.client.read_discrete_inputs)
348
+ bank = self.server.data_bank
349
+ bank.set_discrete_inputs(DI_ALARM_START, [True])
350
+ bank.set_discrete_inputs(DI_ALARM_START + DI_ALARM_COUNT - 1, [True])
351
+ for state, expected in ((0, []), (1, [0, 52]), (2, [0, 52]), (0, [])):
352
+ with self.subTest(state=state):
353
+ bank.set_input_registers(IR_ALARM, [state])
354
+ self.assertEqual((await client.poll()).alarm_codes, expected)
355
+ self.assertEqual(client.client.read_discrete_inputs.call_count, 2)
356
+ client.client.read_discrete_inputs.assert_called_with(19, count=53)
357
+
358
+ async def test_alarm_code_byte_padding_is_ignored(self):
359
+ self.server.data_bank.set_input_registers(IR_ALARM, [1])
360
+ client = S21Client(self.server.host, self.server.port)
361
+ client.client.read_discrete_inputs = AsyncMock(
362
+ return_value=SuccessResponse(bits=[False] * 53 + [True] * 3)
363
+ )
364
+ self.assertEqual((await client.poll()).alarm_codes, [])
365
+
366
+ async def test_alarm_read_errors_invalidate_availability(self):
367
+ for response in (None, ErrorResponse(), SuccessResponse(bits=[False] * 8)):
368
+ with self.subTest(response=response):
369
+ self.server.data_bank.set_input_registers(IR_ALARM, [0])
370
+ client = S21Client(self.server.host, self.server.port)
371
+ await client.poll()
372
+ self.server.data_bank.set_input_registers(IR_ALARM, [1])
373
+ client.client.read_discrete_inputs = AsyncMock(return_value=response)
374
+ with self.assertRaises(ModbusCommunicationException):
375
+ await client.poll()
376
+ self.assertFalse(client.device.available)
377
+ self.assertFalse(client.client.connected)
378
+
379
+ async def test_operating_time_and_filter_countdown_zero_and_rollover(self):
380
+ bank = self.server.data_bank
381
+ client = S21Client(self.server.host, self.server.port)
382
+ device = await client.poll()
383
+ self.assertEqual(device.operating_time_minutes, 0)
384
+ self.assertEqual(device.filter_countdown_hours, 0)
385
+ self.assertEqual(device.filter_countdown_minutes, 0)
386
+ self.assertEqual(device.timer_countdown, "00:00:00")
387
+ for days, hours, minutes, expected in ((1, 23, 59, 2879), (2, 0, 0, 2880)):
388
+ bank.set_input_registers(IR_TotalWorkingTime_HOURS_MINUTES,
389
+ [(hours << 8) | minutes, days])
390
+ self.assertEqual((await client.poll()).operating_time_minutes, expected)
391
+
282
392
  async def test_extract_and_exhaust_temperatures_support_negative_and_zero(self):
283
393
  self.server.data_bank.set_input_registers(IR_CurTEMP_ExAirIn, [0xFFF6])
284
394
  self.server.data_bank.set_input_registers(IR_CurTEMP_ExAirOut, [0])
@@ -652,6 +762,8 @@ class TestClient(unittest.IsolatedAsyncioTestCase):
652
762
  self.assertEqual(device.current_temperature, 21.5)
653
763
  self.assertEqual(device.bypass_mode, BypassMode.AUTO)
654
764
  self.assertIsNone(device.bypass_position)
765
+ self.assertIsNone(device.supply_fan_speed_percent)
766
+ self.assertIsNone(device.extract_fan_speed_percent)
655
767
 
656
768
  async def test_no_bypass_skips_optional_position_read(self):
657
769
  client = S21Client(self.server.host, self.server.port)
@@ -663,7 +775,41 @@ class TestClient(unittest.IsolatedAsyncioTestCase):
663
775
  self.assertIsNone(device.bypass_position)
664
776
  self.assertIsNone(device.bypass_mode)
665
777
  self.assertIsNone(device.manual_bypass_position)
666
- self.assertEqual(client.client.read_input_registers.call_count, 2)
778
+ self.assertEqual(client.client.read_input_registers.call_count, 3)
779
+ client.client.read_input_registers.assert_any_call(IR_CurSuFanSpeed, count=2)
780
+
781
+ async def test_fan_percentages_do_not_change_rpm_readings(self):
782
+ bank = self.server.data_bank
783
+ bank.set_input_registers(IR_SuRPM, [1700, 1800])
784
+ bank.set_input_registers(IR_CurSuFanSpeed, [0, 100])
785
+ device = await S21Client(self.server.host, self.server.port).poll()
786
+ self.assertEqual(device.supply_fan_speed, 1700)
787
+ self.assertEqual(device.extract_fan_speed, 1800)
788
+ self.assertEqual(device.supply_fan_speed_percent, 0)
789
+ self.assertEqual(device.extract_fan_speed_percent, 100)
790
+
791
+ async def test_fan_percentage_errors_are_not_hidden(self):
792
+ for response in (None, ExceptionResponse(4, 4),
793
+ SuccessResponse(registers=[10]), TimeoutError("Timed out")):
794
+ with self.subTest(response=response):
795
+ client = S21Client(self.server.host, self.server.port)
796
+ await client.poll()
797
+ original_read = client.client.read_input_registers
798
+
799
+ async def read(address, *, count):
800
+ if address == IR_CurSuFanSpeed:
801
+ if isinstance(response, Exception):
802
+ raise response
803
+ return response
804
+ return await original_read(address, count=count)
805
+
806
+ client.client.read_input_registers = AsyncMock(side_effect=read)
807
+ expected = (TimeoutError if isinstance(response, TimeoutError)
808
+ else ModbusCommunicationException)
809
+ with self.assertRaises(expected):
810
+ await client.poll()
811
+ self.assertFalse(client.device.available)
812
+ self.assertFalse(client.client.connected)
667
813
 
668
814
  async def test_optional_position_does_not_hide_communication_errors(self):
669
815
  self.server.data_bank.set_holding_registers(HR_BPS_ROTOR_TYPE, [2])
File without changes
File without changes
File without changes
File without changes