python-qube-heatpump 1.2.0__tar.gz → 1.2.2__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.0 → python_qube_heatpump-1.2.2}/PKG-INFO +1 -1
  2. {python_qube_heatpump-1.2.0 → python_qube_heatpump-1.2.2}/pyproject.toml +1 -1
  3. {python_qube_heatpump-1.2.0 → python_qube_heatpump-1.2.2/src/python_qube_heatpump}/client.py +39 -8
  4. {python_qube_heatpump-1.2.0 → python_qube_heatpump-1.2.2/src/python_qube_heatpump}/const.py +2 -0
  5. python_qube_heatpump-1.2.2/tests/test_client.py +73 -0
  6. python_qube_heatpump-1.2.0/src/python_qube_heatpump/client.py +0 -166
  7. python_qube_heatpump-1.2.0/tests/test_client.py +0 -49
  8. {python_qube_heatpump-1.2.0 → python_qube_heatpump-1.2.2}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  9. {python_qube_heatpump-1.2.0 → python_qube_heatpump-1.2.2}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  10. {python_qube_heatpump-1.2.0 → python_qube_heatpump-1.2.2}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  11. {python_qube_heatpump-1.2.0 → python_qube_heatpump-1.2.2}/.github/workflows/ci.yml +0 -0
  12. {python_qube_heatpump-1.2.0 → python_qube_heatpump-1.2.2}/.github/workflows/python-publish.yml +0 -0
  13. {python_qube_heatpump-1.2.0 → python_qube_heatpump-1.2.2}/.gitignore +0 -0
  14. {python_qube_heatpump-1.2.0 → python_qube_heatpump-1.2.2}/LICENSE +0 -0
  15. {python_qube_heatpump-1.2.0 → python_qube_heatpump-1.2.2}/README.md +0 -0
  16. {python_qube_heatpump-1.2.0 → python_qube_heatpump-1.2.2}/pytest.ini +0 -0
  17. {python_qube_heatpump-1.2.0 → python_qube_heatpump-1.2.2}/src/python_qube_heatpump/__init__.py +0 -0
  18. {python_qube_heatpump-1.2.0 → python_qube_heatpump-1.2.2/src/python_qube_heatpump}/models.py +0 -0
  19. {python_qube_heatpump-1.2.0 → python_qube_heatpump-1.2.2}/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.0
3
+ Version: 1.2.2
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.0"
7
+ version = "1.2.2"
8
8
  authors = [
9
9
  { name="MattieGit", email="6250046+MattieGit@users.noreply.github.com" },
10
10
  ]
@@ -1,11 +1,10 @@
1
1
  """Client for Qube Heat Pump."""
2
2
 
3
3
  import logging
4
+ import struct
4
5
  from typing import Optional
5
6
 
6
7
  from pymodbus.client import AsyncModbusTcpClient
7
- from pymodbus.payload import BinaryPayloadDecoder
8
- from pymodbus.constants import Endian
9
8
 
10
9
  from . import const
11
10
  from .models import QubeState
@@ -80,16 +79,48 @@ class QubeClient:
80
79
  _LOGGER.warning("Error reading address %s", address)
81
80
  return None
82
81
 
83
- decoder = BinaryPayloadDecoder.fromRegisters(
84
- result.registers, byteorder=Endian.Big, wordorder=Endian.Little
85
- )
82
+ regs = result.registers
83
+ val = 0
84
+
85
+ # Manual decoding to avoid pymodbus.payload dependencies
86
+ # Assuming Little Endian Word Order for 32-bit values [LSW, MSW] per standard Modbus often used
87
+ # But the original code used Endian.Little WordOrder.
88
+ # Decoder: byteorder=Endian.Big, wordorder=Endian.Little
89
+ # Big Endian Bytes: [H, L]
90
+ # Little Endian Words: [Reg0, Reg1] -> [LSW, MSW]
91
+ #
92
+ # Example Float32: 123.456
93
+ # Reg0 (LSW)
94
+ # Reg1 (MSW)
95
+ # Full 32-bit int: (Reg1 << 16) | Reg0
96
+ # Then pack as >I (Big Endian 32-bit int) and unpack as >f (Big Endian float)?
97
+ #
98
+ # Wait, PyModbus BinaryPayloadDecoder.fromRegisters(registers, byteorder=Endian.Big, wordorder=Endian.Little)
99
+ # ByteOrder Big: Normal network byte order per register.
100
+ # WordOrder Little: The first register is the least significant word.
101
+ #
102
+ # So:
103
+ # 32-bit value = (regs[1] << 16) | regs[0]
104
+ # Then interpret that 32-bit integer as a float.
105
+ # To interpret int bits as float in Python: struct.unpack('!f', struct.pack('!I', int_val))[0]
86
106
 
87
107
  if data_type == const.DataType.FLOAT32:
88
- val = decoder.decode_32bit_float()
108
+ # Combine 2 registers, Little Endian Word Order
109
+ int_val = (regs[1] << 16) | regs[0]
110
+ val = struct.unpack(">f", struct.pack(">I", int_val))[0]
89
111
  elif data_type == const.DataType.INT16:
90
- val = decoder.decode_16bit_int()
112
+ val = regs[0]
113
+ # Signed 16-bit
114
+ if val > 32767:
115
+ val -= 65536
91
116
  elif data_type == const.DataType.UINT16:
92
- val = decoder.decode_16bit_uint()
117
+ val = regs[0]
118
+ elif data_type == const.DataType.UINT32:
119
+ val = (regs[1] << 16) | regs[0]
120
+ elif data_type == const.DataType.INT32:
121
+ val = (regs[1] << 16) | regs[0]
122
+ if val > 2147483647:
123
+ val -= 4294967296
93
124
  else:
94
125
  val = 0
95
126
 
@@ -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,166 +0,0 @@
1
- """Qube Heat Pump Client Library."""
2
-
3
- from __future__ import annotations
4
-
5
- import asyncio
6
- import contextlib
7
- import ipaddress
8
- import logging
9
- import socket
10
- import struct
11
- from typing import Any
12
-
13
- from pymodbus.client import AsyncModbusTcpClient
14
- from pymodbus.exceptions import ModbusException
15
- from pymodbus.pdu import ExceptionResponse
16
-
17
-
18
- class QubeClient:
19
- """Qube Heat Pump Client."""
20
-
21
- def __init__(
22
- self,
23
- host: str,
24
- port: int,
25
- unit_id: int = 1,
26
- ) -> None:
27
- """Initialize the client."""
28
- self._host = host
29
- self._port = port
30
- self._unit = unit_id
31
- self._client: AsyncModbusTcpClient | None = None
32
- self._io_timeout_s: float = 3.0
33
-
34
- @property
35
- def host(self) -> str:
36
- """Return host."""
37
- return self._host
38
-
39
- @property
40
- def port(self) -> int:
41
- """Return port."""
42
- return self._port
43
-
44
- @property
45
- def unit(self) -> int:
46
- """Return unit ID."""
47
- return self._unit
48
-
49
- def set_unit_id(self, unit_id: int) -> None:
50
- """Set unit ID."""
51
- self._unit = int(unit_id)
52
-
53
- async def connect(self) -> bool:
54
- """Connect to the Modbus server."""
55
- if self._client is None:
56
- self._client = AsyncModbusTcpClient(self._host, port=self._port)
57
-
58
- if self.is_connected:
59
- return True
60
-
61
- try:
62
- return await asyncio.wait_for(
63
- self._client.connect(), timeout=self._io_timeout_s
64
- )
65
- except Exception as exc: # pylint: disable=broad-except # noqa: BLE001
66
- logging.getLogger(__name__).debug("Failed to connect: %s", exc)
67
- return False
68
-
69
- @property
70
- def is_connected(self) -> bool:
71
- """Return True if connected."""
72
- return bool(self._client and getattr(self._client, "connected", False))
73
-
74
- async def close(self) -> None:
75
- """Close the connection."""
76
- if self._client:
77
- with contextlib.suppress(Exception):
78
- self._client.close()
79
- self._client = None
80
-
81
- async def _call(self, method: str, **kwargs: Any) -> Any:
82
- if self._client is None:
83
- raise ModbusException("Client not connected")
84
- func = getattr(self._client, method)
85
- # Try with 'slave' then 'unit', finally without either
86
- try:
87
- resp = await asyncio.wait_for(
88
- func(**{**kwargs, "slave": self._unit}), timeout=self._io_timeout_s
89
- )
90
- except TypeError:
91
- try:
92
- resp = await asyncio.wait_for(
93
- func(**{**kwargs, "unit": self._unit}), timeout=self._io_timeout_s
94
- )
95
- except TypeError:
96
- resp = await asyncio.wait_for(
97
- func(**kwargs), timeout=self._io_timeout_s
98
- )
99
- # Normalize error checking
100
- if isinstance(resp, ExceptionResponse) or (
101
- hasattr(resp, "isError") and resp.isError()
102
- ):
103
- raise ModbusException(f"Modbus error on {method} with {kwargs}")
104
- return resp
105
-
106
- async def read_registers(
107
- self, address: int, count: int, input_type: str = "holding"
108
- ) -> list[int]:
109
- """Read registers from the device."""
110
- if input_type == "input":
111
- rr = await self._call("read_input_registers", address=address, count=count)
112
- else:
113
- rr = await self._call(
114
- "read_holding_registers", address=address, count=count
115
- )
116
-
117
- regs = getattr(rr, "registers", None)
118
- if regs is None:
119
- raise ModbusException("No registers returned")
120
- return list(regs)
121
-
122
- @staticmethod
123
- def decode_registers(regs: list[int], data_type: str | None) -> float | int:
124
- """Decode registers to a value."""
125
- # All decoding assumes big-endian word and byte order.
126
- if data_type == "float32":
127
- raw = struct.pack(">HH", int(regs[0]) & 0xFFFF, int(regs[1]) & 0xFFFF)
128
- return float(struct.unpack(">f", raw)[0])
129
- if data_type == "int16":
130
- v = int(regs[0]) & 0xFFFF
131
- return v - 0x10000 if v & 0x8000 else v
132
- if data_type == "uint16":
133
- return int(regs[0]) & 0xFFFF
134
- if data_type == "uint32":
135
- return ((int(regs[0]) & 0xFFFF) << 16) | (int(regs[1]) & 0xFFFF)
136
- if data_type == "int32":
137
- u = ((int(regs[0]) & 0xFFFF) << 16) | (int(regs[1]) & 0xFFFF)
138
- return u - 0x1_0000_0000 if u & 0x8000_0000 else u
139
- # Fallback to first register as unsigned 16-bit
140
- return int(regs[0]) & 0xFFFF
141
-
142
- async def resolve_ip(self) -> str | None:
143
- """Resolve the host to an IP address."""
144
- with contextlib.suppress(ValueError):
145
- return str(ipaddress.ip_address(self._host))
146
-
147
- try:
148
- infos = await asyncio.get_running_loop().getaddrinfo(
149
- self._host,
150
- None,
151
- type=socket.SOCK_STREAM,
152
- )
153
- except OSError:
154
- return None
155
-
156
- for family, _, _, _, sockaddr in infos:
157
- if not sockaddr:
158
- continue
159
- addr = sockaddr[0]
160
- if not isinstance(addr, str):
161
- continue
162
- if family == socket.AF_INET6 and addr.startswith("::ffff:"):
163
- addr = addr.removeprefix("::ffff:")
164
- return addr
165
-
166
- 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