python-qube-heatpump 1.6.2__tar.gz → 1.6.3__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.6.3}/PKG-INFO +1 -1
  2. {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.6.3}/pyproject.toml +1 -1
  3. {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.6.3}/src/python_qube_heatpump/client.py +23 -0
  4. {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.6.3}/tests/test_client.py +123 -0
  5. {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.6.3}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  6. {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.6.3}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  7. {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.6.3}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  8. {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.6.3}/.github/workflows/ci.yml +0 -0
  9. {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.6.3}/.github/workflows/python-publish.yml +0 -0
  10. {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.6.3}/.gitignore +0 -0
  11. {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.6.3}/AGENTS.md +0 -0
  12. {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.6.3}/CLAUDE.md +0 -0
  13. {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.6.3}/LICENSE +0 -0
  14. {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.6.3}/README.md +0 -0
  15. {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.6.3}/docs/modbus-lijst-qube-totaal.pdf +0 -0
  16. {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.6.3}/pytest.ini +0 -0
  17. {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.6.3}/src/python_qube_heatpump/__init__.py +0 -0
  18. {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.6.3}/src/python_qube_heatpump/const.py +0 -0
  19. {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.6.3}/src/python_qube_heatpump/entities/__init__.py +0 -0
  20. {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.6.3}/src/python_qube_heatpump/entities/base.py +0 -0
  21. {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.6.3}/src/python_qube_heatpump/entities/binary_sensors.py +0 -0
  22. {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.6.3}/src/python_qube_heatpump/entities/sensors.py +0 -0
  23. {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.6.3}/src/python_qube_heatpump/entities/switches.py +0 -0
  24. {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.6.3}/src/python_qube_heatpump/models.py +0 -0
  25. {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.6.3}/src/python_qube_heatpump/network.py +0 -0
  26. {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.6.3}/src/python_qube_heatpump/py.typed +0 -0
  27. {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.6.3}/tests/conftest.py +0 -0
  28. {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.6.3}/tests/test_const.py +0 -0
  29. {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.6.3}/tests/test_entities.py +0 -0
  30. {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.6.3}/tests/test_models.py +0 -0
  31. {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.6.3}/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.6.3
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.6.3"
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._previous_values: dict[str, float] = {}
34
37
 
35
38
  async def connect(self) -> bool:
36
39
  """Connect to the Modbus server."""
@@ -117,8 +120,28 @@ 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
+ _MONOTONIC_KEYS = frozenset({"energy_total_electric", "energy_total_thermic"})
127
+
128
+ def _apply_monotonic_clamping(self, state: QubeState) -> None:
129
+ """Prevent total_increasing counters from reporting decreased values.
130
+
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
+ """
135
+ for key in self._MONOTONIC_KEYS:
136
+ 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
144
+
122
145
  async def async_get_software_version(self) -> str | None:
123
146
  """Read the software version from the device.
124
147
 
@@ -342,3 +342,126 @@ 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_monotonic_clamping_prevents_decrease(mock_modbus_client):
349
+ """Test energy counters are clamped when they decrease."""
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
+
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
387
+
388
+
389
+ @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
+
394
+ client = QubeClient("1.2.3.4", 502)
395
+
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
427
+
428
+
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
+
434
+ client = QubeClient("1.2.3.4", 502)
435
+
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)
441
+
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
449
+
450
+
451
+ @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
455
+
456
+ client = QubeClient("1.2.3.4", 502)
457
+
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