python-qube-heatpump 1.7.0__tar.gz → 1.9.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.7.0 → python_qube_heatpump-1.9.0}/PKG-INFO +1 -1
  2. {python_qube_heatpump-1.7.0 → python_qube_heatpump-1.9.0}/pyproject.toml +1 -1
  3. {python_qube_heatpump-1.7.0 → python_qube_heatpump-1.9.0}/src/python_qube_heatpump/__init__.py +2 -0
  4. {python_qube_heatpump-1.7.0 → python_qube_heatpump-1.9.0}/src/python_qube_heatpump/client.py +10 -0
  5. {python_qube_heatpump-1.7.0 → python_qube_heatpump-1.9.0}/src/python_qube_heatpump/const.py +28 -0
  6. python_qube_heatpump-1.9.0/src/python_qube_heatpump/models.py +111 -0
  7. {python_qube_heatpump-1.7.0 → python_qube_heatpump-1.9.0}/tests/test_const.py +28 -0
  8. python_qube_heatpump-1.7.0/src/python_qube_heatpump/models.py +0 -55
  9. {python_qube_heatpump-1.7.0 → python_qube_heatpump-1.9.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  10. {python_qube_heatpump-1.7.0 → python_qube_heatpump-1.9.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  11. {python_qube_heatpump-1.7.0 → python_qube_heatpump-1.9.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  12. {python_qube_heatpump-1.7.0 → python_qube_heatpump-1.9.0}/.github/workflows/ci.yml +0 -0
  13. {python_qube_heatpump-1.7.0 → python_qube_heatpump-1.9.0}/.github/workflows/python-publish.yml +0 -0
  14. {python_qube_heatpump-1.7.0 → python_qube_heatpump-1.9.0}/.gitignore +0 -0
  15. {python_qube_heatpump-1.7.0 → python_qube_heatpump-1.9.0}/AGENTS.md +0 -0
  16. {python_qube_heatpump-1.7.0 → python_qube_heatpump-1.9.0}/CLAUDE.md +0 -0
  17. {python_qube_heatpump-1.7.0 → python_qube_heatpump-1.9.0}/LICENSE +0 -0
  18. {python_qube_heatpump-1.7.0 → python_qube_heatpump-1.9.0}/README.md +0 -0
  19. {python_qube_heatpump-1.7.0 → python_qube_heatpump-1.9.0}/docs/modbus-lijst-qube-totaal.pdf +0 -0
  20. {python_qube_heatpump-1.7.0 → python_qube_heatpump-1.9.0}/pytest.ini +0 -0
  21. {python_qube_heatpump-1.7.0 → python_qube_heatpump-1.9.0}/src/python_qube_heatpump/entities/__init__.py +0 -0
  22. {python_qube_heatpump-1.7.0 → python_qube_heatpump-1.9.0}/src/python_qube_heatpump/entities/base.py +0 -0
  23. {python_qube_heatpump-1.7.0 → python_qube_heatpump-1.9.0}/src/python_qube_heatpump/entities/binary_sensors.py +0 -0
  24. {python_qube_heatpump-1.7.0 → python_qube_heatpump-1.9.0}/src/python_qube_heatpump/entities/sensors.py +0 -0
  25. {python_qube_heatpump-1.7.0 → python_qube_heatpump-1.9.0}/src/python_qube_heatpump/entities/switches.py +0 -0
  26. {python_qube_heatpump-1.7.0 → python_qube_heatpump-1.9.0}/src/python_qube_heatpump/network.py +0 -0
  27. {python_qube_heatpump-1.7.0 → python_qube_heatpump-1.9.0}/src/python_qube_heatpump/py.typed +0 -0
  28. {python_qube_heatpump-1.7.0 → python_qube_heatpump-1.9.0}/tests/conftest.py +0 -0
  29. {python_qube_heatpump-1.7.0 → python_qube_heatpump-1.9.0}/tests/test_client.py +0 -0
  30. {python_qube_heatpump-1.7.0 → python_qube_heatpump-1.9.0}/tests/test_entities.py +0 -0
  31. {python_qube_heatpump-1.7.0 → python_qube_heatpump-1.9.0}/tests/test_models.py +0 -0
  32. {python_qube_heatpump-1.7.0 → python_qube_heatpump-1.9.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.7.0
3
+ Version: 1.9.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.7.0"
7
+ version = "1.9.0"
8
8
  authors = [
9
9
  { name="MattieGit", email="6250046+MattieGit@users.noreply.github.com" },
10
10
  ]
@@ -7,6 +7,7 @@ from .const import (
7
7
  StatusCode,
8
8
  STATUS_CODE_MAP,
9
9
  get_status_code,
10
+ resolve_status,
10
11
  )
11
12
  from .entities import (
12
13
  BINARY_SENSORS,
@@ -37,6 +38,7 @@ __all__ = [
37
38
  "StatusCode",
38
39
  "STATUS_CODE_MAP",
39
40
  "get_status_code",
41
+ "resolve_status",
40
42
  # Network
41
43
  "async_get_mac_address",
42
44
  ]
@@ -121,6 +121,16 @@ class QubeClient:
121
121
  state.setpoint_dhw = await _read(const.USER_DHW_SETPOINT)
122
122
 
123
123
  self._apply_monotonic_clamping(state)
124
+
125
+ # Fetch binary sensors
126
+ binary_data = await self.read_all_binary_sensors()
127
+ for key, value in binary_data.items():
128
+ if hasattr(state, key):
129
+ setattr(state, key, value)
130
+
131
+ # Compute unified status (status_code + anti-legionella override)
132
+ state.status = const.resolve_status(state.status_code, state.req_antileg_1)
133
+
124
134
  return state
125
135
 
126
136
  @property
@@ -32,6 +32,7 @@ class StatusCode(str, Enum):
32
32
  HEATING = "heating"
33
33
  START_FAIL = "start_fail"
34
34
  HEATING_DHW = "heating_dhw"
35
+ ANTI_LEGIONELLA = "anti_legionella"
35
36
  UNKNOWN = "unknown"
36
37
 
37
38
 
@@ -56,6 +57,33 @@ def get_status_code(code: int) -> StatusCode:
56
57
  return STATUS_CODE_MAP.get(code, StatusCode.UNKNOWN)
57
58
 
58
59
 
60
+ def resolve_status(code: int | None, antileg_active: bool | None) -> StatusCode:
61
+ """Resolve the unified heat pump status.
62
+
63
+ Combines the raw status code with the anti-legionella signal so consumers
64
+ receive a single status value. When anti-legionella is active the heat
65
+ pump is running a special DHW cycle that is otherwise hidden behind the
66
+ regular ``HEATING_DHW`` code; surfacing it as its own status makes the
67
+ device state visible without a separate binary sensor.
68
+
69
+ ``ALARM`` always takes precedence over anti-legionella: safety signals
70
+ must not be masked by normal operational modes.
71
+
72
+ Args:
73
+ code: Raw numeric status code from register 38, or ``None`` when
74
+ unavailable.
75
+ antileg_active: Current value of the anti-legionella discrete input
76
+ (``req_antileg_1``), or ``None`` when unavailable.
77
+
78
+ Returns:
79
+ The resolved :class:`StatusCode` value.
80
+ """
81
+ status = get_status_code(code) if code is not None else StatusCode.UNKNOWN
82
+ if antileg_active and status is not StatusCode.ALARM:
83
+ return StatusCode.ANTI_LEGIONELLA
84
+ return status
85
+
86
+
59
87
  # Register definitions (Address, Type, Data Type, Scale, Offset)
60
88
  # Scale/Offset are None if not used.
61
89
  # Format: KEY = (Address, ModbusType, DataType, Scale, Offset)
@@ -0,0 +1,111 @@
1
+ """Models for Qube Heat Pump."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any
5
+
6
+ from .const import StatusCode
7
+
8
+
9
+ @dataclass
10
+ class QubeState:
11
+ """Representation of the Qube Heat Pump state.
12
+
13
+ Typed fields for core sensors (used by official HA integration).
14
+ Extended dict for additional entities (used by HACS integration).
15
+ """
16
+
17
+ # Temperatures
18
+ temp_supply: float | None = None
19
+ temp_return: float | None = None
20
+ temp_source_in: float | None = None
21
+ temp_source_out: float | None = None
22
+ temp_room: float | None = None
23
+ temp_dhw: float | None = None
24
+ temp_outside: float | None = None
25
+
26
+ # Power/Energy
27
+ power_thermic: float | None = None
28
+ power_electric: float | None = None
29
+ energy_total_electric: float | None = None
30
+ energy_total_thermic: float | None = None
31
+ cop_calc: float | None = None
32
+
33
+ # Operation
34
+ status_code: int | None = None
35
+ # Unified status combining status_code with req_antileg_1 override.
36
+ # Computed by QubeClient.get_all_data(); see const.resolve_status().
37
+ status: StatusCode | None = None
38
+ compressor_speed: float | None = None
39
+ flow_rate: float | None = None
40
+
41
+ # Setpoints (Read/Write)
42
+ setpoint_room_heat_day: float | None = None
43
+ setpoint_room_heat_night: float | None = None
44
+ setpoint_room_cool_day: float | None = None
45
+ setpoint_room_cool_night: float | None = None
46
+ setpoint_dhw: float | None = None
47
+
48
+ # Binary sensors - Outputs
49
+ dout_srcpmp_val: bool | None = None
50
+ dout_usrpmp_val: bool | None = None
51
+ dout_fourwayvlv_val: bool | None = None
52
+ dout_cooling_val: bool | None = None
53
+ dout_threewayvlv_val: bool | None = None
54
+ dout_bufferpmp_val: bool | None = None
55
+ dout_heaterstep1_val: bool | None = None
56
+ dout_heaterstep2_val: bool | None = None
57
+ dout_heaterstep3_val: bool | None = None
58
+
59
+ # Binary sensors - System status
60
+ keybonoff: bool | None = None
61
+ daynightmode: bool | None = None
62
+
63
+ # Binary sensors - Alarms
64
+ al_maxtime_antileg_active: bool | None = None
65
+ al_maxtime_dhw_active: bool | None = None
66
+ al_dewpoint_active: bool | None = None
67
+ al_underfloorsafety_active: bool | None = None
68
+ alrm_flw: bool | None = None
69
+ usralrms: bool | None = None
70
+ coolingalrms: bool | None = None
71
+ heatingalrms: bool | None = None
72
+ alarmmng_al_workinghour: bool | None = None
73
+ srsalrm: bool | None = None
74
+ glbal: bool | None = None
75
+ alarmmng_al_pwrplus: bool | None = None
76
+
77
+ # Binary sensors - Sensor/controller status
78
+ roomprb_en: bool | None = None
79
+ plantprb_en: bool | None = None
80
+ bufferprb_en: bool | None = None
81
+ en_dhwpid: bool | None = None
82
+
83
+ # Binary sensors - Demand signals
84
+ plantdemand: bool | None = None
85
+ id_demand: bool | None = None
86
+ thermostatdemand: bool | None = None
87
+ bms_demand: bool | None = None
88
+
89
+ # Binary sensors - Digital inputs
90
+ id_summerwinter: bool | None = None
91
+ dewpoint: bool | None = None
92
+ boostersecurity: bool | None = None
93
+ srcflw: bool | None = None
94
+ req_antileg_1: bool | None = None
95
+
96
+ # Binary sensors - Energy
97
+ surplus_pv: bool | None = None
98
+
99
+ # Extended dict for additional entities not covered by typed fields
100
+ _extended: dict[str, Any] = field(default_factory=dict)
101
+
102
+ def get(self, key: str, default: Any = None) -> Any:
103
+ """Get a value by key, checking typed fields first, then _extended."""
104
+ if hasattr(self, key) and key != "_extended":
105
+ value = getattr(self, key)
106
+ return value if value is not None else default
107
+ return self._extended.get(key, default)
108
+
109
+ def set_extended(self, key: str, value: Any) -> None:
110
+ """Set a value in the extended dict."""
111
+ self._extended[key] = value
@@ -4,6 +4,7 @@ from python_qube_heatpump.const import (
4
4
  StatusCode,
5
5
  STATUS_CODE_MAP,
6
6
  get_status_code,
7
+ resolve_status,
7
8
  )
8
9
 
9
10
 
@@ -55,3 +56,30 @@ def test_get_status_code_unknown():
55
56
  assert get_status_code(0) == StatusCode.UNKNOWN
56
57
  assert get_status_code(99) == StatusCode.UNKNOWN
57
58
  assert get_status_code(-1) == StatusCode.UNKNOWN
59
+
60
+
61
+ def test_resolve_status_without_antileg():
62
+ """Without anti-legionella active, resolve_status follows the code map."""
63
+ assert resolve_status(16, False) == StatusCode.HEATING
64
+ assert resolve_status(22, False) == StatusCode.HEATING_DHW
65
+ assert resolve_status(1, None) == StatusCode.STANDBY
66
+
67
+
68
+ def test_resolve_status_antileg_overrides_normal_statuses():
69
+ """Anti-legionella overrides every non-alarm status."""
70
+ assert resolve_status(16, True) == StatusCode.ANTI_LEGIONELLA
71
+ assert resolve_status(22, True) == StatusCode.ANTI_LEGIONELLA
72
+ assert resolve_status(1, True) == StatusCode.ANTI_LEGIONELLA
73
+ assert resolve_status(99, True) == StatusCode.ANTI_LEGIONELLA
74
+
75
+
76
+ def test_resolve_status_alarm_not_masked_by_antileg():
77
+ """ALARM must not be masked by anti-legionella."""
78
+ assert resolve_status(2, True) == StatusCode.ALARM
79
+
80
+
81
+ def test_resolve_status_none_code():
82
+ """None code resolves to UNKNOWN (or ANTI_LEGIONELLA if active)."""
83
+ assert resolve_status(None, False) == StatusCode.UNKNOWN
84
+ assert resolve_status(None, None) == StatusCode.UNKNOWN
85
+ assert resolve_status(None, True) == StatusCode.ANTI_LEGIONELLA
@@ -1,55 +0,0 @@
1
- """Models for Qube Heat Pump."""
2
-
3
- from dataclasses import dataclass, field
4
- from typing import Any
5
-
6
-
7
- @dataclass
8
- class QubeState:
9
- """Representation of the Qube Heat Pump state.
10
-
11
- Typed fields for core sensors (used by official HA integration).
12
- Extended dict for additional entities (used by HACS integration).
13
- """
14
-
15
- # Temperatures
16
- temp_supply: float | None = None
17
- temp_return: float | None = None
18
- temp_source_in: float | None = None
19
- temp_source_out: float | None = None
20
- temp_room: float | None = None
21
- temp_dhw: float | None = None
22
- temp_outside: float | None = None
23
-
24
- # Power/Energy
25
- power_thermic: float | None = None
26
- power_electric: float | None = None
27
- energy_total_electric: float | None = None
28
- energy_total_thermic: float | None = None
29
- cop_calc: float | None = None
30
-
31
- # Operation
32
- status_code: int | None = None
33
- compressor_speed: float | None = None
34
- flow_rate: float | None = None
35
-
36
- # Setpoints (Read/Write)
37
- setpoint_room_heat_day: float | None = None
38
- setpoint_room_heat_night: float | None = None
39
- setpoint_room_cool_day: float | None = None
40
- setpoint_room_cool_night: float | None = None
41
- setpoint_dhw: float | None = None
42
-
43
- # Extended dict for additional entities not covered by typed fields
44
- _extended: dict[str, Any] = field(default_factory=dict)
45
-
46
- def get(self, key: str, default: Any = None) -> Any:
47
- """Get a value by key, checking typed fields first, then _extended."""
48
- if hasattr(self, key) and key != "_extended":
49
- value = getattr(self, key)
50
- return value if value is not None else default
51
- return self._extended.get(key, default)
52
-
53
- def set_extended(self, key: str, value: Any) -> None:
54
- """Set a value in the extended dict."""
55
- self._extended[key] = value