python-qube-heatpump 1.2.2__py3-none-any.whl → 1.3.0__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,3 +1,39 @@
1
+ """Python library for Qube Heat Pump Modbus communication."""
2
+
1
3
  from .client import QubeClient
4
+ from .const import (
5
+ DataType,
6
+ ModbusType,
7
+ StatusCode,
8
+ STATUS_CODE_MAP,
9
+ get_status_code,
10
+ )
11
+ from .entities import (
12
+ BINARY_SENSORS,
13
+ EntityDef,
14
+ InputType,
15
+ Platform,
16
+ SENSORS,
17
+ SWITCHES,
18
+ )
19
+ from .models import QubeState
2
20
 
3
- __all__ = ["QubeClient"]
21
+ __all__ = [
22
+ # Client
23
+ "QubeClient",
24
+ # State
25
+ "QubeState",
26
+ # Entity definitions
27
+ "BINARY_SENSORS",
28
+ "EntityDef",
29
+ "InputType",
30
+ "Platform",
31
+ "SENSORS",
32
+ "SWITCHES",
33
+ # Constants
34
+ "DataType",
35
+ "ModbusType",
36
+ "StatusCode",
37
+ "STATUS_CODE_MAP",
38
+ "get_status_code",
39
+ ]
@@ -1,12 +1,16 @@
1
1
  """Client for Qube Heat Pump."""
2
2
 
3
+ from __future__ import annotations
4
+
3
5
  import logging
4
6
  import struct
5
- from typing import Optional
7
+ from typing import Any
6
8
 
7
9
  from pymodbus.client import AsyncModbusTcpClient
8
10
 
9
11
  from . import const
12
+ from .entities import BINARY_SENSORS, SENSORS, SWITCHES, EntityDef
13
+ from .entities.base import DataType, InputType
10
14
  from .models import QubeState
11
15
 
12
16
  _LOGGER = logging.getLogger(__name__)
@@ -46,15 +50,37 @@ class QubeClient:
46
50
  async def _read(const_def):
47
51
  return await self.read_value(const_def)
48
52
 
49
- # Fetch basic sensors
53
+ # Fetch temperature sensors
50
54
  state.temp_supply = await _read(const.TEMP_SUPPLY)
51
55
  state.temp_return = await _read(const.TEMP_RETURN)
52
- state.temp_outside = await _read(const.TEMP_OUTSIDE)
56
+ state.temp_source_in = await _read(const.TEMP_SOURCE_IN)
57
+ state.temp_source_out = await _read(const.TEMP_SOURCE_OUT)
58
+ state.temp_room = await _read(const.TEMP_ROOM)
53
59
  state.temp_dhw = await _read(const.TEMP_DHW)
60
+ state.temp_outside = await _read(const.TEMP_OUTSIDE)
61
+
62
+ # Fetch power and energy sensors
63
+ state.power_thermic = await _read(const.POWER_THERMIC)
64
+ state.power_electric = await _read(const.POWER_ELECTRIC_CALC)
65
+ state.energy_total_electric = await _read(const.ENERGY_ELECTRIC_TOTAL)
66
+ state.energy_total_thermic = await _read(const.ENERGY_THERMIC_TOTAL)
67
+ state.cop_calc = await _read(const.COP_CALC)
68
+
69
+ # Fetch operation sensors
70
+ state.status_code = await _read(const.STATUS_CODE)
71
+ state.compressor_speed = await _read(const.COMPRESSOR_SPEED)
72
+ state.flow_rate = await _read(const.FLOW_RATE)
73
+
74
+ # Fetch setpoints (holding registers)
75
+ state.setpoint_room_heat_day = await _read(const.SETPOINT_HEAT_DAY)
76
+ state.setpoint_room_heat_night = await _read(const.SETPOINT_HEAT_NIGHT)
77
+ state.setpoint_room_cool_day = await _read(const.SETPOINT_COOL_DAY)
78
+ state.setpoint_room_cool_night = await _read(const.SETPOINT_COOL_NIGHT)
79
+ state.setpoint_dhw = await _read(const.USER_DHW_SETPOINT)
54
80
 
55
81
  return state
56
82
 
57
- async def read_value(self, definition: tuple) -> Optional[float]:
83
+ async def read_value(self, definition: tuple) -> float | None:
58
84
  """Read a single value based on the constant definition."""
59
85
  address, reg_type, data_type, scale, offset = definition
60
86
 
@@ -134,3 +160,255 @@ class QubeClient:
134
160
  except Exception as e:
135
161
  _LOGGER.error("Exception reading address %s: %s", address, e)
136
162
  return None
163
+
164
+ async def read_entity(self, entity: EntityDef) -> Any:
165
+ """Read a single entity value based on EntityDef.
166
+
167
+ Args:
168
+ entity: The entity definition to read.
169
+
170
+ Returns:
171
+ The read value (float, int, or bool depending on entity type).
172
+ """
173
+ # Determine register count based on data type
174
+ if entity.data_type in (DataType.FLOAT32, DataType.UINT32, DataType.INT32):
175
+ count = 2
176
+ else:
177
+ count = 1
178
+
179
+ try:
180
+ # Read based on input type
181
+ if entity.input_type == InputType.COIL:
182
+ result = await self._client.read_coils(
183
+ entity.address, count=1, slave=self.unit
184
+ )
185
+ if result.isError():
186
+ _LOGGER.warning("Error reading coil %s", entity.address)
187
+ return None
188
+ return bool(result.bits[0])
189
+
190
+ if entity.input_type == InputType.DISCRETE_INPUT:
191
+ result = await self._client.read_discrete_inputs(
192
+ entity.address, count=1, slave=self.unit
193
+ )
194
+ if result.isError():
195
+ _LOGGER.warning("Error reading discrete input %s", entity.address)
196
+ return None
197
+ return bool(result.bits[0])
198
+
199
+ if entity.input_type == InputType.INPUT_REGISTER:
200
+ result = await self._client.read_input_registers(
201
+ entity.address, count, slave=self.unit
202
+ )
203
+ else: # HOLDING_REGISTER
204
+ result = await self._client.read_holding_registers(
205
+ entity.address, count, slave=self.unit
206
+ )
207
+
208
+ if result.isError():
209
+ _LOGGER.warning("Error reading address %s", entity.address)
210
+ return None
211
+
212
+ regs = result.registers
213
+ val: float | int = 0
214
+
215
+ # Decode based on data type
216
+ if entity.data_type == DataType.FLOAT32:
217
+ int_val = (regs[1] << 16) | regs[0]
218
+ val = struct.unpack(">f", struct.pack(">I", int_val))[0]
219
+ elif entity.data_type == DataType.INT16:
220
+ val = regs[0]
221
+ if val > 32767:
222
+ val -= 65536
223
+ elif entity.data_type == DataType.UINT16:
224
+ val = regs[0]
225
+ elif entity.data_type == DataType.UINT32:
226
+ val = (regs[1] << 16) | regs[0]
227
+ elif entity.data_type == DataType.INT32:
228
+ val = (regs[1] << 16) | regs[0]
229
+ if val > 2147483647:
230
+ val -= 4294967296
231
+
232
+ # Apply scale and offset
233
+ if entity.scale is not None:
234
+ val = val * entity.scale
235
+ if entity.offset is not None:
236
+ val = val + entity.offset
237
+
238
+ return val
239
+
240
+ except Exception as e:
241
+ _LOGGER.error("Exception reading entity %s: %s", entity.key, e)
242
+ return None
243
+
244
+ async def read_sensor(self, key: str) -> float | int | None:
245
+ """Read a sensor value by key.
246
+
247
+ Args:
248
+ key: The sensor key (e.g., 'temp_supply').
249
+
250
+ Returns:
251
+ The sensor value, or None if not found or error.
252
+ """
253
+ entity = SENSORS.get(key)
254
+ if entity is None:
255
+ _LOGGER.warning("Unknown sensor key: %s", key)
256
+ return None
257
+ return await self.read_entity(entity)
258
+
259
+ async def read_binary_sensor(self, key: str) -> bool | None:
260
+ """Read a binary sensor value by key.
261
+
262
+ Args:
263
+ key: The binary sensor key (e.g., 'dout_srcpmp_val').
264
+
265
+ Returns:
266
+ The binary sensor value, or None if not found or error.
267
+ """
268
+ entity = BINARY_SENSORS.get(key)
269
+ if entity is None:
270
+ _LOGGER.warning("Unknown binary sensor key: %s", key)
271
+ return None
272
+ return await self.read_entity(entity)
273
+
274
+ async def read_switch(self, key: str) -> bool | None:
275
+ """Read a switch state by key.
276
+
277
+ Args:
278
+ key: The switch key (e.g., 'bms_summerwinter').
279
+
280
+ Returns:
281
+ The switch state, or None if not found or error.
282
+ """
283
+ entity = SWITCHES.get(key)
284
+ if entity is None:
285
+ _LOGGER.warning("Unknown switch key: %s", key)
286
+ return None
287
+ return await self.read_entity(entity)
288
+
289
+ async def read_all_sensors(self) -> dict[str, Any]:
290
+ """Read all sensor values.
291
+
292
+ Returns:
293
+ Dictionary mapping sensor keys to their values.
294
+ """
295
+ result: dict[str, Any] = {}
296
+ for key, entity in SENSORS.items():
297
+ result[key] = await self.read_entity(entity)
298
+ return result
299
+
300
+ async def read_all_binary_sensors(self) -> dict[str, bool | None]:
301
+ """Read all binary sensor values.
302
+
303
+ Returns:
304
+ Dictionary mapping binary sensor keys to their values.
305
+ """
306
+ result: dict[str, bool | None] = {}
307
+ for key, entity in BINARY_SENSORS.items():
308
+ result[key] = await self.read_entity(entity)
309
+ return result
310
+
311
+ async def read_all_switches(self) -> dict[str, bool | None]:
312
+ """Read all switch states.
313
+
314
+ Returns:
315
+ Dictionary mapping switch keys to their states.
316
+ """
317
+ result: dict[str, bool | None] = {}
318
+ for key, entity in SWITCHES.items():
319
+ result[key] = await self.read_entity(entity)
320
+ return result
321
+
322
+ async def write_switch(self, key: str, value: bool) -> bool:
323
+ """Write a switch state by key.
324
+
325
+ Args:
326
+ key: The switch key (e.g., 'bms_summerwinter').
327
+ value: True to turn on, False to turn off.
328
+
329
+ Returns:
330
+ True if write succeeded, False otherwise.
331
+ """
332
+ entity = SWITCHES.get(key)
333
+ if entity is None:
334
+ _LOGGER.warning("Unknown switch key: %s", key)
335
+ return False
336
+
337
+ if not entity.writable:
338
+ _LOGGER.warning("Switch %s is not writable", key)
339
+ return False
340
+
341
+ try:
342
+ result = await self._client.write_coil(
343
+ entity.address, value, slave=self.unit
344
+ )
345
+ if result.isError():
346
+ _LOGGER.warning("Error writing switch %s", key)
347
+ return False
348
+ return True
349
+ except Exception as e:
350
+ _LOGGER.error("Exception writing switch %s: %s", key, e)
351
+ return False
352
+
353
+ async def write_setpoint(self, key: str, value: float) -> bool:
354
+ """Write a setpoint value by key.
355
+
356
+ Args:
357
+ key: The sensor key for the setpoint (e.g., 'setpoint_dhw').
358
+ value: The value to write.
359
+
360
+ Returns:
361
+ True if write succeeded, False otherwise.
362
+ """
363
+ entity = SENSORS.get(key)
364
+ if entity is None:
365
+ _LOGGER.warning("Unknown sensor key: %s", key)
366
+ return False
367
+
368
+ if not entity.writable:
369
+ _LOGGER.warning("Sensor %s is not writable", key)
370
+ return False
371
+
372
+ if entity.input_type != InputType.HOLDING_REGISTER:
373
+ _LOGGER.warning("Sensor %s is not a holding register", key)
374
+ return False
375
+
376
+ try:
377
+ # Reverse scale/offset if needed
378
+ write_value = value
379
+ if entity.offset is not None:
380
+ write_value = write_value - entity.offset
381
+ if entity.scale is not None:
382
+ write_value = write_value / entity.scale
383
+
384
+ # Encode based on data type
385
+ if entity.data_type == DataType.FLOAT32:
386
+ # Pack as big-endian float, then split into two registers
387
+ packed = struct.pack(">f", write_value)
388
+ int_val = struct.unpack(">I", packed)[0]
389
+ regs = [int_val & 0xFFFF, (int_val >> 16) & 0xFFFF]
390
+ result = await self._client.write_registers(
391
+ entity.address, regs, slave=self.unit
392
+ )
393
+ elif entity.data_type == DataType.INT16:
394
+ if write_value < 0:
395
+ write_value = int(write_value) + 65536
396
+ result = await self._client.write_register(
397
+ entity.address, int(write_value), slave=self.unit
398
+ )
399
+ elif entity.data_type == DataType.UINT16:
400
+ result = await self._client.write_register(
401
+ entity.address, int(write_value), slave=self.unit
402
+ )
403
+ else:
404
+ _LOGGER.warning("Unsupported data type for writing: %s", entity.data_type)
405
+ return False
406
+
407
+ if result.isError():
408
+ _LOGGER.warning("Error writing setpoint %s", key)
409
+ return False
410
+ return True
411
+
412
+ except Exception as e:
413
+ _LOGGER.error("Exception writing setpoint %s: %s", key, e)
414
+ return False
@@ -20,6 +20,42 @@ class DataType(str, Enum):
20
20
  UINT32 = "uint32"
21
21
 
22
22
 
23
+ class StatusCode(str, Enum):
24
+ """Heat pump status codes."""
25
+
26
+ STANDBY = "standby"
27
+ ALARM = "alarm"
28
+ KEYBOARD_OFF = "keyboard_off"
29
+ COMPRESSOR_STARTUP = "compressor_startup"
30
+ COMPRESSOR_SHUTDOWN = "compressor_shutdown"
31
+ COOLING = "cooling"
32
+ HEATING = "heating"
33
+ START_FAIL = "start_fail"
34
+ HEATING_DHW = "heating_dhw"
35
+ UNKNOWN = "unknown"
36
+
37
+
38
+ # Map numeric status codes to StatusCode enum values
39
+ STATUS_CODE_MAP: dict[int, StatusCode] = {
40
+ 1: StatusCode.STANDBY,
41
+ 2: StatusCode.ALARM,
42
+ 6: StatusCode.KEYBOARD_OFF,
43
+ 8: StatusCode.COMPRESSOR_STARTUP,
44
+ 9: StatusCode.COMPRESSOR_SHUTDOWN,
45
+ 14: StatusCode.STANDBY,
46
+ 15: StatusCode.COOLING,
47
+ 16: StatusCode.HEATING,
48
+ 17: StatusCode.START_FAIL,
49
+ 18: StatusCode.STANDBY,
50
+ 22: StatusCode.HEATING_DHW,
51
+ }
52
+
53
+
54
+ def get_status_code(code: int) -> StatusCode:
55
+ """Convert numeric status code to StatusCode enum."""
56
+ return STATUS_CODE_MAP.get(code, StatusCode.UNKNOWN)
57
+
58
+
23
59
  # Register definitions (Address, Type, Data Type, Scale, Offset)
24
60
  # Scale/Offset are None if not used.
25
61
  # Format: KEY = (Address, ModbusType, DataType, Scale, Offset)
@@ -0,0 +1,16 @@
1
+ """Entity definitions for Qube Heat Pump."""
2
+
3
+ from .base import DataType, EntityDef, InputType, Platform
4
+ from .binary_sensors import BINARY_SENSORS
5
+ from .sensors import SENSORS
6
+ from .switches import SWITCHES
7
+
8
+ __all__ = [
9
+ "BINARY_SENSORS",
10
+ "DataType",
11
+ "EntityDef",
12
+ "InputType",
13
+ "Platform",
14
+ "SENSORS",
15
+ "SWITCHES",
16
+ ]
@@ -0,0 +1,77 @@
1
+ """Base classes and enums for entity definitions."""
2
+
3
+ from dataclasses import dataclass
4
+ from enum import Enum
5
+
6
+
7
+ class InputType(str, Enum):
8
+ """Modbus input type for reading values."""
9
+
10
+ COIL = "coil"
11
+ DISCRETE_INPUT = "discrete_input"
12
+ INPUT_REGISTER = "input"
13
+ HOLDING_REGISTER = "holding"
14
+
15
+
16
+ class DataType(str, Enum):
17
+ """Data type for register values."""
18
+
19
+ FLOAT32 = "float32"
20
+ INT16 = "int16"
21
+ UINT16 = "uint16"
22
+ INT32 = "int32"
23
+ UINT32 = "uint32"
24
+
25
+
26
+ class Platform(str, Enum):
27
+ """Home Assistant platform type."""
28
+
29
+ SENSOR = "sensor"
30
+ BINARY_SENSOR = "binary_sensor"
31
+ SWITCH = "switch"
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class EntityDef:
36
+ """Definition of a Qube heat pump entity.
37
+
38
+ This dataclass defines the protocol-level properties of an entity.
39
+ Home Assistant-specific metadata (device_class, state_class, etc.)
40
+ should be added by the integration, not here.
41
+ """
42
+
43
+ # Identity
44
+ key: str
45
+ """Unique identifier, e.g., 'temp_supply'."""
46
+
47
+ name: str
48
+ """Human-readable name, e.g., 'Supply temperature'."""
49
+
50
+ # Modbus specifics
51
+ address: int
52
+ """Register or coil address."""
53
+
54
+ input_type: InputType
55
+ """How to read from device (coil, discrete_input, input, holding)."""
56
+
57
+ data_type: DataType | None = None
58
+ """Data type for registers. None for coils/discrete inputs."""
59
+
60
+ # Platform hint
61
+ platform: Platform = Platform.SENSOR
62
+ """Which HA platform this entity belongs to."""
63
+
64
+ # Value transformation
65
+ scale: float | None = None
66
+ """Multiply raw value by this factor."""
67
+
68
+ offset: float | None = None
69
+ """Add this to the scaled value."""
70
+
71
+ # Unit (protocol-level)
72
+ unit: str | None = None
73
+ """Unit of measurement, e.g., '°C', 'kWh', 'W'."""
74
+
75
+ # Write capability
76
+ writable: bool = False
77
+ """Whether this entity can be written to."""