python-qube-heatpump 1.13.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.13.0 → python_qube_heatpump-1.14.0}/.github/workflows/ci.yml +1 -1
  2. {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/PKG-INFO +3 -2
  3. {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/README.md +1 -0
  4. {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/pyproject.toml +1 -1
  5. {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/src/python_qube_heatpump/client.py +66 -5
  6. {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/src/python_qube_heatpump/entities/sensors.py +5 -5
  7. {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/tests/test_client.py +100 -0
  8. {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  9. {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  10. {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  11. {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/.github/workflows/python-publish.yml +0 -0
  12. {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/.gitignore +0 -0
  13. {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/AGENTS.md +0 -0
  14. {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/CLAUDE.md +0 -0
  15. {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/LICENSE +0 -0
  16. {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/docs/modbus-lijst-qube-totaal.pdf +0 -0
  17. {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/pytest.ini +0 -0
  18. {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/src/python_qube_heatpump/__init__.py +0 -0
  19. {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/src/python_qube_heatpump/const.py +0 -0
  20. {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/src/python_qube_heatpump/entities/__init__.py +0 -0
  21. {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/src/python_qube_heatpump/entities/base.py +0 -0
  22. {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/src/python_qube_heatpump/entities/binary_sensors.py +0 -0
  23. {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/src/python_qube_heatpump/entities/switches.py +0 -0
  24. {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/src/python_qube_heatpump/models.py +0 -0
  25. {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/src/python_qube_heatpump/network.py +0 -0
  26. {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/src/python_qube_heatpump/py.typed +0 -0
  27. {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/tests/conftest.py +0 -0
  28. {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/tests/test_client_batching.py +0 -0
  29. {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/tests/test_const.py +0 -0
  30. {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/tests/test_entities.py +0 -0
  31. {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/tests/test_models.py +0 -0
  32. {python_qube_heatpump-1.13.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.13.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.13.0"
7
+ version = "1.14.0"
8
8
  authors = [
9
9
  { name="MattieGit", email="6250046+MattieGit@users.noreply.github.com" },
10
10
  ]
@@ -149,6 +149,9 @@ class QubeClient:
149
149
  self._next_connect_at: float = 0.0
150
150
  # Monotonic clamping for total_increasing counters
151
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] = {}
152
155
  # Reads that already produced a WARNING (transient failures are
153
156
  # logged once per target, then at DEBUG to avoid log spam)
154
157
  self._read_failures_warned: set[str] = set()
@@ -254,17 +257,48 @@ class QubeClient:
254
257
  def monotonic_cache(self, value: dict[str, float]) -> None:
255
258
  """Set the monotonic clamping cache (e.g. restored from disk)."""
256
259
  self._monotonic_cache = dict(value)
260
+ self._monotonic_reset_pending.clear()
257
261
 
258
- def clamp_monotonic(self, key: str, value: float | None) -> float | None:
262
+ def clear_monotonic_cache(self) -> None:
263
+ """Forget all monotonic baselines.
264
+
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:
259
286
  """Clamp a value to prevent decreases for total_increasing counters.
260
287
 
261
- Returns the clamped value. If the new value is lower than the
262
- previously seen value for this key, the previous value is returned.
263
- 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.
264
296
 
265
297
  Args:
266
298
  key: Identifier for this counter (e.g. entity unique_id).
267
299
  value: The current reading.
300
+ reset_threshold: Drop size that counts as a reset; defaults to
301
+ ``MONOTONIC_RESET_THRESHOLD``.
268
302
 
269
303
  Returns:
270
304
  The clamped value, or None if input was None/non-finite.
@@ -272,8 +306,35 @@ class QubeClient:
272
306
  if value is None or not math.isfinite(value):
273
307
  return value
274
308
  previous = self._monotonic_cache.get(key)
275
- 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)
276
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
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)
277
338
  self._monotonic_cache[key] = value
278
339
  return value
279
340
 
@@ -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
@@ -591,3 +592,102 @@ async def test_read_entity_warns_again_per_entity(mock_modbus_client, caplog):
591
592
 
592
593
  warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
593
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