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.
- {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.6.3}/PKG-INFO +1 -1
- {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.6.3}/pyproject.toml +1 -1
- {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.6.3}/src/python_qube_heatpump/client.py +23 -0
- {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.6.3}/tests/test_client.py +123 -0
- {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.6.3}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.6.3}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.6.3}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.6.3}/.github/workflows/ci.yml +0 -0
- {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.6.3}/.github/workflows/python-publish.yml +0 -0
- {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.6.3}/.gitignore +0 -0
- {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.6.3}/AGENTS.md +0 -0
- {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.6.3}/CLAUDE.md +0 -0
- {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.6.3}/LICENSE +0 -0
- {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.6.3}/README.md +0 -0
- {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.6.3}/docs/modbus-lijst-qube-totaal.pdf +0 -0
- {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.6.3}/pytest.ini +0 -0
- {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.6.3}/src/python_qube_heatpump/__init__.py +0 -0
- {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.6.3}/src/python_qube_heatpump/const.py +0 -0
- {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.6.3}/src/python_qube_heatpump/entities/__init__.py +0 -0
- {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.6.3}/src/python_qube_heatpump/entities/base.py +0 -0
- {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.6.3}/src/python_qube_heatpump/entities/binary_sensors.py +0 -0
- {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.6.3}/src/python_qube_heatpump/entities/sensors.py +0 -0
- {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.6.3}/src/python_qube_heatpump/entities/switches.py +0 -0
- {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.6.3}/src/python_qube_heatpump/models.py +0 -0
- {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.6.3}/src/python_qube_heatpump/network.py +0 -0
- {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.6.3}/src/python_qube_heatpump/py.typed +0 -0
- {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.6.3}/tests/conftest.py +0 -0
- {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.6.3}/tests/test_const.py +0 -0
- {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.6.3}/tests/test_entities.py +0 -0
- {python_qube_heatpump-1.6.2 → python_qube_heatpump-1.6.3}/tests/test_models.py +0 -0
- {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.
|
|
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
|
{python_qube_heatpump-1.6.2 → python_qube_heatpump-1.6.3}/src/python_qube_heatpump/client.py
RENAMED
|
@@ -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
|
{python_qube_heatpump-1.6.2 → python_qube_heatpump-1.6.3}/.github/ISSUE_TEMPLATE/bug_report.yml
RENAMED
|
File without changes
|
|
File without changes
|
{python_qube_heatpump-1.6.2 → python_qube_heatpump-1.6.3}/.github/ISSUE_TEMPLATE/feature_request.yml
RENAMED
|
File without changes
|
|
File without changes
|
{python_qube_heatpump-1.6.2 → python_qube_heatpump-1.6.3}/.github/workflows/python-publish.yml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_qube_heatpump-1.6.2 → python_qube_heatpump-1.6.3}/src/python_qube_heatpump/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_qube_heatpump-1.6.2 → python_qube_heatpump-1.6.3}/src/python_qube_heatpump/entities/base.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_qube_heatpump-1.6.2 → python_qube_heatpump-1.6.3}/src/python_qube_heatpump/models.py
RENAMED
|
File without changes
|
{python_qube_heatpump-1.6.2 → python_qube_heatpump-1.6.3}/src/python_qube_heatpump/network.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|