python-qube-heatpump 1.14.0__tar.gz → 1.16.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.14.0 → python_qube_heatpump-1.16.0}/PKG-INFO +19 -1
- {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.16.0}/README.md +17 -0
- {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.16.0}/pyproject.toml +2 -1
- {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.16.0}/src/python_qube_heatpump/__init__.py +5 -0
- {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.16.0}/src/python_qube_heatpump/client.py +229 -85
- {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.16.0}/src/python_qube_heatpump/entities/base.py +7 -0
- {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.16.0}/src/python_qube_heatpump/entities/sensors.py +8 -0
- python_qube_heatpump-1.16.0/src/python_qube_heatpump/mdns.py +141 -0
- {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.16.0}/src/python_qube_heatpump/network.py +3 -2
- {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.16.0}/tests/conftest.py +3 -2
- {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.16.0}/tests/test_client.py +14 -23
- python_qube_heatpump-1.16.0/tests/test_client_read_all.py +83 -0
- python_qube_heatpump-1.16.0/tests/test_client_resilience.py +252 -0
- python_qube_heatpump-1.16.0/tests/test_client_writes.py +197 -0
- python_qube_heatpump-1.16.0/tests/test_mdns.py +183 -0
- {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.16.0}/tests/test_network.py +31 -0
- {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.16.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.16.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.16.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.16.0}/.github/workflows/ci.yml +0 -0
- {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.16.0}/.github/workflows/python-publish.yml +0 -0
- {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.16.0}/.gitignore +0 -0
- {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.16.0}/AGENTS.md +0 -0
- {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.16.0}/CLAUDE.md +0 -0
- {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.16.0}/LICENSE +0 -0
- {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.16.0}/docs/modbus-lijst-qube-totaal.pdf +0 -0
- {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.16.0}/pytest.ini +0 -0
- {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.16.0}/src/python_qube_heatpump/const.py +0 -0
- {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.16.0}/src/python_qube_heatpump/entities/__init__.py +0 -0
- {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.16.0}/src/python_qube_heatpump/entities/binary_sensors.py +0 -0
- {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.16.0}/src/python_qube_heatpump/entities/switches.py +0 -0
- {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.16.0}/src/python_qube_heatpump/models.py +0 -0
- {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.16.0}/src/python_qube_heatpump/py.typed +0 -0
- {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.16.0}/tests/test_client_batching.py +0 -0
- {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.16.0}/tests/test_const.py +0 -0
- {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.16.0}/tests/test_entities.py +0 -0
- {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.16.0}/tests/test_models.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: python-qube-heatpump
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.16.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
|
|
@@ -11,6 +11,7 @@ Classifier: Operating System :: OS Independent
|
|
|
11
11
|
Classifier: Programming Language :: Python :: 3
|
|
12
12
|
Requires-Python: >=3.12
|
|
13
13
|
Requires-Dist: pymodbus>=3.11.0
|
|
14
|
+
Requires-Dist: zeroconf>=0.132.0
|
|
14
15
|
Provides-Extra: test
|
|
15
16
|
Requires-Dist: pytest; extra == 'test'
|
|
16
17
|
Requires-Dist: pytest-asyncio; extra == 'test'
|
|
@@ -53,8 +54,25 @@ asyncio.run(main())
|
|
|
53
54
|
- **Entity definitions** for sensors, binary sensors, and switches
|
|
54
55
|
- **FLOAT32 decoding** with big endian (ABCD) byte order
|
|
55
56
|
- **Type-safe dataclasses** for entity definitions
|
|
57
|
+
- **mDNS device information**: panel software version, controller firmware and UUID from the controller's advertisement
|
|
56
58
|
- **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
|
|
57
59
|
|
|
60
|
+
## Device information over mDNS
|
|
61
|
+
|
|
62
|
+
The controller advertises a `_workstation._tcp` mDNS service whose TXT record holds the software version shown on the panel, the controller firmware and a stable UUID. Register 77 (`async_get_software_version()`) reads 0.0 on recent firmware, so use this for the version instead:
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from zeroconf.asyncio import AsyncZeroconf
|
|
66
|
+
from python_qube_heatpump import async_get_device_info
|
|
67
|
+
|
|
68
|
+
aiozc = AsyncZeroconf() # in Home Assistant: zeroconf.async_get_async_instance(hass)
|
|
69
|
+
info = await async_get_device_info("192.168.1.100", aiozc)
|
|
70
|
+
if info:
|
|
71
|
+
print(info.software_version, info.controller_firmware, info.uuid)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The lookup returns `None` when no record is found within the timeout (default 5 s), for example when mDNS is not forwarded between VLANs. `parse_device_info(properties)` parses TXT properties you already have, such as those from a zeroconf discovery.
|
|
75
|
+
|
|
58
76
|
## Entity Definitions
|
|
59
77
|
|
|
60
78
|
The library provides pre-defined entity definitions for all Qube Modbus registers:
|
|
@@ -34,8 +34,25 @@ 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
|
+
- **mDNS device information**: panel software version, controller firmware and UUID from the controller's advertisement
|
|
37
38
|
- **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
|
|
38
39
|
|
|
40
|
+
## Device information over mDNS
|
|
41
|
+
|
|
42
|
+
The controller advertises a `_workstation._tcp` mDNS service whose TXT record holds the software version shown on the panel, the controller firmware and a stable UUID. Register 77 (`async_get_software_version()`) reads 0.0 on recent firmware, so use this for the version instead:
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from zeroconf.asyncio import AsyncZeroconf
|
|
46
|
+
from python_qube_heatpump import async_get_device_info
|
|
47
|
+
|
|
48
|
+
aiozc = AsyncZeroconf() # in Home Assistant: zeroconf.async_get_async_instance(hass)
|
|
49
|
+
info = await async_get_device_info("192.168.1.100", aiozc)
|
|
50
|
+
if info:
|
|
51
|
+
print(info.software_version, info.controller_firmware, info.uuid)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The lookup returns `None` when no record is found within the timeout (default 5 s), for example when mDNS is not forwarded between VLANs. `parse_device_info(properties)` parses TXT properties you already have, such as those from a zeroconf discovery.
|
|
55
|
+
|
|
39
56
|
## Entity Definitions
|
|
40
57
|
|
|
41
58
|
The library provides pre-defined entity definitions for all Qube Modbus registers:
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "python-qube-heatpump"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.16.0"
|
|
8
8
|
authors = [
|
|
9
9
|
{ name="MattieGit", email="6250046+MattieGit@users.noreply.github.com" },
|
|
10
10
|
]
|
|
@@ -18,6 +18,7 @@ classifiers = [
|
|
|
18
18
|
]
|
|
19
19
|
dependencies = [
|
|
20
20
|
"pymodbus>=3.11.0",
|
|
21
|
+
"zeroconf>=0.132.0",
|
|
21
22
|
]
|
|
22
23
|
|
|
23
24
|
[project.optional-dependencies]
|
{python_qube_heatpump-1.14.0 → python_qube_heatpump-1.16.0}/src/python_qube_heatpump/__init__.py
RENAMED
|
@@ -17,6 +17,7 @@ from .entities import (
|
|
|
17
17
|
SENSORS,
|
|
18
18
|
SWITCHES,
|
|
19
19
|
)
|
|
20
|
+
from .mdns import QubeDeviceInfo, async_get_device_info, parse_device_info
|
|
20
21
|
from .models import QubeState
|
|
21
22
|
from .network import async_get_mac_address
|
|
22
23
|
|
|
@@ -41,4 +42,8 @@ __all__ = [
|
|
|
41
42
|
"resolve_status",
|
|
42
43
|
# Network
|
|
43
44
|
"async_get_mac_address",
|
|
45
|
+
# mDNS device information
|
|
46
|
+
"QubeDeviceInfo",
|
|
47
|
+
"async_get_device_info",
|
|
48
|
+
"parse_device_info",
|
|
44
49
|
]
|
{python_qube_heatpump-1.14.0 → python_qube_heatpump-1.16.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.
|
|
@@ -168,10 +186,29 @@ class QubeClient:
|
|
|
168
186
|
self._read_failures_warned.add(target)
|
|
169
187
|
_LOGGER.warning("Exception reading %s: %s", target, exc)
|
|
170
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
|
+
|
|
171
205
|
async def connect(self) -> bool:
|
|
172
206
|
"""Connect to the Modbus server."""
|
|
173
207
|
if not self._connected:
|
|
174
208
|
self._connected = await self._client.connect()
|
|
209
|
+
if self._connected:
|
|
210
|
+
self._backoff_seconds = 0.0
|
|
211
|
+
self._next_connect_at = 0.0
|
|
175
212
|
return self._connected
|
|
176
213
|
|
|
177
214
|
@property
|
|
@@ -210,7 +247,8 @@ class QubeClient:
|
|
|
210
247
|
"""Fetch all definition data and return a state object.
|
|
211
248
|
|
|
212
249
|
This fetches core sensors for the official HA integration.
|
|
213
|
-
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.
|
|
214
252
|
"""
|
|
215
253
|
await self._ensure_connected()
|
|
216
254
|
if not self._connected:
|
|
@@ -225,6 +263,8 @@ class QubeClient:
|
|
|
225
263
|
# handful of block transactions instead of ~59 per-field reads.
|
|
226
264
|
entities = [*_CORE_STATE_ENTITIES, *BINARY_SENSORS.values()]
|
|
227
265
|
results = await self.read_entities_batched(entities)
|
|
266
|
+
if not self._connected:
|
|
267
|
+
return None
|
|
228
268
|
|
|
229
269
|
for ent in _CORE_STATE_ENTITIES:
|
|
230
270
|
setattr(state, ent.key, results.get(ent.key))
|
|
@@ -450,10 +490,13 @@ class QubeClient:
|
|
|
450
490
|
if offset is not None:
|
|
451
491
|
val += offset
|
|
452
492
|
|
|
493
|
+
self._log_read_ok(f"address {address}")
|
|
453
494
|
return val
|
|
454
495
|
|
|
455
496
|
except Exception as e:
|
|
456
497
|
self._log_read_failure(f"address {address}", e)
|
|
498
|
+
if isinstance(e, _LINK_ERRORS):
|
|
499
|
+
self._mark_disconnected()
|
|
457
500
|
return None
|
|
458
501
|
|
|
459
502
|
@staticmethod
|
|
@@ -542,51 +585,32 @@ class QubeClient:
|
|
|
542
585
|
|
|
543
586
|
Groups entities into a handful of Modbus block reads instead of
|
|
544
587
|
one transaction per entity. If a block read fails, its entities
|
|
545
|
-
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.
|
|
546
592
|
|
|
547
593
|
Returns:
|
|
548
|
-
Dictionary mapping entity
|
|
594
|
+
Dictionary mapping every requested entity key to its value
|
|
595
|
+
(None on error).
|
|
549
596
|
"""
|
|
550
597
|
results: dict[str, Any] = {}
|
|
551
|
-
|
|
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})"
|
|
552
601
|
try:
|
|
553
|
-
|
|
554
|
-
result = await self._client.read_coils(
|
|
555
|
-
start, count=count, device_id=self.unit
|
|
556
|
-
)
|
|
557
|
-
elif input_type_str == "discrete_input":
|
|
558
|
-
result = await self._client.read_discrete_inputs(
|
|
559
|
-
start, count=count, device_id=self.unit
|
|
560
|
-
)
|
|
561
|
-
elif input_type_str == "input":
|
|
562
|
-
result = await self._client.read_input_registers(
|
|
563
|
-
start, count=count, device_id=self.unit
|
|
564
|
-
)
|
|
565
|
-
else: # holding
|
|
566
|
-
result = await self._client.read_holding_registers(
|
|
567
|
-
start, count=count, device_id=self.unit
|
|
568
|
-
)
|
|
569
|
-
if result.isError():
|
|
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
|
-
)
|
|
602
|
+
result = await self._read_block(input_type_str, start, count)
|
|
582
603
|
except Exception as exc:
|
|
583
|
-
self._log_read_failure(
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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
|
|
588
611
|
continue
|
|
589
612
|
|
|
613
|
+
self._log_read_ok(target)
|
|
590
614
|
for ent in members:
|
|
591
615
|
offset = ent.address - start
|
|
592
616
|
if input_type_str in ("coil", "discrete_input"):
|
|
@@ -599,6 +623,63 @@ class QubeClient:
|
|
|
599
623
|
results[ent.key] = self._apply_scaling(ent, val)
|
|
600
624
|
return results
|
|
601
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
|
+
|
|
602
683
|
async def read_entity(self, entity: EntityDef) -> Any:
|
|
603
684
|
"""Read a single entity value based on EntityDef.
|
|
604
685
|
|
|
@@ -608,33 +689,41 @@ class QubeClient:
|
|
|
608
689
|
Returns:
|
|
609
690
|
The read value (float, int, or bool depending on entity type).
|
|
610
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."""
|
|
611
702
|
# Determine register count based on data type
|
|
612
703
|
# Use string comparison to handle potential enum class differences
|
|
613
704
|
data_type_str = entity.data_type.value if entity.data_type else None
|
|
614
705
|
count = self._register_count(entity)
|
|
615
706
|
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
input_type_str = entity.input_type.value if entity.input_type else None
|
|
619
|
-
|
|
620
|
-
if input_type_str == "coil":
|
|
621
|
-
result = await self._client.read_coils(
|
|
622
|
-
entity.address, count=1, device_id=self.unit
|
|
623
|
-
)
|
|
624
|
-
if result.isError():
|
|
625
|
-
_LOGGER.warning("Error reading coil %s", entity.address)
|
|
626
|
-
return None
|
|
627
|
-
return bool(result.bits[0])
|
|
628
|
-
|
|
629
|
-
if input_type_str == "discrete_input":
|
|
630
|
-
result = await self._client.read_discrete_inputs(
|
|
631
|
-
entity.address, count=1, device_id=self.unit
|
|
632
|
-
)
|
|
633
|
-
if result.isError():
|
|
634
|
-
_LOGGER.warning("Error reading discrete input %s", entity.address)
|
|
635
|
-
return None
|
|
636
|
-
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
|
|
637
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:
|
|
638
727
|
if input_type_str == "input":
|
|
639
728
|
result = await self._client.read_input_registers(
|
|
640
729
|
entity.address, count=count, device_id=self.unit
|
|
@@ -649,11 +738,10 @@ class QubeClient:
|
|
|
649
738
|
return None
|
|
650
739
|
|
|
651
740
|
val = self._decode_registers(data_type_str, result.registers)
|
|
652
|
-
|
|
741
|
+
value = self._apply_scaling(entity, val)
|
|
653
742
|
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
return None
|
|
743
|
+
self._log_read_ok(f"entity {entity.key}")
|
|
744
|
+
return value
|
|
657
745
|
|
|
658
746
|
async def read_sensor(self, key: str) -> float | int | None:
|
|
659
747
|
"""Read a sensor value by key.
|
|
@@ -700,16 +788,18 @@ class QubeClient:
|
|
|
700
788
|
return None
|
|
701
789
|
return await self.read_entity(entity)
|
|
702
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
|
+
|
|
703
796
|
async def read_all_sensors(self) -> dict[str, Any]:
|
|
704
797
|
"""Read all sensor values.
|
|
705
798
|
|
|
706
799
|
Returns:
|
|
707
800
|
Dictionary mapping sensor keys to their values.
|
|
708
801
|
"""
|
|
709
|
-
|
|
710
|
-
for key, entity in SENSORS.items():
|
|
711
|
-
result[key] = await self.read_entity(entity)
|
|
712
|
-
return result
|
|
802
|
+
return await self._read_table(SENSORS)
|
|
713
803
|
|
|
714
804
|
async def read_all_binary_sensors(self) -> dict[str, bool | None]:
|
|
715
805
|
"""Read all binary sensor values.
|
|
@@ -717,10 +807,7 @@ class QubeClient:
|
|
|
717
807
|
Returns:
|
|
718
808
|
Dictionary mapping binary sensor keys to their values.
|
|
719
809
|
"""
|
|
720
|
-
|
|
721
|
-
for key, entity in BINARY_SENSORS.items():
|
|
722
|
-
result[key] = await self.read_entity(entity)
|
|
723
|
-
return result
|
|
810
|
+
return await self._read_table(BINARY_SENSORS)
|
|
724
811
|
|
|
725
812
|
async def read_all_switches(self) -> dict[str, bool | None]:
|
|
726
813
|
"""Read all switch states.
|
|
@@ -728,10 +815,7 @@ class QubeClient:
|
|
|
728
815
|
Returns:
|
|
729
816
|
Dictionary mapping switch keys to their states.
|
|
730
817
|
"""
|
|
731
|
-
|
|
732
|
-
for key, entity in SWITCHES.items():
|
|
733
|
-
result[key] = await self.read_entity(entity)
|
|
734
|
-
return result
|
|
818
|
+
return await self._read_table(SWITCHES)
|
|
735
819
|
|
|
736
820
|
async def write_switch(self, key: str, value: bool) -> bool:
|
|
737
821
|
"""Write a switch state by key.
|
|
@@ -762,6 +846,8 @@ class QubeClient:
|
|
|
762
846
|
return True
|
|
763
847
|
except Exception as e:
|
|
764
848
|
_LOGGER.error("Exception writing switch %s: %s", key, e)
|
|
849
|
+
if isinstance(e, _LINK_ERRORS):
|
|
850
|
+
self._mark_disconnected()
|
|
765
851
|
return False
|
|
766
852
|
|
|
767
853
|
# SG Ready mode API
|
|
@@ -782,8 +868,11 @@ class QubeClient:
|
|
|
782
868
|
Returns:
|
|
783
869
|
Mode string ("off", "block", "plus", "max"), or None on error.
|
|
784
870
|
"""
|
|
785
|
-
|
|
786
|
-
|
|
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")
|
|
787
876
|
if bit_a is None or bit_b is None:
|
|
788
877
|
return None
|
|
789
878
|
return self._SGREADY_BITS_TO_MODE.get((bool(bit_a), bool(bit_b)))
|
|
@@ -791,16 +880,41 @@ class QubeClient:
|
|
|
791
880
|
async def set_sg_ready_mode(self, mode: str) -> bool:
|
|
792
881
|
"""Set the SG Ready mode.
|
|
793
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
|
+
|
|
794
887
|
Args:
|
|
795
888
|
mode: One of "off", "block", "plus", "max".
|
|
796
889
|
|
|
797
890
|
Returns:
|
|
798
|
-
True if both
|
|
891
|
+
True if both coils were written, False otherwise.
|
|
799
892
|
"""
|
|
800
893
|
bits = self._SGREADY_MODE_TO_BITS.get(mode)
|
|
801
894
|
if bits is None:
|
|
802
895
|
_LOGGER.warning("Unknown SG Ready mode: %s", mode)
|
|
803
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
|
+
|
|
804
918
|
success_a = await self.write_switch("bms_sgready_a", bits[0])
|
|
805
919
|
success_b = await self.write_switch("bms_sgready_b", bits[1])
|
|
806
920
|
return success_a and success_b
|
|
@@ -828,6 +942,24 @@ class QubeClient:
|
|
|
828
942
|
_LOGGER.warning("Sensor %s is not a holding register", key)
|
|
829
943
|
return False
|
|
830
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
|
+
|
|
831
963
|
try:
|
|
832
964
|
# Reverse scale/offset if needed
|
|
833
965
|
write_value = value
|
|
@@ -846,15 +978,25 @@ class QubeClient:
|
|
|
846
978
|
result = await self._client.write_registers(
|
|
847
979
|
entity.address, regs, device_id=self.unit
|
|
848
980
|
)
|
|
849
|
-
elif entity.data_type
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
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)
|
|
854
987
|
)
|
|
855
|
-
|
|
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
|
|
856
998
|
result = await self._client.write_register(
|
|
857
|
-
entity.address,
|
|
999
|
+
entity.address, raw, device_id=self.unit
|
|
858
1000
|
)
|
|
859
1001
|
else:
|
|
860
1002
|
_LOGGER.warning(
|
|
@@ -869,4 +1011,6 @@ class QubeClient:
|
|
|
869
1011
|
|
|
870
1012
|
except Exception as e:
|
|
871
1013
|
_LOGGER.error("Exception writing setpoint %s: %s", key, e)
|
|
1014
|
+
if isinstance(e, _LINK_ERRORS):
|
|
1015
|
+
self._mark_disconnected()
|
|
872
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."""
|
|
@@ -85,6 +85,8 @@ _SENSOR_DEFS: tuple[EntityDef, ...] = (
|
|
|
85
85
|
platform=Platform.SENSOR,
|
|
86
86
|
unit="°C",
|
|
87
87
|
writable=True, # User-override setpoint, bypasses heating curve
|
|
88
|
+
min_value=20.0,
|
|
89
|
+
max_value=65.0,
|
|
88
90
|
),
|
|
89
91
|
EntityDef(
|
|
90
92
|
key="usr_pid_coolsetp",
|
|
@@ -95,6 +97,8 @@ _SENSOR_DEFS: tuple[EntityDef, ...] = (
|
|
|
95
97
|
platform=Platform.SENSOR,
|
|
96
98
|
unit="°C",
|
|
97
99
|
writable=True, # User-override setpoint, bypasses cooling curve
|
|
100
|
+
min_value=7.0,
|
|
101
|
+
max_value=25.0,
|
|
98
102
|
),
|
|
99
103
|
EntityDef(
|
|
100
104
|
key="regulation_buffersetp_max",
|
|
@@ -114,6 +118,8 @@ _SENSOR_DEFS: tuple[EntityDef, ...] = (
|
|
|
114
118
|
platform=Platform.SENSOR,
|
|
115
119
|
unit="°C",
|
|
116
120
|
writable=True, # This setpoint can be written via Modbus
|
|
121
|
+
min_value=40.0,
|
|
122
|
+
max_value=65.0,
|
|
117
123
|
),
|
|
118
124
|
# Input register sensors (read-only)
|
|
119
125
|
EntityDef(
|
|
@@ -460,6 +466,8 @@ _SENSOR_DEFS: tuple[EntityDef, ...] = (
|
|
|
460
466
|
platform=Platform.SENSOR,
|
|
461
467
|
unit="°C",
|
|
462
468
|
writable=True,
|
|
469
|
+
min_value=40.0,
|
|
470
|
+
max_value=65.0,
|
|
463
471
|
),
|
|
464
472
|
)
|
|
465
473
|
|