python-qube-heatpump 1.13.0__tar.gz → 1.14.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.13.0 → python_qube_heatpump-1.14.0}/.github/workflows/ci.yml +1 -1
- {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/PKG-INFO +3 -2
- {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/README.md +1 -0
- {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/pyproject.toml +1 -1
- {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/src/python_qube_heatpump/client.py +66 -5
- {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/src/python_qube_heatpump/entities/sensors.py +5 -5
- {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/tests/test_client.py +100 -0
- {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/.github/workflows/python-publish.yml +0 -0
- {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/.gitignore +0 -0
- {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/AGENTS.md +0 -0
- {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/CLAUDE.md +0 -0
- {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/LICENSE +0 -0
- {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/docs/modbus-lijst-qube-totaal.pdf +0 -0
- {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/pytest.ini +0 -0
- {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/src/python_qube_heatpump/__init__.py +0 -0
- {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/src/python_qube_heatpump/const.py +0 -0
- {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/src/python_qube_heatpump/entities/__init__.py +0 -0
- {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/src/python_qube_heatpump/entities/base.py +0 -0
- {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/src/python_qube_heatpump/entities/binary_sensors.py +0 -0
- {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/src/python_qube_heatpump/entities/switches.py +0 -0
- {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/src/python_qube_heatpump/models.py +0 -0
- {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/src/python_qube_heatpump/network.py +0 -0
- {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/src/python_qube_heatpump/py.typed +0 -0
- {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/tests/conftest.py +0 -0
- {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/tests/test_client_batching.py +0 -0
- {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/tests/test_const.py +0 -0
- {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/tests/test_entities.py +0 -0
- {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/tests/test_models.py +0 -0
- {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/tests/test_network.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: python-qube-heatpump
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.14.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
|
|
@@ -53,6 +53,7 @@ asyncio.run(main())
|
|
|
53
53
|
- **Entity definitions** for sensors, binary sensors, and switches
|
|
54
54
|
- **FLOAT32 decoding** with big endian (ABCD) byte order
|
|
55
55
|
- **Type-safe dataclasses** for entity definitions
|
|
56
|
+
- **Monotonic clamping with reset detection** for the energy totals: sub-kWh jitter is clamped, while a drop of more than 1 kWh that persists for 3 consecutive reads is accepted as a counter reset (logged as a warning); `clear_monotonic_cache()` forgets all baselines
|
|
56
57
|
|
|
57
58
|
## Entity Definitions
|
|
58
59
|
|
|
@@ -34,6 +34,7 @@ asyncio.run(main())
|
|
|
34
34
|
- **Entity definitions** for sensors, binary sensors, and switches
|
|
35
35
|
- **FLOAT32 decoding** with big endian (ABCD) byte order
|
|
36
36
|
- **Type-safe dataclasses** for entity definitions
|
|
37
|
+
- **Monotonic clamping with reset detection** for the energy totals: sub-kWh jitter is clamped, while a drop of more than 1 kWh that persists for 3 consecutive reads is accepted as a counter reset (logged as a warning); `clear_monotonic_cache()` forgets all baselines
|
|
37
38
|
|
|
38
39
|
## Entity Definitions
|
|
39
40
|
|
{python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/src/python_qube_heatpump/client.py
RENAMED
|
@@ -149,6 +149,9 @@ class QubeClient:
|
|
|
149
149
|
self._next_connect_at: float = 0.0
|
|
150
150
|
# Monotonic clamping for total_increasing counters
|
|
151
151
|
self._monotonic_cache: dict[str, float] = {}
|
|
152
|
+
# Per-key count of consecutive reads that fell below the cached
|
|
153
|
+
# maximum by more than the reset threshold (see clamp_monotonic)
|
|
154
|
+
self._monotonic_reset_pending: dict[str, int] = {}
|
|
152
155
|
# Reads that already produced a WARNING (transient failures are
|
|
153
156
|
# logged once per target, then at DEBUG to avoid log spam)
|
|
154
157
|
self._read_failures_warned: set[str] = set()
|
|
@@ -254,17 +257,48 @@ class QubeClient:
|
|
|
254
257
|
def monotonic_cache(self, value: dict[str, float]) -> None:
|
|
255
258
|
"""Set the monotonic clamping cache (e.g. restored from disk)."""
|
|
256
259
|
self._monotonic_cache = dict(value)
|
|
260
|
+
self._monotonic_reset_pending.clear()
|
|
257
261
|
|
|
258
|
-
def
|
|
262
|
+
def clear_monotonic_cache(self) -> None:
|
|
263
|
+
"""Forget all monotonic baselines.
|
|
264
|
+
|
|
265
|
+
The next reading of every counter is accepted as-is. Use this when
|
|
266
|
+
the controller's counters were reset on purpose and the clamped
|
|
267
|
+
values in the cache are known to be stale.
|
|
268
|
+
"""
|
|
269
|
+
self._monotonic_cache.clear()
|
|
270
|
+
self._monotonic_reset_pending.clear()
|
|
271
|
+
|
|
272
|
+
# A drop below the cached maximum larger than this (in the counter's own
|
|
273
|
+
# unit: kWh for the energy totals) is treated as a counter reset rather
|
|
274
|
+
# than float32 jitter or a transient glitch...
|
|
275
|
+
MONOTONIC_RESET_THRESHOLD: float = 1.0
|
|
276
|
+
# ...but only after it has been seen on this many consecutive reads, so a
|
|
277
|
+
# single glitched zero read never drops a total_increasing counter.
|
|
278
|
+
MONOTONIC_RESET_CONFIRM_READS: int = 3
|
|
279
|
+
|
|
280
|
+
def clamp_monotonic(
|
|
281
|
+
self,
|
|
282
|
+
key: str,
|
|
283
|
+
value: float | None,
|
|
284
|
+
reset_threshold: float | None = None,
|
|
285
|
+
) -> float | None:
|
|
259
286
|
"""Clamp a value to prevent decreases for total_increasing counters.
|
|
260
287
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
288
|
+
If the new value is lower than the previously seen value for this
|
|
289
|
+
key by less than ``reset_threshold``, the previous value is returned
|
|
290
|
+
(jitter suppression). A larger drop is a counter-reset candidate: it
|
|
291
|
+
is still clamped until it has persisted for
|
|
292
|
+
``MONOTONIC_RESET_CONFIRM_READS`` consecutive reads, after which the
|
|
293
|
+
low value is accepted as the new baseline and a warning is logged.
|
|
294
|
+
None and non-finite values pass through unchanged and do not affect
|
|
295
|
+
the reset detection.
|
|
264
296
|
|
|
265
297
|
Args:
|
|
266
298
|
key: Identifier for this counter (e.g. entity unique_id).
|
|
267
299
|
value: The current reading.
|
|
300
|
+
reset_threshold: Drop size that counts as a reset; defaults to
|
|
301
|
+
``MONOTONIC_RESET_THRESHOLD``.
|
|
268
302
|
|
|
269
303
|
Returns:
|
|
270
304
|
The clamped value, or None if input was None/non-finite.
|
|
@@ -272,8 +306,35 @@ class QubeClient:
|
|
|
272
306
|
if value is None or not math.isfinite(value):
|
|
273
307
|
return value
|
|
274
308
|
previous = self._monotonic_cache.get(key)
|
|
275
|
-
if previous is
|
|
309
|
+
if previous is None or value >= previous:
|
|
310
|
+
self._monotonic_cache[key] = value
|
|
311
|
+
self._monotonic_reset_pending.pop(key, None)
|
|
312
|
+
return value
|
|
313
|
+
|
|
314
|
+
threshold = (
|
|
315
|
+
self.MONOTONIC_RESET_THRESHOLD
|
|
316
|
+
if reset_threshold is None
|
|
317
|
+
else reset_threshold
|
|
318
|
+
)
|
|
319
|
+
if previous - value < threshold:
|
|
320
|
+
# Sub-threshold jitter: clamp and forget any pending reset
|
|
321
|
+
self._monotonic_reset_pending.pop(key, None)
|
|
276
322
|
return previous
|
|
323
|
+
|
|
324
|
+
pending = self._monotonic_reset_pending.get(key, 0) + 1
|
|
325
|
+
if pending < self.MONOTONIC_RESET_CONFIRM_READS:
|
|
326
|
+
self._monotonic_reset_pending[key] = pending
|
|
327
|
+
return previous
|
|
328
|
+
|
|
329
|
+
_LOGGER.warning(
|
|
330
|
+
"Counter reset detected for %s: value dropped from %s to %s on %d "
|
|
331
|
+
"consecutive reads; accepting it as the new baseline",
|
|
332
|
+
key,
|
|
333
|
+
previous,
|
|
334
|
+
value,
|
|
335
|
+
pending,
|
|
336
|
+
)
|
|
337
|
+
self._monotonic_reset_pending.pop(key, None)
|
|
277
338
|
self._monotonic_cache[key] = value
|
|
278
339
|
return value
|
|
279
340
|
|
|
@@ -51,7 +51,7 @@ _SENSOR_DEFS: tuple[EntityDef, ...] = (
|
|
|
51
51
|
),
|
|
52
52
|
EntityDef(
|
|
53
53
|
key="tapw_timeprogram_dhws",
|
|
54
|
-
name="
|
|
54
|
+
name="DHW setpoint (user)",
|
|
55
55
|
address=44,
|
|
56
56
|
input_type=InputType.HOLDING_REGISTER,
|
|
57
57
|
data_type=DataType.FLOAT32,
|
|
@@ -60,7 +60,7 @@ _SENSOR_DEFS: tuple[EntityDef, ...] = (
|
|
|
60
60
|
),
|
|
61
61
|
EntityDef(
|
|
62
62
|
key="tapw_timeprogram_dhws_prog",
|
|
63
|
-
name="DHW
|
|
63
|
+
name="DHW setpoint (time program, Linq min.)",
|
|
64
64
|
address=46,
|
|
65
65
|
input_type=InputType.HOLDING_REGISTER,
|
|
66
66
|
data_type=DataType.FLOAT32,
|
|
@@ -107,7 +107,7 @@ _SENSOR_DEFS: tuple[EntityDef, ...] = (
|
|
|
107
107
|
),
|
|
108
108
|
EntityDef(
|
|
109
109
|
key="tapw_timeprogram_dhwsetp_nolinq",
|
|
110
|
-
name="
|
|
110
|
+
name="DHW setpoint (Modbus)",
|
|
111
111
|
address=173,
|
|
112
112
|
input_type=InputType.HOLDING_REGISTER,
|
|
113
113
|
data_type=DataType.FLOAT32,
|
|
@@ -299,7 +299,7 @@ _SENSOR_DEFS: tuple[EntityDef, ...] = (
|
|
|
299
299
|
),
|
|
300
300
|
EntityDef(
|
|
301
301
|
key="dhw_setp",
|
|
302
|
-
name="DHW
|
|
302
|
+
name="Active DHW setpoint",
|
|
303
303
|
address=47,
|
|
304
304
|
input_type=InputType.INPUT_REGISTER,
|
|
305
305
|
data_type=DataType.FLOAT32,
|
|
@@ -453,7 +453,7 @@ _SENSOR_DEFS: tuple[EntityDef, ...] = (
|
|
|
453
453
|
),
|
|
454
454
|
EntityDef(
|
|
455
455
|
key="setpoint_dhw",
|
|
456
|
-
name="
|
|
456
|
+
name="DHW setpoint (Modbus)",
|
|
457
457
|
address=173,
|
|
458
458
|
input_type=InputType.HOLDING_REGISTER,
|
|
459
459
|
data_type=DataType.FLOAT32,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Test the Qube Heat Pump client."""
|
|
2
2
|
|
|
3
|
+
import logging
|
|
3
4
|
from unittest.mock import AsyncMock, MagicMock
|
|
4
5
|
|
|
5
6
|
import pytest
|
|
@@ -591,3 +592,102 @@ async def test_read_entity_warns_again_per_entity(mock_modbus_client, caplog):
|
|
|
591
592
|
|
|
592
593
|
warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
|
|
593
594
|
assert len(warnings) == 2
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
@pytest.mark.asyncio
|
|
598
|
+
async def test_clamp_monotonic_reset_detected_after_confirmation(mock_modbus_client):
|
|
599
|
+
"""A drop larger than the reset threshold becomes the new baseline once confirmed."""
|
|
600
|
+
client = QubeClient("1.2.3.4", 502)
|
|
601
|
+
assert client.clamp_monotonic("energy", 1000.0) == 1000.0
|
|
602
|
+
|
|
603
|
+
# First two low reads are still clamped (could be a transient glitch)
|
|
604
|
+
assert client.clamp_monotonic("energy", 3.0) == 1000.0
|
|
605
|
+
assert client.clamp_monotonic("energy", 3.1) == 1000.0
|
|
606
|
+
# Third consecutive low read confirms the reset
|
|
607
|
+
assert client.clamp_monotonic("energy", 3.2) == 3.2
|
|
608
|
+
assert client.monotonic_cache["energy"] == 3.2
|
|
609
|
+
# Counting resumes from the new baseline
|
|
610
|
+
assert client.clamp_monotonic("energy", 3.5) == 3.5
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
@pytest.mark.asyncio
|
|
614
|
+
async def test_clamp_monotonic_reset_logs_warning(mock_modbus_client, caplog):
|
|
615
|
+
"""Confirming a reset logs a warning naming the key."""
|
|
616
|
+
client = QubeClient("1.2.3.4", 502)
|
|
617
|
+
client.clamp_monotonic("energy_total_thermic", 500.0)
|
|
618
|
+
with caplog.at_level(logging.WARNING):
|
|
619
|
+
for _ in range(QubeClient.MONOTONIC_RESET_CONFIRM_READS):
|
|
620
|
+
client.clamp_monotonic("energy_total_thermic", 0.0)
|
|
621
|
+
assert "Counter reset detected" in caplog.text
|
|
622
|
+
assert "energy_total_thermic" in caplog.text
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
@pytest.mark.asyncio
|
|
626
|
+
async def test_clamp_monotonic_transient_glitch_does_not_reset(mock_modbus_client):
|
|
627
|
+
"""A single low read followed by recovery must not become a new baseline."""
|
|
628
|
+
client = QubeClient("1.2.3.4", 502)
|
|
629
|
+
client.clamp_monotonic("energy", 1000.0)
|
|
630
|
+
|
|
631
|
+
assert client.clamp_monotonic("energy", 0.0) == 1000.0
|
|
632
|
+
assert client.clamp_monotonic("energy", 0.0) == 1000.0
|
|
633
|
+
# Recovery cancels the pending reset
|
|
634
|
+
assert client.clamp_monotonic("energy", 1000.2) == 1000.2
|
|
635
|
+
# A fresh low read starts counting from scratch again
|
|
636
|
+
assert client.clamp_monotonic("energy", 0.0) == 1000.2
|
|
637
|
+
assert client.clamp_monotonic("energy", 0.0) == 1000.2
|
|
638
|
+
assert client.monotonic_cache["energy"] == 1000.2
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
@pytest.mark.asyncio
|
|
642
|
+
async def test_clamp_monotonic_sub_threshold_jitter_still_clamped(mock_modbus_client):
|
|
643
|
+
"""Drops smaller than the threshold are jitter and stay clamped forever."""
|
|
644
|
+
client = QubeClient("1.2.3.4", 502)
|
|
645
|
+
client.clamp_monotonic("energy", 1000.0)
|
|
646
|
+
for _ in range(10):
|
|
647
|
+
assert client.clamp_monotonic("energy", 999.5) == 1000.0
|
|
648
|
+
assert client.monotonic_cache["energy"] == 1000.0
|
|
649
|
+
# Jitter reads do not count towards reset confirmation
|
|
650
|
+
assert client.clamp_monotonic("energy", 0.0) == 1000.0
|
|
651
|
+
assert client.clamp_monotonic("energy", 0.0) == 1000.0
|
|
652
|
+
assert client.clamp_monotonic("energy", 999.5) == 1000.0
|
|
653
|
+
assert client.clamp_monotonic("energy", 0.0) == 1000.0
|
|
654
|
+
assert client.clamp_monotonic("energy", 0.0) == 1000.0
|
|
655
|
+
assert client.monotonic_cache["energy"] == 1000.0
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
@pytest.mark.asyncio
|
|
659
|
+
async def test_clamp_monotonic_custom_threshold(mock_modbus_client):
|
|
660
|
+
"""The reset threshold can be overridden per call."""
|
|
661
|
+
client = QubeClient("1.2.3.4", 502)
|
|
662
|
+
client.clamp_monotonic("hours", 100.0, reset_threshold=10.0)
|
|
663
|
+
# A 5-unit drop is below the custom threshold: clamped, never a reset
|
|
664
|
+
for _ in range(5):
|
|
665
|
+
assert client.clamp_monotonic("hours", 95.0, reset_threshold=10.0) == 100.0
|
|
666
|
+
# A 50-unit drop is a reset
|
|
667
|
+
for _ in range(QubeClient.MONOTONIC_RESET_CONFIRM_READS - 1):
|
|
668
|
+
assert client.clamp_monotonic("hours", 50.0, reset_threshold=10.0) == 100.0
|
|
669
|
+
assert client.clamp_monotonic("hours", 50.0, reset_threshold=10.0) == 50.0
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
@pytest.mark.asyncio
|
|
673
|
+
async def test_clear_monotonic_cache(mock_modbus_client):
|
|
674
|
+
"""Clearing the cache forgets baselines and pending resets."""
|
|
675
|
+
client = QubeClient("1.2.3.4", 502)
|
|
676
|
+
client.clamp_monotonic("energy", 1000.0)
|
|
677
|
+
client.clamp_monotonic("energy", 0.0) # pending reset
|
|
678
|
+
client.clear_monotonic_cache()
|
|
679
|
+
assert client.monotonic_cache == {}
|
|
680
|
+
# Next read is accepted as a fresh baseline
|
|
681
|
+
assert client.clamp_monotonic("energy", 5.0) == 5.0
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
@pytest.mark.asyncio
|
|
685
|
+
async def test_monotonic_cache_setter_discards_pending_resets(mock_modbus_client):
|
|
686
|
+
"""Restoring a cache from disk resets the reset-confirmation counters."""
|
|
687
|
+
client = QubeClient("1.2.3.4", 502)
|
|
688
|
+
client.clamp_monotonic("energy", 1000.0)
|
|
689
|
+
client.clamp_monotonic("energy", 0.0)
|
|
690
|
+
client.clamp_monotonic("energy", 0.0)
|
|
691
|
+
client.monotonic_cache = {"energy": 1000.0}
|
|
692
|
+
# Only one low read so far after the restore: still clamped
|
|
693
|
+
assert client.clamp_monotonic("energy", 0.0) == 1000.0
|
{python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/.github/ISSUE_TEMPLATE/bug_report.yml
RENAMED
|
File without changes
|
{python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/.github/ISSUE_TEMPLATE/config.yml
RENAMED
|
File without changes
|
|
File without changes
|
{python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/.github/workflows/python-publish.yml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/docs/modbus-lijst-qube-totaal.pdf
RENAMED
|
File without changes
|
|
File without changes
|
{python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/src/python_qube_heatpump/__init__.py
RENAMED
|
File without changes
|
{python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/src/python_qube_heatpump/const.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/src/python_qube_heatpump/models.py
RENAMED
|
File without changes
|
{python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/src/python_qube_heatpump/network.py
RENAMED
|
File without changes
|
{python_qube_heatpump-1.13.0 → python_qube_heatpump-1.14.0}/src/python_qube_heatpump/py.typed
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|