python-qube-heatpump 1.11.1__tar.gz → 1.12.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.1 → python_qube_heatpump-1.12.0}/PKG-INFO +1 -1
  2. {python_qube_heatpump-1.11.1 → python_qube_heatpump-1.12.0}/pyproject.toml +1 -1
  3. {python_qube_heatpump-1.11.1 → python_qube_heatpump-1.12.0}/src/python_qube_heatpump/client.py +169 -69
  4. {python_qube_heatpump-1.11.1 → python_qube_heatpump-1.12.0}/tests/test_client.py +51 -0
  5. python_qube_heatpump-1.12.0/tests/test_client_batching.py +174 -0
  6. {python_qube_heatpump-1.11.1 → python_qube_heatpump-1.12.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  7. {python_qube_heatpump-1.11.1 → python_qube_heatpump-1.12.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  8. {python_qube_heatpump-1.11.1 → python_qube_heatpump-1.12.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  9. {python_qube_heatpump-1.11.1 → python_qube_heatpump-1.12.0}/.github/workflows/ci.yml +0 -0
  10. {python_qube_heatpump-1.11.1 → python_qube_heatpump-1.12.0}/.github/workflows/python-publish.yml +0 -0
  11. {python_qube_heatpump-1.11.1 → python_qube_heatpump-1.12.0}/.gitignore +0 -0
  12. {python_qube_heatpump-1.11.1 → python_qube_heatpump-1.12.0}/AGENTS.md +0 -0
  13. {python_qube_heatpump-1.11.1 → python_qube_heatpump-1.12.0}/CLAUDE.md +0 -0
  14. {python_qube_heatpump-1.11.1 → python_qube_heatpump-1.12.0}/LICENSE +0 -0
  15. {python_qube_heatpump-1.11.1 → python_qube_heatpump-1.12.0}/README.md +0 -0
  16. {python_qube_heatpump-1.11.1 → python_qube_heatpump-1.12.0}/docs/modbus-lijst-qube-totaal.pdf +0 -0
  17. {python_qube_heatpump-1.11.1 → python_qube_heatpump-1.12.0}/pytest.ini +0 -0
  18. {python_qube_heatpump-1.11.1 → python_qube_heatpump-1.12.0}/src/python_qube_heatpump/__init__.py +0 -0
  19. {python_qube_heatpump-1.11.1 → python_qube_heatpump-1.12.0}/src/python_qube_heatpump/const.py +0 -0
  20. {python_qube_heatpump-1.11.1 → python_qube_heatpump-1.12.0}/src/python_qube_heatpump/entities/__init__.py +0 -0
  21. {python_qube_heatpump-1.11.1 → python_qube_heatpump-1.12.0}/src/python_qube_heatpump/entities/base.py +0 -0
  22. {python_qube_heatpump-1.11.1 → python_qube_heatpump-1.12.0}/src/python_qube_heatpump/entities/binary_sensors.py +0 -0
  23. {python_qube_heatpump-1.11.1 → python_qube_heatpump-1.12.0}/src/python_qube_heatpump/entities/sensors.py +0 -0
  24. {python_qube_heatpump-1.11.1 → python_qube_heatpump-1.12.0}/src/python_qube_heatpump/entities/switches.py +0 -0
  25. {python_qube_heatpump-1.11.1 → python_qube_heatpump-1.12.0}/src/python_qube_heatpump/models.py +0 -0
  26. {python_qube_heatpump-1.11.1 → python_qube_heatpump-1.12.0}/src/python_qube_heatpump/network.py +0 -0
  27. {python_qube_heatpump-1.11.1 → python_qube_heatpump-1.12.0}/src/python_qube_heatpump/py.typed +0 -0
  28. {python_qube_heatpump-1.11.1 → python_qube_heatpump-1.12.0}/tests/conftest.py +0 -0
  29. {python_qube_heatpump-1.11.1 → python_qube_heatpump-1.12.0}/tests/test_const.py +0 -0
  30. {python_qube_heatpump-1.11.1 → python_qube_heatpump-1.12.0}/tests/test_entities.py +0 -0
  31. {python_qube_heatpump-1.11.1 → python_qube_heatpump-1.12.0}/tests/test_models.py +0 -0
  32. {python_qube_heatpump-1.11.1 → python_qube_heatpump-1.12.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.1
3
+ Version: 1.12.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.1"
7
+ version = "1.12.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
@@ -21,6 +22,14 @@ _LOGGER = logging.getLogger(__name__)
21
22
  class QubeClient:
22
23
  """Qube Modbus Client."""
23
24
 
25
+ # Block-read planning limits. Small address gaps within a block are
26
+ # read along and discarded; a failed block falls back to individual
27
+ # entity reads, so gap registers can never break a value.
28
+ _MAX_BLOCK_REGISTERS = 100 # Modbus allows at most 125 per request
29
+ _MAX_BLOCK_BITS = 256
30
+ _MAX_GAP_REGISTERS = 8
31
+ _MAX_GAP_BITS = 16
32
+
24
33
  def __init__(self, host: str, port: int = 502, unit_id: int = 1):
25
34
  """Initialize."""
26
35
  self.host = host
@@ -34,6 +43,21 @@ class QubeClient:
34
43
  self._next_connect_at: float = 0.0
35
44
  # Monotonic clamping for total_increasing counters
36
45
  self._monotonic_cache: dict[str, float] = {}
46
+ # Reads that already produced a WARNING (transient failures are
47
+ # logged once per target, then at DEBUG to avoid log spam)
48
+ self._read_failures_warned: set[str] = set()
49
+
50
+ def _log_read_failure(self, target: str, exc: Exception) -> None:
51
+ """Log a read failure: WARNING on first occurrence per target, DEBUG after.
52
+
53
+ Transient Modbus timeouts recover on the next poll cycle, so
54
+ repeated occurrences should not flood the log at high severity.
55
+ """
56
+ if target in self._read_failures_warned:
57
+ _LOGGER.debug("Exception reading %s: %s", target, exc)
58
+ else:
59
+ self._read_failures_warned.add(target)
60
+ _LOGGER.warning("Exception reading %s: %s", target, exc)
37
61
 
38
62
  async def connect(self) -> bool:
39
63
  """Connect to the Modbus server."""
@@ -192,9 +216,9 @@ class QubeClient:
192
216
  Version as string (e.g., "2.15"), or None on error.
193
217
  """
194
218
  value = await self.read_value(const.SOFTWARE_VERSION)
195
- if value is None or value == 0.0:
219
+ if value is None:
196
220
  return None
197
- return str(round(value, 2))
221
+ return f"{value:.2f}"
198
222
 
199
223
  async def get_all_entities(self) -> dict[str, Any]:
200
224
  """Fetch all entity values from library definitions.
@@ -205,33 +229,12 @@ class QubeClient:
205
229
  Returns:
206
230
  Dictionary mapping entity keys to their values.
207
231
  """
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
232
+ all_entities = [
233
+ *SENSORS.values(),
234
+ *BINARY_SENSORS.values(),
235
+ *SWITCHES.values(),
236
+ ]
237
+ return await self.read_entities_batched(all_entities)
235
238
 
236
239
  async def read_value(self, definition: tuple) -> float | None:
237
240
  """Read a single value based on the constant definition."""
@@ -309,9 +312,141 @@ class QubeClient:
309
312
  return val
310
313
 
311
314
  except Exception as e:
312
- _LOGGER.error("Exception reading address %s: %s", address, e)
315
+ self._log_read_failure(f"address {address}", e)
313
316
  return None
314
317
 
318
+ @staticmethod
319
+ def _register_count(entity: EntityDef) -> int:
320
+ """Return the number of registers an entity occupies."""
321
+ data_type_str = entity.data_type.value if entity.data_type else None
322
+ return 2 if data_type_str in ("float32", "uint32", "int32") else 1
323
+
324
+ @staticmethod
325
+ def _decode_registers(data_type_str: str | None, regs: list[int]) -> float | int:
326
+ """Decode raw registers based on data type.
327
+
328
+ Qube uses big endian word order (ABCD): regs[0]=MSW, regs[1]=LSW.
329
+ """
330
+ val: float | int = 0
331
+ if data_type_str == "float32":
332
+ int_val = (regs[0] << 16) | regs[1]
333
+ val = struct.unpack(">f", struct.pack(">I", int_val))[0]
334
+ elif data_type_str == "int16":
335
+ val = regs[0]
336
+ if val > 32767:
337
+ val -= 65536
338
+ elif data_type_str == "uint16":
339
+ val = regs[0]
340
+ elif data_type_str == "uint32":
341
+ int_val = (regs[0] << 16) | regs[1]
342
+ val = int_val
343
+ elif data_type_str == "int32":
344
+ int_val = (regs[0] << 16) | regs[1]
345
+ val = int_val
346
+ if val > 2147483647:
347
+ val -= 4294967296
348
+ return val
349
+
350
+ @staticmethod
351
+ def _apply_scaling(entity: EntityDef, val: float | int) -> float | int:
352
+ """Apply an entity's scale, offset and precision to a raw value."""
353
+ if entity.scale is not None:
354
+ val = val * entity.scale
355
+ if entity.offset is not None:
356
+ val = val + entity.offset
357
+ if entity.precision is not None and isinstance(val, float):
358
+ val = round(val, entity.precision)
359
+ return val
360
+
361
+ def _plan_blocks(
362
+ self, entities: Iterable[EntityDef]
363
+ ) -> list[tuple[str, int, int, list[EntityDef]]]:
364
+ """Group entities into contiguous block reads per input type.
365
+
366
+ Returns a list of (input_type, start_address, count, entities)
367
+ tuples. Entities within _MAX_GAP_* of each other share a block.
368
+ """
369
+ groups: dict[str, list[EntityDef]] = {}
370
+ for ent in entities:
371
+ input_type_str = ent.input_type.value if ent.input_type else "holding"
372
+ groups.setdefault(input_type_str, []).append(ent)
373
+
374
+ blocks: list[tuple[str, int, int, list[EntityDef]]] = []
375
+ for input_type_str, ents in groups.items():
376
+ is_bits = input_type_str in ("coil", "discrete_input")
377
+ max_gap = self._MAX_GAP_BITS if is_bits else self._MAX_GAP_REGISTERS
378
+ max_block = self._MAX_BLOCK_BITS if is_bits else self._MAX_BLOCK_REGISTERS
379
+ start: int | None = None
380
+ end = 0
381
+ members: list[EntityDef] = []
382
+ for ent in sorted(ents, key=lambda e: e.address):
383
+ count = 1 if is_bits else self._register_count(ent)
384
+ ent_end = ent.address + count
385
+ if start is None:
386
+ start, end, members = ent.address, ent_end, [ent]
387
+ elif ent.address <= end + max_gap and ent_end - start <= max_block:
388
+ end = max(end, ent_end)
389
+ members.append(ent)
390
+ else:
391
+ blocks.append((input_type_str, start, end - start, members))
392
+ start, end, members = ent.address, ent_end, [ent]
393
+ if start is not None:
394
+ blocks.append((input_type_str, start, end - start, members))
395
+ return blocks
396
+
397
+ async def read_entities_batched(
398
+ self, entities: Iterable[EntityDef]
399
+ ) -> dict[str, Any]:
400
+ """Read entities using contiguous block reads.
401
+
402
+ Groups entities into a handful of Modbus block reads instead of
403
+ one transaction per entity. If a block read fails, its entities
404
+ are read individually as a fallback.
405
+
406
+ Returns:
407
+ Dictionary mapping entity keys to their values (None on error).
408
+ """
409
+ results: dict[str, Any] = {}
410
+ for input_type_str, start, count, members in self._plan_blocks(entities):
411
+ try:
412
+ if input_type_str == "coil":
413
+ result = await self._client.read_coils(
414
+ start, count=count, device_id=self.unit
415
+ )
416
+ elif input_type_str == "discrete_input":
417
+ result = await self._client.read_discrete_inputs(
418
+ start, count=count, device_id=self.unit
419
+ )
420
+ elif input_type_str == "input":
421
+ result = await self._client.read_input_registers(
422
+ start, count=count, device_id=self.unit
423
+ )
424
+ else: # holding
425
+ result = await self._client.read_holding_registers(
426
+ start, count=count, device_id=self.unit
427
+ )
428
+ if result.isError():
429
+ raise OSError(f"Modbus error response for block @{start}")
430
+ except Exception as exc:
431
+ self._log_read_failure(
432
+ f"block {input_type_str}@{start} (count {count})", exc
433
+ )
434
+ for ent in members:
435
+ results[ent.key] = await self.read_entity(ent)
436
+ continue
437
+
438
+ for ent in members:
439
+ offset = ent.address - start
440
+ if input_type_str in ("coil", "discrete_input"):
441
+ results[ent.key] = bool(result.bits[offset])
442
+ else:
443
+ reg_count = self._register_count(ent)
444
+ regs = result.registers[offset : offset + reg_count]
445
+ data_type_str = ent.data_type.value if ent.data_type else None
446
+ val = self._decode_registers(data_type_str, regs)
447
+ results[ent.key] = self._apply_scaling(ent, val)
448
+ return results
449
+
315
450
  async def read_entity(self, entity: EntityDef) -> Any:
316
451
  """Read a single entity value based on EntityDef.
317
452
 
@@ -324,10 +459,7 @@ class QubeClient:
324
459
  # Determine register count based on data type
325
460
  # Use string comparison to handle potential enum class differences
326
461
  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
462
+ count = self._register_count(entity)
331
463
 
332
464
  try:
333
465
  # Read based on input type (use string comparison for safety)
@@ -364,43 +496,11 @@ class QubeClient:
364
496
  _LOGGER.warning("Error reading address %s", entity.address)
365
497
  return None
366
498
 
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
499
+ val = self._decode_registers(data_type_str, result.registers)
500
+ return self._apply_scaling(entity, val)
401
501
 
402
502
  except Exception as e:
403
- _LOGGER.error("Exception reading entity %s: %s", entity.key, e)
503
+ self._log_read_failure(f"entity {entity.key}", e)
404
504
  return None
405
505
 
406
506
  async def read_sensor(self, key: str) -> float | int | None:
@@ -529,3 +529,54 @@ async def test_set_sg_ready_mode_unknown(mock_modbus_client):
529
529
  client = QubeClient("1.2.3.4", 502)
530
530
  result = await client.set_sg_ready_mode("turbo")
531
531
  assert result is False
532
+
533
+
534
+ @pytest.mark.asyncio
535
+ async def test_read_entity_failure_logs_warning_once_then_debug(
536
+ mock_modbus_client, caplog
537
+ ):
538
+ """Transient read failures log WARNING on first occurrence, DEBUG after."""
539
+ import logging
540
+
541
+ from python_qube_heatpump.entities import BINARY_SENSORS
542
+
543
+ client = QubeClient("1.2.3.4", 502)
544
+ mock_instance = mock_modbus_client.return_value
545
+ mock_instance.read_discrete_inputs = AsyncMock(
546
+ side_effect=OSError("No response received after 3 retries")
547
+ )
548
+ client._client = mock_instance
549
+
550
+ entity = BINARY_SENSORS["dout_srcpmp_val"]
551
+
552
+ with caplog.at_level(logging.DEBUG, logger="python_qube_heatpump.client"):
553
+ assert await client.read_entity(entity) is None
554
+ assert await client.read_entity(entity) is None
555
+
556
+ records = [r for r in caplog.records if "dout_srcpmp_val" in r.getMessage()]
557
+ assert len(records) == 2
558
+ assert records[0].levelno == logging.WARNING
559
+ assert records[1].levelno == logging.DEBUG
560
+ assert not any(r.levelno == logging.ERROR for r in records)
561
+
562
+
563
+ @pytest.mark.asyncio
564
+ async def test_read_entity_warns_again_per_entity(mock_modbus_client, caplog):
565
+ """The warn-once tracking is per entity, not global."""
566
+ import logging
567
+
568
+ from python_qube_heatpump.entities import BINARY_SENSORS
569
+
570
+ client = QubeClient("1.2.3.4", 502)
571
+ mock_instance = mock_modbus_client.return_value
572
+ mock_instance.read_discrete_inputs = AsyncMock(
573
+ side_effect=OSError("No response received after 3 retries")
574
+ )
575
+ client._client = mock_instance
576
+
577
+ with caplog.at_level(logging.DEBUG, logger="python_qube_heatpump.client"):
578
+ await client.read_entity(BINARY_SENSORS["dout_srcpmp_val"])
579
+ await client.read_entity(BINARY_SENSORS["dout_usrpmp_val"])
580
+
581
+ warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
582
+ assert len(warnings) == 2
@@ -0,0 +1,174 @@
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_get_all_entities_uses_batched_reads(mock_modbus_client):
148
+ """get_all_entities reads all entities in a handful of transactions."""
149
+ client = QubeClient("1.2.3.4", 502)
150
+ mock_instance = mock_modbus_client.return_value
151
+
152
+ def _bits(address, count=1, **kwargs):
153
+ return _bit_response([False] * count)
154
+
155
+ def _regs(address, count=1, **kwargs):
156
+ return _register_response([0] * count)
157
+
158
+ mock_instance.read_coils = AsyncMock(side_effect=_bits)
159
+ mock_instance.read_discrete_inputs = AsyncMock(side_effect=_bits)
160
+ mock_instance.read_input_registers = AsyncMock(side_effect=_regs)
161
+ mock_instance.read_holding_registers = AsyncMock(side_effect=_regs)
162
+ client._client = mock_instance
163
+
164
+ results = await client.get_all_entities()
165
+
166
+ expected_keys = set(SENSORS) | set(BINARY_SENSORS) | set(SWITCHES)
167
+ assert set(results) == expected_keys
168
+ total_calls = (
169
+ mock_instance.read_coils.call_count
170
+ + mock_instance.read_discrete_inputs.call_count
171
+ + mock_instance.read_input_registers.call_count
172
+ + mock_instance.read_holding_registers.call_count
173
+ )
174
+ assert total_calls <= 15