pybls21 4.2.2__tar.gz → 4.3.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/PKG-INFO +96 -0
- pybls21-4.3.0/README.md +71 -0
- {pybls21-4.2.2 → pybls21-4.3.0}/pybls21/client.py +84 -11
- pybls21-4.3.0/pybls21/constants.py +36 -0
- pybls21-4.3.0/pybls21/models.py +80 -0
- pybls21-4.3.0/pybls21.egg-info/PKG-INFO +96 -0
- pybls21-4.3.0/pybls21.egg-info/requires.txt +1 -0
- {pybls21-4.2.2 → pybls21-4.3.0}/setup.py +2 -2
- {pybls21-4.2.2 → pybls21-4.3.0}/tests/test_client.py +250 -3
- pybls21-4.2.2/PKG-INFO +0 -43
- pybls21-4.2.2/README.md +0 -18
- pybls21-4.2.2/pybls21/constants.py +0 -25
- pybls21-4.2.2/pybls21/models.py +0 -56
- pybls21-4.2.2/pybls21.egg-info/PKG-INFO +0 -43
- pybls21-4.2.2/pybls21.egg-info/requires.txt +0 -1
- {pybls21-4.2.2 → pybls21-4.3.0}/LICENSE +0 -0
- {pybls21-4.2.2 → pybls21-4.3.0}/pybls21/__init__.py +0 -0
- {pybls21-4.2.2 → pybls21-4.3.0}/pybls21/exceptions.py +0 -0
- {pybls21-4.2.2 → pybls21-4.3.0}/pybls21.egg-info/SOURCES.txt +0 -0
- {pybls21-4.2.2 → pybls21-4.3.0}/pybls21.egg-info/dependency_links.txt +0 -0
- {pybls21-4.2.2 → pybls21-4.3.0}/pybls21.egg-info/top_level.txt +0 -0
- {pybls21-4.2.2 → pybls21-4.3.0}/setup.cfg +0 -0
pybls21-4.3.0/PKG-INFO
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pybls21
|
|
3
|
+
Version: 4.3.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
|
+
Requires-Dist: pymodbus<4.0,>=3.13.1
|
|
15
|
+
Dynamic: author
|
|
16
|
+
Dynamic: author-email
|
|
17
|
+
Dynamic: classifier
|
|
18
|
+
Dynamic: description
|
|
19
|
+
Dynamic: description-content-type
|
|
20
|
+
Dynamic: home-page
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
Dynamic: requires-dist
|
|
23
|
+
Dynamic: requires-python
|
|
24
|
+
Dynamic: summary
|
|
25
|
+
|
|
26
|
+
# Blauberg S21 Asynchronous Python API
|
|
27
|
+
An api allowing control of AC state (temperature, on/off, speed) of an Blauberg S21 device locally over TCP.
|
|
28
|
+
|
|
29
|
+
## Usage
|
|
30
|
+
To initialize:
|
|
31
|
+
`client = S21Client("192.168.0.125")`
|
|
32
|
+
|
|
33
|
+
To load:
|
|
34
|
+
`await client.poll()`
|
|
35
|
+
|
|
36
|
+
The following functions are available:
|
|
37
|
+
`turn_on()`
|
|
38
|
+
`turn_off()`
|
|
39
|
+
`set_hvac_mode(hvac_mode: HVACMode)`
|
|
40
|
+
`set_fan_mode(mode: int)`
|
|
41
|
+
`set_manual_fan_speed_percent(speed_percent: int)`
|
|
42
|
+
`set_temperature(temp_celsius: int)`
|
|
43
|
+
`reset_filter_change_timer()`
|
|
44
|
+
`reset_alarm()`
|
|
45
|
+
`boost_on()`
|
|
46
|
+
`boost_off()`
|
|
47
|
+
`set_bypass_mode(mode: BypassMode)`
|
|
48
|
+
`set_bypass_position(position_percent: int)`
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
## Additional readings
|
|
52
|
+
|
|
53
|
+
`await client.poll()` also returns extract and exhaust air temperatures
|
|
54
|
+
(`current_extract_temperature`, `current_exhaust_temperature`), supply and
|
|
55
|
+
extract duct pressure in Pa (`supply_pressure`, `extract_pressure`), and whole
|
|
56
|
+
days remaining until filter replacement (`filter_countdown_days`).
|
|
57
|
+
|
|
58
|
+
Missing or short-circuited temperature sensors are reported as `None`, including
|
|
59
|
+
`current_temperature` and `current_intake_temperature`. In AUTO mode,
|
|
60
|
+
`hvac_action` is also `None` when either temperature needed to infer the action
|
|
61
|
+
is unavailable. Other readings remain usable and `available` stays true after
|
|
62
|
+
a successful poll. Connection and communication failures mark
|
|
63
|
+
`client.device.available` false if a previous poll populated the device; a
|
|
64
|
+
successful subsequent poll restores it. Previously returned models are immutable
|
|
65
|
+
snapshots, so read `client.device` for the updated availability.
|
|
66
|
+
|
|
67
|
+
## Bypass control
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
from pybls21.models import BypassMode
|
|
71
|
+
|
|
72
|
+
# AUTO lets the device control its bypass or rotary heat exchanger.
|
|
73
|
+
await client.set_bypass_mode(BypassMode.AUTO)
|
|
74
|
+
|
|
75
|
+
# For analogue control, set the manual percentage and select manual mode.
|
|
76
|
+
await client.set_bypass_position(60)
|
|
77
|
+
await client.set_bypass_mode(BypassMode.OPEN)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`CLOSED` closes the bypass or starts the rotor. `OPEN` opens the bypass or stops
|
|
81
|
+
the rotor for discrete control; with analogue control it selects the percentage
|
|
82
|
+
set by `set_bypass_position()`. That setter alone does not change the mode.
|
|
83
|
+
A value of 0 means closed bypass / maximum rotor speed; 100 means open bypass /
|
|
84
|
+
stopped rotor. Choose controls appropriate to the reported `bypass_type`.
|
|
85
|
+
|
|
86
|
+
The model exposes `bypass_type`, `bypass_mode`, `manual_bypass_position`, and
|
|
87
|
+
`bypass_position`. Position is read separately from optional input register 51.
|
|
88
|
+
On older firmware that rejects this address, `bypass_position` is `None` and
|
|
89
|
+
polling the other readings still succeeds. Other communication errors are
|
|
90
|
+
propagated. When no bypass/rotor is fitted, its mode and positions are `None`
|
|
91
|
+
and the optional read is skipped.
|
|
92
|
+
|
|
93
|
+
New model fields have defaults, preserving construction with the original
|
|
94
|
+
positional or keyword arguments. The tuple now contains additional fields;
|
|
95
|
+
consumers should access readings by attribute rather than unpacking a fixed
|
|
96
|
+
number of values.
|
pybls21-4.3.0/README.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
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
|
+
|
|
25
|
+
|
|
26
|
+
## Additional readings
|
|
27
|
+
|
|
28
|
+
`await client.poll()` also returns extract and exhaust air temperatures
|
|
29
|
+
(`current_extract_temperature`, `current_exhaust_temperature`), supply and
|
|
30
|
+
extract duct pressure in Pa (`supply_pressure`, `extract_pressure`), and whole
|
|
31
|
+
days remaining until filter replacement (`filter_countdown_days`).
|
|
32
|
+
|
|
33
|
+
Missing or short-circuited temperature sensors are reported as `None`, including
|
|
34
|
+
`current_temperature` and `current_intake_temperature`. In AUTO mode,
|
|
35
|
+
`hvac_action` is also `None` when either temperature needed to infer the action
|
|
36
|
+
is unavailable. Other readings remain usable and `available` stays true after
|
|
37
|
+
a successful poll. Connection and communication failures mark
|
|
38
|
+
`client.device.available` false if a previous poll populated the device; a
|
|
39
|
+
successful subsequent poll restores it. Previously returned models are immutable
|
|
40
|
+
snapshots, so read `client.device` for the updated availability.
|
|
41
|
+
|
|
42
|
+
## Bypass control
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from pybls21.models import BypassMode
|
|
46
|
+
|
|
47
|
+
# AUTO lets the device control its bypass or rotary heat exchanger.
|
|
48
|
+
await client.set_bypass_mode(BypassMode.AUTO)
|
|
49
|
+
|
|
50
|
+
# For analogue control, set the manual percentage and select manual mode.
|
|
51
|
+
await client.set_bypass_position(60)
|
|
52
|
+
await client.set_bypass_mode(BypassMode.OPEN)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`CLOSED` closes the bypass or starts the rotor. `OPEN` opens the bypass or stops
|
|
56
|
+
the rotor for discrete control; with analogue control it selects the percentage
|
|
57
|
+
set by `set_bypass_position()`. That setter alone does not change the mode.
|
|
58
|
+
A value of 0 means closed bypass / maximum rotor speed; 100 means open bypass /
|
|
59
|
+
stopped rotor. Choose controls appropriate to the reported `bypass_type`.
|
|
60
|
+
|
|
61
|
+
The model exposes `bypass_type`, `bypass_mode`, `manual_bypass_position`, and
|
|
62
|
+
`bypass_position`. Position is read separately from optional input register 51.
|
|
63
|
+
On older firmware that rejects this address, `bypass_position` is `None` and
|
|
64
|
+
polling the other readings still succeeds. Other communication errors are
|
|
65
|
+
propagated. When no bypass/rotor is fitted, its mode and positions are `None`
|
|
66
|
+
and the optional read is skipped.
|
|
67
|
+
|
|
68
|
+
New model fields have defaults, preserving construction with the original
|
|
69
|
+
positional or keyword arguments. The tuple now contains additional fields;
|
|
70
|
+
consumers should access readings by attribute rather than unpacking a fixed
|
|
71
|
+
number of values.
|
|
@@ -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,16 @@ 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_bypass_mode(self, mode: BypassMode) -> None:
|
|
87
|
+
mode = BypassMode(mode)
|
|
88
|
+
await self._do_with_connection(lambda: self._set_bypass_mode(mode))
|
|
89
|
+
|
|
90
|
+
async def set_bypass_position(self, position_percent: int) -> None:
|
|
91
|
+
self._validate_bypass_position(position_percent)
|
|
92
|
+
await self._do_with_connection(
|
|
93
|
+
lambda: self._set_bypass_position(position_percent)
|
|
94
|
+
)
|
|
95
|
+
|
|
76
96
|
@staticmethod
|
|
77
97
|
def _validate_modbus_response(response: Any, operation: str) -> Any:
|
|
78
98
|
if response is None:
|
|
@@ -117,10 +137,23 @@ class S21Client:
|
|
|
117
137
|
if not isinstance(temp_celsius, int) or not 15 <= temp_celsius <= 30:
|
|
118
138
|
raise ValueError("Temperature must be between 15 and 30 °C")
|
|
119
139
|
|
|
140
|
+
@staticmethod
|
|
141
|
+
def _validate_bypass_position(position_percent: int) -> None:
|
|
142
|
+
if not isinstance(position_percent, int) or not 0 <= position_percent <= 100:
|
|
143
|
+
raise ValueError("Bypass position percent must be between 0 and 100")
|
|
144
|
+
|
|
120
145
|
async def _read_input_registers(self, address: int, count: int) -> List[int]:
|
|
121
146
|
response = await self.client.read_input_registers(address, count=count)
|
|
122
147
|
return self._get_registers(response, count, f"read input registers at {address}")
|
|
123
148
|
|
|
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;
|
|
152
|
+
# timeouts, malformed replies and other device errors must still surface.
|
|
153
|
+
if isinstance(response, ExceptionResponse) and response.exception_code == 2:
|
|
154
|
+
return None
|
|
155
|
+
return self._get_registers(response, 1, "read bypass position")[0]
|
|
156
|
+
|
|
124
157
|
async def _read_holding_registers(self, address: int, count: int) -> List[int]:
|
|
125
158
|
response = await self.client.read_holding_registers(address, count=count)
|
|
126
159
|
return self._get_registers(response, count, f"read holding registers at {address}")
|
|
@@ -139,14 +172,17 @@ class S21Client:
|
|
|
139
172
|
|
|
140
173
|
async def _do_with_connection(self, func: Callable[[], Awaitable[Any]]) -> Any:
|
|
141
174
|
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
175
|
try:
|
|
176
|
+
if not await self.client.connect():
|
|
177
|
+
raise ModbusCommunicationException(
|
|
178
|
+
"Failed to open Modbus TCP connection"
|
|
179
|
+
)
|
|
146
180
|
return await func()
|
|
147
181
|
except Exception:
|
|
148
182
|
if isinstance(self.device, ClimateDevice):
|
|
149
|
-
|
|
183
|
+
# ClimateDevice is a NamedTuple and therefore immutable,
|
|
184
|
+
# so the flag has to be replaced instead of assigned.
|
|
185
|
+
self.device = self.device._replace(available=False)
|
|
150
186
|
raise
|
|
151
187
|
finally:
|
|
152
188
|
self.client.close() # Also, long connections break over time and become unusable
|
|
@@ -156,7 +192,7 @@ class S21Client:
|
|
|
156
192
|
raise UnsupportedDeviceException("Unsupported device (IR_DeviceTYPE != 1)")
|
|
157
193
|
|
|
158
194
|
coils = await self._read_coils(0, count=4)
|
|
159
|
-
holding_registers = await self._read_holding_registers(0, count=
|
|
195
|
+
holding_registers = await self._read_holding_registers(0, count=76)
|
|
160
196
|
input_registers = await self._read_input_registers(0, count=39)
|
|
161
197
|
|
|
162
198
|
is_on: bool = coils[CL_POWER]
|
|
@@ -167,10 +203,10 @@ class S21Client:
|
|
|
167
203
|
alarm_state: int = input_registers[IR_ALARM]
|
|
168
204
|
max_fan_level: int = holding_registers[HR_MaxSPEED_MODE]
|
|
169
205
|
current_fan_level: int = holding_registers[HR_SPEED_MODE] # 255 - manual
|
|
170
|
-
|
|
206
|
+
temp_before_heating = _parse_temperature(
|
|
171
207
|
input_registers[IR_CurTEMP_SuAirIn]
|
|
172
208
|
)
|
|
173
|
-
|
|
209
|
+
temp_after_heating = _parse_temperature(
|
|
174
210
|
input_registers[IR_CurTEMP_SuAirOut]
|
|
175
211
|
)
|
|
176
212
|
supply_fan_speed: int = input_registers[IR_SuRPM]
|
|
@@ -180,6 +216,22 @@ class S21Client:
|
|
|
180
216
|
]
|
|
181
217
|
operation_mode: int = holding_registers[HR_OPERATION_MODE]
|
|
182
218
|
manual_fan_speed_percent: int = holding_registers[HR_ManualSPEED]
|
|
219
|
+
bypass_type: BypassType = BypassType(holding_registers[HR_BPS_ROTOR_TYPE])
|
|
220
|
+
bypass_mode = (
|
|
221
|
+
BypassMode(holding_registers[HR_BPS_ROTOR_MODE])
|
|
222
|
+
if bypass_type != BypassType.NOT_AVAILABLE
|
|
223
|
+
else None
|
|
224
|
+
)
|
|
225
|
+
manual_bypass_position = (
|
|
226
|
+
holding_registers[HR_SetBpsRotorMANUAL]
|
|
227
|
+
if bypass_type != BypassType.NOT_AVAILABLE
|
|
228
|
+
else None
|
|
229
|
+
)
|
|
230
|
+
bypass_position = (
|
|
231
|
+
await self._read_bypass_position()
|
|
232
|
+
if bypass_type != BypassType.NOT_AVAILABLE
|
|
233
|
+
else None
|
|
234
|
+
)
|
|
183
235
|
|
|
184
236
|
self.device = ClimateDevice(
|
|
185
237
|
available=True,
|
|
@@ -187,7 +239,7 @@ class S21Client:
|
|
|
187
239
|
unique_id=f"S21_{self.host}_{self.port}",
|
|
188
240
|
temperature_unit=TEMP_CELSIUS, # Seems like no Fahrenheit option is available
|
|
189
241
|
precision=1,
|
|
190
|
-
current_temperature=
|
|
242
|
+
current_temperature=temp_after_heating,
|
|
191
243
|
target_temperature=set_temperature,
|
|
192
244
|
target_temperature_step=1,
|
|
193
245
|
min_temp=15,
|
|
@@ -210,10 +262,12 @@ class S21Client:
|
|
|
210
262
|
if operation_mode == 1
|
|
211
263
|
else HVACAction.COOLING
|
|
212
264
|
if operation_mode == 2
|
|
265
|
+
else None
|
|
266
|
+
if temp_before_heating is None or temp_after_heating is None
|
|
213
267
|
else HVACAction.HEATING
|
|
214
|
-
if
|
|
268
|
+
if temp_before_heating < temp_after_heating
|
|
215
269
|
else HVACAction.COOLING
|
|
216
|
-
if
|
|
270
|
+
if temp_before_heating > temp_after_heating
|
|
217
271
|
else HVACAction.IDLE,
|
|
218
272
|
hvac_modes=[
|
|
219
273
|
HVACMode.OFF,
|
|
@@ -230,13 +284,26 @@ class S21Client:
|
|
|
230
284
|
model="S21",
|
|
231
285
|
sw_version=_parse_firmware_version(firmware_info),
|
|
232
286
|
is_boosting=is_boosting,
|
|
233
|
-
current_intake_temperature=
|
|
287
|
+
current_intake_temperature=temp_before_heating,
|
|
234
288
|
manual_fan_speed_percent=manual_fan_speed_percent,
|
|
235
289
|
max_fan_level=max_fan_level,
|
|
236
290
|
filter_state=filter_state,
|
|
237
291
|
alarm_state=alarm_state,
|
|
238
292
|
supply_fan_speed=supply_fan_speed,
|
|
239
293
|
extract_fan_speed=extract_fan_speed,
|
|
294
|
+
current_extract_temperature=_parse_temperature(
|
|
295
|
+
input_registers[IR_CurTEMP_ExAirIn]
|
|
296
|
+
),
|
|
297
|
+
current_exhaust_temperature=_parse_temperature(
|
|
298
|
+
input_registers[IR_CurTEMP_ExAirOut]
|
|
299
|
+
),
|
|
300
|
+
supply_pressure=input_registers[IR_CurSuPRESS],
|
|
301
|
+
extract_pressure=input_registers[IR_CurExPRESS],
|
|
302
|
+
filter_countdown_days=input_registers[IR_CurFILTER_TIMER_DAYS],
|
|
303
|
+
bypass_type=bypass_type,
|
|
304
|
+
bypass_mode=bypass_mode,
|
|
305
|
+
bypass_position=bypass_position,
|
|
306
|
+
manual_bypass_position=manual_bypass_position,
|
|
240
307
|
)
|
|
241
308
|
|
|
242
309
|
return self.device
|
|
@@ -283,3 +350,9 @@ class S21Client:
|
|
|
283
350
|
|
|
284
351
|
async def _set_boost_off(self) -> None:
|
|
285
352
|
await self._write_coil(CL_BoostSWITCH_CTRL, False)
|
|
353
|
+
|
|
354
|
+
async def _set_bypass_mode(self, mode: BypassMode) -> None:
|
|
355
|
+
await self._write_register(HR_BPS_ROTOR_MODE, int(mode))
|
|
356
|
+
|
|
357
|
+
async def _set_bypass_position(self, position_percent: int) -> None:
|
|
358
|
+
await self._write_register(HR_SetBpsRotorMANUAL, position_percent)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Coils
|
|
2
|
+
CL_POWER = 0
|
|
3
|
+
CL_Boost_MODE = 3
|
|
4
|
+
CL_BoostSWITCH_CTRL = 13
|
|
5
|
+
CL_RESET_FILTER_TIMER = 17
|
|
6
|
+
CL_RESET_ALARM = 18
|
|
7
|
+
|
|
8
|
+
# Holding registers
|
|
9
|
+
HR_MaxSPEED_MODE = 1
|
|
10
|
+
HR_SPEED_MODE = 2
|
|
11
|
+
HR_ManualSPEED = 17
|
|
12
|
+
HR_OPERATION_MODE = 43
|
|
13
|
+
HR_SetTEMP = 44
|
|
14
|
+
HR_BPS_ROTOR_TYPE = 57
|
|
15
|
+
HR_BPS_ROTOR_MODE = 74
|
|
16
|
+
HR_SetBpsRotorMANUAL = 75
|
|
17
|
+
|
|
18
|
+
# Input registers
|
|
19
|
+
IR_CurTEMP_SuAirIn = 1
|
|
20
|
+
IR_CurTEMP_SuAirOut = 2
|
|
21
|
+
IR_CurTEMP_ExAirIn = 3 # Extract air from the rooms, at the unit inlet
|
|
22
|
+
IR_CurTEMP_ExAirOut = 4 # Exhaust air to the outside, at the unit outlet
|
|
23
|
+
IR_CurRH_Int = 10
|
|
24
|
+
IR_CurSuPRESS = 21 # Supply duct pressure, Pa
|
|
25
|
+
IR_CurExPRESS = 22 # Extract duct pressure, Pa
|
|
26
|
+
IR_SuRPM = 23
|
|
27
|
+
IR_ExRPM = 24
|
|
28
|
+
IR_CurFILTER_TIMER_HOURS_MINUTES = 27 # High byte: hours, low byte: minutes
|
|
29
|
+
IR_CurFILTER_TIMER_DAYS = 28
|
|
30
|
+
IR_StateFILTER = 31
|
|
31
|
+
IR_VerMAIN_FMW_start = 34
|
|
32
|
+
IR_VerMAIN_FMW_end = 36
|
|
33
|
+
IR_DeviceTYPE = 37
|
|
34
|
+
IR_ALARM = 38
|
|
35
|
+
IR_BPS_ROTOR_U = 45
|
|
36
|
+
IR_StatusBpsRotor = 51
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
from typing import List, NamedTuple, Optional
|
|
3
|
+
|
|
4
|
+
TEMP_CELSIUS: str = "°C"
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ClimateEntityFeature(int, Enum):
|
|
8
|
+
TARGET_TEMPERATURE = 1
|
|
9
|
+
FAN_MODE = 8
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class HVACMode(str, Enum):
|
|
13
|
+
OFF = "off"
|
|
14
|
+
HEAT = "heat"
|
|
15
|
+
COOL = "cool"
|
|
16
|
+
AUTO = "auto"
|
|
17
|
+
FAN_ONLY = "fan_only"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class HVACAction(str, Enum):
|
|
21
|
+
COOLING = "cooling"
|
|
22
|
+
FAN = "fan"
|
|
23
|
+
HEATING = "heating"
|
|
24
|
+
IDLE = "idle"
|
|
25
|
+
OFF = "off"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class BypassType(int, Enum):
|
|
29
|
+
NOT_AVAILABLE = 0
|
|
30
|
+
BYPASS_TWO_POINT = 1 # Discrete open/closed bypass damper control
|
|
31
|
+
BYPASS_ANALOGUE = 2 # Bypass damper position controlled 0-100%
|
|
32
|
+
ROTOR_DISCRETE = 3 # Discrete on/off rotary heat exchanger control
|
|
33
|
+
ROTOR_ANALOGUE = 4 # Rotary heat exchanger speed controlled 0-100%
|
|
34
|
+
BYPASS_THREE_POINT = 5 # Bypass damper driven open/closed via timed pulses
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class BypassMode(int, Enum):
|
|
38
|
+
CLOSED = 0 # Close the bypass / start the rotor
|
|
39
|
+
OPEN = 1 # Open the bypass / stop the rotor (discrete), or manual % (analogue)
|
|
40
|
+
AUTO = 2 # Device controls bypass/rotor automatically based on temperature
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ClimateDevice(NamedTuple):
|
|
44
|
+
available: bool
|
|
45
|
+
name: str
|
|
46
|
+
unique_id: str
|
|
47
|
+
temperature_unit: str
|
|
48
|
+
precision: float
|
|
49
|
+
current_temperature: Optional[float]
|
|
50
|
+
target_temperature: float
|
|
51
|
+
target_temperature_step: float
|
|
52
|
+
max_temp: float
|
|
53
|
+
min_temp: float
|
|
54
|
+
current_humidity: Optional[float]
|
|
55
|
+
hvac_mode: str
|
|
56
|
+
hvac_action: Optional[str]
|
|
57
|
+
hvac_modes: List[str]
|
|
58
|
+
fan_mode: Optional[int]
|
|
59
|
+
fan_modes: Optional[List[int]]
|
|
60
|
+
supported_features: int
|
|
61
|
+
manufacturer: str
|
|
62
|
+
model: Optional[str]
|
|
63
|
+
sw_version: Optional[str]
|
|
64
|
+
is_boosting: bool
|
|
65
|
+
current_intake_temperature: Optional[float]
|
|
66
|
+
manual_fan_speed_percent: int
|
|
67
|
+
max_fan_level: int
|
|
68
|
+
filter_state: int
|
|
69
|
+
alarm_state: int
|
|
70
|
+
supply_fan_speed: int
|
|
71
|
+
extract_fan_speed: int
|
|
72
|
+
current_extract_temperature: Optional[float] = None
|
|
73
|
+
current_exhaust_temperature: Optional[float] = None
|
|
74
|
+
supply_pressure: Optional[int] = None
|
|
75
|
+
extract_pressure: Optional[int] = None
|
|
76
|
+
filter_countdown_days: Optional[int] = None
|
|
77
|
+
bypass_type: BypassType = BypassType.NOT_AVAILABLE
|
|
78
|
+
bypass_mode: Optional[BypassMode] = None
|
|
79
|
+
bypass_position: Optional[int] = None
|
|
80
|
+
manual_bypass_position: Optional[int] = None
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pybls21
|
|
3
|
+
Version: 4.3.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
|
+
Requires-Dist: pymodbus<4.0,>=3.13.1
|
|
15
|
+
Dynamic: author
|
|
16
|
+
Dynamic: author-email
|
|
17
|
+
Dynamic: classifier
|
|
18
|
+
Dynamic: description
|
|
19
|
+
Dynamic: description-content-type
|
|
20
|
+
Dynamic: home-page
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
Dynamic: requires-dist
|
|
23
|
+
Dynamic: requires-python
|
|
24
|
+
Dynamic: summary
|
|
25
|
+
|
|
26
|
+
# Blauberg S21 Asynchronous Python API
|
|
27
|
+
An api allowing control of AC state (temperature, on/off, speed) of an Blauberg S21 device locally over TCP.
|
|
28
|
+
|
|
29
|
+
## Usage
|
|
30
|
+
To initialize:
|
|
31
|
+
`client = S21Client("192.168.0.125")`
|
|
32
|
+
|
|
33
|
+
To load:
|
|
34
|
+
`await client.poll()`
|
|
35
|
+
|
|
36
|
+
The following functions are available:
|
|
37
|
+
`turn_on()`
|
|
38
|
+
`turn_off()`
|
|
39
|
+
`set_hvac_mode(hvac_mode: HVACMode)`
|
|
40
|
+
`set_fan_mode(mode: int)`
|
|
41
|
+
`set_manual_fan_speed_percent(speed_percent: int)`
|
|
42
|
+
`set_temperature(temp_celsius: int)`
|
|
43
|
+
`reset_filter_change_timer()`
|
|
44
|
+
`reset_alarm()`
|
|
45
|
+
`boost_on()`
|
|
46
|
+
`boost_off()`
|
|
47
|
+
`set_bypass_mode(mode: BypassMode)`
|
|
48
|
+
`set_bypass_position(position_percent: int)`
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
## Additional readings
|
|
52
|
+
|
|
53
|
+
`await client.poll()` also returns extract and exhaust air temperatures
|
|
54
|
+
(`current_extract_temperature`, `current_exhaust_temperature`), supply and
|
|
55
|
+
extract duct pressure in Pa (`supply_pressure`, `extract_pressure`), and whole
|
|
56
|
+
days remaining until filter replacement (`filter_countdown_days`).
|
|
57
|
+
|
|
58
|
+
Missing or short-circuited temperature sensors are reported as `None`, including
|
|
59
|
+
`current_temperature` and `current_intake_temperature`. In AUTO mode,
|
|
60
|
+
`hvac_action` is also `None` when either temperature needed to infer the action
|
|
61
|
+
is unavailable. Other readings remain usable and `available` stays true after
|
|
62
|
+
a successful poll. Connection and communication failures mark
|
|
63
|
+
`client.device.available` false if a previous poll populated the device; a
|
|
64
|
+
successful subsequent poll restores it. Previously returned models are immutable
|
|
65
|
+
snapshots, so read `client.device` for the updated availability.
|
|
66
|
+
|
|
67
|
+
## Bypass control
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
from pybls21.models import BypassMode
|
|
71
|
+
|
|
72
|
+
# AUTO lets the device control its bypass or rotary heat exchanger.
|
|
73
|
+
await client.set_bypass_mode(BypassMode.AUTO)
|
|
74
|
+
|
|
75
|
+
# For analogue control, set the manual percentage and select manual mode.
|
|
76
|
+
await client.set_bypass_position(60)
|
|
77
|
+
await client.set_bypass_mode(BypassMode.OPEN)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`CLOSED` closes the bypass or starts the rotor. `OPEN` opens the bypass or stops
|
|
81
|
+
the rotor for discrete control; with analogue control it selects the percentage
|
|
82
|
+
set by `set_bypass_position()`. That setter alone does not change the mode.
|
|
83
|
+
A value of 0 means closed bypass / maximum rotor speed; 100 means open bypass /
|
|
84
|
+
stopped rotor. Choose controls appropriate to the reported `bypass_type`.
|
|
85
|
+
|
|
86
|
+
The model exposes `bypass_type`, `bypass_mode`, `manual_bypass_position`, and
|
|
87
|
+
`bypass_position`. Position is read separately from optional input register 51.
|
|
88
|
+
On older firmware that rejects this address, `bypass_position` is `None` and
|
|
89
|
+
polling the other readings still succeeds. Other communication errors are
|
|
90
|
+
propagated. When no bypass/rotor is fitted, its mode and positions are `None`
|
|
91
|
+
and the optional read is skipped.
|
|
92
|
+
|
|
93
|
+
New model fields have defaults, preserving construction with the original
|
|
94
|
+
positional or keyword arguments. The tuple now contains additional fields;
|
|
95
|
+
consumers should access readings by attribute rather than unpacking a fixed
|
|
96
|
+
number of values.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pymodbus<4.0,>=3.13.1
|
|
@@ -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.3.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,7 +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
|
-
install_requires=["pymodbus>=3.
|
|
16
|
+
install_requires=["pymodbus>=3.13.1,<4.0"],
|
|
17
17
|
classifiers=[
|
|
18
18
|
"Programming Language :: Python :: 3",
|
|
19
19
|
"License :: OSI Approved :: MIT License",
|
|
@@ -2,11 +2,19 @@ import unittest
|
|
|
2
2
|
from unittest.mock import AsyncMock, Mock
|
|
3
3
|
|
|
4
4
|
from pyModbusTCP.server import DataBank, ModbusServer
|
|
5
|
+
from pymodbus.pdu import ExceptionResponse
|
|
5
6
|
|
|
6
7
|
from pybls21.client import S21Client
|
|
7
8
|
from pybls21.constants import *
|
|
8
9
|
from pybls21.exceptions import *
|
|
9
|
-
from pybls21.models import
|
|
10
|
+
from pybls21.models import (
|
|
11
|
+
BypassMode,
|
|
12
|
+
BypassType,
|
|
13
|
+
ClimateDevice,
|
|
14
|
+
ClimateEntityFeature,
|
|
15
|
+
HVACAction,
|
|
16
|
+
HVACMode,
|
|
17
|
+
)
|
|
10
18
|
|
|
11
19
|
|
|
12
20
|
class ErrorResponse:
|
|
@@ -56,6 +64,38 @@ class TestClient(unittest.IsolatedAsyncioTestCase):
|
|
|
56
64
|
with self.assertRaises(ModbusCommunicationException):
|
|
57
65
|
await client.poll()
|
|
58
66
|
|
|
67
|
+
client.client.close.assert_called_once_with()
|
|
68
|
+
|
|
69
|
+
async def test_failed_reconnect_invalidates_device_and_can_recover(self):
|
|
70
|
+
client = S21Client(host=self.server.host, port=self.server.port)
|
|
71
|
+
await client.poll()
|
|
72
|
+
connect = client.client.connect
|
|
73
|
+
close = client.client.close
|
|
74
|
+
client.client.connect = AsyncMock(return_value=False)
|
|
75
|
+
client.client.close = Mock(wraps=close)
|
|
76
|
+
|
|
77
|
+
with self.assertRaises(ModbusCommunicationException):
|
|
78
|
+
await client.poll()
|
|
79
|
+
|
|
80
|
+
self.assertFalse(client.device.available)
|
|
81
|
+
client.client.close.assert_called_once_with()
|
|
82
|
+
client.client.connect = connect
|
|
83
|
+
self.assertTrue((await client.poll()).available)
|
|
84
|
+
|
|
85
|
+
async def test_connect_exception_invalidates_device_and_preserves_error(self):
|
|
86
|
+
client = S21Client(host=self.server.host, port=self.server.port)
|
|
87
|
+
await client.poll()
|
|
88
|
+
error = OSError("Connection failed")
|
|
89
|
+
client.client.connect = AsyncMock(side_effect=error)
|
|
90
|
+
client.client.close = Mock(wraps=client.client.close)
|
|
91
|
+
|
|
92
|
+
with self.assertRaises(OSError) as raised:
|
|
93
|
+
await client.turn_on()
|
|
94
|
+
|
|
95
|
+
self.assertIs(raised.exception, error)
|
|
96
|
+
self.assertFalse(client.device.available)
|
|
97
|
+
client.client.close.assert_called_once_with()
|
|
98
|
+
|
|
59
99
|
async def test_poll_when_modbus_returns_error_raises_exception(self):
|
|
60
100
|
client = S21Client(host=self.server.host, port=self.server.port)
|
|
61
101
|
client.client.connect = AsyncMock(return_value=True)
|
|
@@ -91,6 +131,25 @@ class TestClient(unittest.IsolatedAsyncioTestCase):
|
|
|
91
131
|
with self.assertRaises(ModbusCommunicationException):
|
|
92
132
|
await client.poll()
|
|
93
133
|
|
|
134
|
+
async def test_poll_when_it_fails_after_a_successful_poll_marks_device_unavailable(
|
|
135
|
+
self,
|
|
136
|
+
):
|
|
137
|
+
client = S21Client(host=self.server.host, port=self.server.port)
|
|
138
|
+
|
|
139
|
+
# A device has to be present first - the failure path below is only
|
|
140
|
+
# reached once self.device holds a ClimateDevice.
|
|
141
|
+
await client.poll()
|
|
142
|
+
self.assertTrue(client.device.available)
|
|
143
|
+
|
|
144
|
+
client.client.connect = AsyncMock(return_value=True)
|
|
145
|
+
client.client.close = Mock()
|
|
146
|
+
client.client.read_input_registers = AsyncMock(return_value=ErrorResponse())
|
|
147
|
+
|
|
148
|
+
with self.assertRaises(ModbusCommunicationException):
|
|
149
|
+
await client.poll()
|
|
150
|
+
|
|
151
|
+
self.assertFalse(client.device.available)
|
|
152
|
+
|
|
94
153
|
async def test_turn_on_when_write_fails_raises_exception(self):
|
|
95
154
|
client = S21Client(host=self.server.host, port=self.server.port)
|
|
96
155
|
client.client.connect = AsyncMock(return_value=True)
|
|
@@ -115,6 +174,11 @@ class TestClient(unittest.IsolatedAsyncioTestCase):
|
|
|
115
174
|
self.server.data_bank.set_input_registers(IR_ALARM, [2])
|
|
116
175
|
self.server.data_bank.set_input_registers(IR_CurTEMP_SuAirIn, [108])
|
|
117
176
|
self.server.data_bank.set_input_registers(IR_CurTEMP_SuAirOut, [192])
|
|
177
|
+
self.server.data_bank.set_input_registers(IR_CurTEMP_ExAirIn, [236])
|
|
178
|
+
self.server.data_bank.set_input_registers(IR_CurTEMP_ExAirOut, [227])
|
|
179
|
+
self.server.data_bank.set_input_registers(IR_CurSuPRESS, [45])
|
|
180
|
+
self.server.data_bank.set_input_registers(IR_CurExPRESS, [50])
|
|
181
|
+
self.server.data_bank.set_input_registers(IR_CurFILTER_TIMER_DAYS, [69])
|
|
118
182
|
self.server.data_bank.set_input_registers(
|
|
119
183
|
IR_VerMAIN_FMW_start, [36, 2053, 2019]
|
|
120
184
|
)
|
|
@@ -159,10 +223,76 @@ class TestClient(unittest.IsolatedAsyncioTestCase):
|
|
|
159
223
|
filter_state=3,
|
|
160
224
|
alarm_state=2,
|
|
161
225
|
supply_fan_speed=10,
|
|
162
|
-
extract_fan_speed=20
|
|
226
|
+
extract_fan_speed=20,
|
|
227
|
+
current_extract_temperature=23.6,
|
|
228
|
+
current_exhaust_temperature=22.7,
|
|
229
|
+
supply_pressure=45,
|
|
230
|
+
extract_pressure=50,
|
|
231
|
+
filter_countdown_days=69,
|
|
232
|
+
bypass_type=BypassType.NOT_AVAILABLE,
|
|
233
|
+
bypass_mode=None,
|
|
234
|
+
bypass_position=None,
|
|
235
|
+
manual_bypass_position=None,
|
|
163
236
|
),
|
|
164
237
|
)
|
|
165
238
|
|
|
239
|
+
async def test_original_climate_device_constructor_remains_supported(self):
|
|
240
|
+
client = S21Client(host=self.server.host, port=self.server.port)
|
|
241
|
+
device = await client.poll()
|
|
242
|
+
# The public model originally contained these first 28 fields.
|
|
243
|
+
original_values = device[:28]
|
|
244
|
+
positional = ClimateDevice(*original_values)
|
|
245
|
+
keyword = ClimateDevice(**dict(zip(device._fields[:28], original_values)))
|
|
246
|
+
self.assertEqual(positional, keyword)
|
|
247
|
+
self.assertEqual(positional[:28], original_values)
|
|
248
|
+
self.assertIsNone(positional.current_extract_temperature)
|
|
249
|
+
self.assertIsNone(positional.current_exhaust_temperature)
|
|
250
|
+
self.assertIsNone(positional.supply_pressure)
|
|
251
|
+
self.assertIsNone(positional.extract_pressure)
|
|
252
|
+
self.assertIsNone(positional.filter_countdown_days)
|
|
253
|
+
self.assertEqual(positional.bypass_type, BypassType.NOT_AVAILABLE)
|
|
254
|
+
self.assertIsNone(positional.bypass_mode)
|
|
255
|
+
self.assertIsNone(positional.bypass_position)
|
|
256
|
+
self.assertIsNone(positional.manual_bypass_position)
|
|
257
|
+
|
|
258
|
+
async def test_temperature_faults_are_unknown_and_recover(self):
|
|
259
|
+
client = S21Client(host=self.server.host, port=self.server.port)
|
|
260
|
+
sensors = (
|
|
261
|
+
(IR_CurTEMP_SuAirIn, "current_intake_temperature"),
|
|
262
|
+
(IR_CurTEMP_SuAirOut, "current_temperature"),
|
|
263
|
+
(IR_CurTEMP_ExAirIn, "current_extract_temperature"),
|
|
264
|
+
(IR_CurTEMP_ExAirOut, "current_exhaust_temperature"),
|
|
265
|
+
)
|
|
266
|
+
for address, field in sensors:
|
|
267
|
+
for raw in (0x8000, 0x7FFF):
|
|
268
|
+
with self.subTest(address=address, raw=raw):
|
|
269
|
+
self.server.data_bank.reset()
|
|
270
|
+
self.server.data_bank.set_holding_registers(HR_OPERATION_MODE, [3])
|
|
271
|
+
self.server.data_bank.set_input_registers(address, [raw])
|
|
272
|
+
device = await client.poll()
|
|
273
|
+
self.assertIsNone(getattr(device, field))
|
|
274
|
+
self.assertTrue(device.available)
|
|
275
|
+
if address in (IR_CurTEMP_SuAirIn, IR_CurTEMP_SuAirOut):
|
|
276
|
+
self.assertIsNone(device.hvac_action)
|
|
277
|
+
else:
|
|
278
|
+
self.assertEqual(device.hvac_action, HVACAction.IDLE)
|
|
279
|
+
self.server.data_bank.set_input_registers(address, [100])
|
|
280
|
+
self.assertEqual(getattr(await client.poll(), field), 10.0)
|
|
281
|
+
|
|
282
|
+
async def test_extract_and_exhaust_temperatures_support_negative_and_zero(self):
|
|
283
|
+
self.server.data_bank.set_input_registers(IR_CurTEMP_ExAirIn, [0xFFF6])
|
|
284
|
+
self.server.data_bank.set_input_registers(IR_CurTEMP_ExAirOut, [0])
|
|
285
|
+
device = await S21Client(self.server.host, self.server.port).poll()
|
|
286
|
+
self.assertEqual(device.current_extract_temperature, -1.0)
|
|
287
|
+
self.assertEqual(device.current_exhaust_temperature, 0.0)
|
|
288
|
+
|
|
289
|
+
async def test_missing_temperatures_preserve_off_and_fan_actions(self):
|
|
290
|
+
self.server.data_bank.set_input_registers(IR_CurTEMP_SuAirOut, [0x8000])
|
|
291
|
+
client = S21Client(self.server.host, self.server.port)
|
|
292
|
+
self.assertEqual((await client.poll()).hvac_action, HVACAction.FAN)
|
|
293
|
+
self.server.data_bank.set_coils(CL_POWER, [False])
|
|
294
|
+
self.assertEqual((await client.poll()).hvac_action, HVACAction.OFF)
|
|
295
|
+
|
|
166
296
|
async def test_poll_when_device_is_off(self):
|
|
167
297
|
self.server.data_bank.set_coils(CL_POWER, [False])
|
|
168
298
|
|
|
@@ -492,13 +622,130 @@ class TestClient(unittest.IsolatedAsyncioTestCase):
|
|
|
492
622
|
|
|
493
623
|
self.assertEqual(self.server.data_bank.get_coils(CL_BoostSWITCH_CTRL, 1), [False])
|
|
494
624
|
|
|
625
|
+
async def test_poll_reads_bypass_state(self):
|
|
626
|
+
self.server.data_bank.set_holding_registers(HR_BPS_ROTOR_TYPE, [2])
|
|
627
|
+
self.server.data_bank.set_holding_registers(HR_BPS_ROTOR_MODE, [2])
|
|
628
|
+
self.server.data_bank.set_holding_registers(HR_SetBpsRotorMANUAL, [75])
|
|
629
|
+
self.server.data_bank.set_input_registers(IR_StatusBpsRotor, [42])
|
|
630
|
+
|
|
631
|
+
client = S21Client(host=self.server.host, port=self.server.port)
|
|
632
|
+
device = await client.poll()
|
|
633
|
+
|
|
634
|
+
self.assertEqual(device.bypass_type, BypassType.BYPASS_ANALOGUE)
|
|
635
|
+
self.assertEqual(device.bypass_mode, BypassMode.AUTO)
|
|
636
|
+
self.assertEqual(device.manual_bypass_position, 75)
|
|
637
|
+
self.assertEqual(device.bypass_position, 42)
|
|
638
|
+
|
|
639
|
+
async def test_poll_supports_older_input_register_map(self):
|
|
640
|
+
bank = DataBank(coils_size=25, h_regs_size=182, i_regs_size=51)
|
|
641
|
+
bank.set_input_registers(IR_DeviceTYPE, [1])
|
|
642
|
+
bank.set_input_registers(IR_CurTEMP_SuAirOut, [215])
|
|
643
|
+
bank.set_holding_registers(HR_BPS_ROTOR_TYPE, [2])
|
|
644
|
+
bank.set_holding_registers(HR_BPS_ROTOR_MODE, [2])
|
|
645
|
+
server = ModbusServer(host="localhost", port=5503, no_block=True, data_bank=bank)
|
|
646
|
+
server.start()
|
|
647
|
+
self.addCleanup(server.stop)
|
|
648
|
+
client = S21Client(server.host, server.port)
|
|
649
|
+
for _ in range(2):
|
|
650
|
+
device = await client.poll()
|
|
651
|
+
self.assertTrue(device.available)
|
|
652
|
+
self.assertEqual(device.current_temperature, 21.5)
|
|
653
|
+
self.assertEqual(device.bypass_mode, BypassMode.AUTO)
|
|
654
|
+
self.assertIsNone(device.bypass_position)
|
|
655
|
+
|
|
656
|
+
async def test_no_bypass_skips_optional_position_read(self):
|
|
657
|
+
client = S21Client(self.server.host, self.server.port)
|
|
658
|
+
client.client.read_input_registers = Mock(
|
|
659
|
+
wraps=client.client.read_input_registers
|
|
660
|
+
)
|
|
661
|
+
device = await client.poll()
|
|
662
|
+
self.assertEqual(device.bypass_type, BypassType.NOT_AVAILABLE)
|
|
663
|
+
self.assertIsNone(device.bypass_position)
|
|
664
|
+
self.assertIsNone(device.bypass_mode)
|
|
665
|
+
self.assertIsNone(device.manual_bypass_position)
|
|
666
|
+
self.assertEqual(client.client.read_input_registers.call_count, 2)
|
|
667
|
+
|
|
668
|
+
async def test_optional_position_does_not_hide_communication_errors(self):
|
|
669
|
+
self.server.data_bank.set_holding_registers(HR_BPS_ROTOR_TYPE, [2])
|
|
670
|
+
for response in (
|
|
671
|
+
None,
|
|
672
|
+
ErrorResponse(),
|
|
673
|
+
ExceptionResponse(4, 4),
|
|
674
|
+
SuccessResponse(registers=[]),
|
|
675
|
+
TimeoutError("Timed out"),
|
|
676
|
+
):
|
|
677
|
+
with self.subTest(response=response):
|
|
678
|
+
client = S21Client(self.server.host, self.server.port)
|
|
679
|
+
await client.poll()
|
|
680
|
+
read_input_registers = client.client.read_input_registers
|
|
681
|
+
|
|
682
|
+
async def read(address, *, count):
|
|
683
|
+
if address == IR_StatusBpsRotor:
|
|
684
|
+
if isinstance(response, Exception):
|
|
685
|
+
raise response
|
|
686
|
+
return response
|
|
687
|
+
return await read_input_registers(address, count=count)
|
|
688
|
+
|
|
689
|
+
client.client.read_input_registers = AsyncMock(side_effect=read)
|
|
690
|
+
expected = (
|
|
691
|
+
TimeoutError
|
|
692
|
+
if isinstance(response, TimeoutError)
|
|
693
|
+
else ModbusCommunicationException
|
|
694
|
+
)
|
|
695
|
+
with self.assertRaises(expected):
|
|
696
|
+
await client.poll()
|
|
697
|
+
self.assertFalse(client.device.available)
|
|
698
|
+
self.assertFalse(client.client.connected)
|
|
699
|
+
|
|
700
|
+
async def test_set_bypass_mode(self):
|
|
701
|
+
self.server.data_bank.set_holding_registers(HR_BPS_ROTOR_MODE, [2])
|
|
702
|
+
|
|
703
|
+
client = S21Client(host=self.server.host, port=self.server.port)
|
|
704
|
+
await client.set_bypass_mode(BypassMode.CLOSED)
|
|
705
|
+
|
|
706
|
+
self.assertEqual(
|
|
707
|
+
self.server.data_bank.get_holding_registers(HR_BPS_ROTOR_MODE, 1), [0]
|
|
708
|
+
)
|
|
709
|
+
|
|
710
|
+
async def test_set_bypass_mode_when_value_is_invalid_raises_exception(self):
|
|
711
|
+
client = S21Client(host=self.server.host, port=self.server.port)
|
|
712
|
+
client.client.connect = AsyncMock(return_value=True)
|
|
713
|
+
|
|
714
|
+
for invalid_mode in (-1, 3):
|
|
715
|
+
with self.subTest(mode=invalid_mode):
|
|
716
|
+
with self.assertRaises(ValueError):
|
|
717
|
+
await client.set_bypass_mode(invalid_mode)
|
|
718
|
+
|
|
719
|
+
client.client.connect.assert_not_called()
|
|
720
|
+
|
|
721
|
+
async def test_set_bypass_position(self):
|
|
722
|
+
self.server.data_bank.set_holding_registers(HR_SetBpsRotorMANUAL, [0])
|
|
723
|
+
|
|
724
|
+
client = S21Client(host=self.server.host, port=self.server.port)
|
|
725
|
+
await client.set_bypass_position(60)
|
|
726
|
+
|
|
727
|
+
self.assertEqual(
|
|
728
|
+
self.server.data_bank.get_holding_registers(HR_SetBpsRotorMANUAL, 1), [60]
|
|
729
|
+
)
|
|
730
|
+
|
|
731
|
+
async def test_set_bypass_position_when_value_is_invalid_raises_exception(self):
|
|
732
|
+
client = S21Client(host=self.server.host, port=self.server.port)
|
|
733
|
+
client.client.connect = AsyncMock(return_value=True)
|
|
734
|
+
|
|
735
|
+
for invalid_position in (-1, 101):
|
|
736
|
+
with self.subTest(position=invalid_position):
|
|
737
|
+
with self.assertRaises(ValueError):
|
|
738
|
+
await client.set_bypass_position(invalid_position)
|
|
739
|
+
|
|
740
|
+
client.client.connect.assert_not_called()
|
|
741
|
+
|
|
495
742
|
|
|
496
743
|
class TestDataBank(DataBank):
|
|
497
744
|
__test__ = False
|
|
498
745
|
|
|
499
746
|
def __init__(self):
|
|
500
747
|
super().__init__(
|
|
501
|
-
coils_size=25, d_inputs_size=72, h_regs_size=182, i_regs_size=
|
|
748
|
+
coils_size=25, d_inputs_size=72, h_regs_size=182, i_regs_size=54
|
|
502
749
|
)
|
|
503
750
|
self.reset()
|
|
504
751
|
|
pybls21-4.2.2/PKG-INFO
DELETED
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: pybls21
|
|
3
|
-
Version: 4.2.2
|
|
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
|
-
Requires-Dist: pymodbus<4.0,>=3.11.2
|
|
15
|
-
Dynamic: author
|
|
16
|
-
Dynamic: author-email
|
|
17
|
-
Dynamic: classifier
|
|
18
|
-
Dynamic: description
|
|
19
|
-
Dynamic: description-content-type
|
|
20
|
-
Dynamic: home-page
|
|
21
|
-
Dynamic: license-file
|
|
22
|
-
Dynamic: requires-dist
|
|
23
|
-
Dynamic: requires-python
|
|
24
|
-
Dynamic: summary
|
|
25
|
-
|
|
26
|
-
# Blauberg S21 Asynchronous Python API
|
|
27
|
-
An api allowing control of AC state (temperature, on/off, speed) of an Blauberg S21 device locally over TCP.
|
|
28
|
-
|
|
29
|
-
## Usage
|
|
30
|
-
To initialize:
|
|
31
|
-
`client = S21Client("192.168.0.125")`
|
|
32
|
-
|
|
33
|
-
To load:
|
|
34
|
-
`await client.poll()`
|
|
35
|
-
|
|
36
|
-
The following functions are available:
|
|
37
|
-
`turn_on()`
|
|
38
|
-
`turn_off()`
|
|
39
|
-
`set_hvac_mode(hvac_mode: HVACMode)`
|
|
40
|
-
`set_fan_mode(mode: int)`
|
|
41
|
-
`set_manual_fan_speed_percent(speed_percent: int)`
|
|
42
|
-
`set_temperature(temp_celsius: int)`
|
|
43
|
-
`reset_filter_change_timer()`
|
pybls21-4.2.2/README.md
DELETED
|
@@ -1,18 +0,0 @@
|
|
|
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()`
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
# Coils
|
|
2
|
-
CL_POWER = 0
|
|
3
|
-
CL_Boost_MODE = 3
|
|
4
|
-
CL_BoostSWITCH_CTRL = 13
|
|
5
|
-
CL_RESET_FILTER_TIMER = 17
|
|
6
|
-
CL_RESET_ALARM = 18
|
|
7
|
-
|
|
8
|
-
# Holding registers
|
|
9
|
-
HR_MaxSPEED_MODE = 1
|
|
10
|
-
HR_SPEED_MODE = 2
|
|
11
|
-
HR_ManualSPEED = 17
|
|
12
|
-
HR_OPERATION_MODE = 43
|
|
13
|
-
HR_SetTEMP = 44
|
|
14
|
-
|
|
15
|
-
# Input registers
|
|
16
|
-
IR_CurTEMP_SuAirIn = 1
|
|
17
|
-
IR_CurTEMP_SuAirOut = 2
|
|
18
|
-
IR_CurRH_Int = 10
|
|
19
|
-
IR_SuRPM = 23
|
|
20
|
-
IR_ExRPM = 24
|
|
21
|
-
IR_StateFILTER = 31
|
|
22
|
-
IR_VerMAIN_FMW_start = 34
|
|
23
|
-
IR_VerMAIN_FMW_end = 36
|
|
24
|
-
IR_DeviceTYPE = 37
|
|
25
|
-
IR_ALARM = 38
|
pybls21-4.2.2/pybls21/models.py
DELETED
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
from enum import Enum
|
|
2
|
-
from typing import List, NamedTuple, Optional
|
|
3
|
-
|
|
4
|
-
TEMP_CELSIUS: str = "°C"
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
class ClimateEntityFeature(int, Enum):
|
|
8
|
-
TARGET_TEMPERATURE = 1
|
|
9
|
-
FAN_MODE = 8
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
class HVACMode(str, Enum):
|
|
13
|
-
OFF = "off"
|
|
14
|
-
HEAT = "heat"
|
|
15
|
-
COOL = "cool"
|
|
16
|
-
AUTO = "auto"
|
|
17
|
-
FAN_ONLY = "fan_only"
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
class HVACAction(str, Enum):
|
|
21
|
-
COOLING = "cooling"
|
|
22
|
-
FAN = "fan"
|
|
23
|
-
HEATING = "heating"
|
|
24
|
-
IDLE = "idle"
|
|
25
|
-
OFF = "off"
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
class ClimateDevice(NamedTuple):
|
|
29
|
-
available: bool
|
|
30
|
-
name: str
|
|
31
|
-
unique_id: str
|
|
32
|
-
temperature_unit: str
|
|
33
|
-
precision: float
|
|
34
|
-
current_temperature: float
|
|
35
|
-
target_temperature: float
|
|
36
|
-
target_temperature_step: float
|
|
37
|
-
max_temp: float
|
|
38
|
-
min_temp: float
|
|
39
|
-
current_humidity: Optional[float]
|
|
40
|
-
hvac_mode: str
|
|
41
|
-
hvac_action: str
|
|
42
|
-
hvac_modes: List[str]
|
|
43
|
-
fan_mode: Optional[int]
|
|
44
|
-
fan_modes: Optional[List[int]]
|
|
45
|
-
supported_features: int
|
|
46
|
-
manufacturer: str
|
|
47
|
-
model: Optional[str]
|
|
48
|
-
sw_version: Optional[str]
|
|
49
|
-
is_boosting: bool
|
|
50
|
-
current_intake_temperature: float
|
|
51
|
-
manual_fan_speed_percent: int
|
|
52
|
-
max_fan_level: int
|
|
53
|
-
filter_state: int
|
|
54
|
-
alarm_state: int
|
|
55
|
-
supply_fan_speed: int
|
|
56
|
-
extract_fan_speed: int
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: pybls21
|
|
3
|
-
Version: 4.2.2
|
|
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
|
-
Requires-Dist: pymodbus<4.0,>=3.11.2
|
|
15
|
-
Dynamic: author
|
|
16
|
-
Dynamic: author-email
|
|
17
|
-
Dynamic: classifier
|
|
18
|
-
Dynamic: description
|
|
19
|
-
Dynamic: description-content-type
|
|
20
|
-
Dynamic: home-page
|
|
21
|
-
Dynamic: license-file
|
|
22
|
-
Dynamic: requires-dist
|
|
23
|
-
Dynamic: requires-python
|
|
24
|
-
Dynamic: summary
|
|
25
|
-
|
|
26
|
-
# Blauberg S21 Asynchronous Python API
|
|
27
|
-
An api allowing control of AC state (temperature, on/off, speed) of an Blauberg S21 device locally over TCP.
|
|
28
|
-
|
|
29
|
-
## Usage
|
|
30
|
-
To initialize:
|
|
31
|
-
`client = S21Client("192.168.0.125")`
|
|
32
|
-
|
|
33
|
-
To load:
|
|
34
|
-
`await client.poll()`
|
|
35
|
-
|
|
36
|
-
The following functions are available:
|
|
37
|
-
`turn_on()`
|
|
38
|
-
`turn_off()`
|
|
39
|
-
`set_hvac_mode(hvac_mode: HVACMode)`
|
|
40
|
-
`set_fan_mode(mode: int)`
|
|
41
|
-
`set_manual_fan_speed_percent(speed_percent: int)`
|
|
42
|
-
`set_temperature(temp_celsius: int)`
|
|
43
|
-
`reset_filter_change_timer()`
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
pymodbus<4.0,>=3.11.2
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|