pybls21 4.2.1__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 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.
@@ -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
- self.device.available = False
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=45)
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
- temp_before_heating_x10: int = _to_signed_16bit(
206
+ temp_before_heating = _parse_temperature(
171
207
  input_registers[IR_CurTEMP_SuAirIn]
172
208
  )
173
- temp_after_heating_x10: int = _to_signed_16bit(
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=temp_after_heating_x10 / 10,
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 temp_before_heating_x10 < temp_after_heating_x10
268
+ if temp_before_heating < temp_after_heating
215
269
  else HVACAction.COOLING
216
- if temp_before_heating_x10 > temp_after_heating_x10
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=temp_before_heating_x10 / 10,
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.2.1",
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.11.2,<4.0"],
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 ClimateDevice, ClimateEntityFeature, HVACAction, HVACMode
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=51
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.1/PKG-INFO DELETED
@@ -1,43 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: pybls21
3
- Version: 4.2.1
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.1/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
@@ -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.1
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