From 4e8e20a5ac5b18d967000f16357695611c720b04 Mon Sep 17 00:00:00 2001 From: Constantin Pascal Date: Thu, 29 Jan 2026 10:29:40 +0200 Subject: [PATCH] Adding new sensors to track ventilation/renewal/integration and dehumidification --- .../rdz_hmi_control/binary_sensor.py | 187 ++++++++++++++++++ custom_components/rdz_hmi_control/const.py | 21 ++ .../rdz_hmi_control/coordinator.py | 18 ++ .../rdz_hmi_control/modbus_client.py | 74 +++++++ 4 files changed, 300 insertions(+) diff --git a/custom_components/rdz_hmi_control/binary_sensor.py b/custom_components/rdz_hmi_control/binary_sensor.py index 4bef8a2..c378681 100644 --- a/custom_components/rdz_hmi_control/binary_sensor.py +++ b/custom_components/rdz_hmi_control/binary_sensor.py @@ -15,13 +15,33 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( + CONF_LINKED_VIRTUAL_ZONE, + CONF_ZONE_NAME, + CONF_ZONE_TYPE, + CONF_ZONES, + DATA_DEHUMIDIFICATION_PUMP, + DATA_HUMIDITY_REQUEST, + DATA_INTEGRATION_REQUEST, DATA_PUMP_ACTIVE, + DATA_RENEWAL_REQUEST, + DATA_VENTILATION_REQUEST, + DATA_ZONE_ACTIVITY, DOMAIN, + THERMOSTAT_TYPE_REAL, ) from .coordinator import RDZDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) +# Zone request types with their data keys and display names +ZONE_REQUEST_TYPES: dict[str, tuple[str, str]] = { + "humidity_request": (DATA_HUMIDITY_REQUEST, "Humidity request"), + "ventilation_request": (DATA_VENTILATION_REQUEST, "Ventilation request"), + "renewal_request": (DATA_RENEWAL_REQUEST, "Renewal request"), + "integration_request": (DATA_INTEGRATION_REQUEST, "Integration request"), + "dehumidification_pump": (DATA_DEHUMIDIFICATION_PUMP, "Dehumidification pump"), +} + async def async_setup_entry( hass: HomeAssistant, @@ -30,6 +50,7 @@ async def async_setup_entry( ) -> None: """Set up RDZ HMI binary sensor entities from a config entry.""" coordinator: RDZDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + zones_config = config_entry.data.get(CONF_ZONES, {}) entities: list[BinarySensorEntity] = [] @@ -37,6 +58,17 @@ async def async_setup_entry( for pump_id in range(1, 9): entities.append(RDZPumpActiveBinarySensor(coordinator, pump_id)) + # Add zone pump binary sensors for each configured zone + for zone_id_str, zone_data in zones_config.items(): + zone_id = int(zone_id_str) if isinstance(zone_id_str, str) else zone_id_str + entities.append(RDZZonePumpBinarySensor(coordinator, zone_id, zone_data)) + + # Add zone request binary sensors (5 per zone) + for request_type in ZONE_REQUEST_TYPES: + entities.append( + RDZZoneRequestBinarySensor(coordinator, zone_id, zone_data, request_type) + ) + async_add_entities(entities) @@ -85,3 +117,158 @@ class RDZPumpActiveBinarySensor(CoordinatorEntity[RDZDataUpdateCoordinator], Bin def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" self.async_write_ha_state() + + +class RDZZonePumpBinarySensor(CoordinatorEntity[RDZDataUpdateCoordinator], BinarySensorEntity): + """Representation of an RDZ HMI zone pump binary sensor. + + Indicates whether the zone is actively heating or cooling. + """ + + _attr_has_entity_name = True + _attr_device_class = BinarySensorDeviceClass.RUNNING + + def __init__( + self, + coordinator: RDZDataUpdateCoordinator, + zone_id: int, + zone_data: dict, + ) -> None: + """Initialize the zone pump binary sensor entity.""" + super().__init__(coordinator) + self._zone_id = zone_id + self._zone_data = zone_data + + zone_name = zone_data.get(CONF_ZONE_NAME, f"Zone {zone_id}") + + self._attr_unique_id = f"{DOMAIN}_{coordinator.client.host}_{zone_id}_zone_pump" + self._attr_name = "Zone pump" + + # Place on the zone's device + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{coordinator.client.host}_{zone_id}")}, + name=zone_name, + manufacturer="RDZ", + model="HMI Thermostat", + sw_version="1.0", + ) + + @property + def is_on(self) -> bool | None: + """Return True if the zone is actively heating or cooling. + + Uses the same logic as hvac_action in climate.py: + - For real thermostats: True if this zone or linked virtual zone is active + - For virtual/unconfigured: Always False + """ + zone_type = self._zone_data.get(CONF_ZONE_TYPE) + + if zone_type != THERMOSTAT_TYPE_REAL: + return False + + if self.coordinator.data is None: + return None + + zone_activity = self.coordinator.data.get(DATA_ZONE_ACTIVITY, {}) + + # Check if this zone is active + if zone_activity.get(self._zone_id, False): + return True + + # Check if linked virtual zone is active + linked_virtual = self._zone_data.get(CONF_LINKED_VIRTUAL_ZONE) + if linked_virtual is not None and zone_activity.get(linked_virtual, False): + return True + + return False + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + # Update zone_data in case config changed + zones_config = self.coordinator.config_entry.data.get(CONF_ZONES, {}) + self._zone_data = zones_config.get(str(self._zone_id), self._zone_data) + self.async_write_ha_state() + + +class RDZZoneRequestBinarySensor(CoordinatorEntity[RDZDataUpdateCoordinator], BinarySensorEntity): + """Representation of an RDZ HMI zone request binary sensor. + + Indicates whether the zone has an active request (humidity, ventilation, + renewal, integration, or dehumidification pump). + """ + + _attr_has_entity_name = True + _attr_device_class = BinarySensorDeviceClass.RUNNING + + def __init__( + self, + coordinator: RDZDataUpdateCoordinator, + zone_id: int, + zone_data: dict, + request_type: str, + ) -> None: + """Initialize the zone request binary sensor entity. + + Args: + coordinator: The data coordinator. + zone_id: The zone ID (0-63). + zone_data: Zone configuration data. + request_type: The type of request (key in ZONE_REQUEST_TYPES). + """ + super().__init__(coordinator) + self._zone_id = zone_id + self._zone_data = zone_data + self._request_type = request_type + self._data_key, self._display_name = ZONE_REQUEST_TYPES[request_type] + + zone_name = zone_data.get(CONF_ZONE_NAME, f"Zone {zone_id}") + + self._attr_unique_id = f"{DOMAIN}_{coordinator.client.host}_{zone_id}_{request_type}" + self._attr_name = self._display_name + + # Place on the zone's device + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{coordinator.client.host}_{zone_id}")}, + name=zone_name, + manufacturer="RDZ", + model="HMI Thermostat", + sw_version="1.0", + ) + + @property + def is_on(self) -> bool | None: + """Return True if the zone has the request active. + + Uses the same logic as RDZZonePumpBinarySensor: + - For real thermostats: True if this zone or linked virtual zone is active + - For virtual/unconfigured: Always False + """ + zone_type = self._zone_data.get(CONF_ZONE_TYPE) + + if zone_type != THERMOSTAT_TYPE_REAL: + return False + + if self.coordinator.data is None: + return None + + request_data = self.coordinator.data.get(self._data_key, {}) + + # Check if this zone has the request active + if request_data.get(self._zone_id, False): + return True + + # Check if linked virtual zone has the request active + linked_virtual = self._zone_data.get(CONF_LINKED_VIRTUAL_ZONE) + if linked_virtual is not None and request_data.get(linked_virtual, False): + return True + + return False + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + # Update zone_data in case config changed + zones_config = self.coordinator.config_entry.data.get(CONF_ZONES, {}) + self._zone_data = zones_config.get(str(self._zone_id), self._zone_data) + self.async_write_ha_state() diff --git a/custom_components/rdz_hmi_control/const.py b/custom_components/rdz_hmi_control/const.py index b42bcdc..0846638 100644 --- a/custom_components/rdz_hmi_control/const.py +++ b/custom_components/rdz_hmi_control/const.py @@ -23,6 +23,22 @@ COIL_SEASON = 2 # 0 = winter, 1 = summer REGISTER_ACTIVITY_START = 2892 REGISTER_ACTIVITY_COUNT = 4 +# Zone request bitmask registers (4 registers each, 16 bits = 64 zones) +REGISTER_HUMIDITY_REQUEST_START = 2896 +REGISTER_HUMIDITY_REQUEST_COUNT = 4 + +REGISTER_VENTILATION_REQUEST_START = 2900 +REGISTER_VENTILATION_REQUEST_COUNT = 4 + +REGISTER_RENEWAL_REQUEST_START = 2904 +REGISTER_RENEWAL_REQUEST_COUNT = 4 + +REGISTER_INTEGRATION_REQUEST_START = 2908 +REGISTER_INTEGRATION_REQUEST_COUNT = 4 + +REGISTER_DEHUMIDIFICATION_PUMP_START = 2912 +REGISTER_DEHUMIDIFICATION_PUMP_COUNT = 4 + # Humidity registers REGISTER_HUMIDITY_START = 7701 REGISTER_HUMIDITY_COUNT = 64 @@ -75,6 +91,11 @@ DATA_WINTER_SETPOINTS = "winter_setpoints" DATA_SUMMER_SETPOINTS = "summer_setpoints" DATA_SEASON = "season" # True = summer, False = winter DATA_ZONE_ACTIVITY = "zone_activity" # dict[int, bool] - zone_id -> is_active +DATA_HUMIDITY_REQUEST = "humidity_request" +DATA_VENTILATION_REQUEST = "ventilation_request" +DATA_RENEWAL_REQUEST = "renewal_request" +DATA_INTEGRATION_REQUEST = "integration_request" +DATA_DEHUMIDIFICATION_PUMP = "dehumidification_pump" DATA_HUMIDITY = "humidity" DATA_DEHUMIDIFICATION_SETPOINTS = "dehumidification_setpoints" DATA_DEW_POINTS = "dew_points" diff --git a/custom_components/rdz_hmi_control/coordinator.py b/custom_components/rdz_hmi_control/coordinator.py index 1802043..1c03bbd 100644 --- a/custom_components/rdz_hmi_control/coordinator.py +++ b/custom_components/rdz_hmi_control/coordinator.py @@ -18,17 +18,22 @@ from .const import ( CONF_ZONE_TYPE, CONF_ZONES, DATA_CALCULATED_WATER_TEMPS, + DATA_DEHUMIDIFICATION_PUMP, DATA_DEHUMIDIFICATION_SETPOINTS, DATA_DELIVERY_WATER_TEMPS, DATA_DEW_POINTS, DATA_HUMIDITY, + DATA_HUMIDITY_REQUEST, + DATA_INTEGRATION_REQUEST, DATA_OUTSIDE_TEMP, DATA_PUMP_ACTIVE, + DATA_RENEWAL_REQUEST, DATA_SEASON, DATA_SUMMER_SETPOINTS, DATA_SYSTEM_ACTIVATION, DATA_TEMPERATURES, DATA_TIME_SETTINGS, + DATA_VENTILATION_REQUEST, DATA_WINTER_SETPOINTS, DATA_ZONE_ACTIVITY, DATA_ZONE_MODES, @@ -93,6 +98,13 @@ class RDZDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): calculated_water_temps = await self.client.read_calculated_water_temps() pump_active = await self.client.read_pump_active() + # Read zone request bitmasks + humidity_request = await self.client.read_humidity_request() + ventilation_request = await self.client.read_ventilation_request() + renewal_request = await self.client.read_renewal_request() + integration_request = await self.client.read_integration_request() + dehumidification_pump = await self.client.read_dehumidification_pump() + if temperatures is None or winter_setpoints is None or summer_setpoints is None: raise UpdateFailed("Failed to read data from Modbus device") @@ -119,6 +131,12 @@ class RDZDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): DATA_DELIVERY_WATER_TEMPS: delivery_water_temps, DATA_CALCULATED_WATER_TEMPS: calculated_water_temps, DATA_PUMP_ACTIVE: pump_active, + # Zone request bitmasks + DATA_HUMIDITY_REQUEST: humidity_request, + DATA_VENTILATION_REQUEST: ventilation_request, + DATA_RENEWAL_REQUEST: renewal_request, + DATA_INTEGRATION_REQUEST: integration_request, + DATA_DEHUMIDIFICATION_PUMP: dehumidification_pump, } except Exception as ex: diff --git a/custom_components/rdz_hmi_control/modbus_client.py b/custom_components/rdz_hmi_control/modbus_client.py index aa30052..964baf5 100644 --- a/custom_components/rdz_hmi_control/modbus_client.py +++ b/custom_components/rdz_hmi_control/modbus_client.py @@ -14,6 +14,8 @@ from .const import ( REGISTER_ACTIVITY_START, REGISTER_CALCULATED_WATER_TEMP_COUNT, REGISTER_CALCULATED_WATER_TEMP_START, + REGISTER_DEHUMIDIFICATION_PUMP_COUNT, + REGISTER_DEHUMIDIFICATION_PUMP_START, REGISTER_DEHUMIDIFICATION_SETPOINT_COUNT, REGISTER_DEHUMIDIFICATION_SETPOINT_START, REGISTER_DELIVERY_WATER_TEMP_COUNT, @@ -21,9 +23,15 @@ from .const import ( REGISTER_DEW_POINT_COUNT, REGISTER_DEW_POINT_START, REGISTER_HUMIDITY_COUNT, + REGISTER_HUMIDITY_REQUEST_COUNT, + REGISTER_HUMIDITY_REQUEST_START, REGISTER_HUMIDITY_START, + REGISTER_INTEGRATION_REQUEST_COUNT, + REGISTER_INTEGRATION_REQUEST_START, REGISTER_OUTSIDE_TEMP, REGISTER_PUMP_ACTIVE, + REGISTER_RENEWAL_REQUEST_COUNT, + REGISTER_RENEWAL_REQUEST_START, REGISTER_TEMP_START, REGISTER_TEMP_COUNT, REGISTER_TIME_DAY, @@ -31,6 +39,8 @@ from .const import ( REGISTER_TIME_MINUTE, REGISTER_TIME_MONTH, REGISTER_TIME_YEAR, + REGISTER_VENTILATION_REQUEST_COUNT, + REGISTER_VENTILATION_REQUEST_START, REGISTER_WINTER_SETPOINT_START, REGISTER_WINTER_SETPOINT_COUNT, REGISTER_SUMMER_SETPOINT_START, @@ -468,3 +478,67 @@ class RDZModbusClient: return None bitmask = registers[0] return {i + 1: bool(bitmask & (1 << i)) for i in range(8)} + + async def _read_zone_bitmask(self, start_register: int, count: int) -> dict[int, bool] | None: + """Generic zone bitmask reader for 64 zones across 4 registers. + + Each register contains 16 bits, with bit 0 of register 0 being zone 0, + bit 15 of register 0 being zone 15, bit 0 of register 1 being zone 16, etc. + + Returns dict of zone_id (0-63) -> is_active (True/False). + """ + registers = await self.read_registers(start_register, count) + if registers is None: + return None + + result: dict[int, bool] = {} + for reg_idx, reg_value in enumerate(registers): + for bit in range(16): + zone_id = reg_idx * 16 + bit + result[zone_id] = bool(reg_value & (1 << bit)) + return result + + async def read_humidity_request(self) -> dict[int, bool] | None: + """Read humidity request bitmasks (registers 2896-2899). + + Returns dict of zone_id (0-63) -> is_requesting (True/False). + """ + return await self._read_zone_bitmask( + REGISTER_HUMIDITY_REQUEST_START, REGISTER_HUMIDITY_REQUEST_COUNT + ) + + async def read_ventilation_request(self) -> dict[int, bool] | None: + """Read ventilation request bitmasks (registers 2900-2903). + + Returns dict of zone_id (0-63) -> is_requesting (True/False). + """ + return await self._read_zone_bitmask( + REGISTER_VENTILATION_REQUEST_START, REGISTER_VENTILATION_REQUEST_COUNT + ) + + async def read_renewal_request(self) -> dict[int, bool] | None: + """Read renewal request bitmasks (registers 2904-2907). + + Returns dict of zone_id (0-63) -> is_requesting (True/False). + """ + return await self._read_zone_bitmask( + REGISTER_RENEWAL_REQUEST_START, REGISTER_RENEWAL_REQUEST_COUNT + ) + + async def read_integration_request(self) -> dict[int, bool] | None: + """Read integration request bitmasks (registers 2908-2911). + + Returns dict of zone_id (0-63) -> is_requesting (True/False). + """ + return await self._read_zone_bitmask( + REGISTER_INTEGRATION_REQUEST_START, REGISTER_INTEGRATION_REQUEST_COUNT + ) + + async def read_dehumidification_pump(self) -> dict[int, bool] | None: + """Read dehumidification pump bitmasks (registers 2912-2915). + + Returns dict of zone_id (0-63) -> is_active (True/False). + """ + return await self._read_zone_bitmask( + REGISTER_DEHUMIDIFICATION_PUMP_START, REGISTER_DEHUMIDIFICATION_PUMP_COUNT + )