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.
Files changed (32) hide show
  1. {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.13.0}/PKG-INFO +1 -1
  2. {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.13.0}/pyproject.toml +1 -1
  3. {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.13.0}/src/python_qube_heatpump/client.py +298 -107
  4. {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.13.0}/tests/test_client.py +73 -11
  5. python_qube_heatpump-1.13.0/tests/test_client_batching.py +249 -0
  6. {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.13.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  7. {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.13.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  8. {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.13.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  9. {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.13.0}/.github/workflows/ci.yml +0 -0
  10. {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.13.0}/.github/workflows/python-publish.yml +0 -0
  11. {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.13.0}/.gitignore +0 -0
  12. {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.13.0}/AGENTS.md +0 -0
  13. {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.13.0}/CLAUDE.md +0 -0
  14. {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.13.0}/LICENSE +0 -0
  15. {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.13.0}/README.md +0 -0
  16. {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.13.0}/docs/modbus-lijst-qube-totaal.pdf +0 -0
  17. {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.13.0}/pytest.ini +0 -0
  18. {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.13.0}/src/python_qube_heatpump/__init__.py +0 -0
  19. {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.13.0}/src/python_qube_heatpump/const.py +0 -0
  20. {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.13.0}/src/python_qube_heatpump/entities/__init__.py +0 -0
  21. {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.13.0}/src/python_qube_heatpump/entities/base.py +0 -0
  22. {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.13.0}/src/python_qube_heatpump/entities/binary_sensors.py +0 -0
  23. {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.13.0}/src/python_qube_heatpump/entities/sensors.py +0 -0
  24. {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.13.0}/src/python_qube_heatpump/entities/switches.py +0 -0
  25. {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.13.0}/src/python_qube_heatpump/models.py +0 -0
  26. {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.13.0}/src/python_qube_heatpump/network.py +0 -0
  27. {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.13.0}/src/python_qube_heatpump/py.typed +0 -0
  28. {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.13.0}/tests/conftest.py +0 -0
  29. {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.13.0}/tests/test_const.py +0 -0
  30. {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.13.0}/tests/test_entities.py +0 -0
  31. {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.13.0}/tests/test_models.py +0 -0
  32. {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.11.2
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "python-qube-heatpump"
7
- version = "1.11.2"
7
+ version = "1.13.0"
8
8
  authors = [
9
9
  { name="MattieGit", email="6250046+MattieGit@users.noreply.github.com" },
10
10
  ]
@@ -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
- # Helper to read and assign
89
- async def _read(const_def):
90
- return await self.read_value(const_def)
91
-
92
- # Fetch temperature sensors
93
- state.temp_supply = await _read(const.TEMP_SUPPLY)
94
- state.temp_return = await _read(const.TEMP_RETURN)
95
- state.temp_source_in = await _read(const.TEMP_SOURCE_IN)
96
- state.temp_source_out = await _read(const.TEMP_SOURCE_OUT)
97
- state.temp_room = await _read(const.TEMP_ROOM)
98
- state.temp_dhw = await _read(const.TEMP_DHW)
99
- state.temp_outside = await _read(const.TEMP_OUTSIDE)
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
- # Fetch binary sensors
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, value)
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
- results: dict[str, Any] = {}
209
-
210
- # Read all sensors
211
- for key, entity in SENSORS.items():
212
- try:
213
- results[key] = await self.read_entity(entity)
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
- _LOGGER.error("Exception reading address %s: %s", address, e)
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
- if data_type_str in ("float32", "uint32", "int32"):
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
- regs = result.registers
368
- val: float | int = 0
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
- _LOGGER.error("Exception reading entity %s: %s", entity.key, e)
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
- mock_resp = MagicMock()
336
- mock_resp.isError.return_value = False
337
- mock_resp.registers = [0, 0]
338
- mock_instance.read_input_registers = AsyncMock(return_value=mock_resp)
339
- mock_instance.read_holding_registers = AsyncMock(return_value=mock_resp)
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
- mock_resp = MagicMock()
428
- mock_resp.isError.return_value = False
429
- mock_resp.registers = [0, 0]
430
- mock_instance.read_input_registers = AsyncMock(return_value=mock_resp)
431
- mock_instance.read_holding_registers = AsyncMock(return_value=mock_resp)
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