pybls21 5.1.0__tar.gz → 5.2.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.
Files changed (26) hide show
  1. {pybls21-5.1.0/pybls21.egg-info → pybls21-5.2.0}/PKG-INFO +33 -1
  2. {pybls21-5.1.0 → pybls21-5.2.0}/README.md +32 -0
  3. {pybls21-5.1.0 → pybls21-5.2.0}/pybls21/_decoder.py +3 -0
  4. {pybls21-5.1.0 → pybls21-5.2.0}/pybls21/client.py +10 -5
  5. {pybls21-5.1.0 → pybls21-5.2.0}/pybls21/constants.py +4 -0
  6. {pybls21-5.1.0 → pybls21-5.2.0}/pybls21/models.py +8 -0
  7. {pybls21-5.1.0 → pybls21-5.2.0/pybls21.egg-info}/PKG-INFO +33 -1
  8. {pybls21-5.1.0 → pybls21-5.2.0}/pyproject.toml +1 -1
  9. {pybls21-5.1.0 → pybls21-5.2.0}/tests/test_client.py +90 -3
  10. {pybls21-5.1.0 → pybls21-5.2.0}/tests/test_decoder.py +1 -0
  11. {pybls21-5.1.0 → pybls21-5.2.0}/LICENSE +0 -0
  12. {pybls21-5.1.0 → pybls21-5.2.0}/MANIFEST.in +0 -0
  13. {pybls21-5.1.0 → pybls21-5.2.0}/MIGRATION.md +0 -0
  14. {pybls21-5.1.0 → pybls21-5.2.0}/THIRD_PARTY_NOTICES +0 -0
  15. {pybls21-5.1.0 → pybls21-5.2.0}/pybls21/__init__.py +0 -0
  16. {pybls21-5.1.0 → pybls21-5.2.0}/pybls21/discovery.py +0 -0
  17. {pybls21-5.1.0 → pybls21-5.2.0}/pybls21/exceptions.py +0 -0
  18. {pybls21-5.1.0 → pybls21-5.2.0}/pybls21/py.typed +0 -0
  19. {pybls21-5.1.0 → pybls21-5.2.0}/pybls21.egg-info/SOURCES.txt +0 -0
  20. {pybls21-5.1.0 → pybls21-5.2.0}/pybls21.egg-info/dependency_links.txt +0 -0
  21. {pybls21-5.1.0 → pybls21-5.2.0}/pybls21.egg-info/requires.txt +0 -0
  22. {pybls21-5.1.0 → pybls21-5.2.0}/pybls21.egg-info/top_level.txt +0 -0
  23. {pybls21-5.1.0 → pybls21-5.2.0}/setup.cfg +0 -0
  24. {pybls21-5.1.0 → pybls21-5.2.0}/tests/test_discovery.py +0 -0
  25. {pybls21-5.1.0 → pybls21-5.2.0}/tests/test_lifecycle.py +0 -0
  26. {pybls21-5.1.0 → pybls21-5.2.0}/tests/test_models.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pybls21
3
- Version: 5.1.0
3
+ Version: 5.2.0
4
4
  Summary: Async Modbus TCP client for Blauberg S21 ventilation devices
5
5
  Author-email: Julius Vitkauskas <zadintuvas@gmail.com>
6
6
  License-Expression: MIT
@@ -159,6 +159,38 @@ a successful poll. Connection and communication failures mark
159
159
  successful subsequent poll restores it. Previously returned models are immutable
160
160
  snapshots, so read `client.device` for the updated availability.
161
161
 
162
+ ## Heater and cooler activity
163
+
164
+ ```python
165
+ snapshot = await client.poll()
166
+ print(snapshot.is_heating, snapshot.is_cooling)
167
+ ```
168
+
169
+ `is_heating` and `is_cooling` expose the controller's operation indications at
170
+ **DI7 (`DI_StatusHEATER`)** and **DI8 (`DI_StatusCOOLER`)**, respectively. Every poll
171
+ reads both in one additional Modbus request, regardless of mode or alarm state.
172
+ These booleans come directly from the controller; temperature differences,
173
+ selected HVAC mode, and unit power do not override them. If both bits are set,
174
+ both fields remain true rather than arbitrarily selecting one action.
175
+ They indicate controller-reported operation, not independently measured power
176
+ consumption or proof that the attached heater/cooler is functioning.
177
+
178
+ A successful poll populates both fields. Failed, erroneous, or short responses
179
+ fail the poll and mark the cached snapshot unavailable, like other required
180
+ reads; they never silently report inactive equipment. The new fields default to
181
+ `None` only for manually constructed snapshots that omit them, preserving
182
+ compatibility with existing callers constructing `ClimateDevice`.
183
+
184
+ The reads were verified on a physical S21 running firmware `0.36 (2019-05-08)`:
185
+ both flags were false in fan-only mode while the fans ran. In a controlled test,
186
+ heating mode with a 15 °C target left DI7 false; raising the target to 25 °C
187
+ made DI7 true after about six seconds. Restoring fan-only mode and 15 °C made
188
+ DI7 false again, with fan level and alarm codes unchanged. No cooler was
189
+ configured, so active cooling is covered by the Modbus test server rather than
190
+ a physical cooling test. The legacy `hvac_action` field remains inferred for compatibility. Use the
191
+ new activity flags when actual heater/cooler status is needed; downstream
192
+ integrations must not treat the inferred field as measured activity.
193
+
162
194
  ## Bypass control
163
195
 
164
196
  ```python
@@ -135,6 +135,38 @@ a successful poll. Connection and communication failures mark
135
135
  successful subsequent poll restores it. Previously returned models are immutable
136
136
  snapshots, so read `client.device` for the updated availability.
137
137
 
138
+ ## Heater and cooler activity
139
+
140
+ ```python
141
+ snapshot = await client.poll()
142
+ print(snapshot.is_heating, snapshot.is_cooling)
143
+ ```
144
+
145
+ `is_heating` and `is_cooling` expose the controller's operation indications at
146
+ **DI7 (`DI_StatusHEATER`)** and **DI8 (`DI_StatusCOOLER`)**, respectively. Every poll
147
+ reads both in one additional Modbus request, regardless of mode or alarm state.
148
+ These booleans come directly from the controller; temperature differences,
149
+ selected HVAC mode, and unit power do not override them. If both bits are set,
150
+ both fields remain true rather than arbitrarily selecting one action.
151
+ They indicate controller-reported operation, not independently measured power
152
+ consumption or proof that the attached heater/cooler is functioning.
153
+
154
+ A successful poll populates both fields. Failed, erroneous, or short responses
155
+ fail the poll and mark the cached snapshot unavailable, like other required
156
+ reads; they never silently report inactive equipment. The new fields default to
157
+ `None` only for manually constructed snapshots that omit them, preserving
158
+ compatibility with existing callers constructing `ClimateDevice`.
159
+
160
+ The reads were verified on a physical S21 running firmware `0.36 (2019-05-08)`:
161
+ both flags were false in fan-only mode while the fans ran. In a controlled test,
162
+ heating mode with a 15 °C target left DI7 false; raising the target to 25 °C
163
+ made DI7 true after about six seconds. Restoring fan-only mode and 15 °C made
164
+ DI7 false again, with fan level and alarm codes unchanged. No cooler was
165
+ configured, so active cooling is covered by the Modbus test server rather than
166
+ a physical cooling test. The legacy `hvac_action` field remains inferred for compatibility. Use the
167
+ new activity flags when actual heater/cooler status is needed; downstream
168
+ integrations must not treat the inferred field as measured activity.
169
+
138
170
  ## Bypass control
139
171
 
140
172
  ```python
@@ -78,6 +78,7 @@ def _decode_hvac_action(
78
78
  def decode_device(
79
79
  *,
80
80
  coils: list[bool],
81
+ activity: list[bool],
81
82
  holding_registers: list[int],
82
83
  input_registers: list[int],
83
84
  alarm_codes: list[int],
@@ -126,6 +127,8 @@ def decode_device(
126
127
 
127
128
  return ClimateDevice(
128
129
  available=True,
130
+ is_heating=activity[0],
131
+ is_cooling=activity[reg.DI_StatusCOOLER - reg.DI_StatusHEATER],
129
132
  name="Blauberg S21",
130
133
  temperature_unit=TEMP_CELSIUS,
131
134
  precision=1,
@@ -176,7 +176,7 @@ class S21Client:
176
176
  bits = self._validate_modbus_response(response, operation).bits
177
177
  if not isinstance(bits, list) or len(bits) < count:
178
178
  raise ModbusCommunicationException(
179
- f"Modbus {operation} failed: expected {count} coil bits"
179
+ f"Modbus {operation} failed: expected {count} bits"
180
180
  )
181
181
  return bits
182
182
 
@@ -218,11 +218,12 @@ class S21Client:
218
218
  response, count, f"read input registers at {address}"
219
219
  )
220
220
 
221
+ async def _read_discrete_inputs(self, address: int, count: int) -> list[bool]:
222
+ response = await self._client.read_discrete_inputs(address, count=count)
223
+ return self._get_bits(response, count, f"read discrete inputs at {address}")
224
+
221
225
  async def _read_alarm_codes(self) -> list[int]:
222
- response = await self._client.read_discrete_inputs(
223
- reg.DI_ALARM_START, count=reg.DI_ALARM_COUNT
224
- )
225
- bits = self._get_bits(response, reg.DI_ALARM_COUNT, "read alarm codes")
226
+ bits = await self._read_discrete_inputs(reg.DI_ALARM_START, reg.DI_ALARM_COUNT)
226
227
  # Modbus pads bit responses to whole bytes; ignore bits beyond code 52.
227
228
  return [code for code in range(reg.DI_ALARM_COUNT) if bits[code]]
228
229
 
@@ -274,6 +275,9 @@ class S21Client:
274
275
  coils = await self._read_coils(0, count=4)
275
276
  holding_registers = await self._read_holding_registers(0, count=76)
276
277
  input_registers = await self._read_input_registers(0, count=39)
278
+ activity = await self._read_discrete_inputs(
279
+ reg.DI_StatusHEATER, count=reg.DI_StatusCOOLER - reg.DI_StatusHEATER + 1
280
+ )
277
281
  alarm_codes = (
278
282
  await self._read_alarm_codes() if input_registers[reg.IR_ALARM] else []
279
283
  )
@@ -288,6 +292,7 @@ class S21Client:
288
292
  try:
289
293
  return decode_device(
290
294
  coils=coils,
295
+ activity=activity,
291
296
  holding_registers=holding_registers,
292
297
  input_registers=input_registers,
293
298
  alarm_codes=alarm_codes,
@@ -47,6 +47,10 @@ IR_StatusBpsRotor = 51
47
47
  IR_CurSuFanSpeed = 52 # Actual supply fan performance, percent
48
48
  IR_CurExFanSpeed = 53 # Actual extract fan performance, percent
49
49
 
50
+ # Discrete inputs: controller-reported heating/cooling activity
51
+ DI_StatusHEATER = 7
52
+ DI_StatusCOOLER = 8
53
+
50
54
  # Discrete inputs: alarm codes 0 through 52
51
55
  DI_ALARM_START = 19
52
56
  DI_ALARM_COUNT = 53
@@ -178,3 +178,11 @@ class ClimateDevice:
178
178
  default=None,
179
179
  doc="Actual extract fan performance in percent (0–100); None for unsupported or invalid readings.",
180
180
  )
181
+ is_heating: bool | None = field(
182
+ default=None,
183
+ doc="Controller heater-operation indication (DI7), independent of the selected mode; None if not populated.",
184
+ )
185
+ is_cooling: bool | None = field(
186
+ default=None,
187
+ doc="Controller cooler-operation indication (DI8), independent of the selected mode; None if not populated.",
188
+ )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pybls21
3
- Version: 5.1.0
3
+ Version: 5.2.0
4
4
  Summary: Async Modbus TCP client for Blauberg S21 ventilation devices
5
5
  Author-email: Julius Vitkauskas <zadintuvas@gmail.com>
6
6
  License-Expression: MIT
@@ -159,6 +159,38 @@ a successful poll. Connection and communication failures mark
159
159
  successful subsequent poll restores it. Previously returned models are immutable
160
160
  snapshots, so read `client.device` for the updated availability.
161
161
 
162
+ ## Heater and cooler activity
163
+
164
+ ```python
165
+ snapshot = await client.poll()
166
+ print(snapshot.is_heating, snapshot.is_cooling)
167
+ ```
168
+
169
+ `is_heating` and `is_cooling` expose the controller's operation indications at
170
+ **DI7 (`DI_StatusHEATER`)** and **DI8 (`DI_StatusCOOLER`)**, respectively. Every poll
171
+ reads both in one additional Modbus request, regardless of mode or alarm state.
172
+ These booleans come directly from the controller; temperature differences,
173
+ selected HVAC mode, and unit power do not override them. If both bits are set,
174
+ both fields remain true rather than arbitrarily selecting one action.
175
+ They indicate controller-reported operation, not independently measured power
176
+ consumption or proof that the attached heater/cooler is functioning.
177
+
178
+ A successful poll populates both fields. Failed, erroneous, or short responses
179
+ fail the poll and mark the cached snapshot unavailable, like other required
180
+ reads; they never silently report inactive equipment. The new fields default to
181
+ `None` only for manually constructed snapshots that omit them, preserving
182
+ compatibility with existing callers constructing `ClimateDevice`.
183
+
184
+ The reads were verified on a physical S21 running firmware `0.36 (2019-05-08)`:
185
+ both flags were false in fan-only mode while the fans ran. In a controlled test,
186
+ heating mode with a 15 °C target left DI7 false; raising the target to 25 °C
187
+ made DI7 true after about six seconds. Restoring fan-only mode and 15 °C made
188
+ DI7 false again, with fan level and alarm codes unchanged. No cooler was
189
+ configured, so active cooling is covered by the Modbus test server rather than
190
+ a physical cooling test. The legacy `hvac_action` field remains inferred for compatibility. Use the
191
+ new activity flags when actual heater/cooler status is needed; downstream
192
+ integrations must not treat the inferred field as measured activity.
193
+
162
194
  ## Bypass control
163
195
 
164
196
  ```python
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pybls21"
7
- version = "5.1.0"
7
+ version = "5.2.0"
8
8
  description = "Async Modbus TCP client for Blauberg S21 ventilation devices"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.14.2"
@@ -228,6 +228,8 @@ class TestClient(unittest.IsolatedAsyncioTestCase):
228
228
  model="S21",
229
229
  sw_version="0.36 (2019-05-08)",
230
230
  is_boosting=False,
231
+ is_heating=False,
232
+ is_cooling=False,
231
233
  current_intake_temperature=10.8,
232
234
  manual_fan_speed_percent=100,
233
235
  max_fan_level=3,
@@ -333,6 +335,79 @@ class TestClient(unittest.IsolatedAsyncioTestCase):
333
335
  self.assertEqual(device.fan_level_timer_mode, 1)
334
336
  self.assertEqual(device.fan_level_schedule_mode, 0)
335
337
 
338
+ async def test_activity_bits_are_independent_of_mode_and_temperature(self):
339
+ client = S21Client(self.server.host, self.server.port)
340
+ bank = self.server.data_bank
341
+ bank.set_input_registers(reg.IR_ALARM, [0])
342
+ bank.set_input_registers(reg.IR_CurTEMP_SuAirIn, [100, 300])
343
+ for powered in (False, True):
344
+ bank.set_coils(reg.CL_POWER, [powered])
345
+ for mode in range(4):
346
+ bank.set_holding_registers(reg.HR_OPERATION_MODE, [mode])
347
+ for heating, cooling in (
348
+ (False, False),
349
+ (True, False),
350
+ (False, True),
351
+ (True, True),
352
+ ):
353
+ with self.subTest(
354
+ powered=powered, mode=mode, heating=heating, cooling=cooling
355
+ ):
356
+ bank.set_discrete_inputs(
357
+ reg.DI_StatusHEATER, [heating, cooling]
358
+ )
359
+ snapshot = await client.poll()
360
+ self.assertIs(snapshot.is_heating, heating)
361
+ self.assertIs(snapshot.is_cooling, cooling)
362
+ # An unsupported temperature sensor does not invalidate operation bits.
363
+ bank.set_input_registers(reg.IR_CurTEMP_SuAirIn, [0x8000, 0x7FFF])
364
+ self.assertTrue((await client.poll()).is_heating)
365
+
366
+ async def test_activity_fields_default_to_unknown_for_older_constructors(self):
367
+ from dataclasses import fields
368
+
369
+ snapshot = await S21Client(self.server.host, self.server.port).poll()
370
+ old_arguments = {
371
+ field.name: getattr(snapshot, field.name)
372
+ for field in fields(snapshot)
373
+ if field.name not in {"is_heating", "is_cooling"}
374
+ }
375
+ constructed = ClimateDevice(**old_arguments)
376
+ self.assertIsNone(constructed.is_heating)
377
+ self.assertIsNone(constructed.is_cooling)
378
+
379
+ async def test_activity_byte_padding_does_not_report_cooling(self):
380
+ self.server.data_bank.set_input_registers(reg.IR_ALARM, [0])
381
+ client = S21Client(self.server.host, self.server.port)
382
+ client._client.read_discrete_inputs = AsyncMock(
383
+ return_value=SuccessResponse(bits=[True, False] + [True] * 6)
384
+ )
385
+ snapshot = await client.poll()
386
+ self.assertTrue(snapshot.is_heating)
387
+ self.assertFalse(snapshot.is_cooling)
388
+ client._client.read_discrete_inputs.assert_awaited_once_with(7, count=2)
389
+
390
+ async def test_activity_read_failures_invalidate_cache_and_recover(self):
391
+ client = S21Client(self.server.host, self.server.port)
392
+ read = client._client.read_discrete_inputs
393
+ for response in (
394
+ None,
395
+ ErrorResponse(),
396
+ SuccessResponse(bits=[]),
397
+ SuccessResponse(bits=[True]),
398
+ ):
399
+ with self.subTest(response=response):
400
+ client._client.read_discrete_inputs = read
401
+ snapshot = await client.poll()
402
+ client._client.read_discrete_inputs = AsyncMock(return_value=response)
403
+ with self.assertRaises(ModbusCommunicationException):
404
+ await client.poll()
405
+ self.assertFalse(client.device.available)
406
+ self.assertTrue(snapshot.available)
407
+ self.assertFalse(client._client.connected)
408
+ client._client.read_discrete_inputs = read
409
+ self.assertTrue((await client.poll()).available)
410
+
336
411
  async def test_alarm_codes_are_read_only_for_active_alarms_or_warnings(self):
337
412
  client = S21Client(self.server.host, self.server.port)
338
413
  client._client.read_discrete_inputs = Mock(
@@ -345,8 +420,14 @@ class TestClient(unittest.IsolatedAsyncioTestCase):
345
420
  with self.subTest(state=state):
346
421
  bank.set_input_registers(reg.IR_ALARM, [state])
347
422
  self.assertEqual((await client.poll()).alarm_codes, tuple(expected))
348
- self.assertEqual(client._client.read_discrete_inputs.call_count, 2)
349
- client._client.read_discrete_inputs.assert_called_with(19, count=53)
423
+ self.assertEqual(client._client.read_discrete_inputs.call_count, 6)
424
+ self.assertEqual(
425
+ [
426
+ (call.args[0], call.kwargs["count"])
427
+ for call in client._client.read_discrete_inputs.call_args_list
428
+ ],
429
+ [(7, 2), (7, 2), (19, 53), (7, 2), (19, 53), (7, 2)],
430
+ )
350
431
 
351
432
  async def test_alarm_code_byte_padding_is_ignored(self):
352
433
  self.server.data_bank.set_input_registers(reg.IR_ALARM, [1])
@@ -363,7 +444,13 @@ class TestClient(unittest.IsolatedAsyncioTestCase):
363
444
  client = S21Client(self.server.host, self.server.port)
364
445
  await client.poll()
365
446
  self.server.data_bank.set_input_registers(reg.IR_ALARM, [1])
366
- client._client.read_discrete_inputs = AsyncMock(return_value=response)
447
+ client._client.read_discrete_inputs = AsyncMock(
448
+ side_effect=lambda address, *, count: (
449
+ response
450
+ if address == reg.DI_ALARM_START
451
+ else SuccessResponse(bits=[False] * 8)
452
+ )
453
+ )
367
454
  with self.assertRaises(ModbusCommunicationException):
368
455
  await client.poll()
369
456
  self.assertFalse(client.device.available)
@@ -17,6 +17,7 @@ class TestDecoder(unittest.TestCase):
17
17
  def decode(self):
18
18
  return decode_device(
19
19
  coils=self.coils,
20
+ activity=[False, False],
20
21
  holding_registers=self.holding,
21
22
  input_registers=self.inputs,
22
23
  alarm_codes=[1, 52],
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes