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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/PKG-INFO +1 -1
  2. {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/pyproject.toml +1 -1
  3. {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/src/python_qube_heatpump/client.py +229 -85
  4. {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/src/python_qube_heatpump/entities/base.py +7 -0
  5. {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/src/python_qube_heatpump/entities/sensors.py +8 -0
  6. {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/src/python_qube_heatpump/network.py +3 -2
  7. {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/tests/conftest.py +3 -2
  8. {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/tests/test_client.py +14 -23
  9. python_qube_heatpump-1.15.0/tests/test_client_read_all.py +83 -0
  10. python_qube_heatpump-1.15.0/tests/test_client_resilience.py +252 -0
  11. python_qube_heatpump-1.15.0/tests/test_client_writes.py +197 -0
  12. {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/tests/test_network.py +31 -0
  13. {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  14. {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  15. {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  16. {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/.github/workflows/ci.yml +0 -0
  17. {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/.github/workflows/python-publish.yml +0 -0
  18. {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/.gitignore +0 -0
  19. {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/AGENTS.md +0 -0
  20. {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/CLAUDE.md +0 -0
  21. {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/LICENSE +0 -0
  22. {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/README.md +0 -0
  23. {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/docs/modbus-lijst-qube-totaal.pdf +0 -0
  24. {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/pytest.ini +0 -0
  25. {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/src/python_qube_heatpump/__init__.py +0 -0
  26. {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/src/python_qube_heatpump/const.py +0 -0
  27. {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/src/python_qube_heatpump/entities/__init__.py +0 -0
  28. {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/src/python_qube_heatpump/entities/binary_sensors.py +0 -0
  29. {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/src/python_qube_heatpump/entities/switches.py +0 -0
  30. {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/src/python_qube_heatpump/models.py +0 -0
  31. {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/src/python_qube_heatpump/py.typed +0 -0
  32. {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/tests/test_client_batching.py +0 -0
  33. {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/tests/test_const.py +0 -0
  34. {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/tests/test_entities.py +0 -0
  35. {python_qube_heatpump-1.14.0 → python_qube_heatpump-1.15.0}/tests/test_models.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: python-qube-heatpump
3
- Version: 1.14.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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "python-qube-heatpump"
7
- version = "1.14.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.
@@ -168,10 +186,29 @@ class QubeClient:
168
186
  self._read_failures_warned.add(target)
169
187
  _LOGGER.warning("Exception reading %s: %s", target, exc)
170
188
 
189
+ def _log_read_ok(self, target: str) -> None:
190
+ """Re-arm the warn-once log for a target that reads fine again."""
191
+ if target in self._read_failures_warned:
192
+ self._read_failures_warned.discard(target)
193
+ _LOGGER.info("Reading %s recovered", target)
194
+
195
+ def _mark_disconnected(self) -> None:
196
+ """Drop the connection after a link failure so the next poll reconnects.
197
+
198
+ pymodbus' connect() replaces the transport without closing the old
199
+ one, so the transport is closed here before any reconnect.
200
+ """
201
+ self._connected = False
202
+ with contextlib.suppress(Exception):
203
+ self._client.close()
204
+
171
205
  async def connect(self) -> bool:
172
206
  """Connect to the Modbus server."""
173
207
  if not self._connected:
174
208
  self._connected = await self._client.connect()
209
+ if self._connected:
210
+ self._backoff_seconds = 0.0
211
+ self._next_connect_at = 0.0
175
212
  return self._connected
176
213
 
177
214
  @property
@@ -210,7 +247,8 @@ class QubeClient:
210
247
  """Fetch all definition data and return a state object.
211
248
 
212
249
  This fetches core sensors for the official HA integration.
213
- Returns None if not connected and reconnection fails.
250
+ Returns None if not connected and reconnection fails, or if the
251
+ connection was lost during this poll.
214
252
  """
215
253
  await self._ensure_connected()
216
254
  if not self._connected:
@@ -225,6 +263,8 @@ class QubeClient:
225
263
  # handful of block transactions instead of ~59 per-field reads.
226
264
  entities = [*_CORE_STATE_ENTITIES, *BINARY_SENSORS.values()]
227
265
  results = await self.read_entities_batched(entities)
266
+ if not self._connected:
267
+ return None
228
268
 
229
269
  for ent in _CORE_STATE_ENTITIES:
230
270
  setattr(state, ent.key, results.get(ent.key))
@@ -450,10 +490,13 @@ class QubeClient:
450
490
  if offset is not None:
451
491
  val += offset
452
492
 
493
+ self._log_read_ok(f"address {address}")
453
494
  return val
454
495
 
455
496
  except Exception as e:
456
497
  self._log_read_failure(f"address {address}", e)
498
+ if isinstance(e, _LINK_ERRORS):
499
+ self._mark_disconnected()
457
500
  return None
458
501
 
459
502
  @staticmethod
@@ -542,51 +585,32 @@ class QubeClient:
542
585
 
543
586
  Groups entities into a handful of Modbus block reads instead of
544
587
  one transaction per entity. If a block read fails, its entities
545
- are read individually as a fallback.
588
+ are read individually as a fallback. If one of those single reads
589
+ hits a link failure (no connection, no response), the device is
590
+ treated as unreachable: the client is marked disconnected and the
591
+ remaining reads are skipped.
546
592
 
547
593
  Returns:
548
- Dictionary mapping entity keys to their values (None on error).
594
+ Dictionary mapping every requested entity key to its value
595
+ (None on error).
549
596
  """
550
597
  results: dict[str, Any] = {}
551
- 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})"
552
601
  try:
553
- if input_type_str == "coil":
554
- result = await self._client.read_coils(
555
- start, count=count, device_id=self.unit
556
- )
557
- elif input_type_str == "discrete_input":
558
- result = await self._client.read_discrete_inputs(
559
- start, count=count, device_id=self.unit
560
- )
561
- elif input_type_str == "input":
562
- result = await self._client.read_input_registers(
563
- start, count=count, device_id=self.unit
564
- )
565
- else: # holding
566
- result = await self._client.read_holding_registers(
567
- start, count=count, device_id=self.unit
568
- )
569
- if result.isError():
570
- raise OSError(f"Modbus error response for block @{start}")
571
- if input_type_str in ("coil", "discrete_input"):
572
- if len(result.bits) < count:
573
- raise OSError(
574
- f"Short bit response for block {input_type_str}@{start} "
575
- f"(got {len(result.bits)}, expected {count})"
576
- )
577
- elif len(result.registers) < count:
578
- raise OSError(
579
- f"Short register response for block {input_type_str}@{start} "
580
- f"(got {len(result.registers)}, expected {count})"
581
- )
602
+ result = await self._read_block(input_type_str, start, count)
582
603
  except Exception as exc:
583
- self._log_read_failure(
584
- f"block {input_type_str}@{start} (count {count})", exc
585
- )
586
- for ent in members:
587
- 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
588
611
  continue
589
612
 
613
+ self._log_read_ok(target)
590
614
  for ent in members:
591
615
  offset = ent.address - start
592
616
  if input_type_str in ("coil", "discrete_input"):
@@ -599,6 +623,63 @@ class QubeClient:
599
623
  results[ent.key] = self._apply_scaling(ent, val)
600
624
  return results
601
625
 
626
+ async def _read_block(self, input_type_str: str, start: int, count: int) -> Any:
627
+ """Read one block and validate the response.
628
+
629
+ Raises:
630
+ _BlockReadError: On a Modbus error reply or a short response.
631
+ Any of _LINK_ERRORS: When the device could not be reached.
632
+ """
633
+ if input_type_str == "coil":
634
+ result = await self._client.read_coils(
635
+ start, count=count, device_id=self.unit
636
+ )
637
+ elif input_type_str == "discrete_input":
638
+ result = await self._client.read_discrete_inputs(
639
+ start, count=count, device_id=self.unit
640
+ )
641
+ elif input_type_str == "input":
642
+ result = await self._client.read_input_registers(
643
+ start, count=count, device_id=self.unit
644
+ )
645
+ else: # holding
646
+ result = await self._client.read_holding_registers(
647
+ start, count=count, device_id=self.unit
648
+ )
649
+ if result.isError():
650
+ raise _BlockReadError(f"Modbus error response for block @{start}")
651
+ if input_type_str in ("coil", "discrete_input"):
652
+ if len(result.bits) < count:
653
+ raise _BlockReadError(
654
+ f"Short bit response for block {input_type_str}@{start} "
655
+ f"(got {len(result.bits)}, expected {count})"
656
+ )
657
+ elif len(result.registers) < count:
658
+ raise _BlockReadError(
659
+ f"Short register response for block {input_type_str}@{start} "
660
+ f"(got {len(result.registers)}, expected {count})"
661
+ )
662
+ return result
663
+
664
+ async def _read_individually(
665
+ self, members: list[EntityDef], results: dict[str, Any]
666
+ ) -> bool:
667
+ """Read a failed block's entities one by one into ``results``.
668
+
669
+ Returns False as soon as a read hits a link failure, so a dead
670
+ device costs one extra transaction instead of one per entity.
671
+ """
672
+ for ent in members:
673
+ try:
674
+ results[ent.key] = await self._read_entity_or_raise(ent)
675
+ except _LINK_ERRORS as exc:
676
+ self._log_read_failure(f"entity {ent.key}", exc)
677
+ return False
678
+ except Exception as exc:
679
+ self._log_read_failure(f"entity {ent.key}", exc)
680
+ results[ent.key] = None
681
+ return True
682
+
602
683
  async def read_entity(self, entity: EntityDef) -> Any:
603
684
  """Read a single entity value based on EntityDef.
604
685
 
@@ -608,33 +689,41 @@ class QubeClient:
608
689
  Returns:
609
690
  The read value (float, int, or bool depending on entity type).
610
691
  """
692
+ try:
693
+ return await self._read_entity_or_raise(entity)
694
+ except Exception as e:
695
+ self._log_read_failure(f"entity {entity.key}", e)
696
+ if isinstance(e, _LINK_ERRORS):
697
+ self._mark_disconnected()
698
+ return None
699
+
700
+ async def _read_entity_or_raise(self, entity: EntityDef) -> Any:
701
+ """Read a single entity; return None on an error reply, raise otherwise."""
611
702
  # Determine register count based on data type
612
703
  # Use string comparison to handle potential enum class differences
613
704
  data_type_str = entity.data_type.value if entity.data_type else None
614
705
  count = self._register_count(entity)
615
706
 
616
- try:
617
- # Read based on input type (use string comparison for safety)
618
- input_type_str = entity.input_type.value if entity.input_type else None
619
-
620
- if input_type_str == "coil":
621
- result = await self._client.read_coils(
622
- entity.address, count=1, device_id=self.unit
623
- )
624
- if result.isError():
625
- _LOGGER.warning("Error reading coil %s", entity.address)
626
- return None
627
- return bool(result.bits[0])
628
-
629
- if input_type_str == "discrete_input":
630
- result = await self._client.read_discrete_inputs(
631
- entity.address, count=1, device_id=self.unit
632
- )
633
- if result.isError():
634
- _LOGGER.warning("Error reading discrete input %s", entity.address)
635
- return None
636
- return bool(result.bits[0])
707
+ # Read based on input type (use string comparison for safety)
708
+ input_type_str = entity.input_type.value if entity.input_type else None
637
709
 
710
+ if input_type_str == "coil":
711
+ result = await self._client.read_coils(
712
+ entity.address, count=1, device_id=self.unit
713
+ )
714
+ if result.isError():
715
+ _LOGGER.warning("Error reading coil %s", entity.address)
716
+ return None
717
+ value: Any = bool(result.bits[0])
718
+ elif input_type_str == "discrete_input":
719
+ result = await self._client.read_discrete_inputs(
720
+ entity.address, count=1, device_id=self.unit
721
+ )
722
+ if result.isError():
723
+ _LOGGER.warning("Error reading discrete input %s", entity.address)
724
+ return None
725
+ value = bool(result.bits[0])
726
+ else:
638
727
  if input_type_str == "input":
639
728
  result = await self._client.read_input_registers(
640
729
  entity.address, count=count, device_id=self.unit
@@ -649,11 +738,10 @@ class QubeClient:
649
738
  return None
650
739
 
651
740
  val = self._decode_registers(data_type_str, result.registers)
652
- return self._apply_scaling(entity, val)
741
+ value = self._apply_scaling(entity, val)
653
742
 
654
- except Exception as e:
655
- self._log_read_failure(f"entity {entity.key}", e)
656
- return None
743
+ self._log_read_ok(f"entity {entity.key}")
744
+ return value
657
745
 
658
746
  async def read_sensor(self, key: str) -> float | int | None:
659
747
  """Read a sensor value by key.
@@ -700,16 +788,18 @@ class QubeClient:
700
788
  return None
701
789
  return await self.read_entity(entity)
702
790
 
791
+ async def _read_table(self, table: dict[str, EntityDef]) -> dict[str, Any]:
792
+ """Batch-read every entity in a table, keeping the table's key order."""
793
+ results = await self.read_entities_batched(table.values())
794
+ return {key: results.get(key) for key in table}
795
+
703
796
  async def read_all_sensors(self) -> dict[str, Any]:
704
797
  """Read all sensor values.
705
798
 
706
799
  Returns:
707
800
  Dictionary mapping sensor keys to their values.
708
801
  """
709
- result: dict[str, Any] = {}
710
- for key, entity in SENSORS.items():
711
- result[key] = await self.read_entity(entity)
712
- return result
802
+ return await self._read_table(SENSORS)
713
803
 
714
804
  async def read_all_binary_sensors(self) -> dict[str, bool | None]:
715
805
  """Read all binary sensor values.
@@ -717,10 +807,7 @@ class QubeClient:
717
807
  Returns:
718
808
  Dictionary mapping binary sensor keys to their values.
719
809
  """
720
- result: dict[str, bool | None] = {}
721
- for key, entity in BINARY_SENSORS.items():
722
- result[key] = await self.read_entity(entity)
723
- return result
810
+ return await self._read_table(BINARY_SENSORS)
724
811
 
725
812
  async def read_all_switches(self) -> dict[str, bool | None]:
726
813
  """Read all switch states.
@@ -728,10 +815,7 @@ class QubeClient:
728
815
  Returns:
729
816
  Dictionary mapping switch keys to their states.
730
817
  """
731
- result: dict[str, bool | None] = {}
732
- for key, entity in SWITCHES.items():
733
- result[key] = await self.read_entity(entity)
734
- return result
818
+ return await self._read_table(SWITCHES)
735
819
 
736
820
  async def write_switch(self, key: str, value: bool) -> bool:
737
821
  """Write a switch state by key.
@@ -762,6 +846,8 @@ class QubeClient:
762
846
  return True
763
847
  except Exception as e:
764
848
  _LOGGER.error("Exception writing switch %s: %s", key, e)
849
+ if isinstance(e, _LINK_ERRORS):
850
+ self._mark_disconnected()
765
851
  return False
766
852
 
767
853
  # SG Ready mode API
@@ -782,8 +868,11 @@ class QubeClient:
782
868
  Returns:
783
869
  Mode string ("off", "block", "plus", "max"), or None on error.
784
870
  """
785
- bit_a = await self.read_switch("bms_sgready_a")
786
- 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")
787
876
  if bit_a is None or bit_b is None:
788
877
  return None
789
878
  return self._SGREADY_BITS_TO_MODE.get((bool(bit_a), bool(bit_b)))
@@ -791,16 +880,41 @@ class QubeClient:
791
880
  async def set_sg_ready_mode(self, mode: str) -> bool:
792
881
  """Set the SG Ready mode.
793
882
 
883
+ Both coils are written in one request so the heat pump never sees
884
+ a half-applied mode. If the device rejects the multi-coil write,
885
+ the coils are written one after the other instead.
886
+
794
887
  Args:
795
888
  mode: One of "off", "block", "plus", "max".
796
889
 
797
890
  Returns:
798
- True if both writes succeeded, False otherwise.
891
+ True if both coils were written, False otherwise.
799
892
  """
800
893
  bits = self._SGREADY_MODE_TO_BITS.get(mode)
801
894
  if bits is None:
802
895
  _LOGGER.warning("Unknown SG Ready mode: %s", mode)
803
896
  return False
897
+
898
+ coil_a = SWITCHES["bms_sgready_a"]
899
+ coil_b = SWITCHES["bms_sgready_b"]
900
+ if coil_b.address == coil_a.address + 1:
901
+ try:
902
+ result = await self._client.write_coils(
903
+ coil_a.address, list(bits), device_id=self.unit
904
+ )
905
+ except Exception as e:
906
+ _LOGGER.error("Exception writing SG Ready mode %s: %s", mode, e)
907
+ if isinstance(e, _LINK_ERRORS):
908
+ self._mark_disconnected()
909
+ return False
910
+ if not result.isError():
911
+ return True
912
+ _LOGGER.debug(
913
+ "Multi-coil write rejected for SG Ready mode %s; "
914
+ "falling back to single writes",
915
+ mode,
916
+ )
917
+
804
918
  success_a = await self.write_switch("bms_sgready_a", bits[0])
805
919
  success_b = await self.write_switch("bms_sgready_b", bits[1])
806
920
  return success_a and success_b
@@ -828,6 +942,24 @@ class QubeClient:
828
942
  _LOGGER.warning("Sensor %s is not a holding register", key)
829
943
  return False
830
944
 
945
+ if not isinstance(value, int | float) or not math.isfinite(value):
946
+ _LOGGER.warning(
947
+ "Refusing to write %r to %s: not a finite number", value, key
948
+ )
949
+ return False
950
+
951
+ if (entity.min_value is not None and value < entity.min_value) or (
952
+ entity.max_value is not None and value > entity.max_value
953
+ ):
954
+ _LOGGER.warning(
955
+ "Refusing to write %s to %s: outside allowed range %s..%s",
956
+ value,
957
+ key,
958
+ entity.min_value,
959
+ entity.max_value,
960
+ )
961
+ return False
962
+
831
963
  try:
832
964
  # Reverse scale/offset if needed
833
965
  write_value = value
@@ -846,15 +978,25 @@ class QubeClient:
846
978
  result = await self._client.write_registers(
847
979
  entity.address, regs, device_id=self.unit
848
980
  )
849
- elif entity.data_type == DataType.INT16:
850
- if write_value < 0:
851
- write_value = int(write_value) + 65536
852
- result = await self._client.write_register(
853
- 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)
854
987
  )
855
- 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
856
998
  result = await self._client.write_register(
857
- entity.address, int(write_value), device_id=self.unit
999
+ entity.address, raw, device_id=self.unit
858
1000
  )
859
1001
  else:
860
1002
  _LOGGER.warning(
@@ -869,4 +1011,6 @@ class QubeClient:
869
1011
 
870
1012
  except Exception as e:
871
1013
  _LOGGER.error("Exception writing setpoint %s: %s", key, e)
1014
+ if isinstance(e, _LINK_ERRORS):
1015
+ self._mark_disconnected()
872
1016
  return False
@@ -79,3 +79,10 @@ class EntityDef:
79
79
  # Value precision
80
80
  precision: int | None = None
81
81
  """Number of decimal places to round to. None for no rounding."""
82
+
83
+ # Write limits
84
+ min_value: float | None = None
85
+ """Lowest value write_setpoint() accepts, in the entity's unit."""
86
+
87
+ max_value: float | None = None
88
+ """Highest value write_setpoint() accepts, in the entity's unit."""
@@ -85,6 +85,8 @@ _SENSOR_DEFS: tuple[EntityDef, ...] = (
85
85
  platform=Platform.SENSOR,
86
86
  unit="°C",
87
87
  writable=True, # User-override setpoint, bypasses heating curve
88
+ min_value=20.0,
89
+ max_value=65.0,
88
90
  ),
89
91
  EntityDef(
90
92
  key="usr_pid_coolsetp",
@@ -95,6 +97,8 @@ _SENSOR_DEFS: tuple[EntityDef, ...] = (
95
97
  platform=Platform.SENSOR,
96
98
  unit="°C",
97
99
  writable=True, # User-override setpoint, bypasses cooling curve
100
+ min_value=7.0,
101
+ max_value=25.0,
98
102
  ),
99
103
  EntityDef(
100
104
  key="regulation_buffersetp_max",
@@ -114,6 +118,8 @@ _SENSOR_DEFS: tuple[EntityDef, ...] = (
114
118
  platform=Platform.SENSOR,
115
119
  unit="°C",
116
120
  writable=True, # This setpoint can be written via Modbus
121
+ min_value=40.0,
122
+ max_value=65.0,
117
123
  ),
118
124
  # Input register sensors (read-only)
119
125
  EntityDef(
@@ -460,6 +466,8 @@ _SENSOR_DEFS: tuple[EntityDef, ...] = (
460
466
  platform=Platform.SENSOR,
461
467
  unit="°C",
462
468
  writable=True,
469
+ min_value=40.0,
470
+ max_value=65.0,
463
471
  ),
464
472
  )
465
473
 
@@ -54,7 +54,8 @@ async def async_get_mac_address(host: str, port: int = MODBUS_PORT) -> str | Non
54
54
  Returns:
55
55
  MAC address as lowercase colon-separated string, or None.
56
56
  """
57
- ip = _resolve_ip(host)
57
+ # Both lookups block (DNS, file I/O), so keep them off the event loop
58
+ ip = await asyncio.to_thread(_resolve_ip, host)
58
59
  if ip is None:
59
60
  _LOGGER.debug("Could not resolve host %s to IP", host)
60
61
  return None
@@ -71,4 +72,4 @@ async def async_get_mac_address(host: str, port: int = MODBUS_PORT) -> str | Non
71
72
  _LOGGER.debug("Could not connect to %s:%s to populate ARP", ip, port)
72
73
  return None
73
74
 
74
- return _read_arp_table(ip)
75
+ return await asyncio.to_thread(_read_arp_table, ip)
@@ -1,5 +1,5 @@
1
1
  import pytest
2
- from unittest.mock import AsyncMock
2
+ from unittest.mock import AsyncMock, MagicMock
3
3
 
4
4
 
5
5
  @pytest.fixture
@@ -8,7 +8,8 @@ def mock_modbus_client(mocker):
8
8
  mock_client = mocker.patch("python_qube_heatpump.client.AsyncModbusTcpClient")
9
9
  mock_instance = mock_client.return_value
10
10
  mock_instance.connect = AsyncMock(return_value=True)
11
- mock_instance.close = AsyncMock()
11
+ # pymodbus close() is synchronous
12
+ mock_instance.close = MagicMock()
12
13
  mock_instance.read_holding_registers = AsyncMock()
13
14
  mock_instance.read_input_registers = AsyncMock()
14
15
  mock_instance.write_register = AsyncMock()
@@ -456,16 +456,12 @@ async def test_get_sg_ready_mode(mock_modbus_client):
456
456
  mock_instance = mock_modbus_client.return_value
457
457
  client._client = mock_instance
458
458
 
459
- # Mock coil reads: A=False, B=True -> "plus"
460
- mock_resp_a = MagicMock()
461
- mock_resp_a.isError.return_value = False
462
- mock_resp_a.bits = [False]
463
-
464
- mock_resp_b = MagicMock()
465
- mock_resp_b.isError.return_value = False
466
- mock_resp_b.bits = [True]
459
+ # Both coils come back in one block read: A=False, B=True -> "plus"
460
+ mock_resp = MagicMock()
461
+ mock_resp.isError.return_value = False
462
+ mock_resp.bits = [False, True]
467
463
 
468
- mock_instance.read_coils = AsyncMock(side_effect=[mock_resp_a, mock_resp_b])
464
+ mock_instance.read_coils = AsyncMock(return_value=mock_resp)
469
465
 
470
466
  result = await client.get_sg_ready_mode()
471
467
  assert result == "plus"
@@ -485,13 +481,10 @@ async def test_get_sg_ready_mode_all_modes(mock_modbus_client):
485
481
  ((True, True), "max"),
486
482
  ]
487
483
  for (bit_a, bit_b), expected_mode in cases:
488
- resp_a = MagicMock()
489
- resp_a.isError.return_value = False
490
- resp_a.bits = [bit_a]
491
- resp_b = MagicMock()
492
- resp_b.isError.return_value = False
493
- resp_b.bits = [bit_b]
494
- mock_instance.read_coils = AsyncMock(side_effect=[resp_a, resp_b])
484
+ resp = MagicMock()
485
+ resp.isError.return_value = False
486
+ resp.bits = [bit_a, bit_b]
487
+ mock_instance.read_coils = AsyncMock(return_value=resp)
495
488
 
496
489
  result = await client.get_sg_ready_mode()
497
490
  assert result == expected_mode, (
@@ -516,23 +509,21 @@ async def test_get_sg_ready_mode_read_error(mock_modbus_client):
516
509
 
517
510
  @pytest.mark.asyncio
518
511
  async def test_set_sg_ready_mode(mock_modbus_client):
519
- """Test setting SG Ready mode writes both coils."""
512
+ """Test setting SG Ready mode writes both coils in one request."""
520
513
  client = QubeClient("1.2.3.4", 502)
521
514
  mock_instance = mock_modbus_client.return_value
522
515
  client._client = mock_instance
523
516
 
524
517
  mock_resp = MagicMock()
525
518
  mock_resp.isError.return_value = False
526
- mock_instance.write_coil = AsyncMock(return_value=mock_resp)
519
+ mock_instance.write_coils = AsyncMock(return_value=mock_resp)
527
520
 
528
521
  result = await client.set_sg_ready_mode("plus")
529
522
  assert result is True
530
523
 
531
- # "plus" = (False, True) -> A=False, B=True
532
- calls = mock_instance.write_coil.call_args_list
533
- assert len(calls) == 2
534
- assert calls[0].args == (65, False) # bms_sgready_a address
535
- assert calls[1].args == (66, True) # bms_sgready_b address
524
+ # "plus" = (False, True) -> A=False (coil 65), B=True (coil 66)
525
+ mock_instance.write_coils.assert_awaited_once()
526
+ assert mock_instance.write_coils.call_args.args == (65, [False, True])
536
527
 
537
528
 
538
529
  @pytest.mark.asyncio
@@ -0,0 +1,83 @@
1
+ """read_all_* helpers must match per-entity reads while batching requests."""
2
+
3
+ from unittest.mock import AsyncMock, MagicMock
4
+
5
+ import pytest
6
+
7
+ from python_qube_heatpump import QubeClient
8
+ from python_qube_heatpump.entities import BINARY_SENSORS, SENSORS, SWITCHES
9
+
10
+
11
+ def _fake_device(mock_instance):
12
+ """Serve a fixed, address-dependent register and bit map."""
13
+
14
+ def registers(address, count=1, **kwargs):
15
+ resp = MagicMock()
16
+ resp.isError.return_value = False
17
+ # Varied words so float32/int16/uint32 decoding is exercised
18
+ resp.registers = [
19
+ (a * 7919 + 0x4100) & 0xFFFF for a in range(address, address + count)
20
+ ]
21
+ return resp
22
+
23
+ def bits(address, count=1, **kwargs):
24
+ resp = MagicMock()
25
+ resp.isError.return_value = False
26
+ resp.bits = [
27
+ bool((a * 2654435761) & 4) for a in range(address, address + count)
28
+ ]
29
+ return resp
30
+
31
+ mock_instance.read_input_registers = AsyncMock(side_effect=registers)
32
+ mock_instance.read_holding_registers = AsyncMock(side_effect=registers)
33
+ mock_instance.read_coils = AsyncMock(side_effect=bits)
34
+ mock_instance.read_discrete_inputs = AsyncMock(side_effect=bits)
35
+
36
+
37
+ def _total_reads(mock_instance) -> int:
38
+ return sum(
39
+ getattr(mock_instance, name).call_count
40
+ for name in (
41
+ "read_coils",
42
+ "read_discrete_inputs",
43
+ "read_input_registers",
44
+ "read_holding_registers",
45
+ )
46
+ )
47
+
48
+
49
+ def _same(a, b) -> bool:
50
+ if isinstance(a, float) and isinstance(b, float) and a != a and b != b:
51
+ return True # both NaN
52
+ return a == b and type(a) is type(b)
53
+
54
+
55
+ @pytest.mark.asyncio
56
+ @pytest.mark.parametrize(
57
+ ("method", "table"),
58
+ [
59
+ ("read_all_sensors", SENSORS),
60
+ ("read_all_binary_sensors", BINARY_SENSORS),
61
+ ("read_all_switches", SWITCHES),
62
+ ],
63
+ )
64
+ async def test_read_all_matches_single_reads_with_fewer_requests(
65
+ mock_modbus_client, method, table
66
+ ):
67
+ client = QubeClient("1.2.3.4", 502)
68
+ mock_instance = mock_modbus_client.return_value
69
+ client._client = mock_instance
70
+ _fake_device(mock_instance)
71
+
72
+ expected = {key: await client.read_entity(ent) for key, ent in table.items()}
73
+ single_reads = _total_reads(mock_instance)
74
+
75
+ mock_instance.reset_mock()
76
+ _fake_device(mock_instance)
77
+ result = await getattr(client, method)()
78
+
79
+ assert list(result) == list(table)
80
+ mismatched = [k for k in table if not _same(result[k], expected[k])]
81
+ assert mismatched == []
82
+ assert _total_reads(mock_instance) < single_reads
83
+ assert _total_reads(mock_instance) == len(client._plan_blocks(table.values()))
@@ -0,0 +1,252 @@
1
+ """Tests for QubeClient behaviour when the Modbus link fails."""
2
+
3
+ import logging
4
+ from unittest.mock import AsyncMock, MagicMock
5
+
6
+ import pytest
7
+ from pymodbus.exceptions import ConnectionException, ModbusIOException
8
+
9
+ from python_qube_heatpump import QubeClient
10
+ from python_qube_heatpump.entities import BINARY_SENSORS, SWITCHES
11
+
12
+
13
+ def _ok_registers(address, count=1, **kwargs):
14
+ resp = MagicMock()
15
+ resp.isError.return_value = False
16
+ resp.registers = [0] * count
17
+ return resp
18
+
19
+
20
+ def _ok_bits(address, count=1, **kwargs):
21
+ resp = MagicMock()
22
+ resp.isError.return_value = False
23
+ resp.bits = [False] * count
24
+ return resp
25
+
26
+
27
+ def _error_reply(*args, **kwargs):
28
+ resp = MagicMock()
29
+ resp.isError.return_value = True
30
+ return resp
31
+
32
+
33
+ def _all_reads(mock_instance, side_effect):
34
+ """Point every read function at the same side effect."""
35
+ for name in (
36
+ "read_coils",
37
+ "read_discrete_inputs",
38
+ "read_input_registers",
39
+ "read_holding_registers",
40
+ ):
41
+ setattr(mock_instance, name, AsyncMock(side_effect=side_effect))
42
+
43
+
44
+ def _total_reads(mock_instance) -> int:
45
+ return sum(
46
+ getattr(mock_instance, name).call_count
47
+ for name in (
48
+ "read_coils",
49
+ "read_discrete_inputs",
50
+ "read_input_registers",
51
+ "read_holding_registers",
52
+ )
53
+ )
54
+
55
+
56
+ def _healthy(mock_instance):
57
+ mock_instance.read_coils = AsyncMock(side_effect=_ok_bits)
58
+ mock_instance.read_discrete_inputs = AsyncMock(side_effect=_ok_bits)
59
+ mock_instance.read_input_registers = AsyncMock(side_effect=_ok_registers)
60
+ mock_instance.read_holding_registers = AsyncMock(side_effect=_ok_registers)
61
+
62
+
63
+ @pytest.fixture
64
+ def connected_client(mock_modbus_client):
65
+ client = QubeClient("1.2.3.4", 502)
66
+ mock_instance = mock_modbus_client.return_value
67
+ client._client = mock_instance
68
+ client._connected = True
69
+ return client, mock_instance
70
+
71
+
72
+ @pytest.mark.asyncio
73
+ @pytest.mark.parametrize(
74
+ "exc",
75
+ [
76
+ ConnectionException("down"),
77
+ ModbusIOException("No response received after 3 retries"),
78
+ TimeoutError(),
79
+ ConnectionResetError(),
80
+ ],
81
+ )
82
+ async def test_link_error_marks_client_disconnected(connected_client, exc):
83
+ """A link-level failure clears is_connected and closes the transport."""
84
+ client, mock_instance = connected_client
85
+ _all_reads(mock_instance, exc)
86
+
87
+ await client.read_entities_batched(SWITCHES.values())
88
+
89
+ assert client.is_connected is False
90
+ mock_instance.close.assert_called()
91
+
92
+
93
+ @pytest.mark.asyncio
94
+ async def test_get_all_data_stops_after_first_link_error(connected_client):
95
+ """A dead link costs two transactions per poll, not one per entity.
96
+
97
+ The failed block read is followed by one single-entity read that acts as
98
+ a probe (a device may time out on a large block but answer small reads);
99
+ when that fails too, the poll stops.
100
+ """
101
+ client, mock_instance = connected_client
102
+ _all_reads(mock_instance, ModbusIOException("no response"))
103
+
104
+ state = await client.get_all_data()
105
+
106
+ assert state is None
107
+ assert _total_reads(mock_instance) == 2
108
+
109
+
110
+ @pytest.mark.asyncio
111
+ async def test_batched_read_returns_every_key_after_link_error(connected_client):
112
+ """Callers still get a value (None) for every requested entity."""
113
+ client, mock_instance = connected_client
114
+ _all_reads(mock_instance, ConnectionException("down"))
115
+
116
+ results = await client.read_entities_batched(BINARY_SENSORS.values())
117
+
118
+ assert set(results) == set(BINARY_SENSORS)
119
+ assert all(value is None for value in results.values())
120
+
121
+
122
+ @pytest.mark.asyncio
123
+ async def test_block_timeout_with_working_single_reads_stays_connected(
124
+ connected_client,
125
+ ):
126
+ """A block that times out while single reads work is not a dead link."""
127
+ client, mock_instance = connected_client
128
+ mock_instance.read_discrete_inputs = AsyncMock(
129
+ side_effect=[ModbusIOException("no response"), _ok_bits(0), _ok_bits(1)]
130
+ )
131
+
132
+ entities = [BINARY_SENSORS["dout_srcpmp_val"], BINARY_SENSORS["dout_usrpmp_val"]]
133
+ results = await client.read_entities_batched(entities)
134
+
135
+ assert results == {e.key: False for e in entities}
136
+ assert client.is_connected is True
137
+
138
+
139
+ @pytest.mark.asyncio
140
+ async def test_error_reply_still_falls_back_to_single_reads(connected_client):
141
+ """A Modbus error reply is not a link failure: fall back per entity."""
142
+ client, mock_instance = connected_client
143
+ _all_reads(mock_instance, _error_reply)
144
+
145
+ entities = [BINARY_SENSORS["dout_srcpmp_val"], BINARY_SENSORS["dout_usrpmp_val"]]
146
+ await client.read_entities_batched(entities)
147
+
148
+ # one block read plus one single read per entity
149
+ assert mock_instance.read_discrete_inputs.call_count == 3
150
+ assert client.is_connected is True
151
+
152
+
153
+ @pytest.mark.asyncio
154
+ async def test_single_read_fallback_stops_at_link_error(connected_client):
155
+ """If the link dies during the per-entity fallback, the rest is skipped."""
156
+ client, mock_instance = connected_client
157
+ mock_instance.read_discrete_inputs = AsyncMock(
158
+ side_effect=[_error_reply(), ConnectionException("down")] + [_ok_bits(0)] * 10
159
+ )
160
+
161
+ entities = [
162
+ BINARY_SENSORS["dout_srcpmp_val"],
163
+ BINARY_SENSORS["dout_usrpmp_val"],
164
+ BINARY_SENSORS["dout_fourwayvlv_val"],
165
+ ]
166
+ results = await client.read_entities_batched(entities)
167
+
168
+ assert mock_instance.read_discrete_inputs.call_count == 2
169
+ assert results == {e.key: None for e in entities}
170
+ assert client.is_connected is False
171
+
172
+
173
+ @pytest.mark.asyncio
174
+ async def test_get_all_data_reconnects_on_next_poll(connected_client):
175
+ """After a lost link the next poll reconnects and returns data again."""
176
+ client, mock_instance = connected_client
177
+ _all_reads(mock_instance, ConnectionException("down"))
178
+ assert await client.get_all_data() is None
179
+ assert client.is_connected is False
180
+
181
+ mock_instance.connect = AsyncMock(return_value=True)
182
+ _healthy(mock_instance)
183
+ state = await client.get_all_data()
184
+
185
+ assert state is not None
186
+ mock_instance.connect.assert_awaited_once()
187
+ assert client.is_connected is True
188
+
189
+
190
+ @pytest.mark.asyncio
191
+ async def test_read_entity_link_error_marks_disconnected(connected_client):
192
+ """Single-entity reads detect a lost link too."""
193
+ client, mock_instance = connected_client
194
+ _all_reads(mock_instance, ConnectionException("down"))
195
+
196
+ assert await client.read_entity(BINARY_SENSORS["dout_srcpmp_val"]) is None
197
+ assert client.is_connected is False
198
+
199
+
200
+ @pytest.mark.asyncio
201
+ async def test_write_link_error_marks_disconnected(connected_client):
202
+ """Writes that hit a lost link return False and clear is_connected."""
203
+ client, mock_instance = connected_client
204
+ mock_instance.write_coil = AsyncMock(side_effect=ConnectionException("down"))
205
+
206
+ assert await client.write_switch("bms_summerwinter", True) is False
207
+ assert client.is_connected is False
208
+
209
+
210
+ @pytest.mark.asyncio
211
+ async def test_failure_warns_again_after_recovery(connected_client, caplog):
212
+ """Warn-once resets when the target reads fine again."""
213
+ client, mock_instance = connected_client
214
+ entity = BINARY_SENSORS["dout_srcpmp_val"]
215
+ mock_instance.read_discrete_inputs = AsyncMock(
216
+ side_effect=[
217
+ _error_reply(),
218
+ _error_reply(),
219
+ _ok_bits(0),
220
+ _error_reply(),
221
+ _error_reply(),
222
+ ]
223
+ )
224
+ with caplog.at_level(logging.DEBUG, logger="python_qube_heatpump.client"):
225
+ await client.read_entities_batched([entity]) # block fails, single fails
226
+ await client.read_entities_batched([entity]) # recovers
227
+ await client.read_entities_batched([entity]) # block fails again
228
+
229
+ warnings = [
230
+ r
231
+ for r in caplog.records
232
+ if r.levelno == logging.WARNING and "block" in r.getMessage()
233
+ ]
234
+ assert len(warnings) == 2
235
+
236
+
237
+ @pytest.mark.asyncio
238
+ async def test_link_failure_warns_again_after_recovery(connected_client, caplog):
239
+ """A second outage days later is logged at WARNING again."""
240
+ client, mock_instance = connected_client
241
+ entity = BINARY_SENSORS["dout_srcpmp_val"]
242
+ mock_instance.read_discrete_inputs = AsyncMock(
243
+ side_effect=[OSError("down"), _ok_bits(0), OSError("down again")]
244
+ )
245
+
246
+ with caplog.at_level(logging.DEBUG, logger="python_qube_heatpump.client"):
247
+ await client.read_entity(entity)
248
+ await client.read_entity(entity)
249
+ await client.read_entity(entity)
250
+
251
+ warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
252
+ assert len(warnings) == 2
@@ -0,0 +1,197 @@
1
+ """Tests for write validation and SG Ready writes in QubeClient."""
2
+
3
+ import math
4
+ import struct
5
+ from unittest.mock import AsyncMock, MagicMock
6
+
7
+ import pytest
8
+ from pymodbus.exceptions import ConnectionException
9
+
10
+ from python_qube_heatpump import QubeClient
11
+ from python_qube_heatpump.entities import SENSORS
12
+ from python_qube_heatpump.entities.base import (
13
+ DataType,
14
+ EntityDef,
15
+ InputType,
16
+ Platform,
17
+ )
18
+
19
+
20
+ def _ok():
21
+ resp = MagicMock()
22
+ resp.isError.return_value = False
23
+ return resp
24
+
25
+
26
+ def _error_reply():
27
+ resp = MagicMock()
28
+ resp.isError.return_value = True
29
+ return resp
30
+
31
+
32
+ def _float_from_regs(regs):
33
+ return struct.unpack(">f", struct.pack(">I", (regs[0] << 16) | regs[1]))[0]
34
+
35
+
36
+ @pytest.fixture
37
+ def client(mock_modbus_client):
38
+ qube = QubeClient("1.2.3.4", 502)
39
+ mock_instance = mock_modbus_client.return_value
40
+ mock_instance.write_registers = AsyncMock(return_value=_ok())
41
+ mock_instance.write_register = AsyncMock(return_value=_ok())
42
+ mock_instance.write_coil = AsyncMock(return_value=_ok())
43
+ mock_instance.write_coils = AsyncMock(return_value=_ok())
44
+ qube._client = mock_instance
45
+ qube._connected = True
46
+ return qube, mock_instance
47
+
48
+
49
+ # Ranges the HA core and HACS integrations expose for these setpoints
50
+ @pytest.mark.parametrize(
51
+ ("key", "low", "high"),
52
+ [
53
+ ("setpoint_dhw", 40.0, 65.0),
54
+ ("tapw_timeprogram_dhwsetp_nolinq", 40.0, 65.0),
55
+ ("usr_pid_heatsetp", 20.0, 65.0),
56
+ ("usr_pid_coolsetp", 7.0, 25.0),
57
+ ],
58
+ )
59
+ def test_writable_setpoints_have_safe_ranges(key, low, high):
60
+ entity = SENSORS[key]
61
+ assert entity.min_value == low
62
+ assert entity.max_value == high
63
+
64
+
65
+ def test_every_writable_sensor_has_a_range():
66
+ for entity in SENSORS.values():
67
+ if entity.writable:
68
+ assert entity.min_value is not None, entity.key
69
+ assert entity.max_value is not None, entity.key
70
+
71
+
72
+ @pytest.mark.asyncio
73
+ @pytest.mark.parametrize("value", [40.0, 52.5, 65.0])
74
+ async def test_write_setpoint_accepts_values_in_range(client, value):
75
+ qube, mock_instance = client
76
+ assert await qube.write_setpoint("setpoint_dhw", value) is True
77
+ regs = mock_instance.write_registers.call_args.args[1]
78
+ assert _float_from_regs(regs) == pytest.approx(value)
79
+
80
+
81
+ @pytest.mark.asyncio
82
+ @pytest.mark.parametrize("value", [39.9, 65.1, 500.0, -10.0])
83
+ async def test_write_setpoint_rejects_out_of_range(client, value):
84
+ qube, mock_instance = client
85
+ assert await qube.write_setpoint("setpoint_dhw", value) is False
86
+ mock_instance.write_registers.assert_not_called()
87
+
88
+
89
+ @pytest.mark.asyncio
90
+ @pytest.mark.parametrize("value", [math.nan, math.inf, -math.inf])
91
+ async def test_write_setpoint_rejects_non_finite(client, value):
92
+ qube, mock_instance = client
93
+ assert await qube.write_setpoint("usr_pid_heatsetp", value) is False
94
+ mock_instance.write_registers.assert_not_called()
95
+
96
+
97
+ def _int_entity(data_type, **kwargs):
98
+ return EntityDef(
99
+ key="test_int_setpoint",
100
+ name="Test",
101
+ address=500,
102
+ input_type=InputType.HOLDING_REGISTER,
103
+ data_type=data_type,
104
+ platform=Platform.SENSOR,
105
+ writable=True,
106
+ **kwargs,
107
+ )
108
+
109
+
110
+ @pytest.mark.asyncio
111
+ @pytest.mark.parametrize(
112
+ ("data_type", "value", "written"),
113
+ [
114
+ (DataType.UINT16, 21.6, 22),
115
+ (DataType.UINT16, 21.4, 21),
116
+ (DataType.INT16, -2.6, 65536 - 3),
117
+ ],
118
+ )
119
+ async def test_write_setpoint_rounds_16bit_values(
120
+ client, monkeypatch, data_type, value, written
121
+ ):
122
+ qube, mock_instance = client
123
+ monkeypatch.setitem(SENSORS, "test_int_setpoint", _int_entity(data_type))
124
+ assert await qube.write_setpoint("test_int_setpoint", value) is True
125
+ assert mock_instance.write_register.call_args.args[1] == written
126
+
127
+
128
+ @pytest.mark.asyncio
129
+ @pytest.mark.parametrize(
130
+ ("data_type", "value"),
131
+ [
132
+ (DataType.UINT16, -1),
133
+ (DataType.UINT16, 65536),
134
+ (DataType.INT16, 32768),
135
+ (DataType.INT16, -32769),
136
+ ],
137
+ )
138
+ async def test_write_setpoint_rejects_16bit_overflow(
139
+ client, monkeypatch, data_type, value
140
+ ):
141
+ qube, mock_instance = client
142
+ monkeypatch.setitem(SENSORS, "test_int_setpoint", _int_entity(data_type))
143
+ assert await qube.write_setpoint("test_int_setpoint", value) is False
144
+ mock_instance.write_register.assert_not_called()
145
+
146
+
147
+ @pytest.mark.asyncio
148
+ @pytest.mark.parametrize(
149
+ ("mode", "bits"),
150
+ [
151
+ ("off", [False, False]),
152
+ ("block", [True, False]),
153
+ ("plus", [False, True]),
154
+ ("max", [True, True]),
155
+ ],
156
+ )
157
+ async def test_set_sg_ready_mode_writes_both_coils_at_once(client, mode, bits):
158
+ qube, mock_instance = client
159
+ assert await qube.set_sg_ready_mode(mode) is True
160
+ mock_instance.write_coils.assert_awaited_once()
161
+ call = mock_instance.write_coils.call_args
162
+ assert call.args == (65, bits)
163
+ mock_instance.write_coil.assert_not_called()
164
+
165
+
166
+ @pytest.mark.asyncio
167
+ async def test_set_sg_ready_mode_falls_back_when_multi_write_unsupported(client):
168
+ """A device that rejects FC15 still gets the mode via two single writes."""
169
+ qube, mock_instance = client
170
+ mock_instance.write_coils = AsyncMock(return_value=_error_reply())
171
+
172
+ assert await qube.set_sg_ready_mode("plus") is True
173
+ calls = mock_instance.write_coil.call_args_list
174
+ assert [c.args for c in calls] == [(65, False), (66, True)]
175
+
176
+
177
+ @pytest.mark.asyncio
178
+ async def test_set_sg_ready_mode_link_error_does_not_fall_back(client):
179
+ qube, mock_instance = client
180
+ mock_instance.write_coils = AsyncMock(side_effect=ConnectionException("down"))
181
+
182
+ assert await qube.set_sg_ready_mode("max") is False
183
+ mock_instance.write_coil.assert_not_called()
184
+ assert qube.is_connected is False
185
+
186
+
187
+ @pytest.mark.asyncio
188
+ async def test_get_sg_ready_mode_reads_both_coils_in_one_request(client):
189
+ qube, mock_instance = client
190
+ resp = _ok()
191
+ resp.bits = [True, False, False, False, False, False, False, False]
192
+ mock_instance.read_coils = AsyncMock(return_value=resp)
193
+
194
+ assert await qube.get_sg_ready_mode() == "block"
195
+ mock_instance.read_coils.assert_awaited_once()
196
+ assert mock_instance.read_coils.call_args.args[0] == 65
197
+ assert mock_instance.read_coils.call_args.kwargs["count"] == 2
@@ -118,3 +118,34 @@ async def test_get_mac_address_with_ip_directly():
118
118
  result = await async_get_mac_address("192.168.5.208")
119
119
 
120
120
  assert result == "00:0a:5c:94:83:15"
121
+
122
+
123
+ @pytest.mark.asyncio
124
+ async def test_get_mac_address_does_blocking_work_off_the_event_loop():
125
+ """DNS lookup and the ARP file read must not block the event loop."""
126
+ import threading
127
+
128
+ loop_thread = threading.current_thread()
129
+ seen: dict[str, threading.Thread] = {}
130
+
131
+ def fake_resolve(host):
132
+ seen["resolve"] = threading.current_thread()
133
+ return "192.168.5.208"
134
+
135
+ def fake_arp(ip):
136
+ seen["arp"] = threading.current_thread()
137
+ return "00:0a:5c:94:83:15"
138
+
139
+ with (
140
+ patch(
141
+ "python_qube_heatpump.network.asyncio.open_connection",
142
+ return_value=(AsyncMock(), AsyncMock()),
143
+ ),
144
+ patch("python_qube_heatpump.network._resolve_ip", side_effect=fake_resolve),
145
+ patch("python_qube_heatpump.network._read_arp_table", side_effect=fake_arp),
146
+ ):
147
+ result = await async_get_mac_address("qube.local")
148
+
149
+ assert result == "00:0a:5c:94:83:15"
150
+ assert seen["resolve"] is not loop_thread
151
+ assert seen["arp"] is not loop_thread