python-qube-heatpump 1.14.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.14.0 → python_qube_heatpump-1.15.0}/PKG-INFO +1 -1
- {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/pyproject.toml +1 -1
- {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/src/python_qube_heatpump/client.py +229 -85
- {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/src/python_qube_heatpump/entities/base.py +7 -0
- {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/src/python_qube_heatpump/entities/sensors.py +8 -0
- {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/src/python_qube_heatpump/network.py +3 -2
- {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/tests/conftest.py +3 -2
- {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/tests/test_client.py +14 -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.14.0 → python_qube_heatpump-1.15.0}/tests/test_network.py +31 -0
- {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/.github/workflows/ci.yml +0 -0
- {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/.github/workflows/python-publish.yml +0 -0
- {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/.gitignore +0 -0
- {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/AGENTS.md +0 -0
- {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/CLAUDE.md +0 -0
- {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/LICENSE +0 -0
- {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/README.md +0 -0
- {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/docs/modbus-lijst-qube-totaal.pdf +0 -0
- {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/pytest.ini +0 -0
- {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/src/python_qube_heatpump/__init__.py +0 -0
- {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/src/python_qube_heatpump/const.py +0 -0
- {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/src/python_qube_heatpump/entities/__init__.py +0 -0
- {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/src/python_qube_heatpump/entities/binary_sensors.py +0 -0
- {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/src/python_qube_heatpump/entities/switches.py +0 -0
- {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/src/python_qube_heatpump/models.py +0 -0
- {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/src/python_qube_heatpump/py.typed +0 -0
- {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/tests/test_client_batching.py +0 -0
- {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/tests/test_const.py +0 -0
- {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/tests/test_entities.py +0 -0
- {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.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.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
|
{python_qube_heatpump-1.14.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.
|
|
@@ -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
|
|
{python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/src/python_qube_heatpump/network.py
RENAMED
|
@@ -54,7 +54,8 @@ async def async_get_mac_address(host: str, port: int = MODBUS_PORT) -> str | Non
|
|
|
54
54
|
Returns:
|
|
55
55
|
MAC address as lowercase colon-separated string, or None.
|
|
56
56
|
"""
|
|
57
|
-
|
|
57
|
+
# Both lookups block (DNS, file I/O), so keep them off the event loop
|
|
58
|
+
ip = await asyncio.to_thread(_resolve_ip, host)
|
|
58
59
|
if ip is None:
|
|
59
60
|
_LOGGER.debug("Could not resolve host %s to IP", host)
|
|
60
61
|
return None
|
|
@@ -71,4 +72,4 @@ async def async_get_mac_address(host: str, port: int = MODBUS_PORT) -> str | Non
|
|
|
71
72
|
_LOGGER.debug("Could not connect to %s:%s to populate ARP", ip, port)
|
|
72
73
|
return None
|
|
73
74
|
|
|
74
|
-
return _read_arp_table
|
|
75
|
+
return await asyncio.to_thread(_read_arp_table, ip)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import pytest
|
|
2
|
-
from unittest.mock import AsyncMock
|
|
2
|
+
from unittest.mock import AsyncMock, MagicMock
|
|
3
3
|
|
|
4
4
|
|
|
5
5
|
@pytest.fixture
|
|
@@ -8,7 +8,8 @@ def mock_modbus_client(mocker):
|
|
|
8
8
|
mock_client = mocker.patch("python_qube_heatpump.client.AsyncModbusTcpClient")
|
|
9
9
|
mock_instance = mock_client.return_value
|
|
10
10
|
mock_instance.connect = AsyncMock(return_value=True)
|
|
11
|
-
|
|
11
|
+
# pymodbus close() is synchronous
|
|
12
|
+
mock_instance.close = MagicMock()
|
|
12
13
|
mock_instance.read_holding_registers = AsyncMock()
|
|
13
14
|
mock_instance.read_input_registers = AsyncMock()
|
|
14
15
|
mock_instance.write_register = AsyncMock()
|
|
@@ -456,16 +456,12 @@ async def test_get_sg_ready_mode(mock_modbus_client):
|
|
|
456
456
|
mock_instance = mock_modbus_client.return_value
|
|
457
457
|
client._client = mock_instance
|
|
458
458
|
|
|
459
|
-
#
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
mock_resp_b = MagicMock()
|
|
465
|
-
mock_resp_b.isError.return_value = False
|
|
466
|
-
mock_resp_b.bits = [True]
|
|
459
|
+
# Both coils come back in one block read: A=False, B=True -> "plus"
|
|
460
|
+
mock_resp = MagicMock()
|
|
461
|
+
mock_resp.isError.return_value = False
|
|
462
|
+
mock_resp.bits = [False, True]
|
|
467
463
|
|
|
468
|
-
mock_instance.read_coils = AsyncMock(
|
|
464
|
+
mock_instance.read_coils = AsyncMock(return_value=mock_resp)
|
|
469
465
|
|
|
470
466
|
result = await client.get_sg_ready_mode()
|
|
471
467
|
assert result == "plus"
|
|
@@ -485,13 +481,10 @@ async def test_get_sg_ready_mode_all_modes(mock_modbus_client):
|
|
|
485
481
|
((True, True), "max"),
|
|
486
482
|
]
|
|
487
483
|
for (bit_a, bit_b), expected_mode in cases:
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
resp_b.isError.return_value = False
|
|
493
|
-
resp_b.bits = [bit_b]
|
|
494
|
-
mock_instance.read_coils = AsyncMock(side_effect=[resp_a, resp_b])
|
|
484
|
+
resp = MagicMock()
|
|
485
|
+
resp.isError.return_value = False
|
|
486
|
+
resp.bits = [bit_a, bit_b]
|
|
487
|
+
mock_instance.read_coils = AsyncMock(return_value=resp)
|
|
495
488
|
|
|
496
489
|
result = await client.get_sg_ready_mode()
|
|
497
490
|
assert result == expected_mode, (
|
|
@@ -516,23 +509,21 @@ async def test_get_sg_ready_mode_read_error(mock_modbus_client):
|
|
|
516
509
|
|
|
517
510
|
@pytest.mark.asyncio
|
|
518
511
|
async def test_set_sg_ready_mode(mock_modbus_client):
|
|
519
|
-
"""Test setting SG Ready mode writes both coils."""
|
|
512
|
+
"""Test setting SG Ready mode writes both coils in one request."""
|
|
520
513
|
client = QubeClient("1.2.3.4", 502)
|
|
521
514
|
mock_instance = mock_modbus_client.return_value
|
|
522
515
|
client._client = mock_instance
|
|
523
516
|
|
|
524
517
|
mock_resp = MagicMock()
|
|
525
518
|
mock_resp.isError.return_value = False
|
|
526
|
-
mock_instance.
|
|
519
|
+
mock_instance.write_coils = AsyncMock(return_value=mock_resp)
|
|
527
520
|
|
|
528
521
|
result = await client.set_sg_ready_mode("plus")
|
|
529
522
|
assert result is True
|
|
530
523
|
|
|
531
|
-
# "plus" = (False, True) -> A=False, B=True
|
|
532
|
-
|
|
533
|
-
assert
|
|
534
|
-
assert calls[0].args == (65, False) # bms_sgready_a address
|
|
535
|
-
assert calls[1].args == (66, True) # bms_sgready_b address
|
|
524
|
+
# "plus" = (False, True) -> A=False (coil 65), B=True (coil 66)
|
|
525
|
+
mock_instance.write_coils.assert_awaited_once()
|
|
526
|
+
assert mock_instance.write_coils.call_args.args == (65, [False, True])
|
|
536
527
|
|
|
537
528
|
|
|
538
529
|
@pytest.mark.asyncio
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""read_all_* helpers must match per-entity reads while batching requests."""
|
|
2
|
+
|
|
3
|
+
from unittest.mock import AsyncMock, MagicMock
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from python_qube_heatpump import QubeClient
|
|
8
|
+
from python_qube_heatpump.entities import BINARY_SENSORS, SENSORS, SWITCHES
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _fake_device(mock_instance):
|
|
12
|
+
"""Serve a fixed, address-dependent register and bit map."""
|
|
13
|
+
|
|
14
|
+
def registers(address, count=1, **kwargs):
|
|
15
|
+
resp = MagicMock()
|
|
16
|
+
resp.isError.return_value = False
|
|
17
|
+
# Varied words so float32/int16/uint32 decoding is exercised
|
|
18
|
+
resp.registers = [
|
|
19
|
+
(a * 7919 + 0x4100) & 0xFFFF for a in range(address, address + count)
|
|
20
|
+
]
|
|
21
|
+
return resp
|
|
22
|
+
|
|
23
|
+
def bits(address, count=1, **kwargs):
|
|
24
|
+
resp = MagicMock()
|
|
25
|
+
resp.isError.return_value = False
|
|
26
|
+
resp.bits = [
|
|
27
|
+
bool((a * 2654435761) & 4) for a in range(address, address + count)
|
|
28
|
+
]
|
|
29
|
+
return resp
|
|
30
|
+
|
|
31
|
+
mock_instance.read_input_registers = AsyncMock(side_effect=registers)
|
|
32
|
+
mock_instance.read_holding_registers = AsyncMock(side_effect=registers)
|
|
33
|
+
mock_instance.read_coils = AsyncMock(side_effect=bits)
|
|
34
|
+
mock_instance.read_discrete_inputs = AsyncMock(side_effect=bits)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _total_reads(mock_instance) -> int:
|
|
38
|
+
return sum(
|
|
39
|
+
getattr(mock_instance, name).call_count
|
|
40
|
+
for name in (
|
|
41
|
+
"read_coils",
|
|
42
|
+
"read_discrete_inputs",
|
|
43
|
+
"read_input_registers",
|
|
44
|
+
"read_holding_registers",
|
|
45
|
+
)
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _same(a, b) -> bool:
|
|
50
|
+
if isinstance(a, float) and isinstance(b, float) and a != a and b != b:
|
|
51
|
+
return True # both NaN
|
|
52
|
+
return a == b and type(a) is type(b)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@pytest.mark.asyncio
|
|
56
|
+
@pytest.mark.parametrize(
|
|
57
|
+
("method", "table"),
|
|
58
|
+
[
|
|
59
|
+
("read_all_sensors", SENSORS),
|
|
60
|
+
("read_all_binary_sensors", BINARY_SENSORS),
|
|
61
|
+
("read_all_switches", SWITCHES),
|
|
62
|
+
],
|
|
63
|
+
)
|
|
64
|
+
async def test_read_all_matches_single_reads_with_fewer_requests(
|
|
65
|
+
mock_modbus_client, method, table
|
|
66
|
+
):
|
|
67
|
+
client = QubeClient("1.2.3.4", 502)
|
|
68
|
+
mock_instance = mock_modbus_client.return_value
|
|
69
|
+
client._client = mock_instance
|
|
70
|
+
_fake_device(mock_instance)
|
|
71
|
+
|
|
72
|
+
expected = {key: await client.read_entity(ent) for key, ent in table.items()}
|
|
73
|
+
single_reads = _total_reads(mock_instance)
|
|
74
|
+
|
|
75
|
+
mock_instance.reset_mock()
|
|
76
|
+
_fake_device(mock_instance)
|
|
77
|
+
result = await getattr(client, method)()
|
|
78
|
+
|
|
79
|
+
assert list(result) == list(table)
|
|
80
|
+
mismatched = [k for k in table if not _same(result[k], expected[k])]
|
|
81
|
+
assert mismatched == []
|
|
82
|
+
assert _total_reads(mock_instance) < single_reads
|
|
83
|
+
assert _total_reads(mock_instance) == len(client._plan_blocks(table.values()))
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""Tests for QubeClient behaviour when the Modbus link fails."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from unittest.mock import AsyncMock, MagicMock
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
from pymodbus.exceptions import ConnectionException, ModbusIOException
|
|
8
|
+
|
|
9
|
+
from python_qube_heatpump import QubeClient
|
|
10
|
+
from python_qube_heatpump.entities import BINARY_SENSORS, SWITCHES
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _ok_registers(address, count=1, **kwargs):
|
|
14
|
+
resp = MagicMock()
|
|
15
|
+
resp.isError.return_value = False
|
|
16
|
+
resp.registers = [0] * count
|
|
17
|
+
return resp
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _ok_bits(address, count=1, **kwargs):
|
|
21
|
+
resp = MagicMock()
|
|
22
|
+
resp.isError.return_value = False
|
|
23
|
+
resp.bits = [False] * count
|
|
24
|
+
return resp
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _error_reply(*args, **kwargs):
|
|
28
|
+
resp = MagicMock()
|
|
29
|
+
resp.isError.return_value = True
|
|
30
|
+
return resp
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _all_reads(mock_instance, side_effect):
|
|
34
|
+
"""Point every read function at the same side effect."""
|
|
35
|
+
for name in (
|
|
36
|
+
"read_coils",
|
|
37
|
+
"read_discrete_inputs",
|
|
38
|
+
"read_input_registers",
|
|
39
|
+
"read_holding_registers",
|
|
40
|
+
):
|
|
41
|
+
setattr(mock_instance, name, AsyncMock(side_effect=side_effect))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _total_reads(mock_instance) -> int:
|
|
45
|
+
return sum(
|
|
46
|
+
getattr(mock_instance, name).call_count
|
|
47
|
+
for name in (
|
|
48
|
+
"read_coils",
|
|
49
|
+
"read_discrete_inputs",
|
|
50
|
+
"read_input_registers",
|
|
51
|
+
"read_holding_registers",
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _healthy(mock_instance):
|
|
57
|
+
mock_instance.read_coils = AsyncMock(side_effect=_ok_bits)
|
|
58
|
+
mock_instance.read_discrete_inputs = AsyncMock(side_effect=_ok_bits)
|
|
59
|
+
mock_instance.read_input_registers = AsyncMock(side_effect=_ok_registers)
|
|
60
|
+
mock_instance.read_holding_registers = AsyncMock(side_effect=_ok_registers)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@pytest.fixture
|
|
64
|
+
def connected_client(mock_modbus_client):
|
|
65
|
+
client = QubeClient("1.2.3.4", 502)
|
|
66
|
+
mock_instance = mock_modbus_client.return_value
|
|
67
|
+
client._client = mock_instance
|
|
68
|
+
client._connected = True
|
|
69
|
+
return client, mock_instance
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@pytest.mark.asyncio
|
|
73
|
+
@pytest.mark.parametrize(
|
|
74
|
+
"exc",
|
|
75
|
+
[
|
|
76
|
+
ConnectionException("down"),
|
|
77
|
+
ModbusIOException("No response received after 3 retries"),
|
|
78
|
+
TimeoutError(),
|
|
79
|
+
ConnectionResetError(),
|
|
80
|
+
],
|
|
81
|
+
)
|
|
82
|
+
async def test_link_error_marks_client_disconnected(connected_client, exc):
|
|
83
|
+
"""A link-level failure clears is_connected and closes the transport."""
|
|
84
|
+
client, mock_instance = connected_client
|
|
85
|
+
_all_reads(mock_instance, exc)
|
|
86
|
+
|
|
87
|
+
await client.read_entities_batched(SWITCHES.values())
|
|
88
|
+
|
|
89
|
+
assert client.is_connected is False
|
|
90
|
+
mock_instance.close.assert_called()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@pytest.mark.asyncio
|
|
94
|
+
async def test_get_all_data_stops_after_first_link_error(connected_client):
|
|
95
|
+
"""A dead link costs two transactions per poll, not one per entity.
|
|
96
|
+
|
|
97
|
+
The failed block read is followed by one single-entity read that acts as
|
|
98
|
+
a probe (a device may time out on a large block but answer small reads);
|
|
99
|
+
when that fails too, the poll stops.
|
|
100
|
+
"""
|
|
101
|
+
client, mock_instance = connected_client
|
|
102
|
+
_all_reads(mock_instance, ModbusIOException("no response"))
|
|
103
|
+
|
|
104
|
+
state = await client.get_all_data()
|
|
105
|
+
|
|
106
|
+
assert state is None
|
|
107
|
+
assert _total_reads(mock_instance) == 2
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@pytest.mark.asyncio
|
|
111
|
+
async def test_batched_read_returns_every_key_after_link_error(connected_client):
|
|
112
|
+
"""Callers still get a value (None) for every requested entity."""
|
|
113
|
+
client, mock_instance = connected_client
|
|
114
|
+
_all_reads(mock_instance, ConnectionException("down"))
|
|
115
|
+
|
|
116
|
+
results = await client.read_entities_batched(BINARY_SENSORS.values())
|
|
117
|
+
|
|
118
|
+
assert set(results) == set(BINARY_SENSORS)
|
|
119
|
+
assert all(value is None for value in results.values())
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@pytest.mark.asyncio
|
|
123
|
+
async def test_block_timeout_with_working_single_reads_stays_connected(
|
|
124
|
+
connected_client,
|
|
125
|
+
):
|
|
126
|
+
"""A block that times out while single reads work is not a dead link."""
|
|
127
|
+
client, mock_instance = connected_client
|
|
128
|
+
mock_instance.read_discrete_inputs = AsyncMock(
|
|
129
|
+
side_effect=[ModbusIOException("no response"), _ok_bits(0), _ok_bits(1)]
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
entities = [BINARY_SENSORS["dout_srcpmp_val"], BINARY_SENSORS["dout_usrpmp_val"]]
|
|
133
|
+
results = await client.read_entities_batched(entities)
|
|
134
|
+
|
|
135
|
+
assert results == {e.key: False for e in entities}
|
|
136
|
+
assert client.is_connected is True
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@pytest.mark.asyncio
|
|
140
|
+
async def test_error_reply_still_falls_back_to_single_reads(connected_client):
|
|
141
|
+
"""A Modbus error reply is not a link failure: fall back per entity."""
|
|
142
|
+
client, mock_instance = connected_client
|
|
143
|
+
_all_reads(mock_instance, _error_reply)
|
|
144
|
+
|
|
145
|
+
entities = [BINARY_SENSORS["dout_srcpmp_val"], BINARY_SENSORS["dout_usrpmp_val"]]
|
|
146
|
+
await client.read_entities_batched(entities)
|
|
147
|
+
|
|
148
|
+
# one block read plus one single read per entity
|
|
149
|
+
assert mock_instance.read_discrete_inputs.call_count == 3
|
|
150
|
+
assert client.is_connected is True
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@pytest.mark.asyncio
|
|
154
|
+
async def test_single_read_fallback_stops_at_link_error(connected_client):
|
|
155
|
+
"""If the link dies during the per-entity fallback, the rest is skipped."""
|
|
156
|
+
client, mock_instance = connected_client
|
|
157
|
+
mock_instance.read_discrete_inputs = AsyncMock(
|
|
158
|
+
side_effect=[_error_reply(), ConnectionException("down")] + [_ok_bits(0)] * 10
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
entities = [
|
|
162
|
+
BINARY_SENSORS["dout_srcpmp_val"],
|
|
163
|
+
BINARY_SENSORS["dout_usrpmp_val"],
|
|
164
|
+
BINARY_SENSORS["dout_fourwayvlv_val"],
|
|
165
|
+
]
|
|
166
|
+
results = await client.read_entities_batched(entities)
|
|
167
|
+
|
|
168
|
+
assert mock_instance.read_discrete_inputs.call_count == 2
|
|
169
|
+
assert results == {e.key: None for e in entities}
|
|
170
|
+
assert client.is_connected is False
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@pytest.mark.asyncio
|
|
174
|
+
async def test_get_all_data_reconnects_on_next_poll(connected_client):
|
|
175
|
+
"""After a lost link the next poll reconnects and returns data again."""
|
|
176
|
+
client, mock_instance = connected_client
|
|
177
|
+
_all_reads(mock_instance, ConnectionException("down"))
|
|
178
|
+
assert await client.get_all_data() is None
|
|
179
|
+
assert client.is_connected is False
|
|
180
|
+
|
|
181
|
+
mock_instance.connect = AsyncMock(return_value=True)
|
|
182
|
+
_healthy(mock_instance)
|
|
183
|
+
state = await client.get_all_data()
|
|
184
|
+
|
|
185
|
+
assert state is not None
|
|
186
|
+
mock_instance.connect.assert_awaited_once()
|
|
187
|
+
assert client.is_connected is True
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@pytest.mark.asyncio
|
|
191
|
+
async def test_read_entity_link_error_marks_disconnected(connected_client):
|
|
192
|
+
"""Single-entity reads detect a lost link too."""
|
|
193
|
+
client, mock_instance = connected_client
|
|
194
|
+
_all_reads(mock_instance, ConnectionException("down"))
|
|
195
|
+
|
|
196
|
+
assert await client.read_entity(BINARY_SENSORS["dout_srcpmp_val"]) is None
|
|
197
|
+
assert client.is_connected is False
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@pytest.mark.asyncio
|
|
201
|
+
async def test_write_link_error_marks_disconnected(connected_client):
|
|
202
|
+
"""Writes that hit a lost link return False and clear is_connected."""
|
|
203
|
+
client, mock_instance = connected_client
|
|
204
|
+
mock_instance.write_coil = AsyncMock(side_effect=ConnectionException("down"))
|
|
205
|
+
|
|
206
|
+
assert await client.write_switch("bms_summerwinter", True) is False
|
|
207
|
+
assert client.is_connected is False
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@pytest.mark.asyncio
|
|
211
|
+
async def test_failure_warns_again_after_recovery(connected_client, caplog):
|
|
212
|
+
"""Warn-once resets when the target reads fine again."""
|
|
213
|
+
client, mock_instance = connected_client
|
|
214
|
+
entity = BINARY_SENSORS["dout_srcpmp_val"]
|
|
215
|
+
mock_instance.read_discrete_inputs = AsyncMock(
|
|
216
|
+
side_effect=[
|
|
217
|
+
_error_reply(),
|
|
218
|
+
_error_reply(),
|
|
219
|
+
_ok_bits(0),
|
|
220
|
+
_error_reply(),
|
|
221
|
+
_error_reply(),
|
|
222
|
+
]
|
|
223
|
+
)
|
|
224
|
+
with caplog.at_level(logging.DEBUG, logger="python_qube_heatpump.client"):
|
|
225
|
+
await client.read_entities_batched([entity]) # block fails, single fails
|
|
226
|
+
await client.read_entities_batched([entity]) # recovers
|
|
227
|
+
await client.read_entities_batched([entity]) # block fails again
|
|
228
|
+
|
|
229
|
+
warnings = [
|
|
230
|
+
r
|
|
231
|
+
for r in caplog.records
|
|
232
|
+
if r.levelno == logging.WARNING and "block" in r.getMessage()
|
|
233
|
+
]
|
|
234
|
+
assert len(warnings) == 2
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@pytest.mark.asyncio
|
|
238
|
+
async def test_link_failure_warns_again_after_recovery(connected_client, caplog):
|
|
239
|
+
"""A second outage days later is logged at WARNING again."""
|
|
240
|
+
client, mock_instance = connected_client
|
|
241
|
+
entity = BINARY_SENSORS["dout_srcpmp_val"]
|
|
242
|
+
mock_instance.read_discrete_inputs = AsyncMock(
|
|
243
|
+
side_effect=[OSError("down"), _ok_bits(0), OSError("down again")]
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
with caplog.at_level(logging.DEBUG, logger="python_qube_heatpump.client"):
|
|
247
|
+
await client.read_entity(entity)
|
|
248
|
+
await client.read_entity(entity)
|
|
249
|
+
await client.read_entity(entity)
|
|
250
|
+
|
|
251
|
+
warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
|
|
252
|
+
assert len(warnings) == 2
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Tests for write validation and SG Ready writes in QubeClient."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import struct
|
|
5
|
+
from unittest.mock import AsyncMock, MagicMock
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from pymodbus.exceptions import ConnectionException
|
|
9
|
+
|
|
10
|
+
from python_qube_heatpump import QubeClient
|
|
11
|
+
from python_qube_heatpump.entities import SENSORS
|
|
12
|
+
from python_qube_heatpump.entities.base import (
|
|
13
|
+
DataType,
|
|
14
|
+
EntityDef,
|
|
15
|
+
InputType,
|
|
16
|
+
Platform,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _ok():
|
|
21
|
+
resp = MagicMock()
|
|
22
|
+
resp.isError.return_value = False
|
|
23
|
+
return resp
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _error_reply():
|
|
27
|
+
resp = MagicMock()
|
|
28
|
+
resp.isError.return_value = True
|
|
29
|
+
return resp
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _float_from_regs(regs):
|
|
33
|
+
return struct.unpack(">f", struct.pack(">I", (regs[0] << 16) | regs[1]))[0]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@pytest.fixture
|
|
37
|
+
def client(mock_modbus_client):
|
|
38
|
+
qube = QubeClient("1.2.3.4", 502)
|
|
39
|
+
mock_instance = mock_modbus_client.return_value
|
|
40
|
+
mock_instance.write_registers = AsyncMock(return_value=_ok())
|
|
41
|
+
mock_instance.write_register = AsyncMock(return_value=_ok())
|
|
42
|
+
mock_instance.write_coil = AsyncMock(return_value=_ok())
|
|
43
|
+
mock_instance.write_coils = AsyncMock(return_value=_ok())
|
|
44
|
+
qube._client = mock_instance
|
|
45
|
+
qube._connected = True
|
|
46
|
+
return qube, mock_instance
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# Ranges the HA core and HACS integrations expose for these setpoints
|
|
50
|
+
@pytest.mark.parametrize(
|
|
51
|
+
("key", "low", "high"),
|
|
52
|
+
[
|
|
53
|
+
("setpoint_dhw", 40.0, 65.0),
|
|
54
|
+
("tapw_timeprogram_dhwsetp_nolinq", 40.0, 65.0),
|
|
55
|
+
("usr_pid_heatsetp", 20.0, 65.0),
|
|
56
|
+
("usr_pid_coolsetp", 7.0, 25.0),
|
|
57
|
+
],
|
|
58
|
+
)
|
|
59
|
+
def test_writable_setpoints_have_safe_ranges(key, low, high):
|
|
60
|
+
entity = SENSORS[key]
|
|
61
|
+
assert entity.min_value == low
|
|
62
|
+
assert entity.max_value == high
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_every_writable_sensor_has_a_range():
|
|
66
|
+
for entity in SENSORS.values():
|
|
67
|
+
if entity.writable:
|
|
68
|
+
assert entity.min_value is not None, entity.key
|
|
69
|
+
assert entity.max_value is not None, entity.key
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@pytest.mark.asyncio
|
|
73
|
+
@pytest.mark.parametrize("value", [40.0, 52.5, 65.0])
|
|
74
|
+
async def test_write_setpoint_accepts_values_in_range(client, value):
|
|
75
|
+
qube, mock_instance = client
|
|
76
|
+
assert await qube.write_setpoint("setpoint_dhw", value) is True
|
|
77
|
+
regs = mock_instance.write_registers.call_args.args[1]
|
|
78
|
+
assert _float_from_regs(regs) == pytest.approx(value)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@pytest.mark.asyncio
|
|
82
|
+
@pytest.mark.parametrize("value", [39.9, 65.1, 500.0, -10.0])
|
|
83
|
+
async def test_write_setpoint_rejects_out_of_range(client, value):
|
|
84
|
+
qube, mock_instance = client
|
|
85
|
+
assert await qube.write_setpoint("setpoint_dhw", value) is False
|
|
86
|
+
mock_instance.write_registers.assert_not_called()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@pytest.mark.asyncio
|
|
90
|
+
@pytest.mark.parametrize("value", [math.nan, math.inf, -math.inf])
|
|
91
|
+
async def test_write_setpoint_rejects_non_finite(client, value):
|
|
92
|
+
qube, mock_instance = client
|
|
93
|
+
assert await qube.write_setpoint("usr_pid_heatsetp", value) is False
|
|
94
|
+
mock_instance.write_registers.assert_not_called()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _int_entity(data_type, **kwargs):
|
|
98
|
+
return EntityDef(
|
|
99
|
+
key="test_int_setpoint",
|
|
100
|
+
name="Test",
|
|
101
|
+
address=500,
|
|
102
|
+
input_type=InputType.HOLDING_REGISTER,
|
|
103
|
+
data_type=data_type,
|
|
104
|
+
platform=Platform.SENSOR,
|
|
105
|
+
writable=True,
|
|
106
|
+
**kwargs,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@pytest.mark.asyncio
|
|
111
|
+
@pytest.mark.parametrize(
|
|
112
|
+
("data_type", "value", "written"),
|
|
113
|
+
[
|
|
114
|
+
(DataType.UINT16, 21.6, 22),
|
|
115
|
+
(DataType.UINT16, 21.4, 21),
|
|
116
|
+
(DataType.INT16, -2.6, 65536 - 3),
|
|
117
|
+
],
|
|
118
|
+
)
|
|
119
|
+
async def test_write_setpoint_rounds_16bit_values(
|
|
120
|
+
client, monkeypatch, data_type, value, written
|
|
121
|
+
):
|
|
122
|
+
qube, mock_instance = client
|
|
123
|
+
monkeypatch.setitem(SENSORS, "test_int_setpoint", _int_entity(data_type))
|
|
124
|
+
assert await qube.write_setpoint("test_int_setpoint", value) is True
|
|
125
|
+
assert mock_instance.write_register.call_args.args[1] == written
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@pytest.mark.asyncio
|
|
129
|
+
@pytest.mark.parametrize(
|
|
130
|
+
("data_type", "value"),
|
|
131
|
+
[
|
|
132
|
+
(DataType.UINT16, -1),
|
|
133
|
+
(DataType.UINT16, 65536),
|
|
134
|
+
(DataType.INT16, 32768),
|
|
135
|
+
(DataType.INT16, -32769),
|
|
136
|
+
],
|
|
137
|
+
)
|
|
138
|
+
async def test_write_setpoint_rejects_16bit_overflow(
|
|
139
|
+
client, monkeypatch, data_type, value
|
|
140
|
+
):
|
|
141
|
+
qube, mock_instance = client
|
|
142
|
+
monkeypatch.setitem(SENSORS, "test_int_setpoint", _int_entity(data_type))
|
|
143
|
+
assert await qube.write_setpoint("test_int_setpoint", value) is False
|
|
144
|
+
mock_instance.write_register.assert_not_called()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@pytest.mark.asyncio
|
|
148
|
+
@pytest.mark.parametrize(
|
|
149
|
+
("mode", "bits"),
|
|
150
|
+
[
|
|
151
|
+
("off", [False, False]),
|
|
152
|
+
("block", [True, False]),
|
|
153
|
+
("plus", [False, True]),
|
|
154
|
+
("max", [True, True]),
|
|
155
|
+
],
|
|
156
|
+
)
|
|
157
|
+
async def test_set_sg_ready_mode_writes_both_coils_at_once(client, mode, bits):
|
|
158
|
+
qube, mock_instance = client
|
|
159
|
+
assert await qube.set_sg_ready_mode(mode) is True
|
|
160
|
+
mock_instance.write_coils.assert_awaited_once()
|
|
161
|
+
call = mock_instance.write_coils.call_args
|
|
162
|
+
assert call.args == (65, bits)
|
|
163
|
+
mock_instance.write_coil.assert_not_called()
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@pytest.mark.asyncio
|
|
167
|
+
async def test_set_sg_ready_mode_falls_back_when_multi_write_unsupported(client):
|
|
168
|
+
"""A device that rejects FC15 still gets the mode via two single writes."""
|
|
169
|
+
qube, mock_instance = client
|
|
170
|
+
mock_instance.write_coils = AsyncMock(return_value=_error_reply())
|
|
171
|
+
|
|
172
|
+
assert await qube.set_sg_ready_mode("plus") is True
|
|
173
|
+
calls = mock_instance.write_coil.call_args_list
|
|
174
|
+
assert [c.args for c in calls] == [(65, False), (66, True)]
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@pytest.mark.asyncio
|
|
178
|
+
async def test_set_sg_ready_mode_link_error_does_not_fall_back(client):
|
|
179
|
+
qube, mock_instance = client
|
|
180
|
+
mock_instance.write_coils = AsyncMock(side_effect=ConnectionException("down"))
|
|
181
|
+
|
|
182
|
+
assert await qube.set_sg_ready_mode("max") is False
|
|
183
|
+
mock_instance.write_coil.assert_not_called()
|
|
184
|
+
assert qube.is_connected is False
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@pytest.mark.asyncio
|
|
188
|
+
async def test_get_sg_ready_mode_reads_both_coils_in_one_request(client):
|
|
189
|
+
qube, mock_instance = client
|
|
190
|
+
resp = _ok()
|
|
191
|
+
resp.bits = [True, False, False, False, False, False, False, False]
|
|
192
|
+
mock_instance.read_coils = AsyncMock(return_value=resp)
|
|
193
|
+
|
|
194
|
+
assert await qube.get_sg_ready_mode() == "block"
|
|
195
|
+
mock_instance.read_coils.assert_awaited_once()
|
|
196
|
+
assert mock_instance.read_coils.call_args.args[0] == 65
|
|
197
|
+
assert mock_instance.read_coils.call_args.kwargs["count"] == 2
|
|
@@ -118,3 +118,34 @@ async def test_get_mac_address_with_ip_directly():
|
|
|
118
118
|
result = await async_get_mac_address("192.168.5.208")
|
|
119
119
|
|
|
120
120
|
assert result == "00:0a:5c:94:83:15"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@pytest.mark.asyncio
|
|
124
|
+
async def test_get_mac_address_does_blocking_work_off_the_event_loop():
|
|
125
|
+
"""DNS lookup and the ARP file read must not block the event loop."""
|
|
126
|
+
import threading
|
|
127
|
+
|
|
128
|
+
loop_thread = threading.current_thread()
|
|
129
|
+
seen: dict[str, threading.Thread] = {}
|
|
130
|
+
|
|
131
|
+
def fake_resolve(host):
|
|
132
|
+
seen["resolve"] = threading.current_thread()
|
|
133
|
+
return "192.168.5.208"
|
|
134
|
+
|
|
135
|
+
def fake_arp(ip):
|
|
136
|
+
seen["arp"] = threading.current_thread()
|
|
137
|
+
return "00:0a:5c:94:83:15"
|
|
138
|
+
|
|
139
|
+
with (
|
|
140
|
+
patch(
|
|
141
|
+
"python_qube_heatpump.network.asyncio.open_connection",
|
|
142
|
+
return_value=(AsyncMock(), AsyncMock()),
|
|
143
|
+
),
|
|
144
|
+
patch("python_qube_heatpump.network._resolve_ip", side_effect=fake_resolve),
|
|
145
|
+
patch("python_qube_heatpump.network._read_arp_table", side_effect=fake_arp),
|
|
146
|
+
):
|
|
147
|
+
result = await async_get_mac_address("qube.local")
|
|
148
|
+
|
|
149
|
+
assert result == "00:0a:5c:94:83:15"
|
|
150
|
+
assert seen["resolve"] is not loop_thread
|
|
151
|
+
assert seen["arp"] is not loop_thread
|
{python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/.github/ISSUE_TEMPLATE/bug_report.yml
RENAMED
|
File without changes
|
{python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/.github/ISSUE_TEMPLATE/config.yml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/.github/workflows/python-publish.yml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/docs/modbus-lijst-qube-totaal.pdf
RENAMED
|
File without changes
|
|
File without changes
|
{python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/src/python_qube_heatpump/__init__.py
RENAMED
|
File without changes
|
{python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/src/python_qube_heatpump/const.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/src/python_qube_heatpump/models.py
RENAMED
|
File without changes
|
{python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/src/python_qube_heatpump/py.typed
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|