python-qube-heatpump 1.13.0__tar.gz → 1.15.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.15.0}/.github/workflows/ci.yml +1 -1
  2. {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.15.0}/PKG-INFO +3 -2
  3. {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.15.0}/README.md +1 -0
  4. {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.15.0}/pyproject.toml +1 -1
  5. {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.15.0}/src/python_qube_heatpump/client.py +295 -90
  6. {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.15.0}/src/python_qube_heatpump/entities/base.py +7 -0
  7. {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.15.0}/src/python_qube_heatpump/entities/sensors.py +13 -5
  8. {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.15.0}/src/python_qube_heatpump/network.py +3 -2
  9. {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.15.0}/tests/conftest.py +3 -2
  10. {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.15.0}/tests/test_client.py +114 -23
  11. python_qube_heatpump-1.15.0/tests/test_client_read_all.py +83 -0
  12. python_qube_heatpump-1.15.0/tests/test_client_resilience.py +252 -0
  13. python_qube_heatpump-1.15.0/tests/test_client_writes.py +197 -0
  14. {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.15.0}/tests/test_network.py +31 -0
  15. {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.15.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  16. {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.15.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  17. {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.15.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  18. {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.15.0}/.github/workflows/python-publish.yml +0 -0
  19. {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.15.0}/.gitignore +0 -0
  20. {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.15.0}/AGENTS.md +0 -0
  21. {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.15.0}/CLAUDE.md +0 -0
  22. {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.15.0}/LICENSE +0 -0
  23. {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.15.0}/docs/modbus-lijst-qube-totaal.pdf +0 -0
  24. {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.15.0}/pytest.ini +0 -0
  25. {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.15.0}/src/python_qube_heatpump/__init__.py +0 -0
  26. {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.15.0}/src/python_qube_heatpump/const.py +0 -0
  27. {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.15.0}/src/python_qube_heatpump/entities/__init__.py +0 -0
  28. {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.15.0}/src/python_qube_heatpump/entities/binary_sensors.py +0 -0
  29. {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.15.0}/src/python_qube_heatpump/entities/switches.py +0 -0
  30. {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.15.0}/src/python_qube_heatpump/models.py +0 -0
  31. {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.15.0}/src/python_qube_heatpump/py.typed +0 -0
  32. {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.15.0}/tests/test_client_batching.py +0 -0
  33. {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.15.0}/tests/test_const.py +0 -0
  34. {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.15.0}/tests/test_entities.py +0 -0
  35. {python_qube_heatpump-1.13.0 → python_qube_heatpump-1.15.0}/tests/test_models.py +0 -0
@@ -19,7 +19,7 @@ jobs:
19
19
  - name: Install dependencies
20
20
  run: |
21
21
  python -m pip install --upgrade pip
22
- pip install ".[test]" ruff
22
+ pip install ".[test]" ruff==0.14.14
23
23
  - name: Lint with ruff
24
24
  run: |
25
25
  ruff check .
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: python-qube-heatpump
3
- Version: 1.13.0
3
+ Version: 1.15.0
4
4
  Summary: Async Modbus client for Qube Heat Pumps
5
5
  Project-URL: Homepage, https://github.com/MattieGit/python-qube-heatpump
6
6
  Project-URL: Bug Tracker, https://github.com/MattieGit/python-qube-heatpump/issues
@@ -53,6 +53,7 @@ asyncio.run(main())
53
53
  - **Entity definitions** for sensors, binary sensors, and switches
54
54
  - **FLOAT32 decoding** with big endian (ABCD) byte order
55
55
  - **Type-safe dataclasses** for entity definitions
56
+ - **Monotonic clamping with reset detection** for the energy totals: sub-kWh jitter is clamped, while a drop of more than 1 kWh that persists for 3 consecutive reads is accepted as a counter reset (logged as a warning); `clear_monotonic_cache()` forgets all baselines
56
57
 
57
58
  ## Entity Definitions
58
59
 
@@ -34,6 +34,7 @@ asyncio.run(main())
34
34
  - **Entity definitions** for sensors, binary sensors, and switches
35
35
  - **FLOAT32 decoding** with big endian (ABCD) byte order
36
36
  - **Type-safe dataclasses** for entity definitions
37
+ - **Monotonic clamping with reset detection** for the energy totals: sub-kWh jitter is clamped, while a drop of more than 1 kWh that persists for 3 consecutive reads is accepted as a counter reset (logged as a warning); `clear_monotonic_cache()` forgets all baselines
37
38
 
38
39
  ## Entity Definitions
39
40
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "python-qube-heatpump"
7
- version = "1.13.0"
7
+ version = "1.15.0"
8
8
  authors = [
9
9
  { name="MattieGit", email="6250046+MattieGit@users.noreply.github.com" },
10
10
  ]
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import contextlib
5
6
  import logging
6
7
  import math
7
8
  import struct
@@ -10,6 +11,7 @@ from collections.abc import Iterable
10
11
  from typing import Any
11
12
 
12
13
  from pymodbus.client import AsyncModbusTcpClient
14
+ from pymodbus.exceptions import ConnectionException, ModbusIOException
13
15
 
14
16
  from . import const
15
17
  from .entities import BINARY_SENSORS, SENSORS, SWITCHES, EntityDef
@@ -18,6 +20,22 @@ from .models import QubeState
18
20
 
19
21
  _LOGGER = logging.getLogger(__name__)
20
22
 
23
+ # Exceptions meaning the TCP link (or the device behind it) stopped answering,
24
+ # as opposed to a Modbus error reply to one request. pymodbus raises
25
+ # ConnectionException when it cannot connect and ModbusIOException when a
26
+ # request got no response after its retries. OSError covers socket errors
27
+ # and TimeoutError.
28
+ _LINK_ERRORS: tuple[type[Exception], ...] = (
29
+ ConnectionException,
30
+ ModbusIOException,
31
+ OSError,
32
+ )
33
+
34
+
35
+ class _BlockReadError(Exception):
36
+ """A block read got a Modbus error reply or a short response."""
37
+
38
+
21
39
  # Entities read by get_all_data() (used by the official HA core integration).
22
40
  # These mirror the register definitions in `const` exactly (address, scale,
23
41
  # offset) so batching cannot change any value get_all_data() returns.
@@ -149,6 +167,9 @@ class QubeClient:
149
167
  self._next_connect_at: float = 0.0
150
168
  # Monotonic clamping for total_increasing counters
151
169
  self._monotonic_cache: dict[str, float] = {}
170
+ # Per-key count of consecutive reads that fell below the cached
171
+ # maximum by more than the reset threshold (see clamp_monotonic)
172
+ self._monotonic_reset_pending: dict[str, int] = {}
152
173
  # Reads that already produced a WARNING (transient failures are
153
174
  # logged once per target, then at DEBUG to avoid log spam)
154
175
  self._read_failures_warned: set[str] = set()
@@ -165,10 +186,29 @@ class QubeClient:
165
186
  self._read_failures_warned.add(target)
166
187
  _LOGGER.warning("Exception reading %s: %s", target, exc)
167
188
 
189
+ def _log_read_ok(self, target: str) -> None:
190
+ """Re-arm the warn-once log for a target that reads fine again."""
191
+ if target in self._read_failures_warned:
192
+ self._read_failures_warned.discard(target)
193
+ _LOGGER.info("Reading %s recovered", target)
194
+
195
+ def _mark_disconnected(self) -> None:
196
+ """Drop the connection after a link failure so the next poll reconnects.
197
+
198
+ pymodbus' connect() replaces the transport without closing the old
199
+ one, so the transport is closed here before any reconnect.
200
+ """
201
+ self._connected = False
202
+ with contextlib.suppress(Exception):
203
+ self._client.close()
204
+
168
205
  async def connect(self) -> bool:
169
206
  """Connect to the Modbus server."""
170
207
  if not self._connected:
171
208
  self._connected = await self._client.connect()
209
+ if self._connected:
210
+ self._backoff_seconds = 0.0
211
+ self._next_connect_at = 0.0
172
212
  return self._connected
173
213
 
174
214
  @property
@@ -207,7 +247,8 @@ class QubeClient:
207
247
  """Fetch all definition data and return a state object.
208
248
 
209
249
  This fetches core sensors for the official HA integration.
210
- Returns None if not connected and reconnection fails.
250
+ Returns None if not connected and reconnection fails, or if the
251
+ connection was lost during this poll.
211
252
  """
212
253
  await self._ensure_connected()
213
254
  if not self._connected:
@@ -222,6 +263,8 @@ class QubeClient:
222
263
  # handful of block transactions instead of ~59 per-field reads.
223
264
  entities = [*_CORE_STATE_ENTITIES, *BINARY_SENSORS.values()]
224
265
  results = await self.read_entities_batched(entities)
266
+ if not self._connected:
267
+ return None
225
268
 
226
269
  for ent in _CORE_STATE_ENTITIES:
227
270
  setattr(state, ent.key, results.get(ent.key))
@@ -254,17 +297,48 @@ class QubeClient:
254
297
  def monotonic_cache(self, value: dict[str, float]) -> None:
255
298
  """Set the monotonic clamping cache (e.g. restored from disk)."""
256
299
  self._monotonic_cache = dict(value)
300
+ self._monotonic_reset_pending.clear()
301
+
302
+ def clear_monotonic_cache(self) -> None:
303
+ """Forget all monotonic baselines.
257
304
 
258
- def clamp_monotonic(self, key: str, value: float | None) -> float | None:
305
+ The next reading of every counter is accepted as-is. Use this when
306
+ the controller's counters were reset on purpose and the clamped
307
+ values in the cache are known to be stale.
308
+ """
309
+ self._monotonic_cache.clear()
310
+ self._monotonic_reset_pending.clear()
311
+
312
+ # A drop below the cached maximum larger than this (in the counter's own
313
+ # unit: kWh for the energy totals) is treated as a counter reset rather
314
+ # than float32 jitter or a transient glitch...
315
+ MONOTONIC_RESET_THRESHOLD: float = 1.0
316
+ # ...but only after it has been seen on this many consecutive reads, so a
317
+ # single glitched zero read never drops a total_increasing counter.
318
+ MONOTONIC_RESET_CONFIRM_READS: int = 3
319
+
320
+ def clamp_monotonic(
321
+ self,
322
+ key: str,
323
+ value: float | None,
324
+ reset_threshold: float | None = None,
325
+ ) -> float | None:
259
326
  """Clamp a value to prevent decreases for total_increasing counters.
260
327
 
261
- Returns the clamped value. If the new value is lower than the
262
- previously seen value for this key, the previous value is returned.
263
- None and non-finite values pass through unchanged.
328
+ If the new value is lower than the previously seen value for this
329
+ key by less than ``reset_threshold``, the previous value is returned
330
+ (jitter suppression). A larger drop is a counter-reset candidate: it
331
+ is still clamped until it has persisted for
332
+ ``MONOTONIC_RESET_CONFIRM_READS`` consecutive reads, after which the
333
+ low value is accepted as the new baseline and a warning is logged.
334
+ None and non-finite values pass through unchanged and do not affect
335
+ the reset detection.
264
336
 
265
337
  Args:
266
338
  key: Identifier for this counter (e.g. entity unique_id).
267
339
  value: The current reading.
340
+ reset_threshold: Drop size that counts as a reset; defaults to
341
+ ``MONOTONIC_RESET_THRESHOLD``.
268
342
 
269
343
  Returns:
270
344
  The clamped value, or None if input was None/non-finite.
@@ -272,8 +346,35 @@ class QubeClient:
272
346
  if value is None or not math.isfinite(value):
273
347
  return value
274
348
  previous = self._monotonic_cache.get(key)
275
- if previous is not None and value < previous:
349
+ if previous is None or value >= previous:
350
+ self._monotonic_cache[key] = value
351
+ self._monotonic_reset_pending.pop(key, None)
352
+ return value
353
+
354
+ threshold = (
355
+ self.MONOTONIC_RESET_THRESHOLD
356
+ if reset_threshold is None
357
+ else reset_threshold
358
+ )
359
+ if previous - value < threshold:
360
+ # Sub-threshold jitter: clamp and forget any pending reset
361
+ self._monotonic_reset_pending.pop(key, None)
362
+ return previous
363
+
364
+ pending = self._monotonic_reset_pending.get(key, 0) + 1
365
+ if pending < self.MONOTONIC_RESET_CONFIRM_READS:
366
+ self._monotonic_reset_pending[key] = pending
276
367
  return previous
368
+
369
+ _LOGGER.warning(
370
+ "Counter reset detected for %s: value dropped from %s to %s on %d "
371
+ "consecutive reads; accepting it as the new baseline",
372
+ key,
373
+ previous,
374
+ value,
375
+ pending,
376
+ )
377
+ self._monotonic_reset_pending.pop(key, None)
277
378
  self._monotonic_cache[key] = value
278
379
  return value
279
380
 
@@ -389,10 +490,13 @@ class QubeClient:
389
490
  if offset is not None:
390
491
  val += offset
391
492
 
493
+ self._log_read_ok(f"address {address}")
392
494
  return val
393
495
 
394
496
  except Exception as e:
395
497
  self._log_read_failure(f"address {address}", e)
498
+ if isinstance(e, _LINK_ERRORS):
499
+ self._mark_disconnected()
396
500
  return None
397
501
 
398
502
  @staticmethod
@@ -481,51 +585,32 @@ class QubeClient:
481
585
 
482
586
  Groups entities into a handful of Modbus block reads instead of
483
587
  one transaction per entity. If a block read fails, its entities
484
- are read individually as a fallback.
588
+ are read individually as a fallback. If one of those single reads
589
+ hits a link failure (no connection, no response), the device is
590
+ treated as unreachable: the client is marked disconnected and the
591
+ remaining reads are skipped.
485
592
 
486
593
  Returns:
487
- Dictionary mapping entity keys to their values (None on error).
594
+ Dictionary mapping every requested entity key to its value
595
+ (None on error).
488
596
  """
489
597
  results: dict[str, Any] = {}
490
- for input_type_str, start, count, members in self._plan_blocks(entities):
598
+ blocks = self._plan_blocks(entities)
599
+ for input_type_str, start, count, members in blocks:
600
+ target = f"block {input_type_str}@{start} (count {count})"
491
601
  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
- )
602
+ result = await self._read_block(input_type_str, start, count)
521
603
  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)
604
+ self._log_read_failure(target, exc)
605
+ if not await self._read_individually(members, results):
606
+ self._mark_disconnected()
607
+ for _, _, _, block_members in blocks:
608
+ for ent in block_members:
609
+ results.setdefault(ent.key, None)
610
+ return results
527
611
  continue
528
612
 
613
+ self._log_read_ok(target)
529
614
  for ent in members:
530
615
  offset = ent.address - start
531
616
  if input_type_str in ("coil", "discrete_input"):
@@ -538,6 +623,63 @@ class QubeClient:
538
623
  results[ent.key] = self._apply_scaling(ent, val)
539
624
  return results
540
625
 
626
+ async def _read_block(self, input_type_str: str, start: int, count: int) -> Any:
627
+ """Read one block and validate the response.
628
+
629
+ Raises:
630
+ _BlockReadError: On a Modbus error reply or a short response.
631
+ Any of _LINK_ERRORS: When the device could not be reached.
632
+ """
633
+ if input_type_str == "coil":
634
+ result = await self._client.read_coils(
635
+ start, count=count, device_id=self.unit
636
+ )
637
+ elif input_type_str == "discrete_input":
638
+ result = await self._client.read_discrete_inputs(
639
+ start, count=count, device_id=self.unit
640
+ )
641
+ elif input_type_str == "input":
642
+ result = await self._client.read_input_registers(
643
+ start, count=count, device_id=self.unit
644
+ )
645
+ else: # holding
646
+ result = await self._client.read_holding_registers(
647
+ start, count=count, device_id=self.unit
648
+ )
649
+ if result.isError():
650
+ raise _BlockReadError(f"Modbus error response for block @{start}")
651
+ if input_type_str in ("coil", "discrete_input"):
652
+ if len(result.bits) < count:
653
+ raise _BlockReadError(
654
+ f"Short bit response for block {input_type_str}@{start} "
655
+ f"(got {len(result.bits)}, expected {count})"
656
+ )
657
+ elif len(result.registers) < count:
658
+ raise _BlockReadError(
659
+ f"Short register response for block {input_type_str}@{start} "
660
+ f"(got {len(result.registers)}, expected {count})"
661
+ )
662
+ return result
663
+
664
+ async def _read_individually(
665
+ self, members: list[EntityDef], results: dict[str, Any]
666
+ ) -> bool:
667
+ """Read a failed block's entities one by one into ``results``.
668
+
669
+ Returns False as soon as a read hits a link failure, so a dead
670
+ device costs one extra transaction instead of one per entity.
671
+ """
672
+ for ent in members:
673
+ try:
674
+ results[ent.key] = await self._read_entity_or_raise(ent)
675
+ except _LINK_ERRORS as exc:
676
+ self._log_read_failure(f"entity {ent.key}", exc)
677
+ return False
678
+ except Exception as exc:
679
+ self._log_read_failure(f"entity {ent.key}", exc)
680
+ results[ent.key] = None
681
+ return True
682
+
541
683
  async def read_entity(self, entity: EntityDef) -> Any:
542
684
  """Read a single entity value based on EntityDef.
543
685
 
@@ -547,33 +689,41 @@ class QubeClient:
547
689
  Returns:
548
690
  The read value (float, int, or bool depending on entity type).
549
691
  """
692
+ try:
693
+ return await self._read_entity_or_raise(entity)
694
+ except Exception as e:
695
+ self._log_read_failure(f"entity {entity.key}", e)
696
+ if isinstance(e, _LINK_ERRORS):
697
+ self._mark_disconnected()
698
+ return None
699
+
700
+ async def _read_entity_or_raise(self, entity: EntityDef) -> Any:
701
+ """Read a single entity; return None on an error reply, raise otherwise."""
550
702
  # Determine register count based on data type
551
703
  # Use string comparison to handle potential enum class differences
552
704
  data_type_str = entity.data_type.value if entity.data_type else None
553
705
  count = self._register_count(entity)
554
706
 
555
- try:
556
- # Read based on input type (use string comparison for safety)
557
- input_type_str = entity.input_type.value if entity.input_type else None
558
-
559
- if input_type_str == "coil":
560
- result = await self._client.read_coils(
561
- entity.address, count=1, device_id=self.unit
562
- )
563
- if result.isError():
564
- _LOGGER.warning("Error reading coil %s", entity.address)
565
- return None
566
- return bool(result.bits[0])
567
-
568
- if input_type_str == "discrete_input":
569
- result = await self._client.read_discrete_inputs(
570
- entity.address, count=1, device_id=self.unit
571
- )
572
- if result.isError():
573
- _LOGGER.warning("Error reading discrete input %s", entity.address)
574
- return None
575
- return bool(result.bits[0])
707
+ # Read based on input type (use string comparison for safety)
708
+ input_type_str = entity.input_type.value if entity.input_type else None
576
709
 
710
+ if input_type_str == "coil":
711
+ result = await self._client.read_coils(
712
+ entity.address, count=1, device_id=self.unit
713
+ )
714
+ if result.isError():
715
+ _LOGGER.warning("Error reading coil %s", entity.address)
716
+ return None
717
+ value: Any = bool(result.bits[0])
718
+ elif input_type_str == "discrete_input":
719
+ result = await self._client.read_discrete_inputs(
720
+ entity.address, count=1, device_id=self.unit
721
+ )
722
+ if result.isError():
723
+ _LOGGER.warning("Error reading discrete input %s", entity.address)
724
+ return None
725
+ value = bool(result.bits[0])
726
+ else:
577
727
  if input_type_str == "input":
578
728
  result = await self._client.read_input_registers(
579
729
  entity.address, count=count, device_id=self.unit
@@ -588,11 +738,10 @@ class QubeClient:
588
738
  return None
589
739
 
590
740
  val = self._decode_registers(data_type_str, result.registers)
591
- return self._apply_scaling(entity, val)
741
+ value = self._apply_scaling(entity, val)
592
742
 
593
- except Exception as e:
594
- self._log_read_failure(f"entity {entity.key}", e)
595
- return None
743
+ self._log_read_ok(f"entity {entity.key}")
744
+ return value
596
745
 
597
746
  async def read_sensor(self, key: str) -> float | int | None:
598
747
  """Read a sensor value by key.
@@ -639,16 +788,18 @@ class QubeClient:
639
788
  return None
640
789
  return await self.read_entity(entity)
641
790
 
791
+ async def _read_table(self, table: dict[str, EntityDef]) -> dict[str, Any]:
792
+ """Batch-read every entity in a table, keeping the table's key order."""
793
+ results = await self.read_entities_batched(table.values())
794
+ return {key: results.get(key) for key in table}
795
+
642
796
  async def read_all_sensors(self) -> dict[str, Any]:
643
797
  """Read all sensor values.
644
798
 
645
799
  Returns:
646
800
  Dictionary mapping sensor keys to their values.
647
801
  """
648
- result: dict[str, Any] = {}
649
- for key, entity in SENSORS.items():
650
- result[key] = await self.read_entity(entity)
651
- return result
802
+ return await self._read_table(SENSORS)
652
803
 
653
804
  async def read_all_binary_sensors(self) -> dict[str, bool | None]:
654
805
  """Read all binary sensor values.
@@ -656,10 +807,7 @@ class QubeClient:
656
807
  Returns:
657
808
  Dictionary mapping binary sensor keys to their values.
658
809
  """
659
- result: dict[str, bool | None] = {}
660
- for key, entity in BINARY_SENSORS.items():
661
- result[key] = await self.read_entity(entity)
662
- return result
810
+ return await self._read_table(BINARY_SENSORS)
663
811
 
664
812
  async def read_all_switches(self) -> dict[str, bool | None]:
665
813
  """Read all switch states.
@@ -667,10 +815,7 @@ class QubeClient:
667
815
  Returns:
668
816
  Dictionary mapping switch keys to their states.
669
817
  """
670
- result: dict[str, bool | None] = {}
671
- for key, entity in SWITCHES.items():
672
- result[key] = await self.read_entity(entity)
673
- return result
818
+ return await self._read_table(SWITCHES)
674
819
 
675
820
  async def write_switch(self, key: str, value: bool) -> bool:
676
821
  """Write a switch state by key.
@@ -701,6 +846,8 @@ class QubeClient:
701
846
  return True
702
847
  except Exception as e:
703
848
  _LOGGER.error("Exception writing switch %s: %s", key, e)
849
+ if isinstance(e, _LINK_ERRORS):
850
+ self._mark_disconnected()
704
851
  return False
705
852
 
706
853
  # SG Ready mode API
@@ -721,8 +868,11 @@ class QubeClient:
721
868
  Returns:
722
869
  Mode string ("off", "block", "plus", "max"), or None on error.
723
870
  """
724
- bit_a = await self.read_switch("bms_sgready_a")
725
- bit_b = await self.read_switch("bms_sgready_b")
871
+ results = await self.read_entities_batched(
872
+ [SWITCHES["bms_sgready_a"], SWITCHES["bms_sgready_b"]]
873
+ )
874
+ bit_a = results.get("bms_sgready_a")
875
+ bit_b = results.get("bms_sgready_b")
726
876
  if bit_a is None or bit_b is None:
727
877
  return None
728
878
  return self._SGREADY_BITS_TO_MODE.get((bool(bit_a), bool(bit_b)))
@@ -730,16 +880,41 @@ class QubeClient:
730
880
  async def set_sg_ready_mode(self, mode: str) -> bool:
731
881
  """Set the SG Ready mode.
732
882
 
883
+ Both coils are written in one request so the heat pump never sees
884
+ a half-applied mode. If the device rejects the multi-coil write,
885
+ the coils are written one after the other instead.
886
+
733
887
  Args:
734
888
  mode: One of "off", "block", "plus", "max".
735
889
 
736
890
  Returns:
737
- True if both writes succeeded, False otherwise.
891
+ True if both coils were written, False otherwise.
738
892
  """
739
893
  bits = self._SGREADY_MODE_TO_BITS.get(mode)
740
894
  if bits is None:
741
895
  _LOGGER.warning("Unknown SG Ready mode: %s", mode)
742
896
  return False
897
+
898
+ coil_a = SWITCHES["bms_sgready_a"]
899
+ coil_b = SWITCHES["bms_sgready_b"]
900
+ if coil_b.address == coil_a.address + 1:
901
+ try:
902
+ result = await self._client.write_coils(
903
+ coil_a.address, list(bits), device_id=self.unit
904
+ )
905
+ except Exception as e:
906
+ _LOGGER.error("Exception writing SG Ready mode %s: %s", mode, e)
907
+ if isinstance(e, _LINK_ERRORS):
908
+ self._mark_disconnected()
909
+ return False
910
+ if not result.isError():
911
+ return True
912
+ _LOGGER.debug(
913
+ "Multi-coil write rejected for SG Ready mode %s; "
914
+ "falling back to single writes",
915
+ mode,
916
+ )
917
+
743
918
  success_a = await self.write_switch("bms_sgready_a", bits[0])
744
919
  success_b = await self.write_switch("bms_sgready_b", bits[1])
745
920
  return success_a and success_b
@@ -767,6 +942,24 @@ class QubeClient:
767
942
  _LOGGER.warning("Sensor %s is not a holding register", key)
768
943
  return False
769
944
 
945
+ if not isinstance(value, int | float) or not math.isfinite(value):
946
+ _LOGGER.warning(
947
+ "Refusing to write %r to %s: not a finite number", value, key
948
+ )
949
+ return False
950
+
951
+ if (entity.min_value is not None and value < entity.min_value) or (
952
+ entity.max_value is not None and value > entity.max_value
953
+ ):
954
+ _LOGGER.warning(
955
+ "Refusing to write %s to %s: outside allowed range %s..%s",
956
+ value,
957
+ key,
958
+ entity.min_value,
959
+ entity.max_value,
960
+ )
961
+ return False
962
+
770
963
  try:
771
964
  # Reverse scale/offset if needed
772
965
  write_value = value
@@ -785,15 +978,25 @@ class QubeClient:
785
978
  result = await self._client.write_registers(
786
979
  entity.address, regs, device_id=self.unit
787
980
  )
788
- elif entity.data_type == DataType.INT16:
789
- if write_value < 0:
790
- write_value = int(write_value) + 65536
791
- result = await self._client.write_register(
792
- entity.address, int(write_value), device_id=self.unit
981
+ elif entity.data_type in (DataType.INT16, DataType.UINT16):
982
+ raw = round(write_value)
983
+ low, high = (
984
+ (-32768, 32767)
985
+ if entity.data_type == DataType.INT16
986
+ else (0, 65535)
793
987
  )
794
- elif entity.data_type == DataType.UINT16:
988
+ if not low <= raw <= high:
989
+ _LOGGER.warning(
990
+ "Refusing to write %s to %s: does not fit in %s",
991
+ value,
992
+ key,
993
+ entity.data_type.value,
994
+ )
995
+ return False
996
+ if raw < 0:
997
+ raw += 65536
795
998
  result = await self._client.write_register(
796
- entity.address, int(write_value), device_id=self.unit
999
+ entity.address, raw, device_id=self.unit
797
1000
  )
798
1001
  else:
799
1002
  _LOGGER.warning(
@@ -808,4 +1011,6 @@ class QubeClient:
808
1011
 
809
1012
  except Exception as e:
810
1013
  _LOGGER.error("Exception writing setpoint %s: %s", key, e)
1014
+ if isinstance(e, _LINK_ERRORS):
1015
+ self._mark_disconnected()
811
1016
  return False
@@ -79,3 +79,10 @@ class EntityDef:
79
79
  # Value precision
80
80
  precision: int | None = None
81
81
  """Number of decimal places to round to. None for no rounding."""
82
+
83
+ # Write limits
84
+ min_value: float | None = None
85
+ """Lowest value write_setpoint() accepts, in the entity's unit."""
86
+
87
+ max_value: float | None = None
88
+ """Highest value write_setpoint() accepts, in the entity's unit."""