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.
- {pybls21-4.3.0 → pybls21-4.4.0}/PKG-INFO +46 -1
- {pybls21-4.3.0 → pybls21-4.4.0}/README.md +44 -0
- pybls21-4.4.0/THIRD_PARTY_NOTICES +41 -0
- {pybls21-4.3.0 → pybls21-4.4.0}/pybls21/client.py +61 -7
- {pybls21-4.3.0 → pybls21-4.4.0}/pybls21/constants.py +16 -0
- {pybls21-4.3.0 → pybls21-4.4.0}/pybls21/models.py +13 -0
- {pybls21-4.3.0 → pybls21-4.4.0}/pybls21.egg-info/PKG-INFO +46 -1
- {pybls21-4.3.0 → pybls21-4.4.0}/pybls21.egg-info/SOURCES.txt +1 -0
- {pybls21-4.3.0 → pybls21-4.4.0}/setup.py +2 -1
- {pybls21-4.3.0 → pybls21-4.4.0}/tests/test_client.py +147 -1
- {pybls21-4.3.0 → pybls21-4.4.0}/LICENSE +0 -0
- {pybls21-4.3.0 → pybls21-4.4.0}/pybls21/__init__.py +0 -0
- {pybls21-4.3.0 → pybls21-4.4.0}/pybls21/exceptions.py +0 -0
- {pybls21-4.3.0 → pybls21-4.4.0}/pybls21.egg-info/dependency_links.txt +0 -0
- {pybls21-4.3.0 → pybls21-4.4.0}/pybls21.egg-info/requires.txt +0 -0
- {pybls21-4.3.0 → pybls21-4.4.0}/pybls21.egg-info/top_level.txt +0 -0
- {pybls21-4.3.0 → pybls21-4.4.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pybls21
|
|
3
|
-
Version: 4.
|
|
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
|
|
150
|
-
|
|
151
|
-
|
|
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,
|
|
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
|
-
|
|
231
|
-
await self.
|
|
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=
|
|
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
|
+
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.
|
|
@@ -5,7 +5,7 @@ with open("README.md", "r") as fh:
|
|
|
5
5
|
|
|
6
6
|
setuptools.setup(
|
|
7
7
|
name="pybls21",
|
|
8
|
-
version="4.
|
|
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,
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|