python-qube-heatpump 1.13.0__tar.gz → 1.15.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.15.0}/.github/workflows/ci.yml +1 -1
- {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.15.0}/PKG-INFO +3 -2
- {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.15.0}/README.md +1 -0
- {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.15.0}/pyproject.toml +1 -1
- {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.15.0}/src/python_qube_heatpump/client.py +295 -90
- {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.15.0}/src/python_qube_heatpump/entities/base.py +7 -0
- {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.15.0}/src/python_qube_heatpump/entities/sensors.py +13 -5
- {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.15.0}/src/python_qube_heatpump/network.py +3 -2
- {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.15.0}/tests/conftest.py +3 -2
- {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.15.0}/tests/test_client.py +114 -23
- python_qube_heatpump-1.15.0/tests/test_client_read_all.py +83 -0
- python_qube_heatpump-1.15.0/tests/test_client_resilience.py +252 -0
- python_qube_heatpump-1.15.0/tests/test_client_writes.py +197 -0
- {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.15.0}/tests/test_network.py +31 -0
- {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.15.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.15.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.15.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.15.0}/.github/workflows/python-publish.yml +0 -0
- {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.15.0}/.gitignore +0 -0
- {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.15.0}/AGENTS.md +0 -0
- {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.15.0}/CLAUDE.md +0 -0
- {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.15.0}/LICENSE +0 -0
- {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.15.0}/docs/modbus-lijst-qube-totaal.pdf +0 -0
- {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.15.0}/pytest.ini +0 -0
- {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.15.0}/src/python_qube_heatpump/__init__.py +0 -0
- {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.15.0}/src/python_qube_heatpump/const.py +0 -0
- {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.15.0}/src/python_qube_heatpump/entities/__init__.py +0 -0
- {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.15.0}/src/python_qube_heatpump/entities/binary_sensors.py +0 -0
- {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.15.0}/src/python_qube_heatpump/entities/switches.py +0 -0
- {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.15.0}/src/python_qube_heatpump/models.py +0 -0
- {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.15.0}/src/python_qube_heatpump/py.typed +0 -0
- {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.15.0}/tests/test_client_batching.py +0 -0
- {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.15.0}/tests/test_const.py +0 -0
- {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.15.0}/tests/test_entities.py +0 -0
- {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.15.0}/tests/test_models.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.15.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.15.0}/src/python_qube_heatpump/client.py
RENAMED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import contextlib
|
|
5
6
|
import logging
|
|
6
7
|
import math
|
|
7
8
|
import struct
|
|
@@ -10,6 +11,7 @@ from collections.abc import Iterable
|
|
|
10
11
|
from typing import Any
|
|
11
12
|
|
|
12
13
|
from pymodbus.client import AsyncModbusTcpClient
|
|
14
|
+
from pymodbus.exceptions import ConnectionException, ModbusIOException
|
|
13
15
|
|
|
14
16
|
from . import const
|
|
15
17
|
from .entities import BINARY_SENSORS, SENSORS, SWITCHES, EntityDef
|
|
@@ -18,6 +20,22 @@ from .models import QubeState
|
|
|
18
20
|
|
|
19
21
|
_LOGGER = logging.getLogger(__name__)
|
|
20
22
|
|
|
23
|
+
# Exceptions meaning the TCP link (or the device behind it) stopped answering,
|
|
24
|
+
# as opposed to a Modbus error reply to one request. pymodbus raises
|
|
25
|
+
# ConnectionException when it cannot connect and ModbusIOException when a
|
|
26
|
+
# request got no response after its retries. OSError covers socket errors
|
|
27
|
+
# and TimeoutError.
|
|
28
|
+
_LINK_ERRORS: tuple[type[Exception], ...] = (
|
|
29
|
+
ConnectionException,
|
|
30
|
+
ModbusIOException,
|
|
31
|
+
OSError,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class _BlockReadError(Exception):
|
|
36
|
+
"""A block read got a Modbus error reply or a short response."""
|
|
37
|
+
|
|
38
|
+
|
|
21
39
|
# Entities read by get_all_data() (used by the official HA core integration).
|
|
22
40
|
# These mirror the register definitions in `const` exactly (address, scale,
|
|
23
41
|
# offset) so batching cannot change any value get_all_data() returns.
|
|
@@ -149,6 +167,9 @@ class QubeClient:
|
|
|
149
167
|
self._next_connect_at: float = 0.0
|
|
150
168
|
# Monotonic clamping for total_increasing counters
|
|
151
169
|
self._monotonic_cache: dict[str, float] = {}
|
|
170
|
+
# Per-key count of consecutive reads that fell below the cached
|
|
171
|
+
# maximum by more than the reset threshold (see clamp_monotonic)
|
|
172
|
+
self._monotonic_reset_pending: dict[str, int] = {}
|
|
152
173
|
# Reads that already produced a WARNING (transient failures are
|
|
153
174
|
# logged once per target, then at DEBUG to avoid log spam)
|
|
154
175
|
self._read_failures_warned: set[str] = set()
|
|
@@ -165,10 +186,29 @@ class QubeClient:
|
|
|
165
186
|
self._read_failures_warned.add(target)
|
|
166
187
|
_LOGGER.warning("Exception reading %s: %s", target, exc)
|
|
167
188
|
|
|
189
|
+
def _log_read_ok(self, target: str) -> None:
|
|
190
|
+
"""Re-arm the warn-once log for a target that reads fine again."""
|
|
191
|
+
if target in self._read_failures_warned:
|
|
192
|
+
self._read_failures_warned.discard(target)
|
|
193
|
+
_LOGGER.info("Reading %s recovered", target)
|
|
194
|
+
|
|
195
|
+
def _mark_disconnected(self) -> None:
|
|
196
|
+
"""Drop the connection after a link failure so the next poll reconnects.
|
|
197
|
+
|
|
198
|
+
pymodbus' connect() replaces the transport without closing the old
|
|
199
|
+
one, so the transport is closed here before any reconnect.
|
|
200
|
+
"""
|
|
201
|
+
self._connected = False
|
|
202
|
+
with contextlib.suppress(Exception):
|
|
203
|
+
self._client.close()
|
|
204
|
+
|
|
168
205
|
async def connect(self) -> bool:
|
|
169
206
|
"""Connect to the Modbus server."""
|
|
170
207
|
if not self._connected:
|
|
171
208
|
self._connected = await self._client.connect()
|
|
209
|
+
if self._connected:
|
|
210
|
+
self._backoff_seconds = 0.0
|
|
211
|
+
self._next_connect_at = 0.0
|
|
172
212
|
return self._connected
|
|
173
213
|
|
|
174
214
|
@property
|
|
@@ -207,7 +247,8 @@ class QubeClient:
|
|
|
207
247
|
"""Fetch all definition data and return a state object.
|
|
208
248
|
|
|
209
249
|
This fetches core sensors for the official HA integration.
|
|
210
|
-
Returns None if not connected and reconnection fails
|
|
250
|
+
Returns None if not connected and reconnection fails, or if the
|
|
251
|
+
connection was lost during this poll.
|
|
211
252
|
"""
|
|
212
253
|
await self._ensure_connected()
|
|
213
254
|
if not self._connected:
|
|
@@ -222,6 +263,8 @@ class QubeClient:
|
|
|
222
263
|
# handful of block transactions instead of ~59 per-field reads.
|
|
223
264
|
entities = [*_CORE_STATE_ENTITIES, *BINARY_SENSORS.values()]
|
|
224
265
|
results = await self.read_entities_batched(entities)
|
|
266
|
+
if not self._connected:
|
|
267
|
+
return None
|
|
225
268
|
|
|
226
269
|
for ent in _CORE_STATE_ENTITIES:
|
|
227
270
|
setattr(state, ent.key, results.get(ent.key))
|
|
@@ -254,17 +297,48 @@ class QubeClient:
|
|
|
254
297
|
def monotonic_cache(self, value: dict[str, float]) -> None:
|
|
255
298
|
"""Set the monotonic clamping cache (e.g. restored from disk)."""
|
|
256
299
|
self._monotonic_cache = dict(value)
|
|
300
|
+
self._monotonic_reset_pending.clear()
|
|
301
|
+
|
|
302
|
+
def clear_monotonic_cache(self) -> None:
|
|
303
|
+
"""Forget all monotonic baselines.
|
|
257
304
|
|
|
258
|
-
|
|
305
|
+
The next reading of every counter is accepted as-is. Use this when
|
|
306
|
+
the controller's counters were reset on purpose and the clamped
|
|
307
|
+
values in the cache are known to be stale.
|
|
308
|
+
"""
|
|
309
|
+
self._monotonic_cache.clear()
|
|
310
|
+
self._monotonic_reset_pending.clear()
|
|
311
|
+
|
|
312
|
+
# A drop below the cached maximum larger than this (in the counter's own
|
|
313
|
+
# unit: kWh for the energy totals) is treated as a counter reset rather
|
|
314
|
+
# than float32 jitter or a transient glitch...
|
|
315
|
+
MONOTONIC_RESET_THRESHOLD: float = 1.0
|
|
316
|
+
# ...but only after it has been seen on this many consecutive reads, so a
|
|
317
|
+
# single glitched zero read never drops a total_increasing counter.
|
|
318
|
+
MONOTONIC_RESET_CONFIRM_READS: int = 3
|
|
319
|
+
|
|
320
|
+
def clamp_monotonic(
|
|
321
|
+
self,
|
|
322
|
+
key: str,
|
|
323
|
+
value: float | None,
|
|
324
|
+
reset_threshold: float | None = None,
|
|
325
|
+
) -> float | None:
|
|
259
326
|
"""Clamp a value to prevent decreases for total_increasing counters.
|
|
260
327
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
328
|
+
If the new value is lower than the previously seen value for this
|
|
329
|
+
key by less than ``reset_threshold``, the previous value is returned
|
|
330
|
+
(jitter suppression). A larger drop is a counter-reset candidate: it
|
|
331
|
+
is still clamped until it has persisted for
|
|
332
|
+
``MONOTONIC_RESET_CONFIRM_READS`` consecutive reads, after which the
|
|
333
|
+
low value is accepted as the new baseline and a warning is logged.
|
|
334
|
+
None and non-finite values pass through unchanged and do not affect
|
|
335
|
+
the reset detection.
|
|
264
336
|
|
|
265
337
|
Args:
|
|
266
338
|
key: Identifier for this counter (e.g. entity unique_id).
|
|
267
339
|
value: The current reading.
|
|
340
|
+
reset_threshold: Drop size that counts as a reset; defaults to
|
|
341
|
+
``MONOTONIC_RESET_THRESHOLD``.
|
|
268
342
|
|
|
269
343
|
Returns:
|
|
270
344
|
The clamped value, or None if input was None/non-finite.
|
|
@@ -272,8 +346,35 @@ class QubeClient:
|
|
|
272
346
|
if value is None or not math.isfinite(value):
|
|
273
347
|
return value
|
|
274
348
|
previous = self._monotonic_cache.get(key)
|
|
275
|
-
if previous is
|
|
349
|
+
if previous is None or value >= previous:
|
|
350
|
+
self._monotonic_cache[key] = value
|
|
351
|
+
self._monotonic_reset_pending.pop(key, None)
|
|
352
|
+
return value
|
|
353
|
+
|
|
354
|
+
threshold = (
|
|
355
|
+
self.MONOTONIC_RESET_THRESHOLD
|
|
356
|
+
if reset_threshold is None
|
|
357
|
+
else reset_threshold
|
|
358
|
+
)
|
|
359
|
+
if previous - value < threshold:
|
|
360
|
+
# Sub-threshold jitter: clamp and forget any pending reset
|
|
361
|
+
self._monotonic_reset_pending.pop(key, None)
|
|
362
|
+
return previous
|
|
363
|
+
|
|
364
|
+
pending = self._monotonic_reset_pending.get(key, 0) + 1
|
|
365
|
+
if pending < self.MONOTONIC_RESET_CONFIRM_READS:
|
|
366
|
+
self._monotonic_reset_pending[key] = pending
|
|
276
367
|
return previous
|
|
368
|
+
|
|
369
|
+
_LOGGER.warning(
|
|
370
|
+
"Counter reset detected for %s: value dropped from %s to %s on %d "
|
|
371
|
+
"consecutive reads; accepting it as the new baseline",
|
|
372
|
+
key,
|
|
373
|
+
previous,
|
|
374
|
+
value,
|
|
375
|
+
pending,
|
|
376
|
+
)
|
|
377
|
+
self._monotonic_reset_pending.pop(key, None)
|
|
277
378
|
self._monotonic_cache[key] = value
|
|
278
379
|
return value
|
|
279
380
|
|
|
@@ -389,10 +490,13 @@ class QubeClient:
|
|
|
389
490
|
if offset is not None:
|
|
390
491
|
val += offset
|
|
391
492
|
|
|
493
|
+
self._log_read_ok(f"address {address}")
|
|
392
494
|
return val
|
|
393
495
|
|
|
394
496
|
except Exception as e:
|
|
395
497
|
self._log_read_failure(f"address {address}", e)
|
|
498
|
+
if isinstance(e, _LINK_ERRORS):
|
|
499
|
+
self._mark_disconnected()
|
|
396
500
|
return None
|
|
397
501
|
|
|
398
502
|
@staticmethod
|
|
@@ -481,51 +585,32 @@ class QubeClient:
|
|
|
481
585
|
|
|
482
586
|
Groups entities into a handful of Modbus block reads instead of
|
|
483
587
|
one transaction per entity. If a block read fails, its entities
|
|
484
|
-
are read individually as a fallback.
|
|
588
|
+
are read individually as a fallback. If one of those single reads
|
|
589
|
+
hits a link failure (no connection, no response), the device is
|
|
590
|
+
treated as unreachable: the client is marked disconnected and the
|
|
591
|
+
remaining reads are skipped.
|
|
485
592
|
|
|
486
593
|
Returns:
|
|
487
|
-
Dictionary mapping entity
|
|
594
|
+
Dictionary mapping every requested entity key to its value
|
|
595
|
+
(None on error).
|
|
488
596
|
"""
|
|
489
597
|
results: dict[str, Any] = {}
|
|
490
|
-
|
|
598
|
+
blocks = self._plan_blocks(entities)
|
|
599
|
+
for input_type_str, start, count, members in blocks:
|
|
600
|
+
target = f"block {input_type_str}@{start} (count {count})"
|
|
491
601
|
try:
|
|
492
|
-
|
|
493
|
-
result = await self._client.read_coils(
|
|
494
|
-
start, count=count, device_id=self.unit
|
|
495
|
-
)
|
|
496
|
-
elif input_type_str == "discrete_input":
|
|
497
|
-
result = await self._client.read_discrete_inputs(
|
|
498
|
-
start, count=count, device_id=self.unit
|
|
499
|
-
)
|
|
500
|
-
elif input_type_str == "input":
|
|
501
|
-
result = await self._client.read_input_registers(
|
|
502
|
-
start, count=count, device_id=self.unit
|
|
503
|
-
)
|
|
504
|
-
else: # holding
|
|
505
|
-
result = await self._client.read_holding_registers(
|
|
506
|
-
start, count=count, device_id=self.unit
|
|
507
|
-
)
|
|
508
|
-
if result.isError():
|
|
509
|
-
raise OSError(f"Modbus error response for block @{start}")
|
|
510
|
-
if input_type_str in ("coil", "discrete_input"):
|
|
511
|
-
if len(result.bits) < count:
|
|
512
|
-
raise OSError(
|
|
513
|
-
f"Short bit response for block {input_type_str}@{start} "
|
|
514
|
-
f"(got {len(result.bits)}, expected {count})"
|
|
515
|
-
)
|
|
516
|
-
elif len(result.registers) < count:
|
|
517
|
-
raise OSError(
|
|
518
|
-
f"Short register response for block {input_type_str}@{start} "
|
|
519
|
-
f"(got {len(result.registers)}, expected {count})"
|
|
520
|
-
)
|
|
602
|
+
result = await self._read_block(input_type_str, start, count)
|
|
521
603
|
except Exception as exc:
|
|
522
|
-
self._log_read_failure(
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
604
|
+
self._log_read_failure(target, exc)
|
|
605
|
+
if not await self._read_individually(members, results):
|
|
606
|
+
self._mark_disconnected()
|
|
607
|
+
for _, _, _, block_members in blocks:
|
|
608
|
+
for ent in block_members:
|
|
609
|
+
results.setdefault(ent.key, None)
|
|
610
|
+
return results
|
|
527
611
|
continue
|
|
528
612
|
|
|
613
|
+
self._log_read_ok(target)
|
|
529
614
|
for ent in members:
|
|
530
615
|
offset = ent.address - start
|
|
531
616
|
if input_type_str in ("coil", "discrete_input"):
|
|
@@ -538,6 +623,63 @@ class QubeClient:
|
|
|
538
623
|
results[ent.key] = self._apply_scaling(ent, val)
|
|
539
624
|
return results
|
|
540
625
|
|
|
626
|
+
async def _read_block(self, input_type_str: str, start: int, count: int) -> Any:
|
|
627
|
+
"""Read one block and validate the response.
|
|
628
|
+
|
|
629
|
+
Raises:
|
|
630
|
+
_BlockReadError: On a Modbus error reply or a short response.
|
|
631
|
+
Any of _LINK_ERRORS: When the device could not be reached.
|
|
632
|
+
"""
|
|
633
|
+
if input_type_str == "coil":
|
|
634
|
+
result = await self._client.read_coils(
|
|
635
|
+
start, count=count, device_id=self.unit
|
|
636
|
+
)
|
|
637
|
+
elif input_type_str == "discrete_input":
|
|
638
|
+
result = await self._client.read_discrete_inputs(
|
|
639
|
+
start, count=count, device_id=self.unit
|
|
640
|
+
)
|
|
641
|
+
elif input_type_str == "input":
|
|
642
|
+
result = await self._client.read_input_registers(
|
|
643
|
+
start, count=count, device_id=self.unit
|
|
644
|
+
)
|
|
645
|
+
else: # holding
|
|
646
|
+
result = await self._client.read_holding_registers(
|
|
647
|
+
start, count=count, device_id=self.unit
|
|
648
|
+
)
|
|
649
|
+
if result.isError():
|
|
650
|
+
raise _BlockReadError(f"Modbus error response for block @{start}")
|
|
651
|
+
if input_type_str in ("coil", "discrete_input"):
|
|
652
|
+
if len(result.bits) < count:
|
|
653
|
+
raise _BlockReadError(
|
|
654
|
+
f"Short bit response for block {input_type_str}@{start} "
|
|
655
|
+
f"(got {len(result.bits)}, expected {count})"
|
|
656
|
+
)
|
|
657
|
+
elif len(result.registers) < count:
|
|
658
|
+
raise _BlockReadError(
|
|
659
|
+
f"Short register response for block {input_type_str}@{start} "
|
|
660
|
+
f"(got {len(result.registers)}, expected {count})"
|
|
661
|
+
)
|
|
662
|
+
return result
|
|
663
|
+
|
|
664
|
+
async def _read_individually(
|
|
665
|
+
self, members: list[EntityDef], results: dict[str, Any]
|
|
666
|
+
) -> bool:
|
|
667
|
+
"""Read a failed block's entities one by one into ``results``.
|
|
668
|
+
|
|
669
|
+
Returns False as soon as a read hits a link failure, so a dead
|
|
670
|
+
device costs one extra transaction instead of one per entity.
|
|
671
|
+
"""
|
|
672
|
+
for ent in members:
|
|
673
|
+
try:
|
|
674
|
+
results[ent.key] = await self._read_entity_or_raise(ent)
|
|
675
|
+
except _LINK_ERRORS as exc:
|
|
676
|
+
self._log_read_failure(f"entity {ent.key}", exc)
|
|
677
|
+
return False
|
|
678
|
+
except Exception as exc:
|
|
679
|
+
self._log_read_failure(f"entity {ent.key}", exc)
|
|
680
|
+
results[ent.key] = None
|
|
681
|
+
return True
|
|
682
|
+
|
|
541
683
|
async def read_entity(self, entity: EntityDef) -> Any:
|
|
542
684
|
"""Read a single entity value based on EntityDef.
|
|
543
685
|
|
|
@@ -547,33 +689,41 @@ class QubeClient:
|
|
|
547
689
|
Returns:
|
|
548
690
|
The read value (float, int, or bool depending on entity type).
|
|
549
691
|
"""
|
|
692
|
+
try:
|
|
693
|
+
return await self._read_entity_or_raise(entity)
|
|
694
|
+
except Exception as e:
|
|
695
|
+
self._log_read_failure(f"entity {entity.key}", e)
|
|
696
|
+
if isinstance(e, _LINK_ERRORS):
|
|
697
|
+
self._mark_disconnected()
|
|
698
|
+
return None
|
|
699
|
+
|
|
700
|
+
async def _read_entity_or_raise(self, entity: EntityDef) -> Any:
|
|
701
|
+
"""Read a single entity; return None on an error reply, raise otherwise."""
|
|
550
702
|
# Determine register count based on data type
|
|
551
703
|
# Use string comparison to handle potential enum class differences
|
|
552
704
|
data_type_str = entity.data_type.value if entity.data_type else None
|
|
553
705
|
count = self._register_count(entity)
|
|
554
706
|
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
input_type_str = entity.input_type.value if entity.input_type else None
|
|
558
|
-
|
|
559
|
-
if input_type_str == "coil":
|
|
560
|
-
result = await self._client.read_coils(
|
|
561
|
-
entity.address, count=1, device_id=self.unit
|
|
562
|
-
)
|
|
563
|
-
if result.isError():
|
|
564
|
-
_LOGGER.warning("Error reading coil %s", entity.address)
|
|
565
|
-
return None
|
|
566
|
-
return bool(result.bits[0])
|
|
567
|
-
|
|
568
|
-
if input_type_str == "discrete_input":
|
|
569
|
-
result = await self._client.read_discrete_inputs(
|
|
570
|
-
entity.address, count=1, device_id=self.unit
|
|
571
|
-
)
|
|
572
|
-
if result.isError():
|
|
573
|
-
_LOGGER.warning("Error reading discrete input %s", entity.address)
|
|
574
|
-
return None
|
|
575
|
-
return bool(result.bits[0])
|
|
707
|
+
# Read based on input type (use string comparison for safety)
|
|
708
|
+
input_type_str = entity.input_type.value if entity.input_type else None
|
|
576
709
|
|
|
710
|
+
if input_type_str == "coil":
|
|
711
|
+
result = await self._client.read_coils(
|
|
712
|
+
entity.address, count=1, device_id=self.unit
|
|
713
|
+
)
|
|
714
|
+
if result.isError():
|
|
715
|
+
_LOGGER.warning("Error reading coil %s", entity.address)
|
|
716
|
+
return None
|
|
717
|
+
value: Any = bool(result.bits[0])
|
|
718
|
+
elif input_type_str == "discrete_input":
|
|
719
|
+
result = await self._client.read_discrete_inputs(
|
|
720
|
+
entity.address, count=1, device_id=self.unit
|
|
721
|
+
)
|
|
722
|
+
if result.isError():
|
|
723
|
+
_LOGGER.warning("Error reading discrete input %s", entity.address)
|
|
724
|
+
return None
|
|
725
|
+
value = bool(result.bits[0])
|
|
726
|
+
else:
|
|
577
727
|
if input_type_str == "input":
|
|
578
728
|
result = await self._client.read_input_registers(
|
|
579
729
|
entity.address, count=count, device_id=self.unit
|
|
@@ -588,11 +738,10 @@ class QubeClient:
|
|
|
588
738
|
return None
|
|
589
739
|
|
|
590
740
|
val = self._decode_registers(data_type_str, result.registers)
|
|
591
|
-
|
|
741
|
+
value = self._apply_scaling(entity, val)
|
|
592
742
|
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
return None
|
|
743
|
+
self._log_read_ok(f"entity {entity.key}")
|
|
744
|
+
return value
|
|
596
745
|
|
|
597
746
|
async def read_sensor(self, key: str) -> float | int | None:
|
|
598
747
|
"""Read a sensor value by key.
|
|
@@ -639,16 +788,18 @@ class QubeClient:
|
|
|
639
788
|
return None
|
|
640
789
|
return await self.read_entity(entity)
|
|
641
790
|
|
|
791
|
+
async def _read_table(self, table: dict[str, EntityDef]) -> dict[str, Any]:
|
|
792
|
+
"""Batch-read every entity in a table, keeping the table's key order."""
|
|
793
|
+
results = await self.read_entities_batched(table.values())
|
|
794
|
+
return {key: results.get(key) for key in table}
|
|
795
|
+
|
|
642
796
|
async def read_all_sensors(self) -> dict[str, Any]:
|
|
643
797
|
"""Read all sensor values.
|
|
644
798
|
|
|
645
799
|
Returns:
|
|
646
800
|
Dictionary mapping sensor keys to their values.
|
|
647
801
|
"""
|
|
648
|
-
|
|
649
|
-
for key, entity in SENSORS.items():
|
|
650
|
-
result[key] = await self.read_entity(entity)
|
|
651
|
-
return result
|
|
802
|
+
return await self._read_table(SENSORS)
|
|
652
803
|
|
|
653
804
|
async def read_all_binary_sensors(self) -> dict[str, bool | None]:
|
|
654
805
|
"""Read all binary sensor values.
|
|
@@ -656,10 +807,7 @@ class QubeClient:
|
|
|
656
807
|
Returns:
|
|
657
808
|
Dictionary mapping binary sensor keys to their values.
|
|
658
809
|
"""
|
|
659
|
-
|
|
660
|
-
for key, entity in BINARY_SENSORS.items():
|
|
661
|
-
result[key] = await self.read_entity(entity)
|
|
662
|
-
return result
|
|
810
|
+
return await self._read_table(BINARY_SENSORS)
|
|
663
811
|
|
|
664
812
|
async def read_all_switches(self) -> dict[str, bool | None]:
|
|
665
813
|
"""Read all switch states.
|
|
@@ -667,10 +815,7 @@ class QubeClient:
|
|
|
667
815
|
Returns:
|
|
668
816
|
Dictionary mapping switch keys to their states.
|
|
669
817
|
"""
|
|
670
|
-
|
|
671
|
-
for key, entity in SWITCHES.items():
|
|
672
|
-
result[key] = await self.read_entity(entity)
|
|
673
|
-
return result
|
|
818
|
+
return await self._read_table(SWITCHES)
|
|
674
819
|
|
|
675
820
|
async def write_switch(self, key: str, value: bool) -> bool:
|
|
676
821
|
"""Write a switch state by key.
|
|
@@ -701,6 +846,8 @@ class QubeClient:
|
|
|
701
846
|
return True
|
|
702
847
|
except Exception as e:
|
|
703
848
|
_LOGGER.error("Exception writing switch %s: %s", key, e)
|
|
849
|
+
if isinstance(e, _LINK_ERRORS):
|
|
850
|
+
self._mark_disconnected()
|
|
704
851
|
return False
|
|
705
852
|
|
|
706
853
|
# SG Ready mode API
|
|
@@ -721,8 +868,11 @@ class QubeClient:
|
|
|
721
868
|
Returns:
|
|
722
869
|
Mode string ("off", "block", "plus", "max"), or None on error.
|
|
723
870
|
"""
|
|
724
|
-
|
|
725
|
-
|
|
871
|
+
results = await self.read_entities_batched(
|
|
872
|
+
[SWITCHES["bms_sgready_a"], SWITCHES["bms_sgready_b"]]
|
|
873
|
+
)
|
|
874
|
+
bit_a = results.get("bms_sgready_a")
|
|
875
|
+
bit_b = results.get("bms_sgready_b")
|
|
726
876
|
if bit_a is None or bit_b is None:
|
|
727
877
|
return None
|
|
728
878
|
return self._SGREADY_BITS_TO_MODE.get((bool(bit_a), bool(bit_b)))
|
|
@@ -730,16 +880,41 @@ class QubeClient:
|
|
|
730
880
|
async def set_sg_ready_mode(self, mode: str) -> bool:
|
|
731
881
|
"""Set the SG Ready mode.
|
|
732
882
|
|
|
883
|
+
Both coils are written in one request so the heat pump never sees
|
|
884
|
+
a half-applied mode. If the device rejects the multi-coil write,
|
|
885
|
+
the coils are written one after the other instead.
|
|
886
|
+
|
|
733
887
|
Args:
|
|
734
888
|
mode: One of "off", "block", "plus", "max".
|
|
735
889
|
|
|
736
890
|
Returns:
|
|
737
|
-
True if both
|
|
891
|
+
True if both coils were written, False otherwise.
|
|
738
892
|
"""
|
|
739
893
|
bits = self._SGREADY_MODE_TO_BITS.get(mode)
|
|
740
894
|
if bits is None:
|
|
741
895
|
_LOGGER.warning("Unknown SG Ready mode: %s", mode)
|
|
742
896
|
return False
|
|
897
|
+
|
|
898
|
+
coil_a = SWITCHES["bms_sgready_a"]
|
|
899
|
+
coil_b = SWITCHES["bms_sgready_b"]
|
|
900
|
+
if coil_b.address == coil_a.address + 1:
|
|
901
|
+
try:
|
|
902
|
+
result = await self._client.write_coils(
|
|
903
|
+
coil_a.address, list(bits), device_id=self.unit
|
|
904
|
+
)
|
|
905
|
+
except Exception as e:
|
|
906
|
+
_LOGGER.error("Exception writing SG Ready mode %s: %s", mode, e)
|
|
907
|
+
if isinstance(e, _LINK_ERRORS):
|
|
908
|
+
self._mark_disconnected()
|
|
909
|
+
return False
|
|
910
|
+
if not result.isError():
|
|
911
|
+
return True
|
|
912
|
+
_LOGGER.debug(
|
|
913
|
+
"Multi-coil write rejected for SG Ready mode %s; "
|
|
914
|
+
"falling back to single writes",
|
|
915
|
+
mode,
|
|
916
|
+
)
|
|
917
|
+
|
|
743
918
|
success_a = await self.write_switch("bms_sgready_a", bits[0])
|
|
744
919
|
success_b = await self.write_switch("bms_sgready_b", bits[1])
|
|
745
920
|
return success_a and success_b
|
|
@@ -767,6 +942,24 @@ class QubeClient:
|
|
|
767
942
|
_LOGGER.warning("Sensor %s is not a holding register", key)
|
|
768
943
|
return False
|
|
769
944
|
|
|
945
|
+
if not isinstance(value, int | float) or not math.isfinite(value):
|
|
946
|
+
_LOGGER.warning(
|
|
947
|
+
"Refusing to write %r to %s: not a finite number", value, key
|
|
948
|
+
)
|
|
949
|
+
return False
|
|
950
|
+
|
|
951
|
+
if (entity.min_value is not None and value < entity.min_value) or (
|
|
952
|
+
entity.max_value is not None and value > entity.max_value
|
|
953
|
+
):
|
|
954
|
+
_LOGGER.warning(
|
|
955
|
+
"Refusing to write %s to %s: outside allowed range %s..%s",
|
|
956
|
+
value,
|
|
957
|
+
key,
|
|
958
|
+
entity.min_value,
|
|
959
|
+
entity.max_value,
|
|
960
|
+
)
|
|
961
|
+
return False
|
|
962
|
+
|
|
770
963
|
try:
|
|
771
964
|
# Reverse scale/offset if needed
|
|
772
965
|
write_value = value
|
|
@@ -785,15 +978,25 @@ class QubeClient:
|
|
|
785
978
|
result = await self._client.write_registers(
|
|
786
979
|
entity.address, regs, device_id=self.unit
|
|
787
980
|
)
|
|
788
|
-
elif entity.data_type
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
entity.
|
|
981
|
+
elif entity.data_type in (DataType.INT16, DataType.UINT16):
|
|
982
|
+
raw = round(write_value)
|
|
983
|
+
low, high = (
|
|
984
|
+
(-32768, 32767)
|
|
985
|
+
if entity.data_type == DataType.INT16
|
|
986
|
+
else (0, 65535)
|
|
793
987
|
)
|
|
794
|
-
|
|
988
|
+
if not low <= raw <= high:
|
|
989
|
+
_LOGGER.warning(
|
|
990
|
+
"Refusing to write %s to %s: does not fit in %s",
|
|
991
|
+
value,
|
|
992
|
+
key,
|
|
993
|
+
entity.data_type.value,
|
|
994
|
+
)
|
|
995
|
+
return False
|
|
996
|
+
if raw < 0:
|
|
997
|
+
raw += 65536
|
|
795
998
|
result = await self._client.write_register(
|
|
796
|
-
entity.address,
|
|
999
|
+
entity.address, raw, device_id=self.unit
|
|
797
1000
|
)
|
|
798
1001
|
else:
|
|
799
1002
|
_LOGGER.warning(
|
|
@@ -808,4 +1011,6 @@ class QubeClient:
|
|
|
808
1011
|
|
|
809
1012
|
except Exception as e:
|
|
810
1013
|
_LOGGER.error("Exception writing setpoint %s: %s", key, e)
|
|
1014
|
+
if isinstance(e, _LINK_ERRORS):
|
|
1015
|
+
self._mark_disconnected()
|
|
811
1016
|
return False
|
|
@@ -79,3 +79,10 @@ class EntityDef:
|
|
|
79
79
|
# Value precision
|
|
80
80
|
precision: int | None = None
|
|
81
81
|
"""Number of decimal places to round to. None for no rounding."""
|
|
82
|
+
|
|
83
|
+
# Write limits
|
|
84
|
+
min_value: float | None = None
|
|
85
|
+
"""Lowest value write_setpoint() accepts, in the entity's unit."""
|
|
86
|
+
|
|
87
|
+
max_value: float | None = None
|
|
88
|
+
"""Highest value write_setpoint() accepts, in the entity's unit."""
|