python-qube-heatpump 1.6.2__tar.gz → 1.7.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 (31) hide show
  1. {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.7.0}/PKG-INFO +1 -1
  2. {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.7.0}/pyproject.toml +1 -1
  3. {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.7.0}/src/python_qube_heatpump/client.py +49 -0
  4. {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.7.0}/tests/test_client.py +93 -0
  5. {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.7.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  6. {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.7.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  7. {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.7.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  8. {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.7.0}/.github/workflows/ci.yml +0 -0
  9. {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.7.0}/.github/workflows/python-publish.yml +0 -0
  10. {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.7.0}/.gitignore +0 -0
  11. {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.7.0}/AGENTS.md +0 -0
  12. {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.7.0}/CLAUDE.md +0 -0
  13. {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.7.0}/LICENSE +0 -0
  14. {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.7.0}/README.md +0 -0
  15. {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.7.0}/docs/modbus-lijst-qube-totaal.pdf +0 -0
  16. {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.7.0}/pytest.ini +0 -0
  17. {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.7.0}/src/python_qube_heatpump/__init__.py +0 -0
  18. {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.7.0}/src/python_qube_heatpump/const.py +0 -0
  19. {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.7.0}/src/python_qube_heatpump/entities/__init__.py +0 -0
  20. {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.7.0}/src/python_qube_heatpump/entities/base.py +0 -0
  21. {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.7.0}/src/python_qube_heatpump/entities/binary_sensors.py +0 -0
  22. {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.7.0}/src/python_qube_heatpump/entities/sensors.py +0 -0
  23. {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.7.0}/src/python_qube_heatpump/entities/switches.py +0 -0
  24. {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.7.0}/src/python_qube_heatpump/models.py +0 -0
  25. {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.7.0}/src/python_qube_heatpump/network.py +0 -0
  26. {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.7.0}/src/python_qube_heatpump/py.typed +0 -0
  27. {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.7.0}/tests/conftest.py +0 -0
  28. {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.7.0}/tests/test_const.py +0 -0
  29. {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.7.0}/tests/test_entities.py +0 -0
  30. {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.7.0}/tests/test_models.py +0 -0
  31. {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.7.0}/tests/test_network.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-qube-heatpump
3
- Version: 1.6.2
3
+ Version: 1.7.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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "python-qube-heatpump"
7
- version = "1.6.2"
7
+ version = "1.7.0"
8
8
  authors = [
9
9
  { name="MattieGit", email="6250046+MattieGit@users.noreply.github.com" },
10
10
  ]
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import logging
6
+ import math
6
7
  import struct
7
8
  import time
8
9
  from typing import Any
@@ -31,6 +32,8 @@ class QubeClient:
31
32
  self._backoff_seconds: float = 0.0
32
33
  self._backoff_max: float = 60.0
33
34
  self._next_connect_at: float = 0.0
35
+ # Monotonic clamping for total_increasing counters
36
+ self._monotonic_cache: dict[str, float] = {}
34
37
 
35
38
  async def connect(self) -> bool:
36
39
  """Connect to the Modbus server."""
@@ -117,8 +120,54 @@ class QubeClient:
117
120
  state.setpoint_room_cool_night = await _read(const.SETPOINT_COOL_NIGHT)
118
121
  state.setpoint_dhw = await _read(const.USER_DHW_SETPOINT)
119
122
 
123
+ self._apply_monotonic_clamping(state)
120
124
  return state
121
125
 
126
+ @property
127
+ def monotonic_cache(self) -> dict[str, float]:
128
+ """Return the monotonic clamping cache.
129
+
130
+ Can be used to persist/restore the cache across restarts.
131
+ """
132
+ return self._monotonic_cache
133
+
134
+ @monotonic_cache.setter
135
+ def monotonic_cache(self, value: dict[str, float]) -> None:
136
+ """Set the monotonic clamping cache (e.g. restored from disk)."""
137
+ self._monotonic_cache = dict(value)
138
+
139
+ def clamp_monotonic(self, key: str, value: float | None) -> float | None:
140
+ """Clamp a value to prevent decreases for total_increasing counters.
141
+
142
+ Returns the clamped value. If the new value is lower than the
143
+ previously seen value for this key, the previous value is returned.
144
+ None and non-finite values pass through unchanged.
145
+
146
+ Args:
147
+ key: Identifier for this counter (e.g. entity unique_id).
148
+ value: The current reading.
149
+
150
+ Returns:
151
+ The clamped value, or None if input was None/non-finite.
152
+ """
153
+ if value is None or not math.isfinite(value):
154
+ return value
155
+ previous = self._monotonic_cache.get(key)
156
+ if previous is not None and value < previous:
157
+ return previous
158
+ self._monotonic_cache[key] = value
159
+ return value
160
+
161
+ _MONOTONIC_KEYS = frozenset({"energy_total_electric", "energy_total_thermic"})
162
+
163
+ def _apply_monotonic_clamping(self, state: QubeState) -> None:
164
+ """Apply monotonic clamping to energy counters in a QubeState."""
165
+ for key in self._MONOTONIC_KEYS:
166
+ current = getattr(state, key, None)
167
+ clamped = self.clamp_monotonic(key, current)
168
+ if clamped is not current:
169
+ setattr(state, key, clamped)
170
+
122
171
  async def async_get_software_version(self) -> str | None:
123
172
  """Read the software version from the device.
124
173
 
@@ -342,3 +342,96 @@ async def test_get_all_data_auto_reconnects(mock_modbus_client):
342
342
  assert state is not None
343
343
  # Verify connect was called (auto-reconnect happened)
344
344
  mock_instance.connect.assert_called_once()
345
+
346
+
347
+ @pytest.mark.asyncio
348
+ async def test_clamp_monotonic_basic(mock_modbus_client):
349
+ """Test clamp_monotonic prevents decreases."""
350
+ client = QubeClient("1.2.3.4", 502)
351
+
352
+ # First value establishes baseline
353
+ assert client.clamp_monotonic("energy", 100.0) == 100.0
354
+ # Increase passes through
355
+ assert client.clamp_monotonic("energy", 150.0) == 150.0
356
+ # Decrease gets clamped to previous
357
+ assert client.clamp_monotonic("energy", 50.0) == 150.0
358
+ # Recovery passes through
359
+ assert client.clamp_monotonic("energy", 160.0) == 160.0
360
+
361
+
362
+ @pytest.mark.asyncio
363
+ async def test_clamp_monotonic_none_and_nan(mock_modbus_client):
364
+ """Test clamp_monotonic passes through None and NaN."""
365
+ client = QubeClient("1.2.3.4", 502)
366
+
367
+ # None passes through
368
+ assert client.clamp_monotonic("energy", None) is None
369
+
370
+ # Establish baseline
371
+ assert client.clamp_monotonic("energy", 100.0) == 100.0
372
+
373
+ # None doesn't update cache
374
+ assert client.clamp_monotonic("energy", None) is None
375
+ assert client.monotonic_cache["energy"] == 100.0
376
+
377
+ # NaN doesn't update cache
378
+ result = client.clamp_monotonic("energy", float("nan"))
379
+ assert result != result # NaN != NaN
380
+ assert client.monotonic_cache["energy"] == 100.0
381
+
382
+
383
+ @pytest.mark.asyncio
384
+ async def test_clamp_monotonic_independent_keys(mock_modbus_client):
385
+ """Test clamp_monotonic tracks keys independently."""
386
+ client = QubeClient("1.2.3.4", 502)
387
+
388
+ client.clamp_monotonic("electric", 100.0)
389
+ client.clamp_monotonic("thermic", 200.0)
390
+
391
+ # Decrease in one doesn't affect the other
392
+ assert client.clamp_monotonic("electric", 50.0) == 100.0
393
+ assert client.clamp_monotonic("thermic", 250.0) == 250.0
394
+
395
+
396
+ @pytest.mark.asyncio
397
+ async def test_monotonic_cache_property(mock_modbus_client):
398
+ """Test monotonic_cache can be get/set for persistence."""
399
+ client = QubeClient("1.2.3.4", 502)
400
+
401
+ # Cache starts empty
402
+ assert client.monotonic_cache == {}
403
+
404
+ # Set cache (e.g. restored from disk)
405
+ client.monotonic_cache = {"energy": 100.0, "thermic": 200.0}
406
+ assert client.monotonic_cache == {"energy": 100.0, "thermic": 200.0}
407
+
408
+ # Clamping uses the restored cache
409
+ assert client.clamp_monotonic("energy", 50.0) == 100.0
410
+ assert client.clamp_monotonic("thermic", 250.0) == 250.0
411
+
412
+ # Setting cache makes a copy
413
+ original = {"energy": 300.0}
414
+ client.monotonic_cache = original
415
+ original["energy"] = 999.0
416
+ assert client.monotonic_cache["energy"] == 300.0
417
+
418
+
419
+ @pytest.mark.asyncio
420
+ async def test_get_all_data_applies_clamping(mock_modbus_client):
421
+ """Test get_all_data uses clamp_monotonic for energy counters."""
422
+ client = QubeClient("1.2.3.4", 502)
423
+ mock_instance = mock_modbus_client.return_value
424
+ client._client = mock_instance
425
+ client._connected = True
426
+
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)
432
+
433
+ state = await client.get_all_data()
434
+ assert state is not None
435
+ # Energy values stored in cache
436
+ assert client.monotonic_cache.get("energy_total_electric") == 0.0
437
+ assert client.monotonic_cache.get("energy_total_thermic") == 0.0