"""Client for Qube Heat Pump.""" from __future__ import annotations import logging import math import struct import time from typing import Any from pymodbus.client import AsyncModbusTcpClient from . import const from .entities import BINARY_SENSORS, SENSORS, SWITCHES, EntityDef from .entities.base import DataType, InputType from .models import QubeState _LOGGER = logging.getLogger(__name__) class QubeClient: """Qube Modbus Client.""" def __init__(self, host: str, port: int = 502, unit_id: int = 1): """Initialize.""" self.host = host self.port = port self.unit = unit_id self._client = AsyncModbusTcpClient(host, port=port) self._connected = False # Backoff state self._backoff_seconds: float = 0.0 self._backoff_max: float = 60.0 self._next_connect_at: float = 0.0 # Monotonic clamping for total_increasing counters self._monotonic_cache: dict[str, float] = {} async def connect(self) -> bool: """Connect to the Modbus server.""" if not self._connected: self._connected = await self._client.connect() return self._connected @property def is_connected(self) -> bool: """Return True if connected.""" return self._connected async def _ensure_connected(self) -> None: """Ensure connection is active, reconnecting with backoff if needed.""" if self._connected: return now = time.monotonic() if now < self._next_connect_at: return result = await self._client.connect() if result: self._connected = True self._backoff_seconds = 0.0 self._next_connect_at = 0.0 else: self._backoff_seconds = min( self._backoff_max, max(1.0, self._backoff_seconds * 2) ) self._next_connect_at = now + self._backoff_seconds async def close(self) -> None: """Close connection.""" self._client.close() self._connected = False self._backoff_seconds = 0.0 self._next_connect_at = 0.0 async def get_all_data(self) -> QubeState | None: """Fetch all definition data and return a state object. This fetches core sensors for the official HA integration. Returns None if not connected and reconnection fails. """ await self._ensure_connected() if not self._connected: return None state = QubeState() # Helper to read and assign async def _read(const_def): return await self.read_value(const_def) # Fetch temperature sensors state.temp_supply = await _read(const.TEMP_SUPPLY) state.temp_return = await _read(const.TEMP_RETURN) state.temp_source_in = await _read(const.TEMP_SOURCE_IN) state.temp_source_out = await _read(const.TEMP_SOURCE_OUT) state.temp_room = await _read(const.TEMP_ROOM) state.temp_dhw = await _read(const.TEMP_DHW) state.temp_outside = await _read(const.TEMP_OUTSIDE) # Fetch power and energy sensors state.power_thermic = await _read(const.POWER_THERMIC) state.power_electric = await _read(const.POWER_ELECTRIC_CALC) state.energy_total_electric = await _read(const.ENERGY_ELECTRIC_TOTAL) state.energy_total_thermic = await _read(const.ENERGY_THERMIC_TOTAL) state.cop_calc = await _read(const.COP_CALC) # Fetch operation sensors state.status_code = await _read(const.STATUS_CODE) state.compressor_speed = await _read(const.COMPRESSOR_SPEED) flow_rate = await _read(const.FLOW_RATE) if flow_rate is not None and flow_rate < 0: flow_rate = 0.0 state.flow_rate = flow_rate # Fetch setpoints (holding registers) state.setpoint_room_heat_day = await _read(const.SETPOINT_HEAT_DAY) state.setpoint_room_heat_night = await _read(const.SETPOINT_HEAT_NIGHT) state.setpoint_room_cool_day = await _read(const.SETPOINT_COOL_DAY) state.setpoint_room_cool_night = await _read(const.SETPOINT_COOL_NIGHT) state.setpoint_dhw = await _read(const.USER_DHW_SETPOINT) state.usr_pid_heatsetp = await _read(const.USER_HEAT_SETPOINT) state.usr_pid_coolsetp = await _read(const.USER_COOL_SETPOINT) # LinQ thermostat room temperature (optional) state.modbus_roomtemp = await _read(const.TEMP_ROOM_MODBUS) self._apply_monotonic_clamping(state) # Fetch binary sensors binary_data = await self.read_all_binary_sensors() for key, value in binary_data.items(): if hasattr(state, key): setattr(state, key, value) # Compute unified status (status_code + anti-legionella override) state.status = const.resolve_status(state.status_code, state.req_antileg_1) return state @property def monotonic_cache(self) -> dict[str, float]: """Return the monotonic clamping cache. Can be used to persist/restore the cache across restarts. """ return self._monotonic_cache @monotonic_cache.setter def monotonic_cache(self, value: dict[str, float]) -> None: """Set the monotonic clamping cache (e.g. restored from disk).""" self._monotonic_cache = dict(value) def clamp_monotonic(self, key: str, value: float | None) -> float | None: """Clamp a value to prevent decreases for total_increasing counters. Returns the clamped value. If the new value is lower than the previously seen value for this key, the previous value is returned. None and non-finite values pass through unchanged. Args: key: Identifier for this counter (e.g. entity unique_id). value: The current reading. Returns: The clamped value, or None if input was None/non-finite. """ if value is None or not math.isfinite(value): return value previous = self._monotonic_cache.get(key) if previous is not None and value < previous: return previous self._monotonic_cache[key] = value return value _MONOTONIC_KEYS = frozenset({"energy_total_electric", "energy_total_thermic"}) def _apply_monotonic_clamping(self, state: QubeState) -> None: """Apply monotonic clamping to energy counters in a QubeState.""" for key in self._MONOTONIC_KEYS: current = getattr(state, key, None) clamped = self.clamp_monotonic(key, current) if clamped is not current: setattr(state, key, clamped) async def async_get_software_version(self) -> str | None: """Read the software version from the device. Reads InputRegister 77 (GeneralMng.Softversion). Returns: Version as string (e.g., "2.15"), or None on error. """ value = await self.read_value(const.SOFTWARE_VERSION) if value is None: return None return str(round(value, 2)) async def get_all_entities(self) -> dict[str, Any]: """Fetch all entity values from library definitions. This reads all sensors, binary sensors, and switches defined in the library's entity definitions. Used by the HACS integration. Returns: Dictionary mapping entity keys to their values. """ results: dict[str, Any] = {} # Read all sensors for key, entity in SENSORS.items(): try: results[key] = await self.read_entity(entity) except Exception as exc: _LOGGER.debug("Error reading sensor %s: %s", key, exc) results[key] = None # Read all binary sensors for key, entity in BINARY_SENSORS.items(): try: results[key] = await self.read_entity(entity) except Exception as exc: _LOGGER.debug("Error reading binary sensor %s: %s", key, exc) results[key] = None # Read all switches for key, entity in SWITCHES.items(): try: results[key] = await self.read_entity(entity) except Exception as exc: _LOGGER.debug("Error reading switch %s: %s", key, exc) results[key] = None return results async def read_value(self, definition: tuple) -> float | None: """Read a single value based on the constant definition.""" address, reg_type, data_type, scale, offset = definition count = ( 2 if data_type in (const.DataType.FLOAT32, const.DataType.UINT32, const.DataType.INT32) else 1 ) try: if reg_type == const.ModbusType.INPUT: result = await self._client.read_input_registers( address, count=count, device_id=self.unit ) else: result = await self._client.read_holding_registers( address, count=count, device_id=self.unit ) if result.isError(): _LOGGER.warning("Error reading address %s", address) return None regs = result.registers val = 0 # Manual decoding to avoid pymodbus.payload dependencies # Assuming Little Endian Word Order for 32-bit values [LSW, MSW] per standard Modbus often used # But the original code used Endian.Little WordOrder. # Decoder: byteorder=Endian.Big, wordorder=Endian.Little # Big Endian Bytes: [H, L] # Little Endian Words: [Reg0, Reg1] -> [LSW, MSW] # # Example Float32: 123.456 # Reg0 (LSW) # Reg1 (MSW) # Full 32-bit int: (Reg1 << 16) | Reg0 # Then pack as >I (Big Endian 32-bit int) and unpack as >f (Big Endian float)? # # Qube uses Big Endian word order (ABCD format): # regs[0] = MSW (Most Significant Word) # regs[1] = LSW (Least Significant Word) # 32-bit value = (regs[0] << 16) | regs[1] if data_type == const.DataType.FLOAT32: # Combine 2 registers, Big Endian Word Order int_val = (regs[0] << 16) | regs[1] val = struct.unpack(">f", struct.pack(">I", int_val))[0] elif data_type == const.DataType.INT16: val = regs[0] # Signed 16-bit if val > 32767: val -= 65536 elif data_type == const.DataType.UINT16: val = regs[0] elif data_type == const.DataType.UINT32: int_val = (regs[0] << 16) | regs[1] val = int_val elif data_type == const.DataType.INT32: int_val = (regs[0] << 16) | regs[1] val = int_val if val > 2147483647: val -= 4294967296 else: val = 0 if scale is not None: val *= scale if offset is not None: val += offset return val except Exception as e: _LOGGER.error("Exception reading address %s: %s", address, e) return None async def read_entity(self, entity: EntityDef) -> Any: """Read a single entity value based on EntityDef. Args: entity: The entity definition to read. Returns: The read value (float, int, or bool depending on entity type). """ # Determine register count based on data type # Use string comparison to handle potential enum class differences data_type_str = entity.data_type.value if entity.data_type else None if data_type_str in ("float32", "uint32", "int32"): count = 2 else: count = 1 try: # Read based on input type (use string comparison for safety) input_type_str = entity.input_type.value if entity.input_type else None if input_type_str == "coil": result = await self._client.read_coils( entity.address, count=1, device_id=self.unit ) if result.isError(): _LOGGER.warning("Error reading coil %s", entity.address) return None return bool(result.bits[0]) if input_type_str == "discrete_input": result = await self._client.read_discrete_inputs( entity.address, count=1, device_id=self.unit ) if result.isError(): _LOGGER.warning("Error reading discrete input %s", entity.address) return None return bool(result.bits[0]) if input_type_str == "input": result = await self._client.read_input_registers( entity.address, count=count, device_id=self.unit ) else: # holding result = await self._client.read_holding_registers( entity.address, count=count, device_id=self.unit ) if result.isError(): _LOGGER.warning("Error reading address %s", entity.address) return None regs = result.registers val: float | int = 0 # Decode based on data type (use string comparison for safety) # Qube uses big endian word order (ABCD): regs[0]=MSW, regs[1]=LSW if data_type_str == "float32": int_val = (regs[0] << 16) | regs[1] val = struct.unpack(">f", struct.pack(">I", int_val))[0] elif data_type_str == "int16": val = regs[0] if val > 32767: val -= 65536 elif data_type_str == "uint16": val = regs[0] elif data_type_str == "uint32": int_val = (regs[0] << 16) | regs[1] val = int_val elif data_type_str == "int32": int_val = (regs[0] << 16) | regs[1] val = int_val if val > 2147483647: val -= 4294967296 # Apply scale and offset if entity.scale is not None: val = val * entity.scale if entity.offset is not None: val = val + entity.offset # Apply precision rounding if specified if entity.precision is not None and isinstance(val, float): val = round(val, entity.precision) return val except Exception as e: _LOGGER.error("Exception reading entity %s: %s", entity.key, e) return None async def read_sensor(self, key: str) -> float | int | None: """Read a sensor value by key. Args: key: The sensor key (e.g., 'temp_supply'). Returns: The sensor value, or None if not found or error. """ entity = SENSORS.get(key) if entity is None: _LOGGER.warning("Unknown sensor key: %s", key) return None return await self.read_entity(entity) async def read_binary_sensor(self, key: str) -> bool | None: """Read a binary sensor value by key. Args: key: The binary sensor key (e.g., 'dout_srcpmp_val'). Returns: The binary sensor value, or None if not found or error. """ entity = BINARY_SENSORS.get(key) if entity is None: _LOGGER.warning("Unknown binary sensor key: %s", key) return None return await self.read_entity(entity) async def read_switch(self, key: str) -> bool | None: """Read a switch state by key. Args: key: The switch key (e.g., 'bms_summerwinter'). Returns: The switch state, or None if not found or error. """ entity = SWITCHES.get(key) if entity is None: _LOGGER.warning("Unknown switch key: %s", key) return None return await self.read_entity(entity) async def read_all_sensors(self) -> dict[str, Any]: """Read all sensor values. Returns: Dictionary mapping sensor keys to their values. """ result: dict[str, Any] = {} for key, entity in SENSORS.items(): result[key] = await self.read_entity(entity) return result async def read_all_binary_sensors(self) -> dict[str, bool | None]: """Read all binary sensor values. Returns: Dictionary mapping binary sensor keys to their values. """ result: dict[str, bool | None] = {} for key, entity in BINARY_SENSORS.items(): result[key] = await self.read_entity(entity) return result async def read_all_switches(self) -> dict[str, bool | None]: """Read all switch states. Returns: Dictionary mapping switch keys to their states. """ result: dict[str, bool | None] = {} for key, entity in SWITCHES.items(): result[key] = await self.read_entity(entity) return result async def write_switch(self, key: str, value: bool) -> bool: """Write a switch state by key. Args: key: The switch key (e.g., 'bms_summerwinter'). value: True to turn on, False to turn off. Returns: True if write succeeded, False otherwise. """ entity = SWITCHES.get(key) if entity is None: _LOGGER.warning("Unknown switch key: %s", key) return False if not entity.writable: _LOGGER.warning("Switch %s is not writable", key) return False try: result = await self._client.write_coil( entity.address, value, device_id=self.unit ) if result.isError(): _LOGGER.warning("Error writing switch %s", key) return False return True except Exception as e: _LOGGER.error("Exception writing switch %s: %s", key, e) return False # SG Ready mode API SG_READY_MODES = ("off", "block", "plus", "max") _SGREADY_MODE_TO_BITS: dict[str, tuple[bool, bool]] = { "off": (False, False), "block": (True, False), "plus": (False, True), "max": (True, True), } _SGREADY_BITS_TO_MODE: dict[tuple[bool, bool], str] = { v: k for k, v in _SGREADY_MODE_TO_BITS.items() } async def get_sg_ready_mode(self) -> str | None: """Read the current SG Ready mode. Returns: Mode string ("off", "block", "plus", "max"), or None on error. """ bit_a = await self.read_switch("bms_sgready_a") bit_b = await self.read_switch("bms_sgready_b") if bit_a is None or bit_b is None: return None return self._SGREADY_BITS_TO_MODE.get((bool(bit_a), bool(bit_b))) async def set_sg_ready_mode(self, mode: str) -> bool: """Set the SG Ready mode. Args: mode: One of "off", "block", "plus", "max". Returns: True if both writes succeeded, False otherwise. """ bits = self._SGREADY_MODE_TO_BITS.get(mode) if bits is None: _LOGGER.warning("Unknown SG Ready mode: %s", mode) return False success_a = await self.write_switch("bms_sgready_a", bits[0]) success_b = await self.write_switch("bms_sgready_b", bits[1]) return success_a and success_b async def write_setpoint(self, key: str, value: float) -> bool: """Write a setpoint value by key. Args: key: The sensor key for the setpoint (e.g., 'setpoint_dhw'). value: The value to write. Returns: True if write succeeded, False otherwise. """ entity = SENSORS.get(key) if entity is None: _LOGGER.warning("Unknown sensor key: %s", key) return False if not entity.writable: _LOGGER.warning("Sensor %s is not writable", key) return False if entity.input_type != InputType.HOLDING_REGISTER: _LOGGER.warning("Sensor %s is not a holding register", key) return False try: # Reverse scale/offset if needed write_value = value if entity.offset is not None: write_value = write_value - entity.offset if entity.scale is not None: write_value = write_value / entity.scale # Encode based on data type if entity.data_type == DataType.FLOAT32: # Pack as big-endian float, then split into two registers # Big Endian word order: regs[0]=MSW, regs[1]=LSW packed = struct.pack(">f", write_value) int_val = struct.unpack(">I", packed)[0] regs = [(int_val >> 16) & 0xFFFF, int_val & 0xFFFF] result = await self._client.write_registers( entity.address, regs, device_id=self.unit ) elif entity.data_type == DataType.INT16: if write_value < 0: write_value = int(write_value) + 65536 result = await self._client.write_register( entity.address, int(write_value), device_id=self.unit ) elif entity.data_type == DataType.UINT16: result = await self._client.write_register( entity.address, int(write_value), device_id=self.unit ) else: _LOGGER.warning( "Unsupported data type for writing: %s", entity.data_type ) return False if result.isError(): _LOGGER.warning("Error writing setpoint %s", key) return False return True except Exception as e: _LOGGER.error("Exception writing setpoint %s: %s", key, e) return False