python-qube-heatpump 1.8.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 (31) hide show
  1. {python_qube_heatpump-1.8.0 → python_qube_heatpump-1.9.0}/PKG-INFO +1 -1
  2. {python_qube_heatpump-1.8.0 → python_qube_heatpump-1.9.0}/pyproject.toml +1 -1
  3. {python_qube_heatpump-1.8.0 → python_qube_heatpump-1.9.0}/src/python_qube_heatpump/__init__.py +2 -0
  4. {python_qube_heatpump-1.8.0 → python_qube_heatpump-1.9.0}/src/python_qube_heatpump/client.py +3 -0
  5. {python_qube_heatpump-1.8.0 → python_qube_heatpump-1.9.0}/src/python_qube_heatpump/const.py +28 -0
  6. {python_qube_heatpump-1.8.0 → python_qube_heatpump-1.9.0}/src/python_qube_heatpump/models.py +5 -0
  7. {python_qube_heatpump-1.8.0 → python_qube_heatpump-1.9.0}/tests/test_const.py +28 -0
  8. {python_qube_heatpump-1.8.0 → python_qube_heatpump-1.9.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  9. {python_qube_heatpump-1.8.0 → python_qube_heatpump-1.9.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  10. {python_qube_heatpump-1.8.0 → python_qube_heatpump-1.9.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  11. {python_qube_heatpump-1.8.0 → python_qube_heatpump-1.9.0}/.github/workflows/ci.yml +0 -0
  12. {python_qube_heatpump-1.8.0 → python_qube_heatpump-1.9.0}/.github/workflows/python-publish.yml +0 -0
  13. {python_qube_heatpump-1.8.0 → python_qube_heatpump-1.9.0}/.gitignore +0 -0
  14. {python_qube_heatpump-1.8.0 → python_qube_heatpump-1.9.0}/AGENTS.md +0 -0
  15. {python_qube_heatpump-1.8.0 → python_qube_heatpump-1.9.0}/CLAUDE.md +0 -0
  16. {python_qube_heatpump-1.8.0 → python_qube_heatpump-1.9.0}/LICENSE +0 -0
  17. {python_qube_heatpump-1.8.0 → python_qube_heatpump-1.9.0}/README.md +0 -0
  18. {python_qube_heatpump-1.8.0 → python_qube_heatpump-1.9.0}/docs/modbus-lijst-qube-totaal.pdf +0 -0
  19. {python_qube_heatpump-1.8.0 → python_qube_heatpump-1.9.0}/pytest.ini +0 -0
  20. {python_qube_heatpump-1.8.0 → python_qube_heatpump-1.9.0}/src/python_qube_heatpump/entities/__init__.py +0 -0
  21. {python_qube_heatpump-1.8.0 → python_qube_heatpump-1.9.0}/src/python_qube_heatpump/entities/base.py +0 -0
  22. {python_qube_heatpump-1.8.0 → python_qube_heatpump-1.9.0}/src/python_qube_heatpump/entities/binary_sensors.py +0 -0
  23. {python_qube_heatpump-1.8.0 → python_qube_heatpump-1.9.0}/src/python_qube_heatpump/entities/sensors.py +0 -0
  24. {python_qube_heatpump-1.8.0 → python_qube_heatpump-1.9.0}/src/python_qube_heatpump/entities/switches.py +0 -0
  25. {python_qube_heatpump-1.8.0 → python_qube_heatpump-1.9.0}/src/python_qube_heatpump/network.py +0 -0
  26. {python_qube_heatpump-1.8.0 → python_qube_heatpump-1.9.0}/src/python_qube_heatpump/py.typed +0 -0
  27. {python_qube_heatpump-1.8.0 → python_qube_heatpump-1.9.0}/tests/conftest.py +0 -0
  28. {python_qube_heatpump-1.8.0 → python_qube_heatpump-1.9.0}/tests/test_client.py +0 -0
  29. {python_qube_heatpump-1.8.0 → python_qube_heatpump-1.9.0}/tests/test_entities.py +0 -0
  30. {python_qube_heatpump-1.8.0 → python_qube_heatpump-1.9.0}/tests/test_models.py +0 -0
  31. {python_qube_heatpump-1.8.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.8.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.8.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
  ]
@@ -128,6 +128,9 @@ class QubeClient:
128
128
  if hasattr(state, key):
129
129
  setattr(state, key, value)
130
130
 
131
+ # Compute unified status (status_code + anti-legionella override)
132
+ state.status = const.resolve_status(state.status_code, state.req_antileg_1)
133
+
131
134
  return state
132
135
 
133
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)
@@ -3,6 +3,8 @@
3
3
  from dataclasses import dataclass, field
4
4
  from typing import Any
5
5
 
6
+ from .const import StatusCode
7
+
6
8
 
7
9
  @dataclass
8
10
  class QubeState:
@@ -30,6 +32,9 @@ class QubeState:
30
32
 
31
33
  # Operation
32
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
33
38
  compressor_speed: float | None = None
34
39
  flow_rate: float | None = None
35
40
 
@@ -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