python-qube-heatpump 1.2.0__py3-none-any.whl → 1.2.2__py3-none-any.whl

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.
@@ -1,166 +1,136 @@
1
- """Qube Heat Pump Client Library."""
1
+ """Client for Qube Heat Pump."""
2
2
 
3
- from __future__ import annotations
4
-
5
- import asyncio
6
- import contextlib
7
- import ipaddress
8
3
  import logging
9
- import socket
10
4
  import struct
11
- from typing import Any
5
+ from typing import Optional
12
6
 
13
7
  from pymodbus.client import AsyncModbusTcpClient
14
- from pymodbus.exceptions import ModbusException
15
- from pymodbus.pdu import ExceptionResponse
8
+
9
+ from . import const
10
+ from .models import QubeState
11
+
12
+ _LOGGER = logging.getLogger(__name__)
16
13
 
17
14
 
18
15
  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)
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
52
25
 
53
26
  async def connect(self) -> bool:
54
27
  """Connect to the Modbus server."""
55
- if self._client is None:
56
- self._client = AsyncModbusTcpClient(self._host, port=self._port)
28
+ if not self._connected:
29
+ self._connected = await self._client.connect()
30
+ return self._connected
57
31
 
58
- if self.is_connected:
59
- return True
32
+ async def close(self) -> None:
33
+ """Close connection."""
34
+ self._client.close()
35
+ self._connected = False
60
36
 
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))
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 basic sensors
50
+ state.temp_supply = await _read(const.TEMP_SUPPLY)
51
+ state.temp_return = await _read(const.TEMP_RETURN)
52
+ state.temp_outside = await _read(const.TEMP_OUTSIDE)
53
+ state.temp_dhw = await _read(const.TEMP_DHW)
54
+
55
+ return state
56
+
57
+ async def read_value(self, definition: tuple) -> Optional[float]:
58
+ """Read a single value based on the constant definition."""
59
+ address, reg_type, data_type, scale, offset = definition
60
+
61
+ count = (
62
+ 2
63
+ if data_type
64
+ in (const.DataType.FLOAT32, const.DataType.UINT32, const.DataType.INT32)
65
+ else 1
66
+ )
73
67
 
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
68
  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
69
+ if reg_type == const.ModbusType.INPUT:
70
+ result = await self._client.read_input_registers(
71
+ address, count, slave=self.unit
94
72
  )
95
- except TypeError:
96
- resp = await asyncio.wait_for(
97
- func(**kwargs), timeout=self._io_timeout_s
73
+ else:
74
+ result = await self._client.read_holding_registers(
75
+ address, count, slave=self.unit
98
76
  )
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
77
 
147
- try:
148
- infos = await asyncio.get_running_loop().getaddrinfo(
149
- self._host,
150
- None,
151
- type=socket.SOCK_STREAM,
152
- )
153
- except OSError:
78
+ if result.isError():
79
+ _LOGGER.warning("Error reading address %s", address)
80
+ return None
81
+
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]
106
+
107
+ if data_type == const.DataType.FLOAT32:
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]
111
+ elif data_type == const.DataType.INT16:
112
+ val = regs[0]
113
+ # Signed 16-bit
114
+ if val > 32767:
115
+ val -= 65536
116
+ elif data_type == const.DataType.UINT16:
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
124
+ else:
125
+ val = 0
126
+
127
+ if scale is not None:
128
+ val *= scale
129
+ if offset is not None:
130
+ val += offset
131
+
132
+ return val
133
+
134
+ except Exception as e:
135
+ _LOGGER.error("Exception reading address %s: %s", address, e)
154
136
  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
@@ -0,0 +1,73 @@
1
+ """Constants for Qube Heat Pump."""
2
+
3
+ from enum import Enum
4
+
5
+
6
+ class ModbusType(str, Enum):
7
+ """Modbus register type."""
8
+
9
+ HOLDING = "holding"
10
+ INPUT = "input"
11
+
12
+
13
+ class DataType(str, Enum):
14
+ """Data type."""
15
+
16
+ FLOAT32 = "float32"
17
+ INT16 = "int16"
18
+ UINT16 = "uint16"
19
+ INT32 = "int32"
20
+ UINT32 = "uint32"
21
+
22
+
23
+ # Register definitions (Address, Type, Data Type, Scale, Offset)
24
+ # Scale/Offset are None if not used.
25
+ # Format: KEY = (Address, ModbusType, DataType, Scale, Offset)
26
+
27
+ # --- Sensors (Input Registers) ---
28
+ PCT_USER_PUMP = (4, ModbusType.INPUT, DataType.FLOAT32, -1, 100)
29
+ PCT_SOURCE_PUMP = (6, ModbusType.INPUT, DataType.FLOAT32, -1, 100)
30
+ PCT_SOURCE_VALVE = (8, ModbusType.INPUT, DataType.FLOAT32, None, None)
31
+ REQ_DHW = (14, ModbusType.INPUT, DataType.FLOAT32, None, None)
32
+ REQ_COMPRESSOR = (16, ModbusType.INPUT, DataType.FLOAT32, None, None)
33
+ FLOW_RATE = (18, ModbusType.INPUT, DataType.FLOAT32, None, None)
34
+ TEMP_SUPPLY = (20, ModbusType.INPUT, DataType.FLOAT32, None, None)
35
+ TEMP_RETURN = (22, ModbusType.INPUT, DataType.FLOAT32, None, None)
36
+ TEMP_SOURCE_IN = (24, ModbusType.INPUT, DataType.FLOAT32, None, None)
37
+ TEMP_SOURCE_OUT = (26, ModbusType.INPUT, DataType.FLOAT32, None, None)
38
+ TEMP_ROOM = (28, ModbusType.INPUT, DataType.FLOAT32, None, None)
39
+ TEMP_DHW = (30, ModbusType.INPUT, DataType.FLOAT32, None, None)
40
+ TEMP_OUTSIDE = (32, ModbusType.INPUT, DataType.FLOAT32, None, None)
41
+ COP_CALC = (34, ModbusType.INPUT, DataType.FLOAT32, None, None)
42
+ POWER_THERMIC = (36, ModbusType.INPUT, DataType.FLOAT32, None, None)
43
+ STATUS_CODE = (38, ModbusType.INPUT, DataType.UINT16, None, None)
44
+ TEMP_REG_SETPOINT = (39, ModbusType.INPUT, DataType.FLOAT32, None, None)
45
+ TEMP_COOL_SETPOINT = (41, ModbusType.INPUT, DataType.FLOAT32, None, None)
46
+ TEMP_HEAT_SETPOINT = (43, ModbusType.INPUT, DataType.FLOAT32, None, None)
47
+ COMPRESSOR_SPEED = (45, ModbusType.INPUT, DataType.FLOAT32, 60, None) # RPM
48
+ TEMP_DHW_SETPOINT = (47, ModbusType.INPUT, DataType.FLOAT32, None, None)
49
+ HOURS_DHW = (50, ModbusType.INPUT, DataType.INT16, None, None)
50
+ HOURS_HEAT = (52, ModbusType.INPUT, DataType.INT16, None, None)
51
+ HOURS_COOL = (54, ModbusType.INPUT, DataType.INT16, None, None)
52
+ HOURS_HEATER_1 = (56, ModbusType.INPUT, DataType.INT16, None, None)
53
+ HOURS_HEATER_2 = (58, ModbusType.INPUT, DataType.INT16, None, None)
54
+ HOURS_HEATER_3 = (60, ModbusType.INPUT, DataType.INT16, None, None)
55
+ POWER_ELECTRIC_CALC = (61, ModbusType.INPUT, DataType.FLOAT32, None, None)
56
+ TEMP_PLANT_SETPOINT = (65, ModbusType.INPUT, DataType.FLOAT32, None, None)
57
+ ENERGY_ELECTRIC_TOTAL = (69, ModbusType.INPUT, DataType.FLOAT32, None, None)
58
+ ENERGY_THERMIC_TOTAL = (71, ModbusType.INPUT, DataType.FLOAT32, None, None)
59
+ TEMP_ROOM_MODBUS = (75, ModbusType.INPUT, DataType.FLOAT32, None, None)
60
+
61
+ # --- Configuration (Holding Registers) ---
62
+ SETPOINT_HEAT_DAY = (27, ModbusType.HOLDING, DataType.FLOAT32, None, None)
63
+ SETPOINT_HEAT_NIGHT = (29, ModbusType.HOLDING, DataType.FLOAT32, None, None)
64
+ SETPOINT_COOL_DAY = (31, ModbusType.HOLDING, DataType.FLOAT32, None, None)
65
+ SETPOINT_COOL_NIGHT = (33, ModbusType.HOLDING, DataType.FLOAT32, None, None)
66
+ DT_DHW = (43, ModbusType.HOLDING, DataType.INT16, None, None)
67
+ MIN_TEMP_DHW = (44, ModbusType.HOLDING, DataType.FLOAT32, None, None)
68
+ TEMP_DHW_PROG = (46, ModbusType.HOLDING, DataType.FLOAT32, None, None)
69
+ MIN_SETPOINT_BUFFER = (99, ModbusType.HOLDING, DataType.FLOAT32, None, None)
70
+ USER_HEAT_SETPOINT = (101, ModbusType.HOLDING, DataType.FLOAT32, None, None)
71
+ USER_COOL_SETPOINT = (103, ModbusType.HOLDING, DataType.FLOAT32, None, None)
72
+ MAX_SETPOINT_BUFFER = (169, ModbusType.HOLDING, DataType.FLOAT32, None, None)
73
+ USER_DHW_SETPOINT = (173, ModbusType.HOLDING, DataType.FLOAT32, None, None)
@@ -0,0 +1,37 @@
1
+ """Models for Qube Heat Pump."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Optional
5
+
6
+
7
+ @dataclass
8
+ class QubeState:
9
+ """Representation of the Qube Heat Pump state."""
10
+
11
+ # Temperatures
12
+ temp_supply: Optional[float] = None
13
+ temp_return: Optional[float] = None
14
+ temp_source_in: Optional[float] = None
15
+ temp_source_out: Optional[float] = None
16
+ temp_room: Optional[float] = None
17
+ temp_dhw: Optional[float] = None
18
+ temp_outside: Optional[float] = None
19
+
20
+ # Power/Energy
21
+ power_thermic: Optional[float] = None
22
+ power_electric: Optional[float] = None
23
+ energy_total_electric: Optional[float] = None
24
+ energy_total_thermic: Optional[float] = None
25
+ cop_calc: Optional[float] = None
26
+
27
+ # Operation
28
+ status_code: Optional[int] = None
29
+ compressor_speed: Optional[float] = None
30
+ flow_rate: Optional[float] = None
31
+
32
+ # Setpoints (Read/Write)
33
+ setpoint_room_heat_day: Optional[float] = None
34
+ setpoint_room_heat_night: Optional[float] = None
35
+ setpoint_room_cool_day: Optional[float] = None
36
+ setpoint_room_cool_night: Optional[float] = None
37
+ setpoint_dhw: Optional[float] = None
@@ -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
@@ -0,0 +1,8 @@
1
+ python_qube_heatpump/__init__.py,sha256=eW8_tyAweg7YTOI599pi8gf3mt7aXW0SCw5ZyrBVJpU,57
2
+ python_qube_heatpump/client.py,sha256=sndRUFheuv54A7ghH9HgJKg77PTTD7UxRcSzleKUr9c,4863
3
+ python_qube_heatpump/const.py,sha256=sXvXRcDb1uvBgzH2lV67Me_XtUErKA3fXMLO9gEGrlQ,3680
4
+ python_qube_heatpump/models.py,sha256=3I5U9_rEe1r73hwY4sNAMXfIxyPjBOtx06c_mAURLDk,1141
5
+ python_qube_heatpump-1.2.2.dist-info/METADATA,sha256=YPOSKN5eTRec_yDqkQaUSli5h42JnbqFVYO-D5Wi0Hw,950
6
+ python_qube_heatpump-1.2.2.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
7
+ python_qube_heatpump-1.2.2.dist-info/licenses/LICENSE,sha256=qpuQXN7QwpILG9GYcmqrd3Ax5CxBZUBoT295xTgKnOM,1062
8
+ python_qube_heatpump-1.2.2.dist-info/RECORD,,
@@ -1,6 +0,0 @@
1
- python_qube_heatpump/__init__.py,sha256=eW8_tyAweg7YTOI599pi8gf3mt7aXW0SCw5ZyrBVJpU,57
2
- python_qube_heatpump/client.py,sha256=ubYFve5PH_i4Euxyq1vWy5HLcNA00lispL-3c_csH84,5414
3
- python_qube_heatpump-1.2.0.dist-info/METADATA,sha256=6vXJXkd1y9LEuEMTHAMjv1RB-75i0xNxnvLJBsQi6Sg,950
4
- python_qube_heatpump-1.2.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
5
- python_qube_heatpump-1.2.0.dist-info/licenses/LICENSE,sha256=qpuQXN7QwpILG9GYcmqrd3Ax5CxBZUBoT295xTgKnOM,1062
6
- python_qube_heatpump-1.2.0.dist-info/RECORD,,