python-qube-heatpump 1.6.3__tar.gz → 1.8.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.8.0}/PKG-INFO +1 -1
  2. {python_qube_heatpump-1.6.3 → python_qube_heatpump-1.8.0}/pyproject.toml +1 -1
  3. {python_qube_heatpump-1.6.3 → python_qube_heatpump-1.8.0}/src/python_qube_heatpump/client.py +47 -14
  4. {python_qube_heatpump-1.6.3 → python_qube_heatpump-1.8.0}/src/python_qube_heatpump/models.py +51 -0
  5. {python_qube_heatpump-1.6.3 → python_qube_heatpump-1.8.0}/tests/test_client.py +71 -101
  6. {python_qube_heatpump-1.6.3 → python_qube_heatpump-1.8.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  7. {python_qube_heatpump-1.6.3 → python_qube_heatpump-1.8.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  8. {python_qube_heatpump-1.6.3 → python_qube_heatpump-1.8.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  9. {python_qube_heatpump-1.6.3 → python_qube_heatpump-1.8.0}/.github/workflows/ci.yml +0 -0
  10. {python_qube_heatpump-1.6.3 → python_qube_heatpump-1.8.0}/.github/workflows/python-publish.yml +0 -0
  11. {python_qube_heatpump-1.6.3 → python_qube_heatpump-1.8.0}/.gitignore +0 -0
  12. {python_qube_heatpump-1.6.3 → python_qube_heatpump-1.8.0}/AGENTS.md +0 -0
  13. {python_qube_heatpump-1.6.3 → python_qube_heatpump-1.8.0}/CLAUDE.md +0 -0
  14. {python_qube_heatpump-1.6.3 → python_qube_heatpump-1.8.0}/LICENSE +0 -0
  15. {python_qube_heatpump-1.6.3 → python_qube_heatpump-1.8.0}/README.md +0 -0
  16. {python_qube_heatpump-1.6.3 → python_qube_heatpump-1.8.0}/docs/modbus-lijst-qube-totaal.pdf +0 -0
  17. {python_qube_heatpump-1.6.3 → python_qube_heatpump-1.8.0}/pytest.ini +0 -0
  18. {python_qube_heatpump-1.6.3 → python_qube_heatpump-1.8.0}/src/python_qube_heatpump/__init__.py +0 -0
  19. {python_qube_heatpump-1.6.3 → python_qube_heatpump-1.8.0}/src/python_qube_heatpump/const.py +0 -0
  20. {python_qube_heatpump-1.6.3 → python_qube_heatpump-1.8.0}/src/python_qube_heatpump/entities/__init__.py +0 -0
  21. {python_qube_heatpump-1.6.3 → python_qube_heatpump-1.8.0}/src/python_qube_heatpump/entities/base.py +0 -0
  22. {python_qube_heatpump-1.6.3 → python_qube_heatpump-1.8.0}/src/python_qube_heatpump/entities/binary_sensors.py +0 -0
  23. {python_qube_heatpump-1.6.3 → python_qube_heatpump-1.8.0}/src/python_qube_heatpump/entities/sensors.py +0 -0
  24. {python_qube_heatpump-1.6.3 → python_qube_heatpump-1.8.0}/src/python_qube_heatpump/entities/switches.py +0 -0
  25. {python_qube_heatpump-1.6.3 → python_qube_heatpump-1.8.0}/src/python_qube_heatpump/network.py +0 -0
  26. {python_qube_heatpump-1.6.3 → python_qube_heatpump-1.8.0}/src/python_qube_heatpump/py.typed +0 -0
  27. {python_qube_heatpump-1.6.3 → python_qube_heatpump-1.8.0}/tests/conftest.py +0 -0
  28. {python_qube_heatpump-1.6.3 → python_qube_heatpump-1.8.0}/tests/test_const.py +0 -0
  29. {python_qube_heatpump-1.6.3 → python_qube_heatpump-1.8.0}/tests/test_entities.py +0 -0
  30. {python_qube_heatpump-1.6.3 → python_qube_heatpump-1.8.0}/tests/test_models.py +0 -0
  31. {python_qube_heatpump-1.6.3 → python_qube_heatpump-1.8.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.8.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.8.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."""
@@ -121,26 +121,59 @@ class QubeClient:
121
121
  state.setpoint_dhw = await _read(const.USER_DHW_SETPOINT)
122
122
 
123
123
  self._apply_monotonic_clamping(state)
124
+
125
+ # Fetch binary sensors
126
+ binary_data = await self.read_all_binary_sensors()
127
+ for key, value in binary_data.items():
128
+ if hasattr(state, key):
129
+ setattr(state, key, value)
130
+
124
131
  return state
125
132
 
126
- _MONOTONIC_KEYS = frozenset({"energy_total_electric", "energy_total_thermic"})
133
+ @property
134
+ def monotonic_cache(self) -> dict[str, float]:
135
+ """Return the monotonic clamping cache.
127
136
 
128
- def _apply_monotonic_clamping(self, state: QubeState) -> None:
129
- """Prevent total_increasing counters from reporting decreased values.
137
+ Can be used to persist/restore the cache across restarts.
138
+ """
139
+ return self._monotonic_cache
140
+
141
+ @monotonic_cache.setter
142
+ def monotonic_cache(self, value: dict[str, float]) -> None:
143
+ """Set the monotonic clamping cache (e.g. restored from disk)."""
144
+ self._monotonic_cache = dict(value)
145
+
146
+ def clamp_monotonic(self, key: str, value: float | None) -> float | None:
147
+ """Clamp a value to prevent decreases for total_increasing counters.
148
+
149
+ Returns the clamped value. If the new value is lower than the
150
+ previously seen value for this key, the previous value is returned.
151
+ None and non-finite values pass through unchanged.
130
152
 
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.
153
+ Args:
154
+ key: Identifier for this counter (e.g. entity unique_id).
155
+ value: The current reading.
156
+
157
+ Returns:
158
+ The clamped value, or None if input was None/non-finite.
134
159
  """
160
+ if value is None or not math.isfinite(value):
161
+ return value
162
+ previous = self._monotonic_cache.get(key)
163
+ if previous is not None and value < previous:
164
+ return previous
165
+ self._monotonic_cache[key] = value
166
+ return value
167
+
168
+ _MONOTONIC_KEYS = frozenset({"energy_total_electric", "energy_total_thermic"})
169
+
170
+ def _apply_monotonic_clamping(self, state: QubeState) -> None:
171
+ """Apply monotonic clamping to energy counters in a QubeState."""
135
172
  for key in self._MONOTONIC_KEYS:
136
173
  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
174
+ clamped = self.clamp_monotonic(key, current)
175
+ if clamped is not current:
176
+ setattr(state, key, clamped)
144
177
 
145
178
  async def async_get_software_version(self) -> str | None:
146
179
  """Read the software version from the device.
@@ -40,6 +40,57 @@ class QubeState:
40
40
  setpoint_room_cool_night: float | None = None
41
41
  setpoint_dhw: float | None = None
42
42
 
43
+ # Binary sensors - Outputs
44
+ dout_srcpmp_val: bool | None = None
45
+ dout_usrpmp_val: bool | None = None
46
+ dout_fourwayvlv_val: bool | None = None
47
+ dout_cooling_val: bool | None = None
48
+ dout_threewayvlv_val: bool | None = None
49
+ dout_bufferpmp_val: bool | None = None
50
+ dout_heaterstep1_val: bool | None = None
51
+ dout_heaterstep2_val: bool | None = None
52
+ dout_heaterstep3_val: bool | None = None
53
+
54
+ # Binary sensors - System status
55
+ keybonoff: bool | None = None
56
+ daynightmode: bool | None = None
57
+
58
+ # Binary sensors - Alarms
59
+ al_maxtime_antileg_active: bool | None = None
60
+ al_maxtime_dhw_active: bool | None = None
61
+ al_dewpoint_active: bool | None = None
62
+ al_underfloorsafety_active: bool | None = None
63
+ alrm_flw: bool | None = None
64
+ usralrms: bool | None = None
65
+ coolingalrms: bool | None = None
66
+ heatingalrms: bool | None = None
67
+ alarmmng_al_workinghour: bool | None = None
68
+ srsalrm: bool | None = None
69
+ glbal: bool | None = None
70
+ alarmmng_al_pwrplus: bool | None = None
71
+
72
+ # Binary sensors - Sensor/controller status
73
+ roomprb_en: bool | None = None
74
+ plantprb_en: bool | None = None
75
+ bufferprb_en: bool | None = None
76
+ en_dhwpid: bool | None = None
77
+
78
+ # Binary sensors - Demand signals
79
+ plantdemand: bool | None = None
80
+ id_demand: bool | None = None
81
+ thermostatdemand: bool | None = None
82
+ bms_demand: bool | None = None
83
+
84
+ # Binary sensors - Digital inputs
85
+ id_summerwinter: bool | None = None
86
+ dewpoint: bool | None = None
87
+ boostersecurity: bool | None = None
88
+ srcflw: bool | None = None
89
+ req_antileg_1: bool | None = None
90
+
91
+ # Binary sensors - Energy
92
+ surplus_pv: bool | None = None
93
+
43
94
  # Extended dict for additional entities not covered by typed fields
44
95
  _extended: dict[str, Any] = field(default_factory=dict)
45
96
 
@@ -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