python-qube-heatpump 1.12.0__tar.gz → 1.14.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 (32) hide show
  1. {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.14.0}/.github/workflows/ci.yml +1 -1
  2. {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.14.0}/PKG-INFO +3 -2
  3. {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.14.0}/README.md +1 -0
  4. {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.14.0}/pyproject.toml +1 -1
  5. {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.14.0}/src/python_qube_heatpump/client.py +197 -45
  6. {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.14.0}/src/python_qube_heatpump/entities/sensors.py +5 -5
  7. {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.14.0}/tests/test_client.py +122 -11
  8. {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.14.0}/tests/test_client_batching.py +75 -0
  9. {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.14.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  10. {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.14.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  11. {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.14.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  12. {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.14.0}/.github/workflows/python-publish.yml +0 -0
  13. {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.14.0}/.gitignore +0 -0
  14. {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.14.0}/AGENTS.md +0 -0
  15. {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.14.0}/CLAUDE.md +0 -0
  16. {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.14.0}/LICENSE +0 -0
  17. {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.14.0}/docs/modbus-lijst-qube-totaal.pdf +0 -0
  18. {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.14.0}/pytest.ini +0 -0
  19. {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.14.0}/src/python_qube_heatpump/__init__.py +0 -0
  20. {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.14.0}/src/python_qube_heatpump/const.py +0 -0
  21. {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.14.0}/src/python_qube_heatpump/entities/__init__.py +0 -0
  22. {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.14.0}/src/python_qube_heatpump/entities/base.py +0 -0
  23. {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.14.0}/src/python_qube_heatpump/entities/binary_sensors.py +0 -0
  24. {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.14.0}/src/python_qube_heatpump/entities/switches.py +0 -0
  25. {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.14.0}/src/python_qube_heatpump/models.py +0 -0
  26. {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.14.0}/src/python_qube_heatpump/network.py +0 -0
  27. {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.14.0}/src/python_qube_heatpump/py.typed +0 -0
  28. {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.14.0}/tests/conftest.py +0 -0
  29. {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.14.0}/tests/test_const.py +0 -0
  30. {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.14.0}/tests/test_entities.py +0 -0
  31. {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.14.0}/tests/test_models.py +0 -0
  32. {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.14.0}/tests/test_network.py +0 -0
@@ -19,7 +19,7 @@ jobs:
19
19
  - name: Install dependencies
20
20
  run: |
21
21
  python -m pip install --upgrade pip
22
- pip install ".[test]" ruff
22
+ pip install ".[test]" ruff==0.14.14
23
23
  - name: Lint with ruff
24
24
  run: |
25
25
  ruff check .
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: python-qube-heatpump
3
- Version: 1.12.0
3
+ Version: 1.14.0
4
4
  Summary: Async Modbus client for Qube Heat Pumps
5
5
  Project-URL: Homepage, https://github.com/MattieGit/python-qube-heatpump
6
6
  Project-URL: Bug Tracker, https://github.com/MattieGit/python-qube-heatpump/issues
@@ -53,6 +53,7 @@ asyncio.run(main())
53
53
  - **Entity definitions** for sensors, binary sensors, and switches
54
54
  - **FLOAT32 decoding** with big endian (ABCD) byte order
55
55
  - **Type-safe dataclasses** for entity definitions
56
+ - **Monotonic clamping with reset detection** for the energy totals: sub-kWh jitter is clamped, while a drop of more than 1 kWh that persists for 3 consecutive reads is accepted as a counter reset (logged as a warning); `clear_monotonic_cache()` forgets all baselines
56
57
 
57
58
  ## Entity Definitions
58
59
 
@@ -34,6 +34,7 @@ asyncio.run(main())
34
34
  - **Entity definitions** for sensors, binary sensors, and switches
35
35
  - **FLOAT32 decoding** with big endian (ABCD) byte order
36
36
  - **Type-safe dataclasses** for entity definitions
37
+ - **Monotonic clamping with reset detection** for the energy totals: sub-kWh jitter is clamped, while a drop of more than 1 kWh that persists for 3 consecutive reads is accepted as a counter reset (logged as a warning); `clear_monotonic_cache()` forgets all baselines
37
38
 
38
39
  ## Entity Definitions
39
40
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "python-qube-heatpump"
7
- version = "1.12.0"
7
+ version = "1.14.0"
8
8
  authors = [
9
9
  { name="MattieGit", email="6250046+MattieGit@users.noreply.github.com" },
10
10
  ]
@@ -18,6 +18,112 @@ from .models import QubeState
18
18
 
19
19
  _LOGGER = logging.getLogger(__name__)
20
20
 
21
+ # Entities read by get_all_data() (used by the official HA core integration).
22
+ # These mirror the register definitions in `const` exactly (address, scale,
23
+ # offset) so batching cannot change any value get_all_data() returns.
24
+ # Note: some of these intentionally differ from the corresponding entries in
25
+ # entities/sensors.py (e.g. compressor_speed has a x60 RPM scale here but not
26
+ # there; cop_calc is unrounded here but rounded to 1 decimal there) — reusing
27
+ # SENSORS would silently change get_all_data()'s output, so a dedicated table
28
+ # is kept instead.
29
+ # Format: (key, address, input_type, data_type, scale, offset)
30
+ _CORE_STATE_REGISTERS: tuple[
31
+ tuple[str, int, InputType, DataType, float | None, float | None], ...
32
+ ] = (
33
+ ("temp_supply", 20, InputType.INPUT_REGISTER, DataType.FLOAT32, None, None),
34
+ ("temp_return", 22, InputType.INPUT_REGISTER, DataType.FLOAT32, None, None),
35
+ ("temp_source_in", 24, InputType.INPUT_REGISTER, DataType.FLOAT32, None, None),
36
+ ("temp_source_out", 26, InputType.INPUT_REGISTER, DataType.FLOAT32, None, None),
37
+ ("temp_room", 28, InputType.INPUT_REGISTER, DataType.FLOAT32, None, None),
38
+ ("temp_dhw", 30, InputType.INPUT_REGISTER, DataType.FLOAT32, None, None),
39
+ ("temp_outside", 32, InputType.INPUT_REGISTER, DataType.FLOAT32, None, None),
40
+ ("power_thermic", 36, InputType.INPUT_REGISTER, DataType.FLOAT32, None, None),
41
+ ("power_electric", 61, InputType.INPUT_REGISTER, DataType.FLOAT32, None, None),
42
+ (
43
+ "energy_total_electric",
44
+ 69,
45
+ InputType.INPUT_REGISTER,
46
+ DataType.FLOAT32,
47
+ None,
48
+ None,
49
+ ),
50
+ (
51
+ "energy_total_thermic",
52
+ 71,
53
+ InputType.INPUT_REGISTER,
54
+ DataType.FLOAT32,
55
+ None,
56
+ None,
57
+ ),
58
+ ("cop_calc", 34, InputType.INPUT_REGISTER, DataType.FLOAT32, None, None),
59
+ ("status_code", 38, InputType.INPUT_REGISTER, DataType.UINT16, None, None),
60
+ ("compressor_speed", 45, InputType.INPUT_REGISTER, DataType.FLOAT32, 60, None),
61
+ ("flow_rate", 18, InputType.INPUT_REGISTER, DataType.FLOAT32, None, None),
62
+ (
63
+ "setpoint_room_heat_day",
64
+ 27,
65
+ InputType.HOLDING_REGISTER,
66
+ DataType.FLOAT32,
67
+ None,
68
+ None,
69
+ ),
70
+ (
71
+ "setpoint_room_heat_night",
72
+ 29,
73
+ InputType.HOLDING_REGISTER,
74
+ DataType.FLOAT32,
75
+ None,
76
+ None,
77
+ ),
78
+ (
79
+ "setpoint_room_cool_day",
80
+ 31,
81
+ InputType.HOLDING_REGISTER,
82
+ DataType.FLOAT32,
83
+ None,
84
+ None,
85
+ ),
86
+ (
87
+ "setpoint_room_cool_night",
88
+ 33,
89
+ InputType.HOLDING_REGISTER,
90
+ DataType.FLOAT32,
91
+ None,
92
+ None,
93
+ ),
94
+ ("setpoint_dhw", 173, InputType.HOLDING_REGISTER, DataType.FLOAT32, None, None),
95
+ (
96
+ "usr_pid_heatsetp",
97
+ 101,
98
+ InputType.HOLDING_REGISTER,
99
+ DataType.FLOAT32,
100
+ None,
101
+ None,
102
+ ),
103
+ (
104
+ "usr_pid_coolsetp",
105
+ 103,
106
+ InputType.HOLDING_REGISTER,
107
+ DataType.FLOAT32,
108
+ None,
109
+ None,
110
+ ),
111
+ ("modbus_roomtemp", 75, InputType.INPUT_REGISTER, DataType.FLOAT32, None, None),
112
+ )
113
+
114
+ _CORE_STATE_ENTITIES: tuple[EntityDef, ...] = tuple(
115
+ EntityDef(
116
+ key=key,
117
+ name=key,
118
+ address=address,
119
+ input_type=input_type,
120
+ data_type=data_type,
121
+ scale=scale,
122
+ offset=offset,
123
+ )
124
+ for key, address, input_type, data_type, scale, offset in _CORE_STATE_REGISTERS
125
+ )
126
+
21
127
 
22
128
  class QubeClient:
23
129
  """Qube Modbus Client."""
@@ -43,6 +149,9 @@ class QubeClient:
43
149
  self._next_connect_at: float = 0.0
44
150
  # Monotonic clamping for total_increasing counters
45
151
  self._monotonic_cache: dict[str, float] = {}
152
+ # Per-key count of consecutive reads that fell below the cached
153
+ # maximum by more than the reset threshold (see clamp_monotonic)
154
+ self._monotonic_reset_pending: dict[str, int] = {}
46
155
  # Reads that already produced a WARNING (transient failures are
47
156
  # logged once per target, then at DEBUG to avoid log spam)
48
157
  self._read_failures_warned: set[str] = set()
@@ -109,53 +218,27 @@ class QubeClient:
109
218
 
110
219
  state = QubeState()
111
220
 
112
- # Helper to read and assign
113
- async def _read(const_def):
114
- return await self.read_value(const_def)
115
-
116
- # Fetch temperature sensors
117
- state.temp_supply = await _read(const.TEMP_SUPPLY)
118
- state.temp_return = await _read(const.TEMP_RETURN)
119
- state.temp_source_in = await _read(const.TEMP_SOURCE_IN)
120
- state.temp_source_out = await _read(const.TEMP_SOURCE_OUT)
121
- state.temp_room = await _read(const.TEMP_ROOM)
122
- state.temp_dhw = await _read(const.TEMP_DHW)
123
- state.temp_outside = await _read(const.TEMP_OUTSIDE)
124
-
125
- # Fetch power and energy sensors
126
- state.power_thermic = await _read(const.POWER_THERMIC)
127
- state.power_electric = await _read(const.POWER_ELECTRIC_CALC)
128
- state.energy_total_electric = await _read(const.ENERGY_ELECTRIC_TOTAL)
129
- state.energy_total_thermic = await _read(const.ENERGY_THERMIC_TOTAL)
130
- state.cop_calc = await _read(const.COP_CALC)
131
-
132
- # Fetch operation sensors
133
- state.status_code = await _read(const.STATUS_CODE)
134
- state.compressor_speed = await _read(const.COMPRESSOR_SPEED)
135
- flow_rate = await _read(const.FLOW_RATE)
221
+ # Batch-read the core sensor fields plus all binary sensors together.
222
+ # _plan_blocks groups by input type (input/holding/discrete_input/coil)
223
+ # regardless of which list an entity came from, so combining both
224
+ # tables into a single read_entities_batched() call still yields one
225
+ # handful of block transactions instead of ~59 per-field reads.
226
+ entities = [*_CORE_STATE_ENTITIES, *BINARY_SENSORS.values()]
227
+ results = await self.read_entities_batched(entities)
228
+
229
+ for ent in _CORE_STATE_ENTITIES:
230
+ setattr(state, ent.key, results.get(ent.key))
231
+
232
+ flow_rate = state.flow_rate
136
233
  if flow_rate is not None and flow_rate < 0:
137
234
  flow_rate = 0.0
138
235
  state.flow_rate = flow_rate
139
236
 
140
- # Fetch setpoints (holding registers)
141
- state.setpoint_room_heat_day = await _read(const.SETPOINT_HEAT_DAY)
142
- state.setpoint_room_heat_night = await _read(const.SETPOINT_HEAT_NIGHT)
143
- state.setpoint_room_cool_day = await _read(const.SETPOINT_COOL_DAY)
144
- state.setpoint_room_cool_night = await _read(const.SETPOINT_COOL_NIGHT)
145
- state.setpoint_dhw = await _read(const.USER_DHW_SETPOINT)
146
- state.usr_pid_heatsetp = await _read(const.USER_HEAT_SETPOINT)
147
- state.usr_pid_coolsetp = await _read(const.USER_COOL_SETPOINT)
148
-
149
- # LinQ thermostat room temperature (optional)
150
- state.modbus_roomtemp = await _read(const.TEMP_ROOM_MODBUS)
151
-
152
237
  self._apply_monotonic_clamping(state)
153
238
 
154
- # Fetch binary sensors
155
- binary_data = await self.read_all_binary_sensors()
156
- for key, value in binary_data.items():
239
+ for key in BINARY_SENSORS:
157
240
  if hasattr(state, key):
158
- setattr(state, key, value)
241
+ setattr(state, key, results.get(key))
159
242
 
160
243
  # Compute unified status (status_code + anti-legionella override)
161
244
  state.status = const.resolve_status(state.status_code, state.req_antileg_1)
@@ -174,17 +257,48 @@ class QubeClient:
174
257
  def monotonic_cache(self, value: dict[str, float]) -> None:
175
258
  """Set the monotonic clamping cache (e.g. restored from disk)."""
176
259
  self._monotonic_cache = dict(value)
260
+ self._monotonic_reset_pending.clear()
261
+
262
+ def clear_monotonic_cache(self) -> None:
263
+ """Forget all monotonic baselines.
177
264
 
178
- def clamp_monotonic(self, key: str, value: float | None) -> float | None:
265
+ The next reading of every counter is accepted as-is. Use this when
266
+ the controller's counters were reset on purpose and the clamped
267
+ values in the cache are known to be stale.
268
+ """
269
+ self._monotonic_cache.clear()
270
+ self._monotonic_reset_pending.clear()
271
+
272
+ # A drop below the cached maximum larger than this (in the counter's own
273
+ # unit: kWh for the energy totals) is treated as a counter reset rather
274
+ # than float32 jitter or a transient glitch...
275
+ MONOTONIC_RESET_THRESHOLD: float = 1.0
276
+ # ...but only after it has been seen on this many consecutive reads, so a
277
+ # single glitched zero read never drops a total_increasing counter.
278
+ MONOTONIC_RESET_CONFIRM_READS: int = 3
279
+
280
+ def clamp_monotonic(
281
+ self,
282
+ key: str,
283
+ value: float | None,
284
+ reset_threshold: float | None = None,
285
+ ) -> float | None:
179
286
  """Clamp a value to prevent decreases for total_increasing counters.
180
287
 
181
- Returns the clamped value. If the new value is lower than the
182
- previously seen value for this key, the previous value is returned.
183
- None and non-finite values pass through unchanged.
288
+ If the new value is lower than the previously seen value for this
289
+ key by less than ``reset_threshold``, the previous value is returned
290
+ (jitter suppression). A larger drop is a counter-reset candidate: it
291
+ is still clamped until it has persisted for
292
+ ``MONOTONIC_RESET_CONFIRM_READS`` consecutive reads, after which the
293
+ low value is accepted as the new baseline and a warning is logged.
294
+ None and non-finite values pass through unchanged and do not affect
295
+ the reset detection.
184
296
 
185
297
  Args:
186
298
  key: Identifier for this counter (e.g. entity unique_id).
187
299
  value: The current reading.
300
+ reset_threshold: Drop size that counts as a reset; defaults to
301
+ ``MONOTONIC_RESET_THRESHOLD``.
188
302
 
189
303
  Returns:
190
304
  The clamped value, or None if input was None/non-finite.
@@ -192,8 +306,35 @@ class QubeClient:
192
306
  if value is None or not math.isfinite(value):
193
307
  return value
194
308
  previous = self._monotonic_cache.get(key)
195
- if previous is not None and value < previous:
309
+ if previous is None or value >= previous:
310
+ self._monotonic_cache[key] = value
311
+ self._monotonic_reset_pending.pop(key, None)
312
+ return value
313
+
314
+ threshold = (
315
+ self.MONOTONIC_RESET_THRESHOLD
316
+ if reset_threshold is None
317
+ else reset_threshold
318
+ )
319
+ if previous - value < threshold:
320
+ # Sub-threshold jitter: clamp and forget any pending reset
321
+ self._monotonic_reset_pending.pop(key, None)
322
+ return previous
323
+
324
+ pending = self._monotonic_reset_pending.get(key, 0) + 1
325
+ if pending < self.MONOTONIC_RESET_CONFIRM_READS:
326
+ self._monotonic_reset_pending[key] = pending
196
327
  return previous
328
+
329
+ _LOGGER.warning(
330
+ "Counter reset detected for %s: value dropped from %s to %s on %d "
331
+ "consecutive reads; accepting it as the new baseline",
332
+ key,
333
+ previous,
334
+ value,
335
+ pending,
336
+ )
337
+ self._monotonic_reset_pending.pop(key, None)
197
338
  self._monotonic_cache[key] = value
198
339
  return value
199
340
 
@@ -427,6 +568,17 @@ class QubeClient:
427
568
  )
428
569
  if result.isError():
429
570
  raise OSError(f"Modbus error response for block @{start}")
571
+ if input_type_str in ("coil", "discrete_input"):
572
+ if len(result.bits) < count:
573
+ raise OSError(
574
+ f"Short bit response for block {input_type_str}@{start} "
575
+ f"(got {len(result.bits)}, expected {count})"
576
+ )
577
+ elif len(result.registers) < count:
578
+ raise OSError(
579
+ f"Short register response for block {input_type_str}@{start} "
580
+ f"(got {len(result.registers)}, expected {count})"
581
+ )
430
582
  except Exception as exc:
431
583
  self._log_read_failure(
432
584
  f"block {input_type_str}@{start} (count {count})", exc
@@ -51,7 +51,7 @@ _SENSOR_DEFS: tuple[EntityDef, ...] = (
51
51
  ),
52
52
  EntityDef(
53
53
  key="tapw_timeprogram_dhws",
54
- name="Minimum temperature DHW",
54
+ name="DHW setpoint (user)",
55
55
  address=44,
56
56
  input_type=InputType.HOLDING_REGISTER,
57
57
  data_type=DataType.FLOAT32,
@@ -60,7 +60,7 @@ _SENSOR_DEFS: tuple[EntityDef, ...] = (
60
60
  ),
61
61
  EntityDef(
62
62
  key="tapw_timeprogram_dhws_prog",
63
- name="DHW temperature (active program)",
63
+ name="DHW setpoint (time program, Linq min.)",
64
64
  address=46,
65
65
  input_type=InputType.HOLDING_REGISTER,
66
66
  data_type=DataType.FLOAT32,
@@ -107,7 +107,7 @@ _SENSOR_DEFS: tuple[EntityDef, ...] = (
107
107
  ),
108
108
  EntityDef(
109
109
  key="tapw_timeprogram_dhwsetp_nolinq",
110
- name="User-defined DHW setpoint",
110
+ name="DHW setpoint (Modbus)",
111
111
  address=173,
112
112
  input_type=InputType.HOLDING_REGISTER,
113
113
  data_type=DataType.FLOAT32,
@@ -299,7 +299,7 @@ _SENSOR_DEFS: tuple[EntityDef, ...] = (
299
299
  ),
300
300
  EntityDef(
301
301
  key="dhw_setp",
302
- name="DHW calculated setpoint",
302
+ name="Active DHW setpoint",
303
303
  address=47,
304
304
  input_type=InputType.INPUT_REGISTER,
305
305
  data_type=DataType.FLOAT32,
@@ -453,7 +453,7 @@ _SENSOR_DEFS: tuple[EntityDef, ...] = (
453
453
  ),
454
454
  EntityDef(
455
455
  key="setpoint_dhw",
456
- name="User-defined DHW setpoint",
456
+ name="DHW setpoint (Modbus)",
457
457
  address=173,
458
458
  input_type=InputType.HOLDING_REGISTER,
459
459
  data_type=DataType.FLOAT32,
@@ -1,5 +1,6 @@
1
1
  """Test the Qube Heat Pump client."""
2
2
 
3
+ import logging
3
4
  from unittest.mock import AsyncMock, MagicMock
4
5
 
5
6
  import pytest
@@ -331,12 +332,18 @@ async def test_get_all_data_auto_reconnects(mock_modbus_client):
331
332
  client._client = mock_instance
332
333
  client._connected = False
333
334
 
334
- # Mock successful register reads
335
- mock_resp = MagicMock()
336
- mock_resp.isError.return_value = False
337
- mock_resp.registers = [0, 0]
338
- mock_instance.read_input_registers = AsyncMock(return_value=mock_resp)
339
- mock_instance.read_holding_registers = AsyncMock(return_value=mock_resp)
335
+ # Mock successful register reads. Batched block reads request a
336
+ # variable number of registers per block, so the mock must honor the
337
+ # requested `count` (a real Modbus server would too), unlike a fixed
338
+ # 2-register response.
339
+ def _resp(address, count=1, **kwargs):
340
+ resp = MagicMock()
341
+ resp.isError.return_value = False
342
+ resp.registers = [0] * count
343
+ return resp
344
+
345
+ mock_instance.read_input_registers = AsyncMock(side_effect=_resp)
346
+ mock_instance.read_holding_registers = AsyncMock(side_effect=_resp)
340
347
 
341
348
  state = await client.get_all_data()
342
349
  assert state is not None
@@ -424,11 +431,16 @@ async def test_get_all_data_applies_clamping(mock_modbus_client):
424
431
  client._client = mock_instance
425
432
  client._connected = True
426
433
 
427
- mock_resp = MagicMock()
428
- mock_resp.isError.return_value = False
429
- mock_resp.registers = [0, 0]
430
- mock_instance.read_input_registers = AsyncMock(return_value=mock_resp)
431
- mock_instance.read_holding_registers = AsyncMock(return_value=mock_resp)
434
+ # Batched block reads request a variable `count` of registers per
435
+ # block, so the mock must honor it (a real Modbus server would too).
436
+ def _resp(address, count=1, **kwargs):
437
+ resp = MagicMock()
438
+ resp.isError.return_value = False
439
+ resp.registers = [0] * count
440
+ return resp
441
+
442
+ mock_instance.read_input_registers = AsyncMock(side_effect=_resp)
443
+ mock_instance.read_holding_registers = AsyncMock(side_effect=_resp)
432
444
 
433
445
  state = await client.get_all_data()
434
446
  assert state is not None
@@ -580,3 +592,102 @@ async def test_read_entity_warns_again_per_entity(mock_modbus_client, caplog):
580
592
 
581
593
  warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
582
594
  assert len(warnings) == 2
595
+
596
+
597
+ @pytest.mark.asyncio
598
+ async def test_clamp_monotonic_reset_detected_after_confirmation(mock_modbus_client):
599
+ """A drop larger than the reset threshold becomes the new baseline once confirmed."""
600
+ client = QubeClient("1.2.3.4", 502)
601
+ assert client.clamp_monotonic("energy", 1000.0) == 1000.0
602
+
603
+ # First two low reads are still clamped (could be a transient glitch)
604
+ assert client.clamp_monotonic("energy", 3.0) == 1000.0
605
+ assert client.clamp_monotonic("energy", 3.1) == 1000.0
606
+ # Third consecutive low read confirms the reset
607
+ assert client.clamp_monotonic("energy", 3.2) == 3.2
608
+ assert client.monotonic_cache["energy"] == 3.2
609
+ # Counting resumes from the new baseline
610
+ assert client.clamp_monotonic("energy", 3.5) == 3.5
611
+
612
+
613
+ @pytest.mark.asyncio
614
+ async def test_clamp_monotonic_reset_logs_warning(mock_modbus_client, caplog):
615
+ """Confirming a reset logs a warning naming the key."""
616
+ client = QubeClient("1.2.3.4", 502)
617
+ client.clamp_monotonic("energy_total_thermic", 500.0)
618
+ with caplog.at_level(logging.WARNING):
619
+ for _ in range(QubeClient.MONOTONIC_RESET_CONFIRM_READS):
620
+ client.clamp_monotonic("energy_total_thermic", 0.0)
621
+ assert "Counter reset detected" in caplog.text
622
+ assert "energy_total_thermic" in caplog.text
623
+
624
+
625
+ @pytest.mark.asyncio
626
+ async def test_clamp_monotonic_transient_glitch_does_not_reset(mock_modbus_client):
627
+ """A single low read followed by recovery must not become a new baseline."""
628
+ client = QubeClient("1.2.3.4", 502)
629
+ client.clamp_monotonic("energy", 1000.0)
630
+
631
+ assert client.clamp_monotonic("energy", 0.0) == 1000.0
632
+ assert client.clamp_monotonic("energy", 0.0) == 1000.0
633
+ # Recovery cancels the pending reset
634
+ assert client.clamp_monotonic("energy", 1000.2) == 1000.2
635
+ # A fresh low read starts counting from scratch again
636
+ assert client.clamp_monotonic("energy", 0.0) == 1000.2
637
+ assert client.clamp_monotonic("energy", 0.0) == 1000.2
638
+ assert client.monotonic_cache["energy"] == 1000.2
639
+
640
+
641
+ @pytest.mark.asyncio
642
+ async def test_clamp_monotonic_sub_threshold_jitter_still_clamped(mock_modbus_client):
643
+ """Drops smaller than the threshold are jitter and stay clamped forever."""
644
+ client = QubeClient("1.2.3.4", 502)
645
+ client.clamp_monotonic("energy", 1000.0)
646
+ for _ in range(10):
647
+ assert client.clamp_monotonic("energy", 999.5) == 1000.0
648
+ assert client.monotonic_cache["energy"] == 1000.0
649
+ # Jitter reads do not count towards reset confirmation
650
+ assert client.clamp_monotonic("energy", 0.0) == 1000.0
651
+ assert client.clamp_monotonic("energy", 0.0) == 1000.0
652
+ assert client.clamp_monotonic("energy", 999.5) == 1000.0
653
+ assert client.clamp_monotonic("energy", 0.0) == 1000.0
654
+ assert client.clamp_monotonic("energy", 0.0) == 1000.0
655
+ assert client.monotonic_cache["energy"] == 1000.0
656
+
657
+
658
+ @pytest.mark.asyncio
659
+ async def test_clamp_monotonic_custom_threshold(mock_modbus_client):
660
+ """The reset threshold can be overridden per call."""
661
+ client = QubeClient("1.2.3.4", 502)
662
+ client.clamp_monotonic("hours", 100.0, reset_threshold=10.0)
663
+ # A 5-unit drop is below the custom threshold: clamped, never a reset
664
+ for _ in range(5):
665
+ assert client.clamp_monotonic("hours", 95.0, reset_threshold=10.0) == 100.0
666
+ # A 50-unit drop is a reset
667
+ for _ in range(QubeClient.MONOTONIC_RESET_CONFIRM_READS - 1):
668
+ assert client.clamp_monotonic("hours", 50.0, reset_threshold=10.0) == 100.0
669
+ assert client.clamp_monotonic("hours", 50.0, reset_threshold=10.0) == 50.0
670
+
671
+
672
+ @pytest.mark.asyncio
673
+ async def test_clear_monotonic_cache(mock_modbus_client):
674
+ """Clearing the cache forgets baselines and pending resets."""
675
+ client = QubeClient("1.2.3.4", 502)
676
+ client.clamp_monotonic("energy", 1000.0)
677
+ client.clamp_monotonic("energy", 0.0) # pending reset
678
+ client.clear_monotonic_cache()
679
+ assert client.monotonic_cache == {}
680
+ # Next read is accepted as a fresh baseline
681
+ assert client.clamp_monotonic("energy", 5.0) == 5.0
682
+
683
+
684
+ @pytest.mark.asyncio
685
+ async def test_monotonic_cache_setter_discards_pending_resets(mock_modbus_client):
686
+ """Restoring a cache from disk resets the reset-confirmation counters."""
687
+ client = QubeClient("1.2.3.4", 502)
688
+ client.clamp_monotonic("energy", 1000.0)
689
+ client.clamp_monotonic("energy", 0.0)
690
+ client.clamp_monotonic("energy", 0.0)
691
+ client.monotonic_cache = {"energy": 1000.0}
692
+ # Only one low read so far after the restore: still clamped
693
+ assert client.clamp_monotonic("energy", 0.0) == 1000.0
@@ -143,6 +143,41 @@ async def test_batched_block_failure_falls_back_to_individual_reads(
143
143
  assert round(results["temp_return"], 1) == 25.0
144
144
 
145
145
 
146
+ @pytest.mark.asyncio
147
+ async def test_batched_short_successful_response_falls_back_to_individual_reads(
148
+ mock_modbus_client,
149
+ ):
150
+ """A well-formed but too-short block response degrades gracefully.
151
+
152
+ A device can return isError()==False with fewer registers/bits than the
153
+ requested count (a malformed-but-"successful" response). Previously this
154
+ slipped past the isError() check and crashed with an uncaught IndexError
155
+ while decoding, instead of falling back to individual reads like a failed
156
+ block does. Since get_all_data() is now polled by HA core every 15s via
157
+ this same batching path, such a response must degrade the same way a
158
+ failed block does, not raise.
159
+ """
160
+ client = QubeClient("1.2.3.4", 502)
161
+ mock_instance = mock_modbus_client.return_value
162
+ mock_instance.read_input_registers = AsyncMock(
163
+ side_effect=[
164
+ # Block read: isError() is False, but only 2 registers came back
165
+ # for a block that needs 4 (two float32 entities).
166
+ _register_response(_float32_regs(24.5)),
167
+ _register_response(_float32_regs(24.5)), # individual read
168
+ _register_response(_float32_regs(25.0)), # individual read
169
+ ]
170
+ )
171
+ client._client = mock_instance
172
+
173
+ entities = [SENSORS["temp_supply"], SENSORS["temp_return"]]
174
+ results = await client.read_entities_batched(entities)
175
+
176
+ assert mock_instance.read_input_registers.call_count == 3
177
+ assert round(results["temp_supply"], 1) == 24.5
178
+ assert round(results["temp_return"], 1) == 25.0
179
+
180
+
146
181
  @pytest.mark.asyncio
147
182
  async def test_get_all_entities_uses_batched_reads(mock_modbus_client):
148
183
  """get_all_entities reads all entities in a handful of transactions."""
@@ -172,3 +207,43 @@ async def test_get_all_entities_uses_batched_reads(mock_modbus_client):
172
207
  + mock_instance.read_holding_registers.call_count
173
208
  )
174
209
  assert total_calls <= 15
210
+
211
+
212
+ @pytest.mark.asyncio
213
+ async def test_get_all_data_uses_batched_reads(mock_modbus_client):
214
+ """get_all_data (used by HA core, polled every 15s) reads via block reads.
215
+
216
+ Previously get_all_data() performed one Modbus transaction per field
217
+ (~59 transactions: 23 core sensors + 37 binary sensors). It should now
218
+ use the same batched block-read infrastructure as get_all_entities(),
219
+ landing in a handful of transactions instead.
220
+ """
221
+ client = QubeClient("1.2.3.4", 502)
222
+ mock_instance = mock_modbus_client.return_value
223
+
224
+ def _bits(address, count=1, **kwargs):
225
+ return _bit_response([False] * count)
226
+
227
+ def _regs(address, count=1, **kwargs):
228
+ return _register_response([0] * count)
229
+
230
+ mock_instance.read_coils = AsyncMock(side_effect=_bits)
231
+ mock_instance.read_discrete_inputs = AsyncMock(side_effect=_bits)
232
+ mock_instance.read_input_registers = AsyncMock(side_effect=_regs)
233
+ mock_instance.read_holding_registers = AsyncMock(side_effect=_regs)
234
+ client._client = mock_instance
235
+ client._connected = True
236
+
237
+ state = await client.get_all_data()
238
+
239
+ assert state is not None
240
+ total_calls = (
241
+ mock_instance.read_coils.call_count
242
+ + mock_instance.read_discrete_inputs.call_count
243
+ + mock_instance.read_input_registers.call_count
244
+ + mock_instance.read_holding_registers.call_count
245
+ )
246
+ # Old per-register/per-entity path made ~59 transactions (23 core
247
+ # fields + 37 binary sensors). Batched block reads should collapse
248
+ # this into a handful of transactions.
249
+ assert total_calls <= 15