python-qube-heatpump 1.6.3__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.3 → python_qube_heatpump-1.7.0}/PKG-INFO +1 -1
  2. {python_qube_heatpump-1.6.3 → python_qube_heatpump-1.7.0}/pyproject.toml +1 -1
  3. {python_qube_heatpump-1.6.3 → python_qube_heatpump-1.7.0}/src/python_qube_heatpump/client.py +40 -14
  4. {python_qube_heatpump-1.6.3 → python_qube_heatpump-1.7.0}/tests/test_client.py +71 -101
  5. {python_qube_heatpump-1.6.3 → python_qube_heatpump-1.7.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  6. {python_qube_heatpump-1.6.3 → python_qube_heatpump-1.7.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  7. {python_qube_heatpump-1.6.3 → python_qube_heatpump-1.7.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  8. {python_qube_heatpump-1.6.3 → python_qube_heatpump-1.7.0}/.github/workflows/ci.yml +0 -0
  9. {python_qube_heatpump-1.6.3 → python_qube_heatpump-1.7.0}/.github/workflows/python-publish.yml +0 -0
  10. {python_qube_heatpump-1.6.3 → python_qube_heatpump-1.7.0}/.gitignore +0 -0
  11. {python_qube_heatpump-1.6.3 → python_qube_heatpump-1.7.0}/AGENTS.md +0 -0
  12. {python_qube_heatpump-1.6.3 → python_qube_heatpump-1.7.0}/CLAUDE.md +0 -0
  13. {python_qube_heatpump-1.6.3 → python_qube_heatpump-1.7.0}/LICENSE +0 -0
  14. {python_qube_heatpump-1.6.3 → python_qube_heatpump-1.7.0}/README.md +0 -0
  15. {python_qube_heatpump-1.6.3 → python_qube_heatpump-1.7.0}/docs/modbus-lijst-qube-totaal.pdf +0 -0
  16. {python_qube_heatpump-1.6.3 → python_qube_heatpump-1.7.0}/pytest.ini +0 -0
  17. {python_qube_heatpump-1.6.3 → python_qube_heatpump-1.7.0}/src/python_qube_heatpump/__init__.py +0 -0
  18. {python_qube_heatpump-1.6.3 → python_qube_heatpump-1.7.0}/src/python_qube_heatpump/const.py +0 -0
  19. {python_qube_heatpump-1.6.3 → python_qube_heatpump-1.7.0}/src/python_qube_heatpump/entities/__init__.py +0 -0
  20. {python_qube_heatpump-1.6.3 → python_qube_heatpump-1.7.0}/src/python_qube_heatpump/entities/base.py +0 -0
  21. {python_qube_heatpump-1.6.3 → python_qube_heatpump-1.7.0}/src/python_qube_heatpump/entities/binary_sensors.py +0 -0
  22. {python_qube_heatpump-1.6.3 → python_qube_heatpump-1.7.0}/src/python_qube_heatpump/entities/sensors.py +0 -0
  23. {python_qube_heatpump-1.6.3 → python_qube_heatpump-1.7.0}/src/python_qube_heatpump/entities/switches.py +0 -0
  24. {python_qube_heatpump-1.6.3 → python_qube_heatpump-1.7.0}/src/python_qube_heatpump/models.py +0 -0
  25. {python_qube_heatpump-1.6.3 → python_qube_heatpump-1.7.0}/src/python_qube_heatpump/network.py +0 -0
  26. {python_qube_heatpump-1.6.3 → python_qube_heatpump-1.7.0}/src/python_qube_heatpump/py.typed +0 -0
  27. {python_qube_heatpump-1.6.3 → python_qube_heatpump-1.7.0}/tests/conftest.py +0 -0
  28. {python_qube_heatpump-1.6.3 → python_qube_heatpump-1.7.0}/tests/test_const.py +0 -0
  29. {python_qube_heatpump-1.6.3 → python_qube_heatpump-1.7.0}/tests/test_entities.py +0 -0
  30. {python_qube_heatpump-1.6.3 → python_qube_heatpump-1.7.0}/tests/test_models.py +0 -0
  31. {python_qube_heatpump-1.6.3 → 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.3
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.3"
7
+ version = "1.7.0"
8
8
  authors = [
9
9
  { name="MattieGit", email="6250046+MattieGit@users.noreply.github.com" },
10
10
  ]
@@ -33,7 +33,7 @@ class QubeClient:
33
33
  self._backoff_max: float = 60.0
34
34
  self._next_connect_at: float = 0.0
35
35
  # Monotonic clamping for total_increasing counters
36
- self._previous_values: dict[str, float] = {}
36
+ self._monotonic_cache: dict[str, float] = {}
37
37
 
38
38
  async def connect(self) -> bool:
39
39
  """Connect to the Modbus server."""
@@ -123,24 +123,50 @@ class QubeClient:
123
123
  self._apply_monotonic_clamping(state)
124
124
  return state
125
125
 
126
- _MONOTONIC_KEYS = frozenset({"energy_total_electric", "energy_total_thermic"})
126
+ @property
127
+ def monotonic_cache(self) -> dict[str, float]:
128
+ """Return the monotonic clamping cache.
127
129
 
128
- def _apply_monotonic_clamping(self, state: QubeState) -> None:
129
- """Prevent total_increasing counters from reporting decreased values.
130
+ Can be used to persist/restore the cache across restarts.
131
+ """
132
+ return self._monotonic_cache
130
133
 
131
- Communication glitches can cause energy counters to temporarily read
132
- lower than their actual value. This clamps them to the last known
133
- good value to avoid corrupting long-term statistics.
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.
134
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."""
135
165
  for key in self._MONOTONIC_KEYS:
136
166
  current = getattr(state, key, None)
137
- if current is None or not math.isfinite(current):
138
- continue
139
- previous = self._previous_values.get(key)
140
- if previous is not None and current < previous:
141
- setattr(state, key, previous)
142
- else:
143
- self._previous_values[key] = current
167
+ clamped = self.clamp_monotonic(key, current)
168
+ if clamped is not current:
169
+ setattr(state, key, clamped)
144
170
 
145
171
  async def async_get_software_version(self) -> str | None:
146
172
  """Read the software version from the device.
@@ -345,123 +345,93 @@ async def test_get_all_data_auto_reconnects(mock_modbus_client):
345
345
 
346
346
 
347
347
  @pytest.mark.asyncio
348
- async def test_monotonic_clamping_prevents_decrease(mock_modbus_client):
349
- """Test energy counters are clamped when they decrease."""
348
+ async def test_clamp_monotonic_basic(mock_modbus_client):
349
+ """Test clamp_monotonic prevents decreases."""
350
350
  client = QubeClient("1.2.3.4", 502)
351
- mock_instance = mock_modbus_client.return_value
352
- client._client = mock_instance
353
- client._connected = True
354
-
355
- # Helper to create mock responses returning specific float values
356
- def make_resp(value_pairs):
357
- """Create mock responses for a sequence of register reads."""
358
- import struct as s
359
- responses = []
360
- for val in value_pairs:
361
- resp = MagicMock()
362
- resp.isError.return_value = False
363
- if val is not None:
364
- packed = s.pack(">f", float(val))
365
- int_val = s.unpack(">I", packed)[0]
366
- resp.registers = [(int_val >> 16) & 0xFFFF, int_val & 0xFFFF]
367
- else:
368
- resp.registers = [0, 0]
369
- responses.append(resp)
370
- return responses
371
-
372
- # First call: set initial energy values (100.0 electric, 200.0 thermic)
373
- mock_resp = MagicMock()
374
- mock_resp.isError.return_value = False
375
- mock_resp.registers = [0, 0]
376
- mock_instance.read_input_registers = AsyncMock(return_value=mock_resp)
377
- mock_instance.read_holding_registers = AsyncMock(return_value=mock_resp)
378
351
 
379
- state1 = await client.get_all_data()
380
- assert state1 is not None
381
- # Both should be 0.0 initially (all zeros)
382
- assert state1.energy_total_electric == 0.0
383
- assert state1.energy_total_thermic == 0.0
384
- # Previous values should be stored
385
- assert client._previous_values.get("energy_total_electric") == 0.0
386
- assert client._previous_values.get("energy_total_thermic") == 0.0
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
387
360
 
388
361
 
389
362
  @pytest.mark.asyncio
390
- async def test_monotonic_clamping_unit(mock_modbus_client):
391
- """Test _apply_monotonic_clamping directly."""
392
- from python_qube_heatpump.models import QubeState
393
-
363
+ async def test_clamp_monotonic_none_and_nan(mock_modbus_client):
364
+ """Test clamp_monotonic passes through None and NaN."""
394
365
  client = QubeClient("1.2.3.4", 502)
395
366
 
396
- # First reading: establish baseline
397
- state1 = QubeState()
398
- state1.energy_total_electric = 100.0
399
- state1.energy_total_thermic = 200.0
400
- client._apply_monotonic_clamping(state1)
401
- assert state1.energy_total_electric == 100.0
402
- assert state1.energy_total_thermic == 200.0
403
-
404
- # Second reading: values increase — should pass through
405
- state2 = QubeState()
406
- state2.energy_total_electric = 150.0
407
- state2.energy_total_thermic = 250.0
408
- client._apply_monotonic_clamping(state2)
409
- assert state2.energy_total_electric == 150.0
410
- assert state2.energy_total_thermic == 250.0
411
-
412
- # Third reading: values decrease (glitch) — should clamp to previous
413
- state3 = QubeState()
414
- state3.energy_total_electric = 50.0 # Glitch: dropped below 150
415
- state3.energy_total_thermic = 100.0 # Glitch: dropped below 250
416
- client._apply_monotonic_clamping(state3)
417
- assert state3.energy_total_electric == 150.0 # Clamped
418
- assert state3.energy_total_thermic == 250.0 # Clamped
419
-
420
- # Fourth reading: values recover — should pass through
421
- state4 = QubeState()
422
- state4.energy_total_electric = 160.0
423
- state4.energy_total_thermic = 260.0
424
- client._apply_monotonic_clamping(state4)
425
- assert state4.energy_total_electric == 160.0
426
- assert state4.energy_total_thermic == 260.0
367
+ # None passes through
368
+ assert client.clamp_monotonic("energy", None) is None
427
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
428
381
 
429
- @pytest.mark.asyncio
430
- async def test_monotonic_clamping_skips_none(mock_modbus_client):
431
- """Test clamping skips None values."""
432
- from python_qube_heatpump.models import QubeState
433
382
 
383
+ @pytest.mark.asyncio
384
+ async def test_clamp_monotonic_independent_keys(mock_modbus_client):
385
+ """Test clamp_monotonic tracks keys independently."""
434
386
  client = QubeClient("1.2.3.4", 502)
435
387
 
436
- # Establish baseline
437
- state1 = QubeState()
438
- state1.energy_total_electric = 100.0
439
- state1.energy_total_thermic = 200.0
440
- client._apply_monotonic_clamping(state1)
388
+ client.clamp_monotonic("electric", 100.0)
389
+ client.clamp_monotonic("thermic", 200.0)
441
390
 
442
- # None value should be skipped (not clamped, not stored)
443
- state2 = QubeState()
444
- state2.energy_total_electric = None
445
- state2.energy_total_thermic = 200.0
446
- client._apply_monotonic_clamping(state2)
447
- assert state2.energy_total_electric is None # Unchanged
448
- assert client._previous_values["energy_total_electric"] == 100.0 # Not updated
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
449
394
 
450
395
 
451
396
  @pytest.mark.asyncio
452
- async def test_monotonic_clamping_skips_nan(mock_modbus_client):
453
- """Test clamping skips NaN values."""
454
- from python_qube_heatpump.models import QubeState
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 == {}
455
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."""
456
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
457
426
 
458
- # Establish baseline
459
- state1 = QubeState()
460
- state1.energy_total_electric = 100.0
461
- client._apply_monotonic_clamping(state1)
462
-
463
- # NaN should be skipped
464
- state2 = QubeState()
465
- state2.energy_total_electric = float("nan")
466
- client._apply_monotonic_clamping(state2)
467
- assert client._previous_values["energy_total_electric"] == 100.0
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