python-qube-heatpump 1.11.2__tar.gz → 1.13.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.11.2 → python_qube_heatpump-1.13.0}/PKG-INFO +1 -1
- {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.13.0}/pyproject.toml +1 -1
- {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.13.0}/src/python_qube_heatpump/client.py +298 -107
- {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.13.0}/tests/test_client.py +73 -11
- python_qube_heatpump-1.13.0/tests/test_client_batching.py +249 -0
- {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.13.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.13.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.13.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.13.0}/.github/workflows/ci.yml +0 -0
- {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.13.0}/.github/workflows/python-publish.yml +0 -0
- {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.13.0}/.gitignore +0 -0
- {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.13.0}/AGENTS.md +0 -0
- {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.13.0}/CLAUDE.md +0 -0
- {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.13.0}/LICENSE +0 -0
- {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.13.0}/README.md +0 -0
- {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.13.0}/docs/modbus-lijst-qube-totaal.pdf +0 -0
- {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.13.0}/pytest.ini +0 -0
- {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.13.0}/src/python_qube_heatpump/__init__.py +0 -0
- {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.13.0}/src/python_qube_heatpump/const.py +0 -0
- {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.13.0}/src/python_qube_heatpump/entities/__init__.py +0 -0
- {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.13.0}/src/python_qube_heatpump/entities/base.py +0 -0
- {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.13.0}/src/python_qube_heatpump/entities/binary_sensors.py +0 -0
- {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.13.0}/src/python_qube_heatpump/entities/sensors.py +0 -0
- {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.13.0}/src/python_qube_heatpump/entities/switches.py +0 -0
- {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.13.0}/src/python_qube_heatpump/models.py +0 -0
- {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.13.0}/src/python_qube_heatpump/network.py +0 -0
- {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.13.0}/src/python_qube_heatpump/py.typed +0 -0
- {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.13.0}/tests/conftest.py +0 -0
- {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.13.0}/tests/test_const.py +0 -0
- {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.13.0}/tests/test_entities.py +0 -0
- {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.13.0}/tests/test_models.py +0 -0
- {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.13.0}/tests/test_network.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-qube-heatpump
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.13.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.11.2 → python_qube_heatpump-1.13.0}/src/python_qube_heatpump/client.py
RENAMED
|
@@ -6,6 +6,7 @@ import logging
|
|
|
6
6
|
import math
|
|
7
7
|
import struct
|
|
8
8
|
import time
|
|
9
|
+
from collections.abc import Iterable
|
|
9
10
|
from typing import Any
|
|
10
11
|
|
|
11
12
|
from pymodbus.client import AsyncModbusTcpClient
|
|
@@ -17,10 +18,124 @@ from .models import QubeState
|
|
|
17
18
|
|
|
18
19
|
_LOGGER = logging.getLogger(__name__)
|
|
19
20
|
|
|
21
|
+
# Entities read by get_all_data() (used by the official HA core integration).
|
|
22
|
+
# These mirror the register definitions in `const` exactly (address, scale,
|
|
23
|
+
# offset) so batching cannot change any value get_all_data() returns.
|
|
24
|
+
# Note: some of these intentionally differ from the corresponding entries in
|
|
25
|
+
# entities/sensors.py (e.g. compressor_speed has a x60 RPM scale here but not
|
|
26
|
+
# there; cop_calc is unrounded here but rounded to 1 decimal there) — reusing
|
|
27
|
+
# SENSORS would silently change get_all_data()'s output, so a dedicated table
|
|
28
|
+
# is kept instead.
|
|
29
|
+
# Format: (key, address, input_type, data_type, scale, offset)
|
|
30
|
+
_CORE_STATE_REGISTERS: tuple[
|
|
31
|
+
tuple[str, int, InputType, DataType, float | None, float | None], ...
|
|
32
|
+
] = (
|
|
33
|
+
("temp_supply", 20, InputType.INPUT_REGISTER, DataType.FLOAT32, None, None),
|
|
34
|
+
("temp_return", 22, InputType.INPUT_REGISTER, DataType.FLOAT32, None, None),
|
|
35
|
+
("temp_source_in", 24, InputType.INPUT_REGISTER, DataType.FLOAT32, None, None),
|
|
36
|
+
("temp_source_out", 26, InputType.INPUT_REGISTER, DataType.FLOAT32, None, None),
|
|
37
|
+
("temp_room", 28, InputType.INPUT_REGISTER, DataType.FLOAT32, None, None),
|
|
38
|
+
("temp_dhw", 30, InputType.INPUT_REGISTER, DataType.FLOAT32, None, None),
|
|
39
|
+
("temp_outside", 32, InputType.INPUT_REGISTER, DataType.FLOAT32, None, None),
|
|
40
|
+
("power_thermic", 36, InputType.INPUT_REGISTER, DataType.FLOAT32, None, None),
|
|
41
|
+
("power_electric", 61, InputType.INPUT_REGISTER, DataType.FLOAT32, None, None),
|
|
42
|
+
(
|
|
43
|
+
"energy_total_electric",
|
|
44
|
+
69,
|
|
45
|
+
InputType.INPUT_REGISTER,
|
|
46
|
+
DataType.FLOAT32,
|
|
47
|
+
None,
|
|
48
|
+
None,
|
|
49
|
+
),
|
|
50
|
+
(
|
|
51
|
+
"energy_total_thermic",
|
|
52
|
+
71,
|
|
53
|
+
InputType.INPUT_REGISTER,
|
|
54
|
+
DataType.FLOAT32,
|
|
55
|
+
None,
|
|
56
|
+
None,
|
|
57
|
+
),
|
|
58
|
+
("cop_calc", 34, InputType.INPUT_REGISTER, DataType.FLOAT32, None, None),
|
|
59
|
+
("status_code", 38, InputType.INPUT_REGISTER, DataType.UINT16, None, None),
|
|
60
|
+
("compressor_speed", 45, InputType.INPUT_REGISTER, DataType.FLOAT32, 60, None),
|
|
61
|
+
("flow_rate", 18, InputType.INPUT_REGISTER, DataType.FLOAT32, None, None),
|
|
62
|
+
(
|
|
63
|
+
"setpoint_room_heat_day",
|
|
64
|
+
27,
|
|
65
|
+
InputType.HOLDING_REGISTER,
|
|
66
|
+
DataType.FLOAT32,
|
|
67
|
+
None,
|
|
68
|
+
None,
|
|
69
|
+
),
|
|
70
|
+
(
|
|
71
|
+
"setpoint_room_heat_night",
|
|
72
|
+
29,
|
|
73
|
+
InputType.HOLDING_REGISTER,
|
|
74
|
+
DataType.FLOAT32,
|
|
75
|
+
None,
|
|
76
|
+
None,
|
|
77
|
+
),
|
|
78
|
+
(
|
|
79
|
+
"setpoint_room_cool_day",
|
|
80
|
+
31,
|
|
81
|
+
InputType.HOLDING_REGISTER,
|
|
82
|
+
DataType.FLOAT32,
|
|
83
|
+
None,
|
|
84
|
+
None,
|
|
85
|
+
),
|
|
86
|
+
(
|
|
87
|
+
"setpoint_room_cool_night",
|
|
88
|
+
33,
|
|
89
|
+
InputType.HOLDING_REGISTER,
|
|
90
|
+
DataType.FLOAT32,
|
|
91
|
+
None,
|
|
92
|
+
None,
|
|
93
|
+
),
|
|
94
|
+
("setpoint_dhw", 173, InputType.HOLDING_REGISTER, DataType.FLOAT32, None, None),
|
|
95
|
+
(
|
|
96
|
+
"usr_pid_heatsetp",
|
|
97
|
+
101,
|
|
98
|
+
InputType.HOLDING_REGISTER,
|
|
99
|
+
DataType.FLOAT32,
|
|
100
|
+
None,
|
|
101
|
+
None,
|
|
102
|
+
),
|
|
103
|
+
(
|
|
104
|
+
"usr_pid_coolsetp",
|
|
105
|
+
103,
|
|
106
|
+
InputType.HOLDING_REGISTER,
|
|
107
|
+
DataType.FLOAT32,
|
|
108
|
+
None,
|
|
109
|
+
None,
|
|
110
|
+
),
|
|
111
|
+
("modbus_roomtemp", 75, InputType.INPUT_REGISTER, DataType.FLOAT32, None, None),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
_CORE_STATE_ENTITIES: tuple[EntityDef, ...] = tuple(
|
|
115
|
+
EntityDef(
|
|
116
|
+
key=key,
|
|
117
|
+
name=key,
|
|
118
|
+
address=address,
|
|
119
|
+
input_type=input_type,
|
|
120
|
+
data_type=data_type,
|
|
121
|
+
scale=scale,
|
|
122
|
+
offset=offset,
|
|
123
|
+
)
|
|
124
|
+
for key, address, input_type, data_type, scale, offset in _CORE_STATE_REGISTERS
|
|
125
|
+
)
|
|
126
|
+
|
|
20
127
|
|
|
21
128
|
class QubeClient:
|
|
22
129
|
"""Qube Modbus Client."""
|
|
23
130
|
|
|
131
|
+
# Block-read planning limits. Small address gaps within a block are
|
|
132
|
+
# read along and discarded; a failed block falls back to individual
|
|
133
|
+
# entity reads, so gap registers can never break a value.
|
|
134
|
+
_MAX_BLOCK_REGISTERS = 100 # Modbus allows at most 125 per request
|
|
135
|
+
_MAX_BLOCK_BITS = 256
|
|
136
|
+
_MAX_GAP_REGISTERS = 8
|
|
137
|
+
_MAX_GAP_BITS = 16
|
|
138
|
+
|
|
24
139
|
def __init__(self, host: str, port: int = 502, unit_id: int = 1):
|
|
25
140
|
"""Initialize."""
|
|
26
141
|
self.host = host
|
|
@@ -34,6 +149,21 @@ class QubeClient:
|
|
|
34
149
|
self._next_connect_at: float = 0.0
|
|
35
150
|
# Monotonic clamping for total_increasing counters
|
|
36
151
|
self._monotonic_cache: dict[str, float] = {}
|
|
152
|
+
# Reads that already produced a WARNING (transient failures are
|
|
153
|
+
# logged once per target, then at DEBUG to avoid log spam)
|
|
154
|
+
self._read_failures_warned: set[str] = set()
|
|
155
|
+
|
|
156
|
+
def _log_read_failure(self, target: str, exc: Exception) -> None:
|
|
157
|
+
"""Log a read failure: WARNING on first occurrence per target, DEBUG after.
|
|
158
|
+
|
|
159
|
+
Transient Modbus timeouts recover on the next poll cycle, so
|
|
160
|
+
repeated occurrences should not flood the log at high severity.
|
|
161
|
+
"""
|
|
162
|
+
if target in self._read_failures_warned:
|
|
163
|
+
_LOGGER.debug("Exception reading %s: %s", target, exc)
|
|
164
|
+
else:
|
|
165
|
+
self._read_failures_warned.add(target)
|
|
166
|
+
_LOGGER.warning("Exception reading %s: %s", target, exc)
|
|
37
167
|
|
|
38
168
|
async def connect(self) -> bool:
|
|
39
169
|
"""Connect to the Modbus server."""
|
|
@@ -85,53 +215,27 @@ class QubeClient:
|
|
|
85
215
|
|
|
86
216
|
state = QubeState()
|
|
87
217
|
|
|
88
|
-
#
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
#
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
# Fetch power and energy sensors
|
|
102
|
-
state.power_thermic = await _read(const.POWER_THERMIC)
|
|
103
|
-
state.power_electric = await _read(const.POWER_ELECTRIC_CALC)
|
|
104
|
-
state.energy_total_electric = await _read(const.ENERGY_ELECTRIC_TOTAL)
|
|
105
|
-
state.energy_total_thermic = await _read(const.ENERGY_THERMIC_TOTAL)
|
|
106
|
-
state.cop_calc = await _read(const.COP_CALC)
|
|
107
|
-
|
|
108
|
-
# Fetch operation sensors
|
|
109
|
-
state.status_code = await _read(const.STATUS_CODE)
|
|
110
|
-
state.compressor_speed = await _read(const.COMPRESSOR_SPEED)
|
|
111
|
-
flow_rate = await _read(const.FLOW_RATE)
|
|
218
|
+
# Batch-read the core sensor fields plus all binary sensors together.
|
|
219
|
+
# _plan_blocks groups by input type (input/holding/discrete_input/coil)
|
|
220
|
+
# regardless of which list an entity came from, so combining both
|
|
221
|
+
# tables into a single read_entities_batched() call still yields one
|
|
222
|
+
# handful of block transactions instead of ~59 per-field reads.
|
|
223
|
+
entities = [*_CORE_STATE_ENTITIES, *BINARY_SENSORS.values()]
|
|
224
|
+
results = await self.read_entities_batched(entities)
|
|
225
|
+
|
|
226
|
+
for ent in _CORE_STATE_ENTITIES:
|
|
227
|
+
setattr(state, ent.key, results.get(ent.key))
|
|
228
|
+
|
|
229
|
+
flow_rate = state.flow_rate
|
|
112
230
|
if flow_rate is not None and flow_rate < 0:
|
|
113
231
|
flow_rate = 0.0
|
|
114
232
|
state.flow_rate = flow_rate
|
|
115
233
|
|
|
116
|
-
# Fetch setpoints (holding registers)
|
|
117
|
-
state.setpoint_room_heat_day = await _read(const.SETPOINT_HEAT_DAY)
|
|
118
|
-
state.setpoint_room_heat_night = await _read(const.SETPOINT_HEAT_NIGHT)
|
|
119
|
-
state.setpoint_room_cool_day = await _read(const.SETPOINT_COOL_DAY)
|
|
120
|
-
state.setpoint_room_cool_night = await _read(const.SETPOINT_COOL_NIGHT)
|
|
121
|
-
state.setpoint_dhw = await _read(const.USER_DHW_SETPOINT)
|
|
122
|
-
state.usr_pid_heatsetp = await _read(const.USER_HEAT_SETPOINT)
|
|
123
|
-
state.usr_pid_coolsetp = await _read(const.USER_COOL_SETPOINT)
|
|
124
|
-
|
|
125
|
-
# LinQ thermostat room temperature (optional)
|
|
126
|
-
state.modbus_roomtemp = await _read(const.TEMP_ROOM_MODBUS)
|
|
127
|
-
|
|
128
234
|
self._apply_monotonic_clamping(state)
|
|
129
235
|
|
|
130
|
-
|
|
131
|
-
binary_data = await self.read_all_binary_sensors()
|
|
132
|
-
for key, value in binary_data.items():
|
|
236
|
+
for key in BINARY_SENSORS:
|
|
133
237
|
if hasattr(state, key):
|
|
134
|
-
setattr(state, key,
|
|
238
|
+
setattr(state, key, results.get(key))
|
|
135
239
|
|
|
136
240
|
# Compute unified status (status_code + anti-legionella override)
|
|
137
241
|
state.status = const.resolve_status(state.status_code, state.req_antileg_1)
|
|
@@ -205,33 +309,12 @@ class QubeClient:
|
|
|
205
309
|
Returns:
|
|
206
310
|
Dictionary mapping entity keys to their values.
|
|
207
311
|
"""
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
except Exception as exc:
|
|
215
|
-
_LOGGER.debug("Error reading sensor %s: %s", key, exc)
|
|
216
|
-
results[key] = None
|
|
217
|
-
|
|
218
|
-
# Read all binary sensors
|
|
219
|
-
for key, entity in BINARY_SENSORS.items():
|
|
220
|
-
try:
|
|
221
|
-
results[key] = await self.read_entity(entity)
|
|
222
|
-
except Exception as exc:
|
|
223
|
-
_LOGGER.debug("Error reading binary sensor %s: %s", key, exc)
|
|
224
|
-
results[key] = None
|
|
225
|
-
|
|
226
|
-
# Read all switches
|
|
227
|
-
for key, entity in SWITCHES.items():
|
|
228
|
-
try:
|
|
229
|
-
results[key] = await self.read_entity(entity)
|
|
230
|
-
except Exception as exc:
|
|
231
|
-
_LOGGER.debug("Error reading switch %s: %s", key, exc)
|
|
232
|
-
results[key] = None
|
|
233
|
-
|
|
234
|
-
return results
|
|
312
|
+
all_entities = [
|
|
313
|
+
*SENSORS.values(),
|
|
314
|
+
*BINARY_SENSORS.values(),
|
|
315
|
+
*SWITCHES.values(),
|
|
316
|
+
]
|
|
317
|
+
return await self.read_entities_batched(all_entities)
|
|
235
318
|
|
|
236
319
|
async def read_value(self, definition: tuple) -> float | None:
|
|
237
320
|
"""Read a single value based on the constant definition."""
|
|
@@ -309,9 +392,152 @@ class QubeClient:
|
|
|
309
392
|
return val
|
|
310
393
|
|
|
311
394
|
except Exception as e:
|
|
312
|
-
|
|
395
|
+
self._log_read_failure(f"address {address}", e)
|
|
313
396
|
return None
|
|
314
397
|
|
|
398
|
+
@staticmethod
|
|
399
|
+
def _register_count(entity: EntityDef) -> int:
|
|
400
|
+
"""Return the number of registers an entity occupies."""
|
|
401
|
+
data_type_str = entity.data_type.value if entity.data_type else None
|
|
402
|
+
return 2 if data_type_str in ("float32", "uint32", "int32") else 1
|
|
403
|
+
|
|
404
|
+
@staticmethod
|
|
405
|
+
def _decode_registers(data_type_str: str | None, regs: list[int]) -> float | int:
|
|
406
|
+
"""Decode raw registers based on data type.
|
|
407
|
+
|
|
408
|
+
Qube uses big endian word order (ABCD): regs[0]=MSW, regs[1]=LSW.
|
|
409
|
+
"""
|
|
410
|
+
val: float | int = 0
|
|
411
|
+
if data_type_str == "float32":
|
|
412
|
+
int_val = (regs[0] << 16) | regs[1]
|
|
413
|
+
val = struct.unpack(">f", struct.pack(">I", int_val))[0]
|
|
414
|
+
elif data_type_str == "int16":
|
|
415
|
+
val = regs[0]
|
|
416
|
+
if val > 32767:
|
|
417
|
+
val -= 65536
|
|
418
|
+
elif data_type_str == "uint16":
|
|
419
|
+
val = regs[0]
|
|
420
|
+
elif data_type_str == "uint32":
|
|
421
|
+
int_val = (regs[0] << 16) | regs[1]
|
|
422
|
+
val = int_val
|
|
423
|
+
elif data_type_str == "int32":
|
|
424
|
+
int_val = (regs[0] << 16) | regs[1]
|
|
425
|
+
val = int_val
|
|
426
|
+
if val > 2147483647:
|
|
427
|
+
val -= 4294967296
|
|
428
|
+
return val
|
|
429
|
+
|
|
430
|
+
@staticmethod
|
|
431
|
+
def _apply_scaling(entity: EntityDef, val: float | int) -> float | int:
|
|
432
|
+
"""Apply an entity's scale, offset and precision to a raw value."""
|
|
433
|
+
if entity.scale is not None:
|
|
434
|
+
val = val * entity.scale
|
|
435
|
+
if entity.offset is not None:
|
|
436
|
+
val = val + entity.offset
|
|
437
|
+
if entity.precision is not None and isinstance(val, float):
|
|
438
|
+
val = round(val, entity.precision)
|
|
439
|
+
return val
|
|
440
|
+
|
|
441
|
+
def _plan_blocks(
|
|
442
|
+
self, entities: Iterable[EntityDef]
|
|
443
|
+
) -> list[tuple[str, int, int, list[EntityDef]]]:
|
|
444
|
+
"""Group entities into contiguous block reads per input type.
|
|
445
|
+
|
|
446
|
+
Returns a list of (input_type, start_address, count, entities)
|
|
447
|
+
tuples. Entities within _MAX_GAP_* of each other share a block.
|
|
448
|
+
"""
|
|
449
|
+
groups: dict[str, list[EntityDef]] = {}
|
|
450
|
+
for ent in entities:
|
|
451
|
+
input_type_str = ent.input_type.value if ent.input_type else "holding"
|
|
452
|
+
groups.setdefault(input_type_str, []).append(ent)
|
|
453
|
+
|
|
454
|
+
blocks: list[tuple[str, int, int, list[EntityDef]]] = []
|
|
455
|
+
for input_type_str, ents in groups.items():
|
|
456
|
+
is_bits = input_type_str in ("coil", "discrete_input")
|
|
457
|
+
max_gap = self._MAX_GAP_BITS if is_bits else self._MAX_GAP_REGISTERS
|
|
458
|
+
max_block = self._MAX_BLOCK_BITS if is_bits else self._MAX_BLOCK_REGISTERS
|
|
459
|
+
start: int | None = None
|
|
460
|
+
end = 0
|
|
461
|
+
members: list[EntityDef] = []
|
|
462
|
+
for ent in sorted(ents, key=lambda e: e.address):
|
|
463
|
+
count = 1 if is_bits else self._register_count(ent)
|
|
464
|
+
ent_end = ent.address + count
|
|
465
|
+
if start is None:
|
|
466
|
+
start, end, members = ent.address, ent_end, [ent]
|
|
467
|
+
elif ent.address <= end + max_gap and ent_end - start <= max_block:
|
|
468
|
+
end = max(end, ent_end)
|
|
469
|
+
members.append(ent)
|
|
470
|
+
else:
|
|
471
|
+
blocks.append((input_type_str, start, end - start, members))
|
|
472
|
+
start, end, members = ent.address, ent_end, [ent]
|
|
473
|
+
if start is not None:
|
|
474
|
+
blocks.append((input_type_str, start, end - start, members))
|
|
475
|
+
return blocks
|
|
476
|
+
|
|
477
|
+
async def read_entities_batched(
|
|
478
|
+
self, entities: Iterable[EntityDef]
|
|
479
|
+
) -> dict[str, Any]:
|
|
480
|
+
"""Read entities using contiguous block reads.
|
|
481
|
+
|
|
482
|
+
Groups entities into a handful of Modbus block reads instead of
|
|
483
|
+
one transaction per entity. If a block read fails, its entities
|
|
484
|
+
are read individually as a fallback.
|
|
485
|
+
|
|
486
|
+
Returns:
|
|
487
|
+
Dictionary mapping entity keys to their values (None on error).
|
|
488
|
+
"""
|
|
489
|
+
results: dict[str, Any] = {}
|
|
490
|
+
for input_type_str, start, count, members in self._plan_blocks(entities):
|
|
491
|
+
try:
|
|
492
|
+
if input_type_str == "coil":
|
|
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
|
+
)
|
|
521
|
+
except Exception as exc:
|
|
522
|
+
self._log_read_failure(
|
|
523
|
+
f"block {input_type_str}@{start} (count {count})", exc
|
|
524
|
+
)
|
|
525
|
+
for ent in members:
|
|
526
|
+
results[ent.key] = await self.read_entity(ent)
|
|
527
|
+
continue
|
|
528
|
+
|
|
529
|
+
for ent in members:
|
|
530
|
+
offset = ent.address - start
|
|
531
|
+
if input_type_str in ("coil", "discrete_input"):
|
|
532
|
+
results[ent.key] = bool(result.bits[offset])
|
|
533
|
+
else:
|
|
534
|
+
reg_count = self._register_count(ent)
|
|
535
|
+
regs = result.registers[offset : offset + reg_count]
|
|
536
|
+
data_type_str = ent.data_type.value if ent.data_type else None
|
|
537
|
+
val = self._decode_registers(data_type_str, regs)
|
|
538
|
+
results[ent.key] = self._apply_scaling(ent, val)
|
|
539
|
+
return results
|
|
540
|
+
|
|
315
541
|
async def read_entity(self, entity: EntityDef) -> Any:
|
|
316
542
|
"""Read a single entity value based on EntityDef.
|
|
317
543
|
|
|
@@ -324,10 +550,7 @@ class QubeClient:
|
|
|
324
550
|
# Determine register count based on data type
|
|
325
551
|
# Use string comparison to handle potential enum class differences
|
|
326
552
|
data_type_str = entity.data_type.value if entity.data_type else None
|
|
327
|
-
|
|
328
|
-
count = 2
|
|
329
|
-
else:
|
|
330
|
-
count = 1
|
|
553
|
+
count = self._register_count(entity)
|
|
331
554
|
|
|
332
555
|
try:
|
|
333
556
|
# Read based on input type (use string comparison for safety)
|
|
@@ -364,43 +587,11 @@ class QubeClient:
|
|
|
364
587
|
_LOGGER.warning("Error reading address %s", entity.address)
|
|
365
588
|
return None
|
|
366
589
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
# Decode based on data type (use string comparison for safety)
|
|
371
|
-
# Qube uses big endian word order (ABCD): regs[0]=MSW, regs[1]=LSW
|
|
372
|
-
if data_type_str == "float32":
|
|
373
|
-
int_val = (regs[0] << 16) | regs[1]
|
|
374
|
-
val = struct.unpack(">f", struct.pack(">I", int_val))[0]
|
|
375
|
-
elif data_type_str == "int16":
|
|
376
|
-
val = regs[0]
|
|
377
|
-
if val > 32767:
|
|
378
|
-
val -= 65536
|
|
379
|
-
elif data_type_str == "uint16":
|
|
380
|
-
val = regs[0]
|
|
381
|
-
elif data_type_str == "uint32":
|
|
382
|
-
int_val = (regs[0] << 16) | regs[1]
|
|
383
|
-
val = int_val
|
|
384
|
-
elif data_type_str == "int32":
|
|
385
|
-
int_val = (regs[0] << 16) | regs[1]
|
|
386
|
-
val = int_val
|
|
387
|
-
if val > 2147483647:
|
|
388
|
-
val -= 4294967296
|
|
389
|
-
|
|
390
|
-
# Apply scale and offset
|
|
391
|
-
if entity.scale is not None:
|
|
392
|
-
val = val * entity.scale
|
|
393
|
-
if entity.offset is not None:
|
|
394
|
-
val = val + entity.offset
|
|
395
|
-
|
|
396
|
-
# Apply precision rounding if specified
|
|
397
|
-
if entity.precision is not None and isinstance(val, float):
|
|
398
|
-
val = round(val, entity.precision)
|
|
399
|
-
|
|
400
|
-
return val
|
|
590
|
+
val = self._decode_registers(data_type_str, result.registers)
|
|
591
|
+
return self._apply_scaling(entity, val)
|
|
401
592
|
|
|
402
593
|
except Exception as e:
|
|
403
|
-
|
|
594
|
+
self._log_read_failure(f"entity {entity.key}", e)
|
|
404
595
|
return None
|
|
405
596
|
|
|
406
597
|
async def read_sensor(self, key: str) -> float | int | None:
|
|
@@ -331,12 +331,18 @@ async def test_get_all_data_auto_reconnects(mock_modbus_client):
|
|
|
331
331
|
client._client = mock_instance
|
|
332
332
|
client._connected = False
|
|
333
333
|
|
|
334
|
-
# Mock successful register reads
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
334
|
+
# Mock successful register reads. Batched block reads request a
|
|
335
|
+
# variable number of registers per block, so the mock must honor the
|
|
336
|
+
# requested `count` (a real Modbus server would too), unlike a fixed
|
|
337
|
+
# 2-register response.
|
|
338
|
+
def _resp(address, count=1, **kwargs):
|
|
339
|
+
resp = MagicMock()
|
|
340
|
+
resp.isError.return_value = False
|
|
341
|
+
resp.registers = [0] * count
|
|
342
|
+
return resp
|
|
343
|
+
|
|
344
|
+
mock_instance.read_input_registers = AsyncMock(side_effect=_resp)
|
|
345
|
+
mock_instance.read_holding_registers = AsyncMock(side_effect=_resp)
|
|
340
346
|
|
|
341
347
|
state = await client.get_all_data()
|
|
342
348
|
assert state is not None
|
|
@@ -424,11 +430,16 @@ async def test_get_all_data_applies_clamping(mock_modbus_client):
|
|
|
424
430
|
client._client = mock_instance
|
|
425
431
|
client._connected = True
|
|
426
432
|
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
433
|
+
# Batched block reads request a variable `count` of registers per
|
|
434
|
+
# block, so the mock must honor it (a real Modbus server would too).
|
|
435
|
+
def _resp(address, count=1, **kwargs):
|
|
436
|
+
resp = MagicMock()
|
|
437
|
+
resp.isError.return_value = False
|
|
438
|
+
resp.registers = [0] * count
|
|
439
|
+
return resp
|
|
440
|
+
|
|
441
|
+
mock_instance.read_input_registers = AsyncMock(side_effect=_resp)
|
|
442
|
+
mock_instance.read_holding_registers = AsyncMock(side_effect=_resp)
|
|
432
443
|
|
|
433
444
|
state = await client.get_all_data()
|
|
434
445
|
assert state is not None
|
|
@@ -529,3 +540,54 @@ async def test_set_sg_ready_mode_unknown(mock_modbus_client):
|
|
|
529
540
|
client = QubeClient("1.2.3.4", 502)
|
|
530
541
|
result = await client.set_sg_ready_mode("turbo")
|
|
531
542
|
assert result is False
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
@pytest.mark.asyncio
|
|
546
|
+
async def test_read_entity_failure_logs_warning_once_then_debug(
|
|
547
|
+
mock_modbus_client, caplog
|
|
548
|
+
):
|
|
549
|
+
"""Transient read failures log WARNING on first occurrence, DEBUG after."""
|
|
550
|
+
import logging
|
|
551
|
+
|
|
552
|
+
from python_qube_heatpump.entities import BINARY_SENSORS
|
|
553
|
+
|
|
554
|
+
client = QubeClient("1.2.3.4", 502)
|
|
555
|
+
mock_instance = mock_modbus_client.return_value
|
|
556
|
+
mock_instance.read_discrete_inputs = AsyncMock(
|
|
557
|
+
side_effect=OSError("No response received after 3 retries")
|
|
558
|
+
)
|
|
559
|
+
client._client = mock_instance
|
|
560
|
+
|
|
561
|
+
entity = BINARY_SENSORS["dout_srcpmp_val"]
|
|
562
|
+
|
|
563
|
+
with caplog.at_level(logging.DEBUG, logger="python_qube_heatpump.client"):
|
|
564
|
+
assert await client.read_entity(entity) is None
|
|
565
|
+
assert await client.read_entity(entity) is None
|
|
566
|
+
|
|
567
|
+
records = [r for r in caplog.records if "dout_srcpmp_val" in r.getMessage()]
|
|
568
|
+
assert len(records) == 2
|
|
569
|
+
assert records[0].levelno == logging.WARNING
|
|
570
|
+
assert records[1].levelno == logging.DEBUG
|
|
571
|
+
assert not any(r.levelno == logging.ERROR for r in records)
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
@pytest.mark.asyncio
|
|
575
|
+
async def test_read_entity_warns_again_per_entity(mock_modbus_client, caplog):
|
|
576
|
+
"""The warn-once tracking is per entity, not global."""
|
|
577
|
+
import logging
|
|
578
|
+
|
|
579
|
+
from python_qube_heatpump.entities import BINARY_SENSORS
|
|
580
|
+
|
|
581
|
+
client = QubeClient("1.2.3.4", 502)
|
|
582
|
+
mock_instance = mock_modbus_client.return_value
|
|
583
|
+
mock_instance.read_discrete_inputs = AsyncMock(
|
|
584
|
+
side_effect=OSError("No response received after 3 retries")
|
|
585
|
+
)
|
|
586
|
+
client._client = mock_instance
|
|
587
|
+
|
|
588
|
+
with caplog.at_level(logging.DEBUG, logger="python_qube_heatpump.client"):
|
|
589
|
+
await client.read_entity(BINARY_SENSORS["dout_srcpmp_val"])
|
|
590
|
+
await client.read_entity(BINARY_SENSORS["dout_usrpmp_val"])
|
|
591
|
+
|
|
592
|
+
warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
|
|
593
|
+
assert len(warnings) == 2
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Tests for batched block reads in QubeClient."""
|
|
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
|
+
from python_qube_heatpump.entities.base import (
|
|
10
|
+
DataType,
|
|
11
|
+
EntityDef,
|
|
12
|
+
InputType,
|
|
13
|
+
Platform,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _bit_response(bits):
|
|
18
|
+
resp = MagicMock()
|
|
19
|
+
resp.isError.return_value = False
|
|
20
|
+
resp.bits = bits
|
|
21
|
+
return resp
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _register_response(registers):
|
|
25
|
+
resp = MagicMock()
|
|
26
|
+
resp.isError.return_value = False
|
|
27
|
+
resp.registers = registers
|
|
28
|
+
return resp
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _float32_regs(value):
|
|
32
|
+
import struct
|
|
33
|
+
|
|
34
|
+
int_val = struct.unpack(">I", struct.pack(">f", value))[0]
|
|
35
|
+
return [(int_val >> 16) & 0xFFFF, int_val & 0xFFFF]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@pytest.mark.asyncio
|
|
39
|
+
async def test_batched_discrete_inputs_single_transaction(mock_modbus_client):
|
|
40
|
+
"""Adjacent discrete inputs are read in one block transaction."""
|
|
41
|
+
client = QubeClient("1.2.3.4", 502)
|
|
42
|
+
mock_instance = mock_modbus_client.return_value
|
|
43
|
+
mock_instance.read_discrete_inputs = AsyncMock(
|
|
44
|
+
return_value=_bit_response([True, False, True])
|
|
45
|
+
)
|
|
46
|
+
client._client = mock_instance
|
|
47
|
+
|
|
48
|
+
entities = [
|
|
49
|
+
BINARY_SENSORS["dout_srcpmp_val"], # address 0
|
|
50
|
+
BINARY_SENSORS["dout_usrpmp_val"], # address 1
|
|
51
|
+
BINARY_SENSORS["dout_fourwayvlv_val"], # address 2
|
|
52
|
+
]
|
|
53
|
+
results = await client.read_entities_batched(entities)
|
|
54
|
+
|
|
55
|
+
assert mock_instance.read_discrete_inputs.call_count == 1
|
|
56
|
+
call = mock_instance.read_discrete_inputs.call_args
|
|
57
|
+
assert call.args[0] == 0
|
|
58
|
+
assert call.kwargs["count"] >= 3
|
|
59
|
+
assert results == {
|
|
60
|
+
"dout_srcpmp_val": True,
|
|
61
|
+
"dout_usrpmp_val": False,
|
|
62
|
+
"dout_fourwayvlv_val": True,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@pytest.mark.asyncio
|
|
67
|
+
async def test_batched_input_registers_decode_float32(mock_modbus_client):
|
|
68
|
+
"""Adjacent float32 input registers decode correctly from one block."""
|
|
69
|
+
client = QubeClient("1.2.3.4", 502)
|
|
70
|
+
mock_instance = mock_modbus_client.return_value
|
|
71
|
+
regs = _float32_regs(24.5) + _float32_regs(25.0)
|
|
72
|
+
mock_instance.read_input_registers = AsyncMock(
|
|
73
|
+
return_value=_register_response(regs)
|
|
74
|
+
)
|
|
75
|
+
client._client = mock_instance
|
|
76
|
+
|
|
77
|
+
entities = [
|
|
78
|
+
SENSORS["temp_supply"], # address 20, float32
|
|
79
|
+
SENSORS["temp_return"], # address 22, float32
|
|
80
|
+
]
|
|
81
|
+
results = await client.read_entities_batched(entities)
|
|
82
|
+
|
|
83
|
+
assert mock_instance.read_input_registers.call_count == 1
|
|
84
|
+
call = mock_instance.read_input_registers.call_args
|
|
85
|
+
assert call.args[0] == 20
|
|
86
|
+
assert call.kwargs["count"] == 4
|
|
87
|
+
assert round(results["temp_supply"], 1) == 24.5
|
|
88
|
+
assert round(results["temp_return"], 1) == 25.0
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@pytest.mark.asyncio
|
|
92
|
+
async def test_batched_far_apart_registers_split_into_blocks(mock_modbus_client):
|
|
93
|
+
"""Registers far apart are read in separate block transactions."""
|
|
94
|
+
client = QubeClient("1.2.3.4", 502)
|
|
95
|
+
mock_instance = mock_modbus_client.return_value
|
|
96
|
+
mock_instance.read_holding_registers = AsyncMock(
|
|
97
|
+
return_value=_register_response(_float32_regs(1.0))
|
|
98
|
+
)
|
|
99
|
+
client._client = mock_instance
|
|
100
|
+
|
|
101
|
+
ent_a = EntityDef(
|
|
102
|
+
key="a",
|
|
103
|
+
name="A",
|
|
104
|
+
address=0,
|
|
105
|
+
input_type=InputType.HOLDING_REGISTER,
|
|
106
|
+
platform=Platform.SENSOR,
|
|
107
|
+
data_type=DataType.FLOAT32,
|
|
108
|
+
)
|
|
109
|
+
ent_b = EntityDef(
|
|
110
|
+
key="b",
|
|
111
|
+
name="B",
|
|
112
|
+
address=500,
|
|
113
|
+
input_type=InputType.HOLDING_REGISTER,
|
|
114
|
+
platform=Platform.SENSOR,
|
|
115
|
+
data_type=DataType.FLOAT32,
|
|
116
|
+
)
|
|
117
|
+
await client.read_entities_batched([ent_a, ent_b])
|
|
118
|
+
|
|
119
|
+
assert mock_instance.read_holding_registers.call_count == 2
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@pytest.mark.asyncio
|
|
123
|
+
async def test_batched_block_failure_falls_back_to_individual_reads(
|
|
124
|
+
mock_modbus_client,
|
|
125
|
+
):
|
|
126
|
+
"""If a block read fails, entities in it are read individually."""
|
|
127
|
+
client = QubeClient("1.2.3.4", 502)
|
|
128
|
+
mock_instance = mock_modbus_client.return_value
|
|
129
|
+
mock_instance.read_input_registers = AsyncMock(
|
|
130
|
+
side_effect=[
|
|
131
|
+
OSError("No response received after 3 retries"), # block read
|
|
132
|
+
_register_response(_float32_regs(24.5)), # individual read
|
|
133
|
+
_register_response(_float32_regs(25.0)), # individual read
|
|
134
|
+
]
|
|
135
|
+
)
|
|
136
|
+
client._client = mock_instance
|
|
137
|
+
|
|
138
|
+
entities = [SENSORS["temp_supply"], SENSORS["temp_return"]]
|
|
139
|
+
results = await client.read_entities_batched(entities)
|
|
140
|
+
|
|
141
|
+
assert mock_instance.read_input_registers.call_count == 3
|
|
142
|
+
assert round(results["temp_supply"], 1) == 24.5
|
|
143
|
+
assert round(results["temp_return"], 1) == 25.0
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@pytest.mark.asyncio
|
|
147
|
+
async def test_batched_short_successful_response_falls_back_to_individual_reads(
|
|
148
|
+
mock_modbus_client,
|
|
149
|
+
):
|
|
150
|
+
"""A well-formed but too-short block response degrades gracefully.
|
|
151
|
+
|
|
152
|
+
A device can return isError()==False with fewer registers/bits than the
|
|
153
|
+
requested count (a malformed-but-"successful" response). Previously this
|
|
154
|
+
slipped past the isError() check and crashed with an uncaught IndexError
|
|
155
|
+
while decoding, instead of falling back to individual reads like a failed
|
|
156
|
+
block does. Since get_all_data() is now polled by HA core every 15s via
|
|
157
|
+
this same batching path, such a response must degrade the same way a
|
|
158
|
+
failed block does, not raise.
|
|
159
|
+
"""
|
|
160
|
+
client = QubeClient("1.2.3.4", 502)
|
|
161
|
+
mock_instance = mock_modbus_client.return_value
|
|
162
|
+
mock_instance.read_input_registers = AsyncMock(
|
|
163
|
+
side_effect=[
|
|
164
|
+
# Block read: isError() is False, but only 2 registers came back
|
|
165
|
+
# for a block that needs 4 (two float32 entities).
|
|
166
|
+
_register_response(_float32_regs(24.5)),
|
|
167
|
+
_register_response(_float32_regs(24.5)), # individual read
|
|
168
|
+
_register_response(_float32_regs(25.0)), # individual read
|
|
169
|
+
]
|
|
170
|
+
)
|
|
171
|
+
client._client = mock_instance
|
|
172
|
+
|
|
173
|
+
entities = [SENSORS["temp_supply"], SENSORS["temp_return"]]
|
|
174
|
+
results = await client.read_entities_batched(entities)
|
|
175
|
+
|
|
176
|
+
assert mock_instance.read_input_registers.call_count == 3
|
|
177
|
+
assert round(results["temp_supply"], 1) == 24.5
|
|
178
|
+
assert round(results["temp_return"], 1) == 25.0
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@pytest.mark.asyncio
|
|
182
|
+
async def test_get_all_entities_uses_batched_reads(mock_modbus_client):
|
|
183
|
+
"""get_all_entities reads all entities in a handful of transactions."""
|
|
184
|
+
client = QubeClient("1.2.3.4", 502)
|
|
185
|
+
mock_instance = mock_modbus_client.return_value
|
|
186
|
+
|
|
187
|
+
def _bits(address, count=1, **kwargs):
|
|
188
|
+
return _bit_response([False] * count)
|
|
189
|
+
|
|
190
|
+
def _regs(address, count=1, **kwargs):
|
|
191
|
+
return _register_response([0] * count)
|
|
192
|
+
|
|
193
|
+
mock_instance.read_coils = AsyncMock(side_effect=_bits)
|
|
194
|
+
mock_instance.read_discrete_inputs = AsyncMock(side_effect=_bits)
|
|
195
|
+
mock_instance.read_input_registers = AsyncMock(side_effect=_regs)
|
|
196
|
+
mock_instance.read_holding_registers = AsyncMock(side_effect=_regs)
|
|
197
|
+
client._client = mock_instance
|
|
198
|
+
|
|
199
|
+
results = await client.get_all_entities()
|
|
200
|
+
|
|
201
|
+
expected_keys = set(SENSORS) | set(BINARY_SENSORS) | set(SWITCHES)
|
|
202
|
+
assert set(results) == expected_keys
|
|
203
|
+
total_calls = (
|
|
204
|
+
mock_instance.read_coils.call_count
|
|
205
|
+
+ mock_instance.read_discrete_inputs.call_count
|
|
206
|
+
+ mock_instance.read_input_registers.call_count
|
|
207
|
+
+ mock_instance.read_holding_registers.call_count
|
|
208
|
+
)
|
|
209
|
+
assert total_calls <= 15
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@pytest.mark.asyncio
|
|
213
|
+
async def test_get_all_data_uses_batched_reads(mock_modbus_client):
|
|
214
|
+
"""get_all_data (used by HA core, polled every 15s) reads via block reads.
|
|
215
|
+
|
|
216
|
+
Previously get_all_data() performed one Modbus transaction per field
|
|
217
|
+
(~59 transactions: 23 core sensors + 37 binary sensors). It should now
|
|
218
|
+
use the same batched block-read infrastructure as get_all_entities(),
|
|
219
|
+
landing in a handful of transactions instead.
|
|
220
|
+
"""
|
|
221
|
+
client = QubeClient("1.2.3.4", 502)
|
|
222
|
+
mock_instance = mock_modbus_client.return_value
|
|
223
|
+
|
|
224
|
+
def _bits(address, count=1, **kwargs):
|
|
225
|
+
return _bit_response([False] * count)
|
|
226
|
+
|
|
227
|
+
def _regs(address, count=1, **kwargs):
|
|
228
|
+
return _register_response([0] * count)
|
|
229
|
+
|
|
230
|
+
mock_instance.read_coils = AsyncMock(side_effect=_bits)
|
|
231
|
+
mock_instance.read_discrete_inputs = AsyncMock(side_effect=_bits)
|
|
232
|
+
mock_instance.read_input_registers = AsyncMock(side_effect=_regs)
|
|
233
|
+
mock_instance.read_holding_registers = AsyncMock(side_effect=_regs)
|
|
234
|
+
client._client = mock_instance
|
|
235
|
+
client._connected = True
|
|
236
|
+
|
|
237
|
+
state = await client.get_all_data()
|
|
238
|
+
|
|
239
|
+
assert state is not None
|
|
240
|
+
total_calls = (
|
|
241
|
+
mock_instance.read_coils.call_count
|
|
242
|
+
+ mock_instance.read_discrete_inputs.call_count
|
|
243
|
+
+ mock_instance.read_input_registers.call_count
|
|
244
|
+
+ mock_instance.read_holding_registers.call_count
|
|
245
|
+
)
|
|
246
|
+
# Old per-register/per-entity path made ~59 transactions (23 core
|
|
247
|
+
# fields + 37 binary sensors). Batched block reads should collapse
|
|
248
|
+
# this into a handful of transactions.
|
|
249
|
+
assert total_calls <= 15
|
{python_qube_heatpump-1.11.2 → python_qube_heatpump-1.13.0}/.github/ISSUE_TEMPLATE/bug_report.yml
RENAMED
|
File without changes
|
{python_qube_heatpump-1.11.2 → python_qube_heatpump-1.13.0}/.github/ISSUE_TEMPLATE/config.yml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_qube_heatpump-1.11.2 → python_qube_heatpump-1.13.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.11.2 → python_qube_heatpump-1.13.0}/docs/modbus-lijst-qube-totaal.pdf
RENAMED
|
File without changes
|
|
File without changes
|
{python_qube_heatpump-1.11.2 → python_qube_heatpump-1.13.0}/src/python_qube_heatpump/__init__.py
RENAMED
|
File without changes
|
{python_qube_heatpump-1.11.2 → python_qube_heatpump-1.13.0}/src/python_qube_heatpump/const.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_qube_heatpump-1.11.2 → python_qube_heatpump-1.13.0}/src/python_qube_heatpump/models.py
RENAMED
|
File without changes
|
{python_qube_heatpump-1.11.2 → python_qube_heatpump-1.13.0}/src/python_qube_heatpump/network.py
RENAMED
|
File without changes
|
{python_qube_heatpump-1.11.2 → python_qube_heatpump-1.13.0}/src/python_qube_heatpump/py.typed
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|