diff --git a/CLAUDE.md b/CLAUDE.md index 5c084f1..44fe102 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,8 +46,12 @@ When you change the target temperature, it writes to the appropriate setpoint ba │ __init__.py Entry point, platform setup │ │ coordinator.py Data polling, setpoint syncing │ │ climate.py Climate entities (thermostats) │ -│ switch.py Season switch entity │ -│ sensor.py Setpoint diagnostic sensors │ +│ humidifier.py Dehumidifier entities (humidity ctrl) │ +│ switch.py Season + system activation switches │ +│ select.py Zone mode select entities │ +│ sensor.py Setpoint, temperature, dew point │ +│ binary_sensor.py Pump active sensors │ +│ number.py Time setting entities │ │ modbus_client.py Async Modbus TCP communication │ │ config_flow.py UI configuration wizard │ │ const.py Constants and register addresses │ @@ -68,21 +72,42 @@ When you change the target temperature, it writes to the appropriate setpoint ba | `__init__.py` | Integration setup, platform loading, lifecycle management | | `coordinator.py` | `RDZDataUpdateCoordinator` - polls Modbus every 30s, syncs linked thermostats | | `climate.py` | `RDZClimateEntity` - thermostat entities with season-based HVAC mode | -| `switch.py` | `RDZSeasonSwitch` - global summer/winter mode control | -| `sensor.py` | `RDZSetpointSensor` - diagnostic sensors showing both setpoints | +| `humidifier.py` | `RDZDehumidifierEntity` - dehumidifier control with humidity setpoints | +| `switch.py` | `RDZSeasonSwitch` + `RDZSystemActivationSwitch` - season and system control | +| `select.py` | `RDZZoneModeSelect` - zone mode selection (Off/Man/Pgm/Pgm-Man) | +| `sensor.py` | `RDZSetpointSensor`, `RDZDewPointSensor`, `RDZOutsideTemperatureSensor`, `RDZWaterTemperatureSensor` | +| `binary_sensor.py` | `RDZPumpActiveBinarySensor` - pump running status | +| `number.py` | `RDZTimeSettingNumber` - system time configuration | | `modbus_client.py` | `RDZModbusClient` - async Modbus TCP wrapper with connection management | | `config_flow.py` | Configuration UI for adding/configuring the integration | | `const.py` | Constants: register addresses, defaults, config keys | ## Modbus Register Map +### Zone Registers + | Register/Coil | Address | Description | |---------------|---------|-------------| | Coil 2 | 2 | Season (0=winter, 1=summer) | | Registers 300-363 | 300 + zone_id | Winter setpoints (value * 10) | | Registers 364-427 | 364 + zone_id | Summer setpoints (value * 10) | +| Registers 428-491 | 428 + zone_id | Dehumidification setpoints (raw %, no scaling) | | Registers 2700-2763 | 2700 + zone_id | Current temperatures (value * 10, read-only) | +| Registers 2828-2891 | 2828 + zone_id | Dew point temperatures (value * 10, read-only) | | Registers 2892-2895 | 2892 + (zone_id / 16) | Zone activity bitmasks (16-bit, read-only) | +| Registers 5301-5364 | 5301 + zone_id | Zone mode (0=Off, 1=Man, 2=Pgm, 3=Pgm/Man) | +| Registers 7701-7764 | 7701 + zone_id | Current humidity (value * 10, read-only) | + +### System Registers + +| Register/Coil | Address | Description | +|---------------|---------|-------------| +| Coils 100-107 | 100 + (system_id - 1) | System activation (1-8) | +| Register 2600 | 2600 | Outside temperature (value * 10, read-only) | +| Registers 2650-2657 | 2650 + (system_id - 1) | Delivery water temperatures 1-8 (value * 10, read-only) | +| Registers 2658-2665 | 2658 + (system_id - 1) | Calculated water temperatures 1-8 (value * 10, read-only) | +| Registers 5009-5013 | 5009-5013 | Time settings: Day, Month, Year, Hour, Minute | +| Register 7615 | 7615 | Pump active bitmask (bit 0 = pump 1, read-only) | Temperature values are stored as integers with a scale factor of 10 (e.g., 21.5°C = 215). @@ -96,6 +121,16 @@ Registers 2892-2895 contain 16-bit bitmasks indicating which zones are actively Bit = 1: Zone is actively heating/cooling Bit = 0: Zone is idle +### Pump Active Bitmask +Register 7615 contains an 8-bit bitmask indicating which pumps are currently running: +- Bit 0: Pump 1 active +- Bit 1: Pump 2 active +- ... +- Bit 7: Pump 8 active + +Bit = 1: Pump is running +Bit = 0: Pump is idle + ## Data Flow ### Reading Data (Coordinator) @@ -103,8 +138,18 @@ Bit = 0: Zone is idle - All temperature registers (2700-2763) - Winter setpoints (300-363) - Summer setpoints (364-427) + - Dehumidification setpoints (428-491) + - Humidity values (7701-7764) + - Dew point temperatures (2828-2891) + - Zone modes (5301-5364) - Season coil (2) - Zone activity bitmasks (2892-2895) + - Outside temperature (2600) + - Time settings (5009-5013) + - System activation coils (100-107) + - Delivery water temperatures (2650-2657) + - Calculated water temperatures (2658-2665) + - Pump active bitmask (7615) 2. Data stored in `coordinator.data` dictionary 3. Entities update via `_handle_coordinator_update()` @@ -135,9 +180,16 @@ For each configured zone: - 1x Climate entity (thermostat) - 1x Winter setpoint sensor (diagnostic) - 1x Summer setpoint sensor (diagnostic) +- 1x Dew point sensor (diagnostic) -System-level: -- 1x Season switch +System-level (on the "RDZ HMI System" device): +- 1x Season switch (summer/winter mode) +- 8x System activation switches (System 1-8) +- 1x Outside temperature sensor +- 8x Delivery water temperature sensors (1-8) +- 8x Calculated water temperature sensors (1-8) +- 8x Pump active binary sensors (1-8) +- 5x Time setting number entities (Day, Month, Year, Hour, Minute) ## Configuration diff --git a/README.md b/README.md index f4b4c03..75797f8 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,11 @@ A custom Home Assistant integration for controlling RDZ Graphic Tablet (HMI) the - Global season switch to toggle between heating and cooling - Automatic setpoint synchronization between real and virtual thermostats - Real-time temperature monitoring +- Outside temperature sensor +- Water temperature monitoring (delivery and calculated temperatures for 8 systems) +- Pump status monitoring (8 pumps) +- System activation control (8 systems) +- System time configuration (day, month, year, hour, minute) ## Prerequisites @@ -125,6 +130,25 @@ When you link a real thermostat to a virtual one: - The virtual thermostat's setpoint stays synchronized automatically - This allows you to use the physical thermostat dial to control cooling temperature +### System-Level Entities + +The integration creates a "RDZ HMI System" device with the following entities: + +**Sensors:** +- **Outside Temperature**: Current outdoor temperature from the system +- **Delivery Water Temperature 1-8**: Actual water temperatures being delivered by each system +- **Calculated Water Temperature 1-8**: Target water temperatures calculated by each system + +**Binary Sensors:** +- **Pump 1-8 Active**: Shows whether each circulation pump is currently running + +**Switches:** +- **Season Switch**: Controls global heating/cooling mode +- **System 1-8**: Enable/disable each heating/cooling system + +**Number Entities:** +- **Day, Month, Year, Hour, Minute**: Configure the RDZ system's internal clock + ## Troubleshooting ### Thermostats show "Unavailable" diff --git a/custom_components/rdz_hmi_control/__init__.py b/custom_components/rdz_hmi_control/__init__.py index a945a7a..01d3a5d 100644 --- a/custom_components/rdz_hmi_control/__init__.py +++ b/custom_components/rdz_hmi_control/__init__.py @@ -13,7 +13,15 @@ from .coordinator import RDZDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.SENSOR, Platform.SWITCH] +PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, + Platform.CLIMATE, + Platform.HUMIDIFIER, + Platform.NUMBER, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, +] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/custom_components/rdz_hmi_control/binary_sensor.py b/custom_components/rdz_hmi_control/binary_sensor.py new file mode 100644 index 0000000..4bef8a2 --- /dev/null +++ b/custom_components/rdz_hmi_control/binary_sensor.py @@ -0,0 +1,87 @@ +"""Binary sensor platform for RDZ HMI Control integration.""" + +from __future__ import annotations + +import logging + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import ( + DATA_PUMP_ACTIVE, + DOMAIN, +) +from .coordinator import RDZDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up RDZ HMI binary sensor entities from a config entry.""" + coordinator: RDZDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + entities: list[BinarySensorEntity] = [] + + # Add pump active binary sensors (1-8) + for pump_id in range(1, 9): + entities.append(RDZPumpActiveBinarySensor(coordinator, pump_id)) + + async_add_entities(entities) + + +class RDZPumpActiveBinarySensor(CoordinatorEntity[RDZDataUpdateCoordinator], BinarySensorEntity): + """Representation of an RDZ HMI pump active binary sensor.""" + + _attr_has_entity_name = True + _attr_device_class = BinarySensorDeviceClass.RUNNING + + def __init__( + self, + coordinator: RDZDataUpdateCoordinator, + pump_id: int, + ) -> None: + """Initialize the pump active binary sensor entity. + + Args: + coordinator: The data coordinator. + pump_id: The pump ID (1-8). + """ + super().__init__(coordinator) + self._pump_id = pump_id + + self._attr_unique_id = f"{DOMAIN}_{coordinator.client.host}_pump_{pump_id}_active" + self._attr_translation_key = f"pump_{pump_id}_active" + self._attr_name = f"Pump {pump_id} active" + + # Place on the System device + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{coordinator.client.host}_system")}, + name="RDZ HMI System", + manufacturer="RDZ", + model="HMI Control System", + sw_version="1.0", + ) + + @property + def is_on(self) -> bool | None: + """Return True if the pump is active.""" + if self.coordinator.data is None: + return None + pump_active = self.coordinator.data.get(DATA_PUMP_ACTIVE, {}) + return pump_active.get(self._pump_id) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self.async_write_ha_state() diff --git a/custom_components/rdz_hmi_control/const.py b/custom_components/rdz_hmi_control/const.py index c3a14f4..b42bcdc 100644 --- a/custom_components/rdz_hmi_control/const.py +++ b/custom_components/rdz_hmi_control/const.py @@ -23,6 +23,35 @@ COIL_SEASON = 2 # 0 = winter, 1 = summer REGISTER_ACTIVITY_START = 2892 REGISTER_ACTIVITY_COUNT = 4 +# Humidity registers +REGISTER_HUMIDITY_START = 7701 +REGISTER_HUMIDITY_COUNT = 64 + +# Summer dehumidification setpoint registers +REGISTER_DEHUMIDIFICATION_SETPOINT_START = 428 +REGISTER_DEHUMIDIFICATION_SETPOINT_COUNT = 64 + +# Dew point registers +REGISTER_DEW_POINT_START = 2828 +REGISTER_DEW_POINT_COUNT = 64 + +# Zone mode registers +REGISTER_ZONE_MODE_START = 5301 +REGISTER_ZONE_MODE_COUNT = 64 + +# Zone mode values +ZONE_MODE_OFF = 0 +ZONE_MODE_MAN = 1 +ZONE_MODE_PGM = 2 +ZONE_MODE_PGM_MAN = 3 + +ZONE_MODE_OPTIONS = { + ZONE_MODE_OFF: "Off", + ZONE_MODE_MAN: "Man", + ZONE_MODE_PGM: "Pgm", + ZONE_MODE_PGM_MAN: "Pgm/Man", +} + # Config keys CONF_HOST = "host" CONF_PORT = "port" @@ -46,6 +75,10 @@ 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 = "humidity" +DATA_DEHUMIDIFICATION_SETPOINTS = "dehumidification_setpoints" +DATA_DEW_POINTS = "dew_points" +DATA_ZONE_MODES = "zone_modes" # Temperature scale factor (Modbus returns values * 10) TEMP_SCALE_FACTOR = 10.0 @@ -54,3 +87,38 @@ TEMP_SCALE_FACTOR = 10.0 MIN_TEMP = 5.0 MAX_TEMP = 35.0 TEMP_STEP = 0.5 + +# Humidity limits +MIN_HUMIDITY = 30 +MAX_HUMIDITY = 90 + +# Outside temperature register +REGISTER_OUTSIDE_TEMP = 2600 + +# Time settings registers (Day, Month, Year, Hour, Minute) +REGISTER_TIME_DAY = 5009 +REGISTER_TIME_MONTH = 5010 +REGISTER_TIME_YEAR = 5011 +REGISTER_TIME_HOUR = 5012 +REGISTER_TIME_MINUTE = 5013 + +# System activation coils +COIL_SYSTEM_ACTIVATION_START = 100 +COIL_SYSTEM_ACTIVATION_COUNT = 8 + +# Water temperature registers +REGISTER_DELIVERY_WATER_TEMP_START = 2650 +REGISTER_DELIVERY_WATER_TEMP_COUNT = 8 +REGISTER_CALCULATED_WATER_TEMP_START = 2658 +REGISTER_CALCULATED_WATER_TEMP_COUNT = 8 + +# Pump active bitmask register +REGISTER_PUMP_ACTIVE = 7615 + +# Data keys for new features +DATA_OUTSIDE_TEMP = "outside_temperature" +DATA_TIME_SETTINGS = "time_settings" +DATA_SYSTEM_ACTIVATION = "system_activation" +DATA_DELIVERY_WATER_TEMPS = "delivery_water_temps" +DATA_CALCULATED_WATER_TEMPS = "calculated_water_temps" +DATA_PUMP_ACTIVE = "pump_active" diff --git a/custom_components/rdz_hmi_control/coordinator.py b/custom_components/rdz_hmi_control/coordinator.py index e7eeb4e..1802043 100644 --- a/custom_components/rdz_hmi_control/coordinator.py +++ b/custom_components/rdz_hmi_control/coordinator.py @@ -17,11 +17,21 @@ from .const import ( CONF_PORT, CONF_ZONE_TYPE, CONF_ZONES, + DATA_CALCULATED_WATER_TEMPS, + DATA_DEHUMIDIFICATION_SETPOINTS, + DATA_DELIVERY_WATER_TEMPS, + DATA_DEW_POINTS, + DATA_HUMIDITY, + DATA_OUTSIDE_TEMP, + DATA_PUMP_ACTIVE, DATA_SEASON, DATA_SUMMER_SETPOINTS, + DATA_SYSTEM_ACTIVATION, DATA_TEMPERATURES, + DATA_TIME_SETTINGS, DATA_WINTER_SETPOINTS, DATA_ZONE_ACTIVITY, + DATA_ZONE_MODES, DEFAULT_PORT, DEFAULT_SCAN_INTERVAL, DOMAIN, @@ -70,6 +80,18 @@ class RDZDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): summer_setpoints = await self.client.read_summer_setpoints() season = await self.client.read_season() zone_activity = await self.client.read_zone_activity() + humidity = await self.client.read_humidity() + dehumidification_setpoints = await self.client.read_dehumidification_setpoints() + dew_points = await self.client.read_dew_points() + zone_modes = await self.client.read_zone_modes() + + # Read new system-level data + outside_temp = await self.client.read_outside_temperature() + time_settings = await self.client.read_time_settings() + system_activation = await self.client.read_system_activation() + delivery_water_temps = await self.client.read_delivery_water_temps() + calculated_water_temps = await self.client.read_calculated_water_temps() + pump_active = await self.client.read_pump_active() if temperatures is None or winter_setpoints is None or summer_setpoints is None: raise UpdateFailed("Failed to read data from Modbus device") @@ -86,6 +108,17 @@ class RDZDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): DATA_SUMMER_SETPOINTS: summer_setpoints, DATA_SEASON: season, # True = summer, False = winter, None = unknown DATA_ZONE_ACTIVITY: zone_activity, # dict of zone_id -> is_active + DATA_HUMIDITY: humidity, + DATA_DEHUMIDIFICATION_SETPOINTS: dehumidification_setpoints, + DATA_DEW_POINTS: dew_points, + DATA_ZONE_MODES: zone_modes, + # New system-level data + DATA_OUTSIDE_TEMP: outside_temp, + DATA_TIME_SETTINGS: time_settings, + DATA_SYSTEM_ACTIVATION: system_activation, + DATA_DELIVERY_WATER_TEMPS: delivery_water_temps, + DATA_CALCULATED_WATER_TEMPS: calculated_water_temps, + DATA_PUMP_ACTIVE: pump_active, } except Exception as ex: @@ -177,6 +210,64 @@ class RDZDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): return success + async def async_set_dehumidification_setpoint( + self, zone_id: int, humidity: float + ) -> bool: + """Set the dehumidification setpoint for a zone.""" + success = await self.client.write_dehumidification_setpoint(zone_id, humidity) + if success: + await self.async_request_refresh() + return success + + async def async_set_zone_mode(self, zone_id: int, mode: int) -> bool: + """Set the zone mode for a zone.""" + success = await self.client.write_zone_mode(zone_id, mode) + if success: + await self.async_request_refresh() + return success + + async def async_set_system_activation(self, system_id: int, is_on: bool) -> bool: + """Set system activation for a system (1-8).""" + success = await self.client.write_system_activation(system_id, is_on) + if success: + await self.async_request_refresh() + return success + + async def async_set_time_day(self, day: int) -> bool: + """Set the day value.""" + success = await self.client.write_time_day(day) + if success: + await self.async_request_refresh() + return success + + async def async_set_time_month(self, month: int) -> bool: + """Set the month value.""" + success = await self.client.write_time_month(month) + if success: + await self.async_request_refresh() + return success + + async def async_set_time_year(self, year: int) -> bool: + """Set the year value.""" + success = await self.client.write_time_year(year) + if success: + await self.async_request_refresh() + return success + + async def async_set_time_hour(self, hour: int) -> bool: + """Set the hour value.""" + success = await self.client.write_time_hour(hour) + if success: + await self.async_request_refresh() + return success + + async def async_set_time_minute(self, minute: int) -> bool: + """Set the minute value.""" + success = await self.client.write_time_minute(minute) + if success: + await self.async_request_refresh() + return success + async def async_shutdown(self) -> None: """Disconnect from the Modbus device.""" await self.client.disconnect() diff --git a/custom_components/rdz_hmi_control/humidifier.py b/custom_components/rdz_hmi_control/humidifier.py new file mode 100644 index 0000000..7bd7102 --- /dev/null +++ b/custom_components/rdz_hmi_control/humidifier.py @@ -0,0 +1,174 @@ +"""Humidifier platform for RDZ HMI Control integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from homeassistant.components.humidifier import ( + HumidifierDeviceClass, + HumidifierEntity, + HumidifierEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import ( + CONF_ZONE_NAME, + CONF_ZONES, + DATA_DEHUMIDIFICATION_SETPOINTS, + DATA_HUMIDITY, + DOMAIN, + MAX_HUMIDITY, + MIN_HUMIDITY, +) +from .coordinator import RDZDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up RDZ HMI dehumidifier entities from a config entry.""" + coordinator: RDZDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + zones_config = config_entry.data.get(CONF_ZONES, {}) + + entities = [] + 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(RDZDehumidifierEntity(coordinator, zone_id, zone_data)) + + async_add_entities(entities) + + +class RDZDehumidifierEntity( + CoordinatorEntity[RDZDataUpdateCoordinator], HumidifierEntity +): + """Representation of an RDZ HMI dehumidifier entity.""" + + _attr_has_entity_name = True + _attr_device_class = HumidifierDeviceClass.DEHUMIDIFIER + _attr_supported_features = HumidifierEntityFeature(0) + _attr_min_humidity = MIN_HUMIDITY + _attr_max_humidity = MAX_HUMIDITY + + def __init__( + self, + coordinator: RDZDataUpdateCoordinator, + zone_id: int, + zone_data: dict[str, Any], + ) -> None: + """Initialize the dehumidifier entity.""" + super().__init__(coordinator) + self._zone_id = zone_id + self._zone_data = zone_data + + self._attr_unique_id = ( + f"{DOMAIN}_{coordinator.client.host}_{zone_id}_dehumidifier" + ) + self._attr_translation_key = "dehumidifier" + self._attr_name = "Dehumidifier" + + self._update_device_info() + + def _update_device_info(self) -> None: + """Update device info from zone data.""" + zones_config = self.coordinator.config_entry.data.get(CONF_ZONES, {}) + zone_data = zones_config.get(str(self._zone_id), self._zone_data) + self._zone_data = zone_data + + zone_name = zone_data.get(CONF_ZONE_NAME, f"Zone {self._zone_id}") + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{self.coordinator.client.host}_{self._zone_id}")}, + name=zone_name, + manufacturer="RDZ", + model="HMI Thermostat", + sw_version="1.0", + ) + + @property + def is_on(self) -> bool: + """Return True if the dehumidifier is on. + + The dehumidifier is always considered 'on' as it's system-controlled. + """ + return True + + @property + def current_humidity(self) -> float | None: + """Return the current humidity.""" + if self.coordinator.data is None: + return None + + humidity_data = self.coordinator.data.get(DATA_HUMIDITY, {}) + return humidity_data.get(self._zone_id) + + @property + def target_humidity(self) -> float | None: + """Return the target humidity (dehumidification setpoint).""" + if self.coordinator.data is None: + return None + + setpoints = self.coordinator.data.get(DATA_DEHUMIDIFICATION_SETPOINTS, {}) + return setpoints.get(self._zone_id) + + async def async_set_humidity(self, humidity: int) -> None: + """Set the target humidity (dehumidification setpoint).""" + success = await self.coordinator.async_set_dehumidification_setpoint( + self._zone_id, float(humidity) + ) + if not success: + _LOGGER.error( + "Failed to set dehumidification setpoint for zone %d", self._zone_id + ) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the dehumidifier. + + The dehumidifier is system-controlled, so this is a no-op. + """ + _LOGGER.debug( + "Dehumidifier turn_on called for zone %d, but system is auto-controlled", + self._zone_id, + ) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the dehumidifier. + + The dehumidifier is system-controlled, so this is a no-op. + """ + _LOGGER.debug( + "Dehumidifier turn_off called for zone %d, but system is auto-controlled", + self._zone_id, + ) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._update_device_info() + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + + # Listen for config entry updates + self.async_on_remove( + self.coordinator.config_entry.add_update_listener( + self._async_config_entry_updated + ) + ) + + async def _async_config_entry_updated( + self, hass: HomeAssistant, entry: ConfigEntry + ) -> None: + """Handle config entry update.""" + self._update_device_info() + self.async_write_ha_state() diff --git a/custom_components/rdz_hmi_control/modbus_client.py b/custom_components/rdz_hmi_control/modbus_client.py index 5f0209c..aa30052 100644 --- a/custom_components/rdz_hmi_control/modbus_client.py +++ b/custom_components/rdz_hmi_control/modbus_client.py @@ -8,14 +8,35 @@ from pymodbus.exceptions import ModbusException from .const import ( COIL_SEASON, + COIL_SYSTEM_ACTIVATION_START, + COIL_SYSTEM_ACTIVATION_COUNT, REGISTER_ACTIVITY_COUNT, REGISTER_ACTIVITY_START, + REGISTER_CALCULATED_WATER_TEMP_COUNT, + REGISTER_CALCULATED_WATER_TEMP_START, + REGISTER_DEHUMIDIFICATION_SETPOINT_COUNT, + REGISTER_DEHUMIDIFICATION_SETPOINT_START, + REGISTER_DELIVERY_WATER_TEMP_COUNT, + REGISTER_DELIVERY_WATER_TEMP_START, + REGISTER_DEW_POINT_COUNT, + REGISTER_DEW_POINT_START, + REGISTER_HUMIDITY_COUNT, + REGISTER_HUMIDITY_START, + REGISTER_OUTSIDE_TEMP, + REGISTER_PUMP_ACTIVE, REGISTER_TEMP_START, REGISTER_TEMP_COUNT, + REGISTER_TIME_DAY, + REGISTER_TIME_HOUR, + REGISTER_TIME_MINUTE, + REGISTER_TIME_MONTH, + REGISTER_TIME_YEAR, REGISTER_WINTER_SETPOINT_START, REGISTER_WINTER_SETPOINT_COUNT, REGISTER_SUMMER_SETPOINT_START, REGISTER_SUMMER_SETPOINT_COUNT, + REGISTER_ZONE_MODE_COUNT, + REGISTER_ZONE_MODE_START, TEMP_SCALE_FACTOR, ) @@ -231,6 +252,81 @@ class RDZModbusClient: value = int(temperature * TEMP_SCALE_FACTOR) return await self.write_register(address, value) + async def read_humidity(self) -> dict[int, float] | None: + """Read all humidity registers and return dict of zone_id -> humidity %. + + Values are stored as integer * 10 (e.g., 55.0% = 550). + """ + registers = await self.read_registers( + REGISTER_HUMIDITY_START, REGISTER_HUMIDITY_COUNT + ) + if registers is None: + return None + + return {i: value / TEMP_SCALE_FACTOR for i, value in enumerate(registers)} + + async def read_dehumidification_setpoints(self) -> dict[int, int] | None: + """Read all dehumidification setpoint registers. + + Values are stored as raw percentage (e.g., 50% = 50). + """ + registers = await self.read_registers( + REGISTER_DEHUMIDIFICATION_SETPOINT_START, + REGISTER_DEHUMIDIFICATION_SETPOINT_COUNT, + ) + if registers is None: + return None + + return {i: value for i, value in enumerate(registers)} + + async def write_dehumidification_setpoint( + self, zone_id: int, humidity: int + ) -> bool: + """Write a dehumidification setpoint for a zone. + + Args: + zone_id: The zone ID (0-63). + humidity: The target humidity percentage (e.g., 50). + """ + address = REGISTER_DEHUMIDIFICATION_SETPOINT_START + zone_id + return await self.write_register(address, humidity) + + async def read_dew_points(self) -> dict[int, float] | None: + """Read all dew point registers. + + Values are stored as integer * 10 (e.g., 15.5°C = 155). + """ + registers = await self.read_registers( + REGISTER_DEW_POINT_START, REGISTER_DEW_POINT_COUNT + ) + if registers is None: + return None + + return {i: value / TEMP_SCALE_FACTOR for i, value in enumerate(registers)} + + async def read_zone_modes(self) -> dict[int, int] | None: + """Read all zone mode registers. + + Returns dict of zone_id -> mode (0=Off, 1=Man, 2=Pgm, 3=Pgm/Man). + """ + registers = await self.read_registers( + REGISTER_ZONE_MODE_START, REGISTER_ZONE_MODE_COUNT + ) + if registers is None: + return None + + return {i: value for i, value in enumerate(registers)} + + async def write_zone_mode(self, zone_id: int, mode: int) -> bool: + """Write a zone mode. + + Args: + zone_id: The zone ID (0-63). + mode: The mode value (0=Off, 1=Man, 2=Pgm, 3=Pgm/Man). + """ + address = REGISTER_ZONE_MODE_START + zone_id + return await self.write_register(address, mode) + async def discover_zones(self) -> list[int]: """Discover active zones by reading temperature registers.""" temperatures = await self.read_temperatures() @@ -246,3 +342,129 @@ class RDZModbusClient: # Try reading temperature registers as a connection test result = await self.read_registers(REGISTER_TEMP_START, 1) return result is not None + + async def read_outside_temperature(self) -> float | None: + """Read the outside temperature from register 2600. + + Value is stored as integer * 10 (e.g., 21.5°C = 215). + """ + registers = await self.read_registers(REGISTER_OUTSIDE_TEMP, 1) + if registers is None: + return None + return registers[0] / TEMP_SCALE_FACTOR + + async def read_time_settings(self) -> dict[str, int] | None: + """Read time settings from registers 5009-5013. + + Returns dict with keys: day, month, year, hour, minute + """ + registers = await self.read_registers(REGISTER_TIME_DAY, 5) + if registers is None: + return None + return { + "day": registers[0], + "month": registers[1], + "year": registers[2], + "hour": registers[3], + "minute": registers[4], + } + + async def write_time_day(self, day: int) -> bool: + """Write the day value to register 5009.""" + return await self.write_register(REGISTER_TIME_DAY, day) + + async def write_time_month(self, month: int) -> bool: + """Write the month value to register 5010.""" + return await self.write_register(REGISTER_TIME_MONTH, month) + + async def write_time_year(self, year: int) -> bool: + """Write the year value to register 5011.""" + return await self.write_register(REGISTER_TIME_YEAR, year) + + async def write_time_hour(self, hour: int) -> bool: + """Write the hour value to register 5012.""" + return await self.write_register(REGISTER_TIME_HOUR, hour) + + async def write_time_minute(self, minute: int) -> bool: + """Write the minute value to register 5013.""" + return await self.write_register(REGISTER_TIME_MINUTE, minute) + + async def read_coils(self, address: int, count: int) -> list[bool] | None: + """Read multiple coils from the Modbus device.""" + async with self._lock: + if not await self._connect_unlocked(): + return None + + try: + result = await self._client.read_coils(address=address, count=count) + if result.isError(): + _LOGGER.error("Error reading coils at %d: %s", address, result) + return None + return list(result.bits[:count]) + except ModbusException as ex: + _LOGGER.error("Modbus exception reading coils at %d: %s", address, ex) + return None + except Exception as ex: + _LOGGER.error("Unexpected error reading coils at %d: %s", address, ex) + return None + + async def read_system_activation(self) -> dict[int, bool] | None: + """Read system activation coils 100-107. + + Returns dict of system_id (1-8) -> is_on (True/False). + """ + coils = await self.read_coils(COIL_SYSTEM_ACTIVATION_START, COIL_SYSTEM_ACTIVATION_COUNT) + if coils is None: + return None + return {i + 1: coils[i] for i in range(COIL_SYSTEM_ACTIVATION_COUNT)} + + async def write_system_activation(self, system_id: int, is_on: bool) -> bool: + """Write a system activation coil. + + Args: + system_id: The system ID (1-8). + is_on: Whether to activate (True) or deactivate (False). + """ + if not 1 <= system_id <= 8: + _LOGGER.error("Invalid system_id %d, must be 1-8", system_id) + return False + address = COIL_SYSTEM_ACTIVATION_START + (system_id - 1) + return await self.write_coil(address, is_on) + + async def read_delivery_water_temps(self) -> dict[int, float] | None: + """Read delivery water temperatures from registers 2650-2657. + + Returns dict of system_id (1-8) -> temperature. + Values are stored as integer * 10. + """ + registers = await self.read_registers( + REGISTER_DELIVERY_WATER_TEMP_START, REGISTER_DELIVERY_WATER_TEMP_COUNT + ) + if registers is None: + return None + return {i + 1: value / TEMP_SCALE_FACTOR for i, value in enumerate(registers)} + + async def read_calculated_water_temps(self) -> dict[int, float] | None: + """Read calculated water temperatures from registers 2658-2665. + + Returns dict of system_id (1-8) -> temperature. + Values are stored as integer * 10. + """ + registers = await self.read_registers( + REGISTER_CALCULATED_WATER_TEMP_START, REGISTER_CALCULATED_WATER_TEMP_COUNT + ) + if registers is None: + return None + return {i + 1: value / TEMP_SCALE_FACTOR for i, value in enumerate(registers)} + + async def read_pump_active(self) -> dict[int, bool] | None: + """Read pump active status from register 7615 bitmask. + + Returns dict of pump_id (1-8) -> is_active (True/False). + Bit 0 = pump 1, bit 7 = pump 8. + """ + registers = await self.read_registers(REGISTER_PUMP_ACTIVE, 1) + if registers is None: + return None + bitmask = registers[0] + return {i + 1: bool(bitmask & (1 << i)) for i in range(8)} diff --git a/custom_components/rdz_hmi_control/number.py b/custom_components/rdz_hmi_control/number.py new file mode 100644 index 0000000..eb71a34 --- /dev/null +++ b/custom_components/rdz_hmi_control/number.py @@ -0,0 +1,129 @@ +"""Number platform for RDZ HMI Control integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from homeassistant.components.number import NumberEntity, NumberMode +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import ( + DATA_TIME_SETTINGS, + DOMAIN, +) +from .coordinator import RDZDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + +# Time setting configurations: (key, name, min, max) +TIME_SETTINGS = [ + ("day", "Day", 1, 31), + ("month", "Month", 1, 12), + ("year", "Year", 2000, 2099), + ("hour", "Hour", 0, 23), + ("minute", "Minute", 0, 59), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up RDZ HMI number entities from a config entry.""" + coordinator: RDZDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + entities: list[NumberEntity] = [] + + # Add time setting number entities + for key, name, min_val, max_val in TIME_SETTINGS: + entities.append( + RDZTimeSettingNumber(coordinator, key, name, min_val, max_val) + ) + + async_add_entities(entities) + + +class RDZTimeSettingNumber(CoordinatorEntity[RDZDataUpdateCoordinator], NumberEntity): + """Representation of an RDZ HMI time setting number entity.""" + + _attr_has_entity_name = True + _attr_mode = NumberMode.BOX + + def __init__( + self, + coordinator: RDZDataUpdateCoordinator, + setting_key: str, + setting_name: str, + min_value: int, + max_value: int, + ) -> None: + """Initialize the time setting number entity. + + Args: + coordinator: The data coordinator. + setting_key: The key in the time settings dict (day, month, year, hour, minute). + setting_name: The display name for the entity. + min_value: The minimum allowed value. + max_value: The maximum allowed value. + """ + super().__init__(coordinator) + self._setting_key = setting_key + + self._attr_unique_id = f"{DOMAIN}_{coordinator.client.host}_time_{setting_key}" + self._attr_translation_key = f"time_{setting_key}" + self._attr_name = setting_name + self._attr_native_min_value = min_value + self._attr_native_max_value = max_value + self._attr_native_step = 1 + + # Place on the System device + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{coordinator.client.host}_system")}, + name="RDZ HMI System", + manufacturer="RDZ", + model="HMI Control System", + sw_version="1.0", + ) + + @property + def native_value(self) -> float | None: + """Return the current value.""" + if self.coordinator.data is None: + return None + time_settings = self.coordinator.data.get(DATA_TIME_SETTINGS, {}) + if time_settings is None: + return None + return time_settings.get(self._setting_key) + + async def async_set_native_value(self, value: float) -> None: + """Set the value.""" + int_value = int(value) + + # Call the appropriate setter method on the coordinator + setter_methods = { + "day": self.coordinator.async_set_time_day, + "month": self.coordinator.async_set_time_month, + "year": self.coordinator.async_set_time_year, + "hour": self.coordinator.async_set_time_hour, + "minute": self.coordinator.async_set_time_minute, + } + + setter = setter_methods.get(self._setting_key) + if setter is None: + _LOGGER.error("Unknown time setting key: %s", self._setting_key) + return + + success = await setter(int_value) + if not success: + _LOGGER.error("Failed to set time %s to %d", self._setting_key, int_value) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self.async_write_ha_state() diff --git a/custom_components/rdz_hmi_control/select.py b/custom_components/rdz_hmi_control/select.py new file mode 100644 index 0000000..97790e5 --- /dev/null +++ b/custom_components/rdz_hmi_control/select.py @@ -0,0 +1,140 @@ +"""Select platform for RDZ HMI Control integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from homeassistant.components.select import SelectEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import ( + CONF_ZONE_NAME, + CONF_ZONES, + DATA_ZONE_MODES, + DOMAIN, + ZONE_MODE_MAN, + ZONE_MODE_OFF, + ZONE_MODE_OPTIONS, + ZONE_MODE_PGM, + ZONE_MODE_PGM_MAN, +) +from .coordinator import RDZDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + +# Reverse mapping from option string to mode value +ZONE_MODE_VALUES = {v: k for k, v in ZONE_MODE_OPTIONS.items()} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up RDZ HMI zone mode select entities from a config entry.""" + coordinator: RDZDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + zones_config = config_entry.data.get(CONF_ZONES, {}) + + entities = [] + 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(RDZZoneModeSelect(coordinator, zone_id, zone_data)) + + async_add_entities(entities) + + +class RDZZoneModeSelect(CoordinatorEntity[RDZDataUpdateCoordinator], SelectEntity): + """Representation of an RDZ HMI zone mode select entity.""" + + _attr_has_entity_name = True + _attr_options = list(ZONE_MODE_OPTIONS.values()) + + def __init__( + self, + coordinator: RDZDataUpdateCoordinator, + zone_id: int, + zone_data: dict[str, Any], + ) -> None: + """Initialize the zone mode select entity.""" + super().__init__(coordinator) + self._zone_id = zone_id + self._zone_data = zone_data + + self._attr_unique_id = ( + f"{DOMAIN}_{coordinator.client.host}_{zone_id}_zone_mode" + ) + self._attr_translation_key = "zone_mode" + self._attr_name = "Zone mode" + + self._update_device_info() + + def _update_device_info(self) -> None: + """Update device info from zone data.""" + zones_config = self.coordinator.config_entry.data.get(CONF_ZONES, {}) + zone_data = zones_config.get(str(self._zone_id), self._zone_data) + self._zone_data = zone_data + + zone_name = zone_data.get(CONF_ZONE_NAME, f"Zone {self._zone_id}") + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{self.coordinator.client.host}_{self._zone_id}")}, + name=zone_name, + manufacturer="RDZ", + model="HMI Thermostat", + sw_version="1.0", + ) + + @property + def current_option(self) -> str | None: + """Return the currently selected option.""" + if self.coordinator.data is None: + return None + + zone_modes = self.coordinator.data.get(DATA_ZONE_MODES, {}) + mode_value = zone_modes.get(self._zone_id) + + if mode_value is None: + return None + + return ZONE_MODE_OPTIONS.get(mode_value, ZONE_MODE_OPTIONS[ZONE_MODE_OFF]) + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + mode_value = ZONE_MODE_VALUES.get(option) + + if mode_value is None: + _LOGGER.error("Invalid zone mode option: %s", option) + return + + success = await self.coordinator.async_set_zone_mode(self._zone_id, mode_value) + if not success: + _LOGGER.error("Failed to set zone mode for zone %d", self._zone_id) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._update_device_info() + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + + # Listen for config entry updates + self.async_on_remove( + self.coordinator.config_entry.add_update_listener( + self._async_config_entry_updated + ) + ) + + async def _async_config_entry_updated( + self, hass: HomeAssistant, entry: ConfigEntry + ) -> None: + """Handle config entry update.""" + self._update_device_info() + self.async_write_ha_state() diff --git a/custom_components/rdz_hmi_control/sensor.py b/custom_components/rdz_hmi_control/sensor.py index cb31b2d..4f64116 100644 --- a/custom_components/rdz_hmi_control/sensor.py +++ b/custom_components/rdz_hmi_control/sensor.py @@ -20,6 +20,10 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( CONF_ZONE_NAME, CONF_ZONES, + DATA_CALCULATED_WATER_TEMPS, + DATA_DELIVERY_WATER_TEMPS, + DATA_DEW_POINTS, + DATA_OUTSIDE_TEMP, DATA_SUMMER_SETPOINTS, DATA_WINTER_SETPOINTS, DOMAIN, @@ -38,7 +42,9 @@ async def async_setup_entry( coordinator: RDZDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] zones_config = config_entry.data.get(CONF_ZONES, {}) - entities = [] + entities: list[SensorEntity] = [] + + # Zone-level sensors 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 # Create winter and summer setpoint sensors for each zone @@ -48,6 +54,19 @@ async def async_setup_entry( entities.append( RDZSetpointSensor(coordinator, zone_id, zone_data, "summer") ) + # Create dew point sensor for each zone + entities.append( + RDZDewPointSensor(coordinator, zone_id, zone_data) + ) + + # System-level sensors + # Outside temperature sensor + entities.append(RDZOutsideTemperatureSensor(coordinator)) + + # Water temperature sensors (1-8 for each type) + for system_id in range(1, 9): + entities.append(RDZWaterTemperatureSensor(coordinator, system_id, "delivery")) + entities.append(RDZWaterTemperatureSensor(coordinator, system_id, "calculated")) async_add_entities(entities) @@ -137,3 +156,186 @@ class RDZSetpointSensor(CoordinatorEntity[RDZDataUpdateCoordinator], SensorEntit """Handle config entry update.""" self._update_device_info() self.async_write_ha_state() + + +class RDZDewPointSensor(CoordinatorEntity[RDZDataUpdateCoordinator], SensorEntity): + """Representation of an RDZ HMI dew point sensor.""" + + _attr_has_entity_name = True + _attr_device_class = SensorDeviceClass.TEMPERATURE + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS + _attr_entity_category = EntityCategory.DIAGNOSTIC + + def __init__( + self, + coordinator: RDZDataUpdateCoordinator, + zone_id: int, + zone_data: dict[str, Any], + ) -> None: + """Initialize the dew point sensor entity.""" + super().__init__(coordinator) + self._zone_id = zone_id + self._zone_data = zone_data + + self._attr_unique_id = ( + f"{DOMAIN}_{coordinator.client.host}_{zone_id}_dew_point" + ) + self._attr_translation_key = "dew_point" + self._attr_name = "Dew point" + + self._update_device_info() + + def _update_device_info(self) -> None: + """Update device info from zone data.""" + zones_config = self.coordinator.config_entry.data.get(CONF_ZONES, {}) + zone_data = zones_config.get(str(self._zone_id), self._zone_data) + self._zone_data = zone_data + + zone_name = zone_data.get(CONF_ZONE_NAME, f"Zone {self._zone_id}") + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{self.coordinator.client.host}_{self._zone_id}")}, + name=zone_name, + manufacturer="RDZ", + model="HMI Thermostat", + sw_version="1.0", + ) + + @property + def native_value(self) -> float | None: + """Return the sensor value.""" + if self.coordinator.data is None: + return None + + dew_points = self.coordinator.data.get(DATA_DEW_POINTS, {}) + return dew_points.get(self._zone_id) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._update_device_info() + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + + # Listen for config entry updates + self.async_on_remove( + self.coordinator.config_entry.add_update_listener(self._async_config_entry_updated) + ) + + async def _async_config_entry_updated( + self, hass: HomeAssistant, entry: ConfigEntry + ) -> None: + """Handle config entry update.""" + self._update_device_info() + self.async_write_ha_state() + + +class RDZOutsideTemperatureSensor(CoordinatorEntity[RDZDataUpdateCoordinator], SensorEntity): + """Representation of an RDZ HMI outside temperature sensor.""" + + _attr_has_entity_name = True + _attr_device_class = SensorDeviceClass.TEMPERATURE + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS + _attr_translation_key = "outside_temperature" + _attr_name = "Outside temperature" + + def __init__( + self, + coordinator: RDZDataUpdateCoordinator, + ) -> None: + """Initialize the outside temperature sensor entity.""" + super().__init__(coordinator) + + self._attr_unique_id = ( + f"{DOMAIN}_{coordinator.client.host}_outside_temperature" + ) + + # Place on the System device + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{coordinator.client.host}_system")}, + name="RDZ HMI System", + manufacturer="RDZ", + model="HMI Control System", + sw_version="1.0", + ) + + @property + def native_value(self) -> float | None: + """Return the sensor value.""" + if self.coordinator.data is None: + return None + return self.coordinator.data.get(DATA_OUTSIDE_TEMP) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self.async_write_ha_state() + + +class RDZWaterTemperatureSensor(CoordinatorEntity[RDZDataUpdateCoordinator], SensorEntity): + """Representation of an RDZ HMI water temperature sensor.""" + + _attr_has_entity_name = True + _attr_device_class = SensorDeviceClass.TEMPERATURE + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS + + def __init__( + self, + coordinator: RDZDataUpdateCoordinator, + system_id: int, + temp_type: str, + ) -> None: + """Initialize the water temperature sensor entity. + + Args: + coordinator: The data coordinator. + system_id: The system ID (1-8). + temp_type: Either "delivery" or "calculated". + """ + super().__init__(coordinator) + self._system_id = system_id + self._temp_type = temp_type + + self._attr_unique_id = ( + f"{DOMAIN}_{coordinator.client.host}_{temp_type}_water_temp_{system_id}" + ) + + if temp_type == "delivery": + self._attr_translation_key = f"delivery_water_temp_{system_id}" + self._attr_name = f"Delivery water temperature {system_id}" + else: + self._attr_translation_key = f"calculated_water_temp_{system_id}" + self._attr_name = f"Calculated water temperature {system_id}" + + # Place on the System device + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{coordinator.client.host}_system")}, + name="RDZ HMI System", + manufacturer="RDZ", + model="HMI Control System", + sw_version="1.0", + ) + + @property + def native_value(self) -> float | None: + """Return the sensor value.""" + if self.coordinator.data is None: + return None + + if self._temp_type == "delivery": + temps = self.coordinator.data.get(DATA_DELIVERY_WATER_TEMPS, {}) + else: + temps = self.coordinator.data.get(DATA_CALCULATED_WATER_TEMPS, {}) + + return temps.get(self._system_id) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self.async_write_ha_state() \ No newline at end of file diff --git a/custom_components/rdz_hmi_control/switch.py b/custom_components/rdz_hmi_control/switch.py index c8cb386..46edd75 100644 --- a/custom_components/rdz_hmi_control/switch.py +++ b/custom_components/rdz_hmi_control/switch.py @@ -14,6 +14,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( DATA_SEASON, + DATA_SYSTEM_ACTIVATION, DOMAIN, ) from .coordinator import RDZDataUpdateCoordinator @@ -29,10 +30,14 @@ async def async_setup_entry( """Set up RDZ HMI switch entities from a config entry.""" coordinator: RDZDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] - entities = [ + entities: list[SwitchEntity] = [ RDZSeasonSwitch(coordinator), ] + # Add system activation switches (1-8) + for system_id in range(1, 9): + entities.append(RDZSystemActivationSwitch(coordinator, system_id)) + async_add_entities(entities) @@ -91,6 +96,65 @@ class RDZSeasonSwitch(CoordinatorEntity[RDZDataUpdateCoordinator], SwitchEntity) if not success: _LOGGER.error("Failed to set season to winter") + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self.async_write_ha_state() + + +class RDZSystemActivationSwitch(CoordinatorEntity[RDZDataUpdateCoordinator], SwitchEntity): + """Representation of an RDZ HMI system activation switch.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: RDZDataUpdateCoordinator, + system_id: int, + ) -> None: + """Initialize the system activation switch entity. + + Args: + coordinator: The data coordinator. + system_id: The system ID (1-8). + """ + super().__init__(coordinator) + self._system_id = system_id + + self._attr_unique_id = f"{DOMAIN}_{coordinator.client.host}_system_{system_id}" + self._attr_translation_key = f"system_{system_id}" + self._attr_name = f"System {system_id}" + self._attr_icon = "mdi:power" + + # Place on the System device + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{coordinator.client.host}_system")}, + name="RDZ HMI System", + manufacturer="RDZ", + model="HMI Control System", + sw_version="1.0", + ) + + @property + def is_on(self) -> bool | None: + """Return True if the system is active.""" + if self.coordinator.data is None: + return None + activation = self.coordinator.data.get(DATA_SYSTEM_ACTIVATION, {}) + return activation.get(self._system_id) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the system.""" + success = await self.coordinator.async_set_system_activation(self._system_id, True) + if not success: + _LOGGER.error("Failed to activate system %d", self._system_id) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the system.""" + success = await self.coordinator.async_set_system_activation(self._system_id, False) + if not success: + _LOGGER.error("Failed to deactivate system %d", self._system_id) + @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..4a5a1ea --- /dev/null +++ b/hacs.json @@ -0,0 +1,5 @@ +{ + "name": "RDZ HMI Control", + "render_readme": true, + "homeassistant": "2024.1.0" +} \ No newline at end of file