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