python-qube-heatpump 1.12.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.12.0 → python_qube_heatpump-1.14.0}/.github/workflows/ci.yml +1 -1
- {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.14.0}/PKG-INFO +3 -2
- {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.14.0}/README.md +1 -0
- {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.14.0}/pyproject.toml +1 -1
- {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.14.0}/src/python_qube_heatpump/client.py +197 -45
- {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.14.0}/src/python_qube_heatpump/entities/sensors.py +5 -5
- {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.14.0}/tests/test_client.py +122 -11
- {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.14.0}/tests/test_client_batching.py +75 -0
- {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.14.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.14.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.14.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.14.0}/.github/workflows/python-publish.yml +0 -0
- {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.14.0}/.gitignore +0 -0
- {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.14.0}/AGENTS.md +0 -0
- {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.14.0}/CLAUDE.md +0 -0
- {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.14.0}/LICENSE +0 -0
- {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.14.0}/docs/modbus-lijst-qube-totaal.pdf +0 -0
- {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.14.0}/pytest.ini +0 -0
- {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.14.0}/src/python_qube_heatpump/__init__.py +0 -0
- {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.14.0}/src/python_qube_heatpump/const.py +0 -0
- {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.14.0}/src/python_qube_heatpump/entities/__init__.py +0 -0
- {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.14.0}/src/python_qube_heatpump/entities/base.py +0 -0
- {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.14.0}/src/python_qube_heatpump/entities/binary_sensors.py +0 -0
- {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.14.0}/src/python_qube_heatpump/entities/switches.py +0 -0
- {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.14.0}/src/python_qube_heatpump/models.py +0 -0
- {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.14.0}/src/python_qube_heatpump/network.py +0 -0
- {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.14.0}/src/python_qube_heatpump/py.typed +0 -0
- {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.14.0}/tests/conftest.py +0 -0
- {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.14.0}/tests/test_const.py +0 -0
- {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.14.0}/tests/test_entities.py +0 -0
- {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.14.0}/tests/test_models.py +0 -0
- {python_qube_heatpump-1.12.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.12.0 → python_qube_heatpump-1.14.0}/src/python_qube_heatpump/client.py
RENAMED
|
@@ -18,6 +18,112 @@ from .models import QubeState
|
|
|
18
18
|
|
|
19
19
|
_LOGGER = logging.getLogger(__name__)
|
|
20
20
|
|
|
21
|
+
# Entities read by get_all_data() (used by the official HA core integration).
|
|
22
|
+
# These mirror the register definitions in `const` exactly (address, scale,
|
|
23
|
+
# offset) so batching cannot change any value get_all_data() returns.
|
|
24
|
+
# Note: some of these intentionally differ from the corresponding entries in
|
|
25
|
+
# entities/sensors.py (e.g. compressor_speed has a x60 RPM scale here but not
|
|
26
|
+
# there; cop_calc is unrounded here but rounded to 1 decimal there) — reusing
|
|
27
|
+
# SENSORS would silently change get_all_data()'s output, so a dedicated table
|
|
28
|
+
# is kept instead.
|
|
29
|
+
# Format: (key, address, input_type, data_type, scale, offset)
|
|
30
|
+
_CORE_STATE_REGISTERS: tuple[
|
|
31
|
+
tuple[str, int, InputType, DataType, float | None, float | None], ...
|
|
32
|
+
] = (
|
|
33
|
+
("temp_supply", 20, InputType.INPUT_REGISTER, DataType.FLOAT32, None, None),
|
|
34
|
+
("temp_return", 22, InputType.INPUT_REGISTER, DataType.FLOAT32, None, None),
|
|
35
|
+
("temp_source_in", 24, InputType.INPUT_REGISTER, DataType.FLOAT32, None, None),
|
|
36
|
+
("temp_source_out", 26, InputType.INPUT_REGISTER, DataType.FLOAT32, None, None),
|
|
37
|
+
("temp_room", 28, InputType.INPUT_REGISTER, DataType.FLOAT32, None, None),
|
|
38
|
+
("temp_dhw", 30, InputType.INPUT_REGISTER, DataType.FLOAT32, None, None),
|
|
39
|
+
("temp_outside", 32, InputType.INPUT_REGISTER, DataType.FLOAT32, None, None),
|
|
40
|
+
("power_thermic", 36, InputType.INPUT_REGISTER, DataType.FLOAT32, None, None),
|
|
41
|
+
("power_electric", 61, InputType.INPUT_REGISTER, DataType.FLOAT32, None, None),
|
|
42
|
+
(
|
|
43
|
+
"energy_total_electric",
|
|
44
|
+
69,
|
|
45
|
+
InputType.INPUT_REGISTER,
|
|
46
|
+
DataType.FLOAT32,
|
|
47
|
+
None,
|
|
48
|
+
None,
|
|
49
|
+
),
|
|
50
|
+
(
|
|
51
|
+
"energy_total_thermic",
|
|
52
|
+
71,
|
|
53
|
+
InputType.INPUT_REGISTER,
|
|
54
|
+
DataType.FLOAT32,
|
|
55
|
+
None,
|
|
56
|
+
None,
|
|
57
|
+
),
|
|
58
|
+
("cop_calc", 34, InputType.INPUT_REGISTER, DataType.FLOAT32, None, None),
|
|
59
|
+
("status_code", 38, InputType.INPUT_REGISTER, DataType.UINT16, None, None),
|
|
60
|
+
("compressor_speed", 45, InputType.INPUT_REGISTER, DataType.FLOAT32, 60, None),
|
|
61
|
+
("flow_rate", 18, InputType.INPUT_REGISTER, DataType.FLOAT32, None, None),
|
|
62
|
+
(
|
|
63
|
+
"setpoint_room_heat_day",
|
|
64
|
+
27,
|
|
65
|
+
InputType.HOLDING_REGISTER,
|
|
66
|
+
DataType.FLOAT32,
|
|
67
|
+
None,
|
|
68
|
+
None,
|
|
69
|
+
),
|
|
70
|
+
(
|
|
71
|
+
"setpoint_room_heat_night",
|
|
72
|
+
29,
|
|
73
|
+
InputType.HOLDING_REGISTER,
|
|
74
|
+
DataType.FLOAT32,
|
|
75
|
+
None,
|
|
76
|
+
None,
|
|
77
|
+
),
|
|
78
|
+
(
|
|
79
|
+
"setpoint_room_cool_day",
|
|
80
|
+
31,
|
|
81
|
+
InputType.HOLDING_REGISTER,
|
|
82
|
+
DataType.FLOAT32,
|
|
83
|
+
None,
|
|
84
|
+
None,
|
|
85
|
+
),
|
|
86
|
+
(
|
|
87
|
+
"setpoint_room_cool_night",
|
|
88
|
+
33,
|
|
89
|
+
InputType.HOLDING_REGISTER,
|
|
90
|
+
DataType.FLOAT32,
|
|
91
|
+
None,
|
|
92
|
+
None,
|
|
93
|
+
),
|
|
94
|
+
("setpoint_dhw", 173, InputType.HOLDING_REGISTER, DataType.FLOAT32, None, None),
|
|
95
|
+
(
|
|
96
|
+
"usr_pid_heatsetp",
|
|
97
|
+
101,
|
|
98
|
+
InputType.HOLDING_REGISTER,
|
|
99
|
+
DataType.FLOAT32,
|
|
100
|
+
None,
|
|
101
|
+
None,
|
|
102
|
+
),
|
|
103
|
+
(
|
|
104
|
+
"usr_pid_coolsetp",
|
|
105
|
+
103,
|
|
106
|
+
InputType.HOLDING_REGISTER,
|
|
107
|
+
DataType.FLOAT32,
|
|
108
|
+
None,
|
|
109
|
+
None,
|
|
110
|
+
),
|
|
111
|
+
("modbus_roomtemp", 75, InputType.INPUT_REGISTER, DataType.FLOAT32, None, None),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
_CORE_STATE_ENTITIES: tuple[EntityDef, ...] = tuple(
|
|
115
|
+
EntityDef(
|
|
116
|
+
key=key,
|
|
117
|
+
name=key,
|
|
118
|
+
address=address,
|
|
119
|
+
input_type=input_type,
|
|
120
|
+
data_type=data_type,
|
|
121
|
+
scale=scale,
|
|
122
|
+
offset=offset,
|
|
123
|
+
)
|
|
124
|
+
for key, address, input_type, data_type, scale, offset in _CORE_STATE_REGISTERS
|
|
125
|
+
)
|
|
126
|
+
|
|
21
127
|
|
|
22
128
|
class QubeClient:
|
|
23
129
|
"""Qube Modbus Client."""
|
|
@@ -43,6 +149,9 @@ class QubeClient:
|
|
|
43
149
|
self._next_connect_at: float = 0.0
|
|
44
150
|
# Monotonic clamping for total_increasing counters
|
|
45
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] = {}
|
|
46
155
|
# Reads that already produced a WARNING (transient failures are
|
|
47
156
|
# logged once per target, then at DEBUG to avoid log spam)
|
|
48
157
|
self._read_failures_warned: set[str] = set()
|
|
@@ -109,53 +218,27 @@ class QubeClient:
|
|
|
109
218
|
|
|
110
219
|
state = QubeState()
|
|
111
220
|
|
|
112
|
-
#
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
#
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
# Fetch power and energy sensors
|
|
126
|
-
state.power_thermic = await _read(const.POWER_THERMIC)
|
|
127
|
-
state.power_electric = await _read(const.POWER_ELECTRIC_CALC)
|
|
128
|
-
state.energy_total_electric = await _read(const.ENERGY_ELECTRIC_TOTAL)
|
|
129
|
-
state.energy_total_thermic = await _read(const.ENERGY_THERMIC_TOTAL)
|
|
130
|
-
state.cop_calc = await _read(const.COP_CALC)
|
|
131
|
-
|
|
132
|
-
# Fetch operation sensors
|
|
133
|
-
state.status_code = await _read(const.STATUS_CODE)
|
|
134
|
-
state.compressor_speed = await _read(const.COMPRESSOR_SPEED)
|
|
135
|
-
flow_rate = await _read(const.FLOW_RATE)
|
|
221
|
+
# Batch-read the core sensor fields plus all binary sensors together.
|
|
222
|
+
# _plan_blocks groups by input type (input/holding/discrete_input/coil)
|
|
223
|
+
# regardless of which list an entity came from, so combining both
|
|
224
|
+
# tables into a single read_entities_batched() call still yields one
|
|
225
|
+
# handful of block transactions instead of ~59 per-field reads.
|
|
226
|
+
entities = [*_CORE_STATE_ENTITIES, *BINARY_SENSORS.values()]
|
|
227
|
+
results = await self.read_entities_batched(entities)
|
|
228
|
+
|
|
229
|
+
for ent in _CORE_STATE_ENTITIES:
|
|
230
|
+
setattr(state, ent.key, results.get(ent.key))
|
|
231
|
+
|
|
232
|
+
flow_rate = state.flow_rate
|
|
136
233
|
if flow_rate is not None and flow_rate < 0:
|
|
137
234
|
flow_rate = 0.0
|
|
138
235
|
state.flow_rate = flow_rate
|
|
139
236
|
|
|
140
|
-
# Fetch setpoints (holding registers)
|
|
141
|
-
state.setpoint_room_heat_day = await _read(const.SETPOINT_HEAT_DAY)
|
|
142
|
-
state.setpoint_room_heat_night = await _read(const.SETPOINT_HEAT_NIGHT)
|
|
143
|
-
state.setpoint_room_cool_day = await _read(const.SETPOINT_COOL_DAY)
|
|
144
|
-
state.setpoint_room_cool_night = await _read(const.SETPOINT_COOL_NIGHT)
|
|
145
|
-
state.setpoint_dhw = await _read(const.USER_DHW_SETPOINT)
|
|
146
|
-
state.usr_pid_heatsetp = await _read(const.USER_HEAT_SETPOINT)
|
|
147
|
-
state.usr_pid_coolsetp = await _read(const.USER_COOL_SETPOINT)
|
|
148
|
-
|
|
149
|
-
# LinQ thermostat room temperature (optional)
|
|
150
|
-
state.modbus_roomtemp = await _read(const.TEMP_ROOM_MODBUS)
|
|
151
|
-
|
|
152
237
|
self._apply_monotonic_clamping(state)
|
|
153
238
|
|
|
154
|
-
|
|
155
|
-
binary_data = await self.read_all_binary_sensors()
|
|
156
|
-
for key, value in binary_data.items():
|
|
239
|
+
for key in BINARY_SENSORS:
|
|
157
240
|
if hasattr(state, key):
|
|
158
|
-
setattr(state, key,
|
|
241
|
+
setattr(state, key, results.get(key))
|
|
159
242
|
|
|
160
243
|
# Compute unified status (status_code + anti-legionella override)
|
|
161
244
|
state.status = const.resolve_status(state.status_code, state.req_antileg_1)
|
|
@@ -174,17 +257,48 @@ class QubeClient:
|
|
|
174
257
|
def monotonic_cache(self, value: dict[str, float]) -> None:
|
|
175
258
|
"""Set the monotonic clamping cache (e.g. restored from disk)."""
|
|
176
259
|
self._monotonic_cache = dict(value)
|
|
260
|
+
self._monotonic_reset_pending.clear()
|
|
261
|
+
|
|
262
|
+
def clear_monotonic_cache(self) -> None:
|
|
263
|
+
"""Forget all monotonic baselines.
|
|
177
264
|
|
|
178
|
-
|
|
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:
|
|
179
286
|
"""Clamp a value to prevent decreases for total_increasing counters.
|
|
180
287
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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.
|
|
184
296
|
|
|
185
297
|
Args:
|
|
186
298
|
key: Identifier for this counter (e.g. entity unique_id).
|
|
187
299
|
value: The current reading.
|
|
300
|
+
reset_threshold: Drop size that counts as a reset; defaults to
|
|
301
|
+
``MONOTONIC_RESET_THRESHOLD``.
|
|
188
302
|
|
|
189
303
|
Returns:
|
|
190
304
|
The clamped value, or None if input was None/non-finite.
|
|
@@ -192,8 +306,35 @@ class QubeClient:
|
|
|
192
306
|
if value is None or not math.isfinite(value):
|
|
193
307
|
return value
|
|
194
308
|
previous = self._monotonic_cache.get(key)
|
|
195
|
-
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)
|
|
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
|
|
196
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)
|
|
197
338
|
self._monotonic_cache[key] = value
|
|
198
339
|
return value
|
|
199
340
|
|
|
@@ -427,6 +568,17 @@ class QubeClient:
|
|
|
427
568
|
)
|
|
428
569
|
if result.isError():
|
|
429
570
|
raise OSError(f"Modbus error response for block @{start}")
|
|
571
|
+
if input_type_str in ("coil", "discrete_input"):
|
|
572
|
+
if len(result.bits) < count:
|
|
573
|
+
raise OSError(
|
|
574
|
+
f"Short bit response for block {input_type_str}@{start} "
|
|
575
|
+
f"(got {len(result.bits)}, expected {count})"
|
|
576
|
+
)
|
|
577
|
+
elif len(result.registers) < count:
|
|
578
|
+
raise OSError(
|
|
579
|
+
f"Short register response for block {input_type_str}@{start} "
|
|
580
|
+
f"(got {len(result.registers)}, expected {count})"
|
|
581
|
+
)
|
|
430
582
|
except Exception as exc:
|
|
431
583
|
self._log_read_failure(
|
|
432
584
|
f"block {input_type_str}@{start} (count {count})", exc
|
|
@@ -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
|
|
@@ -331,12 +332,18 @@ async def test_get_all_data_auto_reconnects(mock_modbus_client):
|
|
|
331
332
|
client._client = mock_instance
|
|
332
333
|
client._connected = False
|
|
333
334
|
|
|
334
|
-
# Mock successful register reads
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
335
|
+
# Mock successful register reads. Batched block reads request a
|
|
336
|
+
# variable number of registers per block, so the mock must honor the
|
|
337
|
+
# requested `count` (a real Modbus server would too), unlike a fixed
|
|
338
|
+
# 2-register response.
|
|
339
|
+
def _resp(address, count=1, **kwargs):
|
|
340
|
+
resp = MagicMock()
|
|
341
|
+
resp.isError.return_value = False
|
|
342
|
+
resp.registers = [0] * count
|
|
343
|
+
return resp
|
|
344
|
+
|
|
345
|
+
mock_instance.read_input_registers = AsyncMock(side_effect=_resp)
|
|
346
|
+
mock_instance.read_holding_registers = AsyncMock(side_effect=_resp)
|
|
340
347
|
|
|
341
348
|
state = await client.get_all_data()
|
|
342
349
|
assert state is not None
|
|
@@ -424,11 +431,16 @@ async def test_get_all_data_applies_clamping(mock_modbus_client):
|
|
|
424
431
|
client._client = mock_instance
|
|
425
432
|
client._connected = True
|
|
426
433
|
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
434
|
+
# Batched block reads request a variable `count` of registers per
|
|
435
|
+
# block, so the mock must honor it (a real Modbus server would too).
|
|
436
|
+
def _resp(address, count=1, **kwargs):
|
|
437
|
+
resp = MagicMock()
|
|
438
|
+
resp.isError.return_value = False
|
|
439
|
+
resp.registers = [0] * count
|
|
440
|
+
return resp
|
|
441
|
+
|
|
442
|
+
mock_instance.read_input_registers = AsyncMock(side_effect=_resp)
|
|
443
|
+
mock_instance.read_holding_registers = AsyncMock(side_effect=_resp)
|
|
432
444
|
|
|
433
445
|
state = await client.get_all_data()
|
|
434
446
|
assert state is not None
|
|
@@ -580,3 +592,102 @@ async def test_read_entity_warns_again_per_entity(mock_modbus_client, caplog):
|
|
|
580
592
|
|
|
581
593
|
warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
|
|
582
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
|
|
@@ -143,6 +143,41 @@ async def test_batched_block_failure_falls_back_to_individual_reads(
|
|
|
143
143
|
assert round(results["temp_return"], 1) == 25.0
|
|
144
144
|
|
|
145
145
|
|
|
146
|
+
@pytest.mark.asyncio
|
|
147
|
+
async def test_batched_short_successful_response_falls_back_to_individual_reads(
|
|
148
|
+
mock_modbus_client,
|
|
149
|
+
):
|
|
150
|
+
"""A well-formed but too-short block response degrades gracefully.
|
|
151
|
+
|
|
152
|
+
A device can return isError()==False with fewer registers/bits than the
|
|
153
|
+
requested count (a malformed-but-"successful" response). Previously this
|
|
154
|
+
slipped past the isError() check and crashed with an uncaught IndexError
|
|
155
|
+
while decoding, instead of falling back to individual reads like a failed
|
|
156
|
+
block does. Since get_all_data() is now polled by HA core every 15s via
|
|
157
|
+
this same batching path, such a response must degrade the same way a
|
|
158
|
+
failed block does, not raise.
|
|
159
|
+
"""
|
|
160
|
+
client = QubeClient("1.2.3.4", 502)
|
|
161
|
+
mock_instance = mock_modbus_client.return_value
|
|
162
|
+
mock_instance.read_input_registers = AsyncMock(
|
|
163
|
+
side_effect=[
|
|
164
|
+
# Block read: isError() is False, but only 2 registers came back
|
|
165
|
+
# for a block that needs 4 (two float32 entities).
|
|
166
|
+
_register_response(_float32_regs(24.5)),
|
|
167
|
+
_register_response(_float32_regs(24.5)), # individual read
|
|
168
|
+
_register_response(_float32_regs(25.0)), # individual read
|
|
169
|
+
]
|
|
170
|
+
)
|
|
171
|
+
client._client = mock_instance
|
|
172
|
+
|
|
173
|
+
entities = [SENSORS["temp_supply"], SENSORS["temp_return"]]
|
|
174
|
+
results = await client.read_entities_batched(entities)
|
|
175
|
+
|
|
176
|
+
assert mock_instance.read_input_registers.call_count == 3
|
|
177
|
+
assert round(results["temp_supply"], 1) == 24.5
|
|
178
|
+
assert round(results["temp_return"], 1) == 25.0
|
|
179
|
+
|
|
180
|
+
|
|
146
181
|
@pytest.mark.asyncio
|
|
147
182
|
async def test_get_all_entities_uses_batched_reads(mock_modbus_client):
|
|
148
183
|
"""get_all_entities reads all entities in a handful of transactions."""
|
|
@@ -172,3 +207,43 @@ async def test_get_all_entities_uses_batched_reads(mock_modbus_client):
|
|
|
172
207
|
+ mock_instance.read_holding_registers.call_count
|
|
173
208
|
)
|
|
174
209
|
assert total_calls <= 15
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@pytest.mark.asyncio
|
|
213
|
+
async def test_get_all_data_uses_batched_reads(mock_modbus_client):
|
|
214
|
+
"""get_all_data (used by HA core, polled every 15s) reads via block reads.
|
|
215
|
+
|
|
216
|
+
Previously get_all_data() performed one Modbus transaction per field
|
|
217
|
+
(~59 transactions: 23 core sensors + 37 binary sensors). It should now
|
|
218
|
+
use the same batched block-read infrastructure as get_all_entities(),
|
|
219
|
+
landing in a handful of transactions instead.
|
|
220
|
+
"""
|
|
221
|
+
client = QubeClient("1.2.3.4", 502)
|
|
222
|
+
mock_instance = mock_modbus_client.return_value
|
|
223
|
+
|
|
224
|
+
def _bits(address, count=1, **kwargs):
|
|
225
|
+
return _bit_response([False] * count)
|
|
226
|
+
|
|
227
|
+
def _regs(address, count=1, **kwargs):
|
|
228
|
+
return _register_response([0] * count)
|
|
229
|
+
|
|
230
|
+
mock_instance.read_coils = AsyncMock(side_effect=_bits)
|
|
231
|
+
mock_instance.read_discrete_inputs = AsyncMock(side_effect=_bits)
|
|
232
|
+
mock_instance.read_input_registers = AsyncMock(side_effect=_regs)
|
|
233
|
+
mock_instance.read_holding_registers = AsyncMock(side_effect=_regs)
|
|
234
|
+
client._client = mock_instance
|
|
235
|
+
client._connected = True
|
|
236
|
+
|
|
237
|
+
state = await client.get_all_data()
|
|
238
|
+
|
|
239
|
+
assert state is not None
|
|
240
|
+
total_calls = (
|
|
241
|
+
mock_instance.read_coils.call_count
|
|
242
|
+
+ mock_instance.read_discrete_inputs.call_count
|
|
243
|
+
+ mock_instance.read_input_registers.call_count
|
|
244
|
+
+ mock_instance.read_holding_registers.call_count
|
|
245
|
+
)
|
|
246
|
+
# Old per-register/per-entity path made ~59 transactions (23 core
|
|
247
|
+
# fields + 37 binary sensors). Batched block reads should collapse
|
|
248
|
+
# this into a handful of transactions.
|
|
249
|
+
assert total_calls <= 15
|
{python_qube_heatpump-1.12.0 → python_qube_heatpump-1.14.0}/.github/ISSUE_TEMPLATE/bug_report.yml
RENAMED
|
File without changes
|
{python_qube_heatpump-1.12.0 → python_qube_heatpump-1.14.0}/.github/ISSUE_TEMPLATE/config.yml
RENAMED
|
File without changes
|
|
File without changes
|
{python_qube_heatpump-1.12.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.12.0 → python_qube_heatpump-1.14.0}/docs/modbus-lijst-qube-totaal.pdf
RENAMED
|
File without changes
|
|
File without changes
|
{python_qube_heatpump-1.12.0 → python_qube_heatpump-1.14.0}/src/python_qube_heatpump/__init__.py
RENAMED
|
File without changes
|
{python_qube_heatpump-1.12.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.12.0 → python_qube_heatpump-1.14.0}/src/python_qube_heatpump/models.py
RENAMED
|
File without changes
|
{python_qube_heatpump-1.12.0 → python_qube_heatpump-1.14.0}/src/python_qube_heatpump/network.py
RENAMED
|
File without changes
|
{python_qube_heatpump-1.12.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
|