python-qube-heatpump 1.2.1__tar.gz → 1.2.3__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 (19) hide show
  1. {python_qube_heatpump-1.2.1 → python_qube_heatpump-1.2.3}/PKG-INFO +1 -1
  2. {python_qube_heatpump-1.2.1 → python_qube_heatpump-1.2.3}/pyproject.toml +1 -1
  3. python_qube_heatpump-1.2.3/src/python_qube_heatpump/client.py +158 -0
  4. {python_qube_heatpump-1.2.1 → python_qube_heatpump-1.2.3}/src/python_qube_heatpump/const.py +2 -0
  5. python_qube_heatpump-1.2.3/tests/test_client.py +73 -0
  6. python_qube_heatpump-1.2.1/src/python_qube_heatpump/client.py +0 -105
  7. python_qube_heatpump-1.2.1/tests/test_client.py +0 -49
  8. {python_qube_heatpump-1.2.1 → python_qube_heatpump-1.2.3}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  9. {python_qube_heatpump-1.2.1 → python_qube_heatpump-1.2.3}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  10. {python_qube_heatpump-1.2.1 → python_qube_heatpump-1.2.3}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  11. {python_qube_heatpump-1.2.1 → python_qube_heatpump-1.2.3}/.github/workflows/ci.yml +0 -0
  12. {python_qube_heatpump-1.2.1 → python_qube_heatpump-1.2.3}/.github/workflows/python-publish.yml +0 -0
  13. {python_qube_heatpump-1.2.1 → python_qube_heatpump-1.2.3}/.gitignore +0 -0
  14. {python_qube_heatpump-1.2.1 → python_qube_heatpump-1.2.3}/LICENSE +0 -0
  15. {python_qube_heatpump-1.2.1 → python_qube_heatpump-1.2.3}/README.md +0 -0
  16. {python_qube_heatpump-1.2.1 → python_qube_heatpump-1.2.3}/pytest.ini +0 -0
  17. {python_qube_heatpump-1.2.1 → python_qube_heatpump-1.2.3}/src/python_qube_heatpump/__init__.py +0 -0
  18. {python_qube_heatpump-1.2.1 → python_qube_heatpump-1.2.3}/src/python_qube_heatpump/models.py +0 -0
  19. {python_qube_heatpump-1.2.1 → python_qube_heatpump-1.2.3}/tests/conftest.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-qube-heatpump
3
- Version: 1.2.1
3
+ Version: 1.2.3
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.2.1"
7
+ version = "1.2.3"
8
8
  authors = [
9
9
  { name="MattieGit", email="6250046+MattieGit@users.noreply.github.com" },
10
10
  ]
@@ -0,0 +1,158 @@
1
+ """Client for Qube Heat Pump."""
2
+
3
+ import logging
4
+ import struct
5
+ from typing import Optional
6
+
7
+ from pymodbus.client import AsyncModbusTcpClient
8
+
9
+ from . import const
10
+ from .models import QubeState
11
+
12
+ _LOGGER = logging.getLogger(__name__)
13
+
14
+
15
+ class QubeClient:
16
+ """Qube Modbus Client."""
17
+
18
+ def __init__(self, host: str, port: int = 502, unit_id: int = 1):
19
+ """Initialize."""
20
+ self.host = host
21
+ self.port = port
22
+ self.unit = unit_id
23
+ self._client = AsyncModbusTcpClient(host, port=port)
24
+ self._connected = False
25
+
26
+ async def connect(self) -> bool:
27
+ """Connect to the Modbus server."""
28
+ if not self._connected:
29
+ self._connected = await self._client.connect()
30
+ return self._connected
31
+
32
+ async def close(self) -> None:
33
+ """Close connection."""
34
+ self._client.close()
35
+ self._connected = False
36
+
37
+ async def get_all_data(self) -> QubeState:
38
+ """Fetch all definition data and return a state object."""
39
+ # Note: In a real implementation you might want to optimize this
40
+ # by reading contiguous blocks instead of one-by-one.
41
+ # For now, we wrap the individual reads for abstraction.
42
+
43
+ state = QubeState()
44
+
45
+ # Helper to read and assign
46
+ async def _read(const_def):
47
+ return await self.read_value(const_def)
48
+
49
+ # Fetch temperature sensors
50
+ state.temp_supply = await _read(const.TEMP_SUPPLY)
51
+ state.temp_return = await _read(const.TEMP_RETURN)
52
+ state.temp_source_in = await _read(const.TEMP_SOURCE_IN)
53
+ state.temp_source_out = await _read(const.TEMP_SOURCE_OUT)
54
+ state.temp_room = await _read(const.TEMP_ROOM)
55
+ state.temp_dhw = await _read(const.TEMP_DHW)
56
+ state.temp_outside = await _read(const.TEMP_OUTSIDE)
57
+
58
+ # Fetch power and energy sensors
59
+ state.power_thermic = await _read(const.POWER_THERMIC)
60
+ state.power_electric = await _read(const.POWER_ELECTRIC_CALC)
61
+ state.energy_total_electric = await _read(const.ENERGY_ELECTRIC_TOTAL)
62
+ state.energy_total_thermic = await _read(const.ENERGY_THERMIC_TOTAL)
63
+ state.cop_calc = await _read(const.COP_CALC)
64
+
65
+ # Fetch operation sensors
66
+ state.status_code = await _read(const.STATUS_CODE)
67
+ state.compressor_speed = await _read(const.COMPRESSOR_SPEED)
68
+ state.flow_rate = await _read(const.FLOW_RATE)
69
+
70
+ # Fetch setpoints (holding registers)
71
+ state.setpoint_room_heat_day = await _read(const.SETPOINT_HEAT_DAY)
72
+ state.setpoint_room_heat_night = await _read(const.SETPOINT_HEAT_NIGHT)
73
+ state.setpoint_room_cool_day = await _read(const.SETPOINT_COOL_DAY)
74
+ state.setpoint_room_cool_night = await _read(const.SETPOINT_COOL_NIGHT)
75
+ state.setpoint_dhw = await _read(const.USER_DHW_SETPOINT)
76
+
77
+ return state
78
+
79
+ async def read_value(self, definition: tuple) -> Optional[float]:
80
+ """Read a single value based on the constant definition."""
81
+ address, reg_type, data_type, scale, offset = definition
82
+
83
+ count = (
84
+ 2
85
+ if data_type
86
+ in (const.DataType.FLOAT32, const.DataType.UINT32, const.DataType.INT32)
87
+ else 1
88
+ )
89
+
90
+ try:
91
+ if reg_type == const.ModbusType.INPUT:
92
+ result = await self._client.read_input_registers(
93
+ address, count, slave=self.unit
94
+ )
95
+ else:
96
+ result = await self._client.read_holding_registers(
97
+ address, count, slave=self.unit
98
+ )
99
+
100
+ if result.isError():
101
+ _LOGGER.warning("Error reading address %s", address)
102
+ return None
103
+
104
+ regs = result.registers
105
+ val = 0
106
+
107
+ # Manual decoding to avoid pymodbus.payload dependencies
108
+ # Assuming Little Endian Word Order for 32-bit values [LSW, MSW] per standard Modbus often used
109
+ # But the original code used Endian.Little WordOrder.
110
+ # Decoder: byteorder=Endian.Big, wordorder=Endian.Little
111
+ # Big Endian Bytes: [H, L]
112
+ # Little Endian Words: [Reg0, Reg1] -> [LSW, MSW]
113
+ #
114
+ # Example Float32: 123.456
115
+ # Reg0 (LSW)
116
+ # Reg1 (MSW)
117
+ # Full 32-bit int: (Reg1 << 16) | Reg0
118
+ # Then pack as >I (Big Endian 32-bit int) and unpack as >f (Big Endian float)?
119
+ #
120
+ # Wait, PyModbus BinaryPayloadDecoder.fromRegisters(registers, byteorder=Endian.Big, wordorder=Endian.Little)
121
+ # ByteOrder Big: Normal network byte order per register.
122
+ # WordOrder Little: The first register is the least significant word.
123
+ #
124
+ # So:
125
+ # 32-bit value = (regs[1] << 16) | regs[0]
126
+ # Then interpret that 32-bit integer as a float.
127
+ # To interpret int bits as float in Python: struct.unpack('!f', struct.pack('!I', int_val))[0]
128
+
129
+ if data_type == const.DataType.FLOAT32:
130
+ # Combine 2 registers, Little Endian Word Order
131
+ int_val = (regs[1] << 16) | regs[0]
132
+ val = struct.unpack(">f", struct.pack(">I", int_val))[0]
133
+ elif data_type == const.DataType.INT16:
134
+ val = regs[0]
135
+ # Signed 16-bit
136
+ if val > 32767:
137
+ val -= 65536
138
+ elif data_type == const.DataType.UINT16:
139
+ val = regs[0]
140
+ elif data_type == const.DataType.UINT32:
141
+ val = (regs[1] << 16) | regs[0]
142
+ elif data_type == const.DataType.INT32:
143
+ val = (regs[1] << 16) | regs[0]
144
+ if val > 2147483647:
145
+ val -= 4294967296
146
+ else:
147
+ val = 0
148
+
149
+ if scale is not None:
150
+ val *= scale
151
+ if offset is not None:
152
+ val += offset
153
+
154
+ return val
155
+
156
+ except Exception as e:
157
+ _LOGGER.error("Exception reading address %s: %s", address, e)
158
+ return None
@@ -16,6 +16,8 @@ class DataType(str, Enum):
16
16
  FLOAT32 = "float32"
17
17
  INT16 = "int16"
18
18
  UINT16 = "uint16"
19
+ INT32 = "int32"
20
+ UINT32 = "uint32"
19
21
 
20
22
 
21
23
  # Register definitions (Address, Type, Data Type, Scale, Offset)
@@ -0,0 +1,73 @@
1
+ """Test the Qube Heat Pump client."""
2
+
3
+ from unittest.mock import AsyncMock, MagicMock
4
+ import pytest
5
+ from python_qube_heatpump import QubeClient
6
+
7
+
8
+ @pytest.mark.asyncio
9
+ async def test_connect(mock_modbus_client):
10
+ """Test connection."""
11
+ client = QubeClient("1.2.3.4", 502)
12
+ mock_instance = mock_modbus_client.return_value
13
+ mock_instance.connect.return_value = True
14
+ mock_instance.connected = False
15
+ assert await client.connect() is True
16
+ mock_modbus_client.assert_called_with("1.2.3.4", port=502)
17
+
18
+
19
+ @pytest.mark.asyncio
20
+ async def test_read_value(mock_modbus_client):
21
+ """Test reading values."""
22
+ client = QubeClient("1.2.3.4", 502)
23
+ mock_instance = mock_modbus_client.return_value
24
+ mock_instance.connected = True
25
+
26
+ # Mock response for reading holding registers (FLOAT32)
27
+ # 24.5 = 0x41C40000 -> 16836 (0x41C4), 0 (0x0000) (Big Endian)
28
+ # Our decoder expects [0, 16836] for Little Endian Word Order?
29
+ # Logic in client.py: int_val = (regs[1] << 16) | regs[0]
30
+ # To get 0x41C40000: regs[1]=0x41C4, regs[0]=0x0000
31
+ mock_resp = MagicMock()
32
+ mock_resp.isError.return_value = False
33
+ mock_resp.registers = [0, 16836]
34
+
35
+ mock_instance.read_holding_registers = AsyncMock(return_value=mock_resp)
36
+ client._client = mock_instance
37
+
38
+ # Test reading a FLOAT32 holding register
39
+ # definition = (address, reg_type, data_type, scale, offset)
40
+ # We use a dummy definition
41
+ from python_qube_heatpump import const
42
+
43
+ definition = (10, const.ModbusType.HOLDING, const.DataType.FLOAT32, None, None)
44
+
45
+ result = await client.read_value(definition)
46
+
47
+ # Verify result is approximately 24.5
48
+ assert result is not None
49
+ assert round(result, 1) == 24.5
50
+
51
+ mock_instance.read_holding_registers.assert_called_once()
52
+
53
+
54
+ @pytest.mark.asyncio
55
+ async def test_read_value_int16(mock_modbus_client):
56
+ """Test reading INT16 value."""
57
+ client = QubeClient("1.2.3.4", 502)
58
+ mock_instance = mock_modbus_client.return_value
59
+
60
+ # Mock response for -10 (0xFFF6 = 65526)
61
+ mock_resp = MagicMock()
62
+ mock_resp.isError.return_value = False
63
+ mock_resp.registers = [65526]
64
+
65
+ mock_instance.read_input_registers = AsyncMock(return_value=mock_resp)
66
+ client._client = mock_instance
67
+
68
+ from python_qube_heatpump import const
69
+
70
+ definition = (20, const.ModbusType.INPUT, const.DataType.INT16, None, None)
71
+
72
+ result = await client.read_value(definition)
73
+ assert result == -10
@@ -1,105 +0,0 @@
1
- """Client for Qube Heat Pump."""
2
-
3
- import logging
4
- from typing import Optional
5
-
6
- from pymodbus.client import AsyncModbusTcpClient
7
- from pymodbus.payload import BinaryPayloadDecoder
8
- from pymodbus.constants import Endian
9
-
10
- from . import const
11
- from .models import QubeState
12
-
13
- _LOGGER = logging.getLogger(__name__)
14
-
15
-
16
- class QubeClient:
17
- """Qube Modbus Client."""
18
-
19
- def __init__(self, host: str, port: int = 502, unit_id: int = 1):
20
- """Initialize."""
21
- self.host = host
22
- self.port = port
23
- self.unit = unit_id
24
- self._client = AsyncModbusTcpClient(host, port=port)
25
- self._connected = False
26
-
27
- async def connect(self) -> bool:
28
- """Connect to the Modbus server."""
29
- if not self._connected:
30
- self._connected = await self._client.connect()
31
- return self._connected
32
-
33
- async def close(self) -> None:
34
- """Close connection."""
35
- self._client.close()
36
- self._connected = False
37
-
38
- async def get_all_data(self) -> QubeState:
39
- """Fetch all definition data and return a state object."""
40
- # Note: In a real implementation you might want to optimize this
41
- # by reading contiguous blocks instead of one-by-one.
42
- # For now, we wrap the individual reads for abstraction.
43
-
44
- state = QubeState()
45
-
46
- # Helper to read and assign
47
- async def _read(const_def):
48
- return await self.read_value(const_def)
49
-
50
- # Fetch basic sensors
51
- state.temp_supply = await _read(const.TEMP_SUPPLY)
52
- state.temp_return = await _read(const.TEMP_RETURN)
53
- state.temp_outside = await _read(const.TEMP_OUTSIDE)
54
- state.temp_dhw = await _read(const.TEMP_DHW)
55
-
56
- return state
57
-
58
- async def read_value(self, definition: tuple) -> Optional[float]:
59
- """Read a single value based on the constant definition."""
60
- address, reg_type, data_type, scale, offset = definition
61
-
62
- count = (
63
- 2
64
- if data_type
65
- in (const.DataType.FLOAT32, const.DataType.UINT32, const.DataType.INT32)
66
- else 1
67
- )
68
-
69
- try:
70
- if reg_type == const.ModbusType.INPUT:
71
- result = await self._client.read_input_registers(
72
- address, count, slave=self.unit
73
- )
74
- else:
75
- result = await self._client.read_holding_registers(
76
- address, count, slave=self.unit
77
- )
78
-
79
- if result.isError():
80
- _LOGGER.warning("Error reading address %s", address)
81
- return None
82
-
83
- decoder = BinaryPayloadDecoder.fromRegisters(
84
- result.registers, byteorder=Endian.Big, wordorder=Endian.Little
85
- )
86
-
87
- if data_type == const.DataType.FLOAT32:
88
- val = decoder.decode_32bit_float()
89
- elif data_type == const.DataType.INT16:
90
- val = decoder.decode_16bit_int()
91
- elif data_type == const.DataType.UINT16:
92
- val = decoder.decode_16bit_uint()
93
- else:
94
- val = 0
95
-
96
- if scale is not None:
97
- val *= scale
98
- if offset is not None:
99
- val += offset
100
-
101
- return val
102
-
103
- except Exception as e:
104
- _LOGGER.error("Exception reading address %s: %s", address, e)
105
- return None
@@ -1,49 +0,0 @@
1
- """Test the Qube Heat Pump client."""
2
-
3
- from unittest.mock import AsyncMock, MagicMock
4
- import pytest
5
- from python_qube_heatpump import QubeClient
6
-
7
-
8
- @pytest.mark.asyncio
9
- async def test_connect(mock_modbus_client):
10
- """Test connection."""
11
- client = QubeClient("1.2.3.4", 502)
12
- mock_instance = mock_modbus_client.return_value
13
- mock_instance.connect.return_value = True
14
- mock_instance.connected = False
15
- assert await client.connect() is True
16
- mock_modbus_client.assert_called_with("1.2.3.4", port=502)
17
-
18
-
19
- @pytest.mark.asyncio
20
- async def test_read_registers(mock_modbus_client):
21
- """Test reading registers."""
22
- client = QubeClient("1.2.3.4", 502)
23
- mock_instance = mock_modbus_client.return_value
24
- mock_instance.connected = True
25
- # Mock response
26
- mock_resp = MagicMock()
27
- mock_resp.isError.return_value = False
28
- mock_resp.registers = [123]
29
- # Setup the read_holding_registers method on the mock
30
- mock_instance.read_holding_registers = AsyncMock(return_value=mock_resp)
31
- # We need to manually set the client on the wrapper if we bypass connect
32
- client._client = mock_instance
33
- result = await client.read_registers(10, 1)
34
- assert result == [123]
35
- mock_instance.read_holding_registers.assert_called_once()
36
-
37
-
38
- @pytest.mark.asyncio
39
- async def test_decode_registers():
40
- """Test Register Decoding."""
41
- # float32: 24.5 = 0x41C40000 -> 16836, 0 (Big Endian)
42
- # struct.unpack('>f', struct.pack('>HH', 16836, 0)) -> 24.5
43
- regs = [16836, 0]
44
- val = QubeClient.decode_registers(regs, "float32")
45
- assert round(val, 1) == 24.5
46
- # int16 (negative): -10 = 0xFFF6 = 65526
47
- regs = [65526]
48
- val = QubeClient.decode_registers(regs, "int16")
49
- assert val == -10