Initial commit

This commit is contained in:
Constantin Pascal
2026-01-21 16:51:02 +02:00
commit d1f068e7fc
15 changed files with 1673 additions and 0 deletions
@@ -0,0 +1,60 @@
"""RDZ HMI Control integration for Home Assistant."""
from __future__ import annotations
import logging
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .const import CONF_HOST, CONF_PORT, DEFAULT_PORT, DOMAIN
from .coordinator import RDZDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.SENSOR, Platform.SWITCH]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up RDZ HMI Control from a config entry."""
hass.data.setdefault(DOMAIN, {})
# Create coordinator
coordinator = RDZDataUpdateCoordinator(hass, entry)
# Fetch initial data
await coordinator.async_config_entry_first_refresh()
# Store coordinator
hass.data[DOMAIN][entry.entry_id] = coordinator
# Set up platforms
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
# Register update listener for options changes
entry.async_on_unload(entry.add_update_listener(async_update_options))
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
# Unload platforms
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
# Disconnect from Modbus device
coordinator: RDZDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
await coordinator.async_shutdown()
# Remove coordinator
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle options update."""
# Reload the integration when options change
await hass.config_entries.async_reload(entry.entry_id)
@@ -0,0 +1,249 @@
"""Climate platform for RDZ HMI Control integration."""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.components.climate import (
ClimateEntity,
ClimateEntityFeature,
HVACAction,
HVACMode,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
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_LINKED_VIRTUAL_ZONE,
CONF_ZONE_NAME,
CONF_ZONE_TYPE,
CONF_ZONES,
DATA_SEASON,
DATA_SUMMER_SETPOINTS,
DATA_TEMPERATURES,
DATA_WINTER_SETPOINTS,
DATA_ZONE_ACTIVITY,
DOMAIN,
MAX_TEMP,
MIN_TEMP,
TEMP_STEP,
THERMOSTAT_TYPE_UNCONFIGURED,
THERMOSTAT_TYPE_VIRTUAL,
)
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 climate 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(RDZClimateEntity(coordinator, zone_id, zone_data))
async_add_entities(entities)
class RDZClimateEntity(CoordinatorEntity[RDZDataUpdateCoordinator], ClimateEntity):
"""Representation of an RDZ HMI thermostat."""
_attr_has_entity_name = True
_attr_name = None
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_min_temp = MIN_TEMP
_attr_max_temp = MAX_TEMP
_attr_target_temperature_step = TEMP_STEP
_enable_turn_on_off_backwards_compat = False
def __init__(
self,
coordinator: RDZDataUpdateCoordinator,
zone_id: int,
zone_data: dict[str, Any],
) -> None:
"""Initialize the climate 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}_climate"
self._update_from_zone_data()
def _update_from_zone_data(self) -> None:
"""Update entity attributes 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}")
zone_type = zone_data.get(CONF_ZONE_TYPE, THERMOSTAT_TYPE_UNCONFIGURED)
# Set supported features and HVAC modes based on configuration
if zone_type == THERMOSTAT_TYPE_UNCONFIGURED:
self._attr_supported_features = ClimateEntityFeature(0)
self._attr_hvac_modes = [HVACMode.OFF]
elif zone_type == THERMOSTAT_TYPE_VIRTUAL:
# Virtual thermostats are read-only (synced from real thermostats)
self._attr_supported_features = ClimateEntityFeature(0)
self._attr_hvac_modes = [HVACMode.OFF]
else: # THERMOSTAT_TYPE_REAL
# Real thermostats support temperature control
# HVAC mode is determined by season switch (read-only)
self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE
self._attr_hvac_modes = [HVACMode.HEAT, HVACMode.COOL]
# Update device info
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",
)
def _get_season_based_hvac_mode(self) -> HVACMode:
"""Get HVAC mode based on season switch (summer=COOL, winter=HEAT)."""
if self.coordinator.data is None:
return HVACMode.HEAT # Default to heat if no data
season = self.coordinator.data.get(DATA_SEASON)
if season is True: # Summer
return HVACMode.COOL
else: # Winter or unknown
return HVACMode.HEAT
@property
def current_temperature(self) -> float | None:
"""Return the current temperature."""
if self.coordinator.data is None:
return None
temperatures = self.coordinator.data.get(DATA_TEMPERATURES, {})
return temperatures.get(self._zone_id)
@property
def target_temperature(self) -> float | None:
"""Return the target temperature based on season (summer=cooling setpoint, winter=heating setpoint)."""
if self.coordinator.data is None:
return None
current_mode = self._get_season_based_hvac_mode()
if current_mode == HVACMode.COOL:
setpoints = self.coordinator.data.get(DATA_SUMMER_SETPOINTS, {})
else: # HEAT
setpoints = self.coordinator.data.get(DATA_WINTER_SETPOINTS, {})
return setpoints.get(self._zone_id)
@property
def hvac_mode(self) -> HVACMode:
"""Return current HVAC mode based on season switch (read-only)."""
zone_type = self._zone_data.get(CONF_ZONE_TYPE, THERMOSTAT_TYPE_UNCONFIGURED)
if zone_type in (THERMOSTAT_TYPE_UNCONFIGURED, THERMOSTAT_TYPE_VIRTUAL):
return HVACMode.OFF
return self._get_season_based_hvac_mode()
@property
def hvac_action(self) -> HVACAction | None:
"""Return current HVAC action based on zone activity bitmasks.
For real thermostats:
- HEATING: This zone's bit is set in the activity bitmask
- COOLING: Linked virtual zone's bit is set in the activity bitmask
- IDLE: Neither this zone nor linked virtual zone is active
"""
zone_type = self._zone_data.get(CONF_ZONE_TYPE, THERMOSTAT_TYPE_UNCONFIGURED)
if zone_type in (THERMOSTAT_TYPE_UNCONFIGURED, THERMOSTAT_TYPE_VIRTUAL):
return HVACAction.OFF
if self.coordinator.data is None:
return HVACAction.IDLE
zone_activity = self.coordinator.data.get(DATA_ZONE_ACTIVITY, {})
# Check if this zone is actively heating
if zone_activity.get(self._zone_id, False):
return HVACAction.HEATING
# Check if linked virtual zone is active (cooling)
linked_virtual = self._zone_data.get(CONF_LINKED_VIRTUAL_ZONE)
if linked_virtual is not None and zone_activity.get(linked_virtual, False):
return HVACAction.COOLING
return HVACAction.IDLE
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""HVAC mode is read-only - determined by season switch."""
_LOGGER.debug(
"HVAC mode change requested for zone %d, but mode is determined by season switch",
self._zone_id,
)
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
zone_type = self._zone_data.get(CONF_ZONE_TYPE, THERMOSTAT_TYPE_UNCONFIGURED)
if zone_type == THERMOSTAT_TYPE_UNCONFIGURED:
_LOGGER.warning(
"Cannot set temperature for unconfigured zone %d. "
"Please configure the thermostat type in integration options.",
self._zone_id,
)
return
if zone_type == THERMOSTAT_TYPE_VIRTUAL:
_LOGGER.warning(
"Cannot set temperature for virtual zone %d. "
"Virtual thermostats are synced from linked real thermostats.",
self._zone_id,
)
return
temperature = kwargs.get(ATTR_TEMPERATURE)
if temperature is None:
return
current_mode = self._get_season_based_hvac_mode()
success = await self.coordinator.async_set_temperature(
self._zone_id, temperature, current_mode
)
if not success:
_LOGGER.error("Failed to set temperature for zone %d", self._zone_id)
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self._update_from_zone_data()
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_from_zone_data()
self.async_write_ha_state()
@@ -0,0 +1,261 @@
"""Config flow for RDZ HMI Control integration."""
from __future__ import annotations
import logging
from typing import Any
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import selector
from .const import (
CONF_HOST,
CONF_LINKED_VIRTUAL_ZONE,
CONF_PORT,
CONF_ZONE_ID,
CONF_ZONE_NAME,
CONF_ZONE_TYPE,
CONF_ZONES,
DEFAULT_PORT,
DOMAIN,
THERMOSTAT_TYPE_REAL,
THERMOSTAT_TYPE_UNCONFIGURED,
THERMOSTAT_TYPE_VIRTUAL,
)
from .modbus_client import RDZModbusClient
_LOGGER = logging.getLogger(__name__)
class RDZHMIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for RDZ HMI Control."""
VERSION = 1
def __init__(self) -> None:
"""Initialize the config flow."""
self._host: str | None = None
self._port: int = DEFAULT_PORT
self._discovered_zones: list[int] = []
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the initial step - get host and port."""
errors: dict[str, str] = {}
if user_input is not None:
self._host = user_input[CONF_HOST]
self._port = user_input.get(CONF_PORT, DEFAULT_PORT)
# Validate connection
client = RDZModbusClient(self._host, self._port)
try:
if await client.test_connection():
# Discover zones
self._discovered_zones = await client.discover_zones()
await client.disconnect()
if not self._discovered_zones:
errors["base"] = "no_zones_found"
else:
# Create unique ID based on host
await self.async_set_unique_id(f"rdz_hmi_{self._host}_{self._port}")
self._abort_if_unique_id_configured()
# Build initial zone config (all unconfigured)
zones_config = {
zone_id: {
CONF_ZONE_ID: zone_id,
CONF_ZONE_NAME: f"Zone {zone_id}",
CONF_ZONE_TYPE: THERMOSTAT_TYPE_UNCONFIGURED,
CONF_LINKED_VIRTUAL_ZONE: None,
}
for zone_id in self._discovered_zones
}
return self.async_create_entry(
title=f"RDZ HMI ({self._host})",
data={
CONF_HOST: self._host,
CONF_PORT: self._port,
CONF_ZONES: zones_config,
},
)
else:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected error during connection test")
errors["base"] = "unknown"
finally:
await client.disconnect()
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_HOST): str,
vol.Optional(CONF_PORT, default=DEFAULT_PORT): int,
}
),
errors=errors,
)
@staticmethod
@callback
def async_get_options_flow(
config_entry: config_entries.ConfigEntry,
) -> RDZHMIOptionsFlow:
"""Get the options flow for this handler."""
return RDZHMIOptionsFlow(config_entry)
class RDZHMIOptionsFlow(config_entries.OptionsFlow):
"""Handle options flow for RDZ HMI Control."""
def __init__(self, config_entry: config_entries.ConfigEntry) -> None:
"""Initialize options flow."""
self._config_entry = config_entry
self._selected_zone_id: int | None = None
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the initial options step - select zone to configure."""
zones_config: dict = self._config_entry.data.get(CONF_ZONES, {})
if not zones_config:
return self.async_abort(reason="no_zones")
if user_input is not None:
self._selected_zone_id = int(user_input[CONF_ZONE_ID])
return await self.async_step_configure_zone()
# Build zone selection options
zone_options = []
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
zone_name = zone_data.get(CONF_ZONE_NAME, f"Zone {zone_id}")
zone_type = zone_data.get(CONF_ZONE_TYPE, THERMOSTAT_TYPE_UNCONFIGURED)
type_label = {
THERMOSTAT_TYPE_REAL: "Real",
THERMOSTAT_TYPE_VIRTUAL: "Virtual",
THERMOSTAT_TYPE_UNCONFIGURED: "Unconfigured",
}.get(zone_type, "Unknown")
zone_options.append(
selector.SelectOptionDict(
value=str(zone_id),
label=f"{zone_name} ({type_label})",
)
)
return self.async_show_form(
step_id="init",
data_schema=vol.Schema(
{
vol.Required(CONF_ZONE_ID): selector.SelectSelector(
selector.SelectSelectorConfig(
options=zone_options,
mode=selector.SelectSelectorMode.LIST,
)
),
}
),
)
async def async_step_configure_zone(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle zone configuration step."""
zones_config: dict = dict(self._config_entry.data.get(CONF_ZONES, {}))
zone_id_str = str(self._selected_zone_id)
current_zone = zones_config.get(zone_id_str, {})
if user_input is not None:
# Update zone configuration
new_zone_config = {
CONF_ZONE_ID: self._selected_zone_id,
CONF_ZONE_NAME: user_input[CONF_ZONE_NAME],
CONF_ZONE_TYPE: user_input[CONF_ZONE_TYPE],
CONF_LINKED_VIRTUAL_ZONE: None,
}
# Handle linked virtual zone (only for real thermostats)
if user_input[CONF_ZONE_TYPE] == THERMOSTAT_TYPE_REAL:
linked = user_input.get(CONF_LINKED_VIRTUAL_ZONE)
if linked and linked != "none":
new_zone_config[CONF_LINKED_VIRTUAL_ZONE] = int(linked)
zones_config[zone_id_str] = new_zone_config
# Update config entry data
new_data = dict(self._config_entry.data)
new_data[CONF_ZONES] = zones_config
self.hass.config_entries.async_update_entry(
self._config_entry,
data=new_data,
)
return self.async_create_entry(title="", data={})
current_name = current_zone.get(CONF_ZONE_NAME, f"Zone {self._selected_zone_id}")
current_type = current_zone.get(CONF_ZONE_TYPE, THERMOSTAT_TYPE_UNCONFIGURED)
current_linked = current_zone.get(CONF_LINKED_VIRTUAL_ZONE)
# Build virtual zone options for linking (exclude current zone)
virtual_zone_options = [
selector.SelectOptionDict(value="none", label="None")
]
for zid_str, zone_data in zones_config.items():
zid = int(zid_str) if isinstance(zid_str, str) else zid_str
if zid != self._selected_zone_id and zone_data.get(CONF_ZONE_TYPE) == THERMOSTAT_TYPE_VIRTUAL:
zone_name = zone_data.get(CONF_ZONE_NAME, f"Zone {zid}")
virtual_zone_options.append(
selector.SelectOptionDict(value=str(zid), label=zone_name)
)
# Build schema
schema_dict = {
vol.Required(CONF_ZONE_NAME, default=current_name): str,
vol.Required(CONF_ZONE_TYPE, default=current_type): selector.SelectSelector(
selector.SelectSelectorConfig(
options=[
selector.SelectOptionDict(
value=THERMOSTAT_TYPE_UNCONFIGURED, label="Unconfigured"
),
selector.SelectOptionDict(
value=THERMOSTAT_TYPE_REAL, label="Real"
),
selector.SelectOptionDict(
value=THERMOSTAT_TYPE_VIRTUAL, label="Virtual"
),
],
mode=selector.SelectSelectorMode.DROPDOWN,
)
),
}
# Only show linked virtual zone option for real thermostats with available virtual zones
if current_type == THERMOSTAT_TYPE_REAL and len(virtual_zone_options) > 1:
schema_dict[vol.Optional(
CONF_LINKED_VIRTUAL_ZONE,
default=str(current_linked) if current_linked else "none"
)] = selector.SelectSelector(
selector.SelectSelectorConfig(
options=virtual_zone_options,
mode=selector.SelectSelectorMode.DROPDOWN,
)
)
return self.async_show_form(
step_id="configure_zone",
data_schema=vol.Schema(schema_dict),
description_placeholders={
"zone_name": current_name,
},
)
@@ -0,0 +1,56 @@
"""Constants for RDZ HMI Control integration."""
DOMAIN = "rdz_hmi_control"
# Modbus connection defaults
DEFAULT_PORT = 8000
DEFAULT_SCAN_INTERVAL = 30
# Modbus register addresses
REGISTER_TEMP_START = 2700
REGISTER_TEMP_COUNT = 64
REGISTER_WINTER_SETPOINT_START = 300
REGISTER_WINTER_SETPOINT_COUNT = 64
REGISTER_SUMMER_SETPOINT_START = 364
REGISTER_SUMMER_SETPOINT_COUNT = 64
# Coil addresses
COIL_SEASON = 2 # 0 = winter, 1 = summer
# Activity bitmask registers (4 registers, 16 bits each = 64 zones)
REGISTER_ACTIVITY_START = 2892
REGISTER_ACTIVITY_COUNT = 4
# Config keys
CONF_HOST = "host"
CONF_PORT = "port"
CONF_ZONES = "zones"
# Zone config keys
CONF_ZONE_ID = "zone_id"
CONF_ZONE_NAME = "name"
CONF_ZONE_TYPE = "type"
CONF_LINKED_VIRTUAL_ZONE = "linked_virtual_zone"
CONF_HVAC_MODE = "hvac_mode"
# Thermostat types
THERMOSTAT_TYPE_REAL = "real"
THERMOSTAT_TYPE_VIRTUAL = "virtual"
THERMOSTAT_TYPE_UNCONFIGURED = "unconfigured"
# Data keys in coordinator
DATA_TEMPERATURES = "temperatures"
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
# Temperature scale factor (Modbus returns values * 10)
TEMP_SCALE_FACTOR = 10.0
# Min/Max temperature limits
MIN_TEMP = 5.0
MAX_TEMP = 35.0
TEMP_STEP = 0.5
@@ -0,0 +1,182 @@
"""Data coordinator for RDZ HMI Control integration."""
from __future__ import annotations
from datetime import timedelta
import logging
from typing import Any
from homeassistant.components.climate import HVACMode
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import (
CONF_HOST,
CONF_LINKED_VIRTUAL_ZONE,
CONF_PORT,
CONF_ZONE_TYPE,
CONF_ZONES,
DATA_SEASON,
DATA_SUMMER_SETPOINTS,
DATA_TEMPERATURES,
DATA_WINTER_SETPOINTS,
DATA_ZONE_ACTIVITY,
DEFAULT_PORT,
DEFAULT_SCAN_INTERVAL,
DOMAIN,
THERMOSTAT_TYPE_REAL,
)
from .modbus_client import RDZModbusClient
_LOGGER = logging.getLogger(__name__)
class RDZDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Class to manage fetching RDZ HMI data from Modbus."""
def __init__(
self,
hass: HomeAssistant,
config_entry: ConfigEntry,
) -> None:
"""Initialize the coordinator."""
self.config_entry = config_entry
self._host = config_entry.data[CONF_HOST]
self._port = config_entry.data.get(CONF_PORT, DEFAULT_PORT)
self.client = RDZModbusClient(self._host, self._port)
self._previous_winter_setpoints: dict[int, float] = {}
self._previous_summer_setpoints: dict[int, float] = {}
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL),
)
@property
def zones_config(self) -> dict[str, dict]:
"""Get current zones configuration."""
return self.config_entry.data.get(CONF_ZONES, {})
async def _async_update_data(self) -> dict[str, Any]:
"""Fetch data from Modbus device."""
try:
# Read all data in parallel would be nice, but we need to serialize
# to avoid issues with the Modbus connection
temperatures = await self.client.read_temperatures()
winter_setpoints = await self.client.read_winter_setpoints()
summer_setpoints = await self.client.read_summer_setpoints()
season = await self.client.read_season()
zone_activity = await self.client.read_zone_activity()
if temperatures is None or winter_setpoints is None or summer_setpoints is None:
raise UpdateFailed("Failed to read data from Modbus device")
# Check for setpoint changes and sync from real to linked virtual thermostats
await self._sync_linked_setpoints(winter_setpoints, summer_setpoints)
self._previous_winter_setpoints = dict(winter_setpoints)
self._previous_summer_setpoints = dict(summer_setpoints)
return {
DATA_TEMPERATURES: temperatures,
DATA_WINTER_SETPOINTS: winter_setpoints,
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
}
except Exception as ex:
raise UpdateFailed(f"Error communicating with Modbus device: {ex}") from ex
async def async_set_season(self, is_summer: bool) -> bool:
"""Set the season (True = summer, False = winter)."""
success = await self.client.write_season(is_summer)
if success:
await self.async_request_refresh()
return success
async def _sync_linked_setpoints(
self,
current_winter_setpoints: dict[int, float],
current_summer_setpoints: dict[int, float],
) -> None:
"""Sync setpoint changes from real thermostats to linked virtual thermostats."""
zones_config = self.zones_config
# Find all real thermostats with linked virtual thermostats
for zone_id_str, zone_data in zones_config.items():
real_zone_id = int(zone_id_str) if isinstance(zone_id_str, str) else zone_id_str
if zone_data.get(CONF_ZONE_TYPE) != THERMOSTAT_TYPE_REAL:
continue
virtual_zone_id = zone_data.get(CONF_LINKED_VIRTUAL_ZONE)
if virtual_zone_id is None:
continue
# Check if the real thermostat's winter setpoint changed
prev_winter = self._previous_winter_setpoints.get(real_zone_id)
curr_winter = current_winter_setpoints.get(real_zone_id)
if prev_winter is not None and curr_winter is not None and prev_winter != curr_winter:
_LOGGER.info(
"Real zone %d winter setpoint changed from %.1f to %.1f, "
"syncing to virtual zone %d",
real_zone_id,
prev_winter,
curr_winter,
virtual_zone_id,
)
success = await self.client.write_winter_setpoint(virtual_zone_id, curr_winter)
if not success:
_LOGGER.error(
"Failed to sync winter setpoint to virtual zone %d",
virtual_zone_id,
)
# Check if the real thermostat's summer setpoint changed
prev_summer = self._previous_summer_setpoints.get(real_zone_id)
curr_summer = current_summer_setpoints.get(real_zone_id)
if prev_summer is not None and curr_summer is not None and prev_summer != curr_summer:
_LOGGER.info(
"Real zone %d summer setpoint changed from %.1f to %.1f, "
"syncing to virtual zone %d",
real_zone_id,
prev_summer,
curr_summer,
virtual_zone_id,
)
success = await self.client.write_summer_setpoint(virtual_zone_id, curr_summer)
if not success:
_LOGGER.error(
"Failed to sync summer setpoint to virtual zone %d",
virtual_zone_id,
)
async def async_set_temperature(
self, zone_id: int, temperature: float, hvac_mode: HVACMode
) -> bool:
"""Set the temperature for a zone based on current HVAC mode."""
if hvac_mode == HVACMode.HEAT:
success = await self.client.write_winter_setpoint(zone_id, temperature)
elif hvac_mode == HVACMode.COOL:
success = await self.client.write_summer_setpoint(zone_id, temperature)
else:
_LOGGER.warning(
"Cannot set temperature for zone %d in %s mode", zone_id, hvac_mode
)
return False
if success:
# Request a data refresh to update the UI
await self.async_request_refresh()
return success
async def async_shutdown(self) -> None:
"""Disconnect from the Modbus device."""
await self.client.disconnect()
@@ -0,0 +1,12 @@
{
"domain": "rdz_hmi_control",
"name": "RDZ HMI Control",
"codeowners": [],
"config_flow": true,
"dependencies": [],
"documentation": "https://github.com/your-repo/rdz_hmi_control",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["pymodbus>=3.6.0"],
"version": "1.0.0"
}
@@ -0,0 +1,248 @@
"""Async Modbus TCP client wrapper for RDZ HMI Control."""
import asyncio
import logging
from pymodbus.client import AsyncModbusTcpClient
from pymodbus.exceptions import ModbusException
from .const import (
COIL_SEASON,
REGISTER_ACTIVITY_COUNT,
REGISTER_ACTIVITY_START,
REGISTER_TEMP_START,
REGISTER_TEMP_COUNT,
REGISTER_WINTER_SETPOINT_START,
REGISTER_WINTER_SETPOINT_COUNT,
REGISTER_SUMMER_SETPOINT_START,
REGISTER_SUMMER_SETPOINT_COUNT,
TEMP_SCALE_FACTOR,
)
_LOGGER = logging.getLogger(__name__)
class RDZModbusClient:
"""Async Modbus TCP client for RDZ HMI thermostats."""
def __init__(self, host: str, port: int) -> None:
"""Initialize the Modbus client."""
self._host = host
self._port = port
self._client: AsyncModbusTcpClient | None = None
self._lock = asyncio.Lock()
@property
def host(self) -> str:
"""Return the host."""
return self._host
@property
def port(self) -> int:
"""Return the port."""
return self._port
async def _connect_unlocked(self) -> bool:
"""Connect to the Modbus device (must be called with lock held)."""
if self._client is not None and self._client.connected:
return True
self._client = AsyncModbusTcpClient(
host=self._host,
port=self._port,
timeout=10, # 10 second timeout
)
try:
connected = await self._client.connect()
if connected:
_LOGGER.debug("Connected to Modbus device at %s:%s", self._host, self._port)
else:
_LOGGER.error("Failed to connect to Modbus device at %s:%s", self._host, self._port)
return connected
except Exception as ex:
_LOGGER.error("Error connecting to Modbus device: %s", ex)
return False
async def connect(self) -> bool:
"""Connect to the Modbus device."""
async with self._lock:
return await self._connect_unlocked()
async def disconnect(self) -> None:
"""Disconnect from the Modbus device."""
async with self._lock:
if self._client is not None:
self._client.close()
self._client = None
_LOGGER.debug("Disconnected from Modbus device")
async def read_registers(self, address: int, count: int) -> list[int] | None:
"""Read holding registers from the Modbus device."""
async with self._lock:
if not await self._connect_unlocked():
return None
try:
result = await self._client.read_holding_registers(address=address, count=count)
if result.isError():
_LOGGER.error("Error reading registers at %d: %s", address, result)
return None
return list(result.registers)
except ModbusException as ex:
_LOGGER.error("Modbus exception reading registers at %d: %s", address, ex)
return None
except Exception as ex:
_LOGGER.error("Unexpected error reading registers at %d: %s", address, ex)
return None
async def write_register(self, address: int, value: int) -> bool:
"""Write a single holding register to the Modbus device."""
async with self._lock:
if not await self._connect_unlocked():
return False
try:
result = await self._client.write_register(address=address, value=value)
if result.isError():
_LOGGER.error("Error writing register at %d: %s", address, result)
return False
_LOGGER.debug("Wrote value %d to register %d", value, address)
return True
except ModbusException as ex:
_LOGGER.error("Modbus exception writing register at %d: %s", address, ex)
return False
except Exception as ex:
_LOGGER.error("Unexpected error writing register at %d: %s", address, ex)
return False
async def read_coil(self, address: int) -> bool | None:
"""Read a single coil 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=1)
if result.isError():
_LOGGER.error("Error reading coil at %d: %s", address, result)
return None
return result.bits[0]
except ModbusException as ex:
_LOGGER.error("Modbus exception reading coil at %d: %s", address, ex)
return None
except Exception as ex:
_LOGGER.error("Unexpected error reading coil at %d: %s", address, ex)
return None
async def write_coil(self, address: int, value: bool) -> bool:
"""Write a single coil to the Modbus device."""
async with self._lock:
if not await self._connect_unlocked():
return False
try:
result = await self._client.write_coil(address=address, value=value)
if result.isError():
_LOGGER.error("Error writing coil at %d: %s", address, result)
return False
_LOGGER.debug("Wrote value %s to coil %d", value, address)
return True
except ModbusException as ex:
_LOGGER.error("Modbus exception writing coil at %d: %s", address, ex)
return False
except Exception as ex:
_LOGGER.error("Unexpected error writing coil at %d: %s", address, ex)
return False
async def read_season(self) -> bool | None:
"""Read the season coil. Returns True for summer, False for winter."""
return await self.read_coil(COIL_SEASON)
async def write_season(self, is_summer: bool) -> bool:
"""Write the season coil. True for summer, False for winter."""
return await self.write_coil(COIL_SEASON, is_summer)
async def read_temperatures(self) -> dict[int, float] | None:
"""Read all temperature registers and return non-zero values."""
registers = await self.read_registers(REGISTER_TEMP_START, REGISTER_TEMP_COUNT)
if registers is None:
return None
temperatures = {}
for i, value in enumerate(registers):
if value != 0:
temperatures[i] = value / TEMP_SCALE_FACTOR
return temperatures
async def read_winter_setpoints(self) -> dict[int, float] | None:
"""Read all winter setpoint registers."""
registers = await self.read_registers(
REGISTER_WINTER_SETPOINT_START, REGISTER_WINTER_SETPOINT_COUNT
)
if registers is None:
return None
return {i: value / TEMP_SCALE_FACTOR for i, value in enumerate(registers)}
async def read_summer_setpoints(self) -> dict[int, float] | None:
"""Read all summer setpoint registers."""
registers = await self.read_registers(
REGISTER_SUMMER_SETPOINT_START, REGISTER_SUMMER_SETPOINT_COUNT
)
if registers is None:
return None
return {i: value / TEMP_SCALE_FACTOR for i, value in enumerate(registers)}
async def read_zone_activity(self) -> dict[int, bool] | None:
"""Read zone activity bitmasks and return dict of zone_id -> is_active.
Registers 2892-2895 contain 16-bit bitmasks indicating active zones:
- Register 2892: Zones 0-15 (bit 0 = zone 0)
- Register 2893: Zones 16-31 (bit 0 = zone 16)
- Register 2894: Zones 32-47 (bit 0 = zone 32)
- Register 2895: Zones 48-63 (bit 0 = zone 48)
Bit = 1 means zone is actively heating/cooling, bit = 0 means idle.
"""
registers = await self.read_registers(
REGISTER_ACTIVITY_START, REGISTER_ACTIVITY_COUNT
)
if registers is None:
return None
activity: dict[int, bool] = {}
for reg_idx, reg_value in enumerate(registers):
for bit in range(16):
zone_id = reg_idx * 16 + bit
activity[zone_id] = bool(reg_value & (1 << bit))
return activity
async def write_winter_setpoint(self, zone_id: int, temperature: float) -> bool:
"""Write a winter setpoint for a zone."""
address = REGISTER_WINTER_SETPOINT_START + zone_id
value = int(temperature * TEMP_SCALE_FACTOR)
return await self.write_register(address, value)
async def write_summer_setpoint(self, zone_id: int, temperature: float) -> bool:
"""Write a summer setpoint for a zone."""
address = REGISTER_SUMMER_SETPOINT_START + zone_id
value = int(temperature * TEMP_SCALE_FACTOR)
return await self.write_register(address, value)
async def discover_zones(self) -> list[int]:
"""Discover active zones by reading temperature registers."""
temperatures = await self.read_temperatures()
if temperatures is None:
return []
return list(temperatures.keys())
async def test_connection(self) -> bool:
"""Test the connection to the Modbus device."""
if not await self.connect():
return False
# Try reading temperature registers as a connection test
result = await self.read_registers(REGISTER_TEMP_START, 1)
return result is not None
+139
View File
@@ -0,0 +1,139 @@
"""Sensor platform for RDZ HMI Control integration."""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory, UnitOfTemperature
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_SUMMER_SETPOINTS,
DATA_WINTER_SETPOINTS,
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 sensor 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
# Create winter and summer setpoint sensors for each zone
entities.append(
RDZSetpointSensor(coordinator, zone_id, zone_data, "winter")
)
entities.append(
RDZSetpointSensor(coordinator, zone_id, zone_data, "summer")
)
async_add_entities(entities)
class RDZSetpointSensor(CoordinatorEntity[RDZDataUpdateCoordinator], SensorEntity):
"""Representation of an RDZ HMI setpoint 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],
setpoint_type: str,
) -> None:
"""Initialize the sensor entity."""
super().__init__(coordinator)
self._zone_id = zone_id
self._zone_data = zone_data
self._setpoint_type = setpoint_type # "winter" or "summer"
self._attr_unique_id = (
f"{DOMAIN}_{coordinator.client.host}_{zone_id}_{setpoint_type}_setpoint"
)
if setpoint_type == "winter":
self._attr_translation_key = "winter_setpoint"
self._attr_name = "Winter setpoint"
else:
self._attr_translation_key = "summer_setpoint"
self._attr_name = "Summer setpoint"
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
if self._setpoint_type == "winter":
setpoints = self.coordinator.data.get(DATA_WINTER_SETPOINTS, {})
else:
setpoints = self.coordinator.data.get(DATA_SUMMER_SETPOINTS, {})
return setpoints.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()
@@ -0,0 +1,73 @@
{
"config": {
"step": {
"user": {
"title": "RDZ HMI Control Setup",
"description": "Enter the Modbus TCP connection details for your RDZ HMI device.",
"data": {
"host": "Host",
"port": "Port"
},
"data_description": {
"host": "IP address or hostname of the Modbus TCP device",
"port": "Modbus TCP port (default: 8000)"
}
}
},
"error": {
"cannot_connect": "Failed to connect to the Modbus device. Please check the host and port.",
"no_zones_found": "No active zones found. Make sure thermostats are connected and have non-zero temperature readings.",
"unknown": "An unexpected error occurred."
},
"abort": {
"already_configured": "This device is already configured."
}
},
"options": {
"step": {
"init": {
"title": "Configure Thermostats",
"description": "Select a zone to configure.",
"data": {
"zone_id": "Zone"
}
},
"configure_zone": {
"title": "Configure Zone",
"description": "Configure the thermostat settings for this zone.",
"data": {
"name": "Name",
"type": "Thermostat Type",
"linked_virtual_zone": "Linked Virtual Thermostat"
},
"data_description": {
"name": "Custom name for this zone (e.g., Living Room)",
"type": "Select Real for a physical thermostat or Virtual for a thermostat that mirrors a real one",
"linked_virtual_zone": "When this thermostat's setpoints change, automatically update the linked virtual thermostat"
}
}
},
"abort": {
"no_zones": "No zones available to configure."
}
},
"entity": {
"sensor": {
"winter_setpoint": {
"name": "Winter setpoint"
},
"summer_setpoint": {
"name": "Summer setpoint"
}
},
"switch": {
"season": {
"name": "Season",
"state": {
"on": "Summer",
"off": "Winter"
}
}
}
}
}
@@ -0,0 +1,97 @@
"""Switch platform for RDZ HMI Control integration."""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.components.switch import SwitchEntity
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_SEASON,
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 switch entities from a config entry."""
coordinator: RDZDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
entities = [
RDZSeasonSwitch(coordinator),
]
async_add_entities(entities)
class RDZSeasonSwitch(CoordinatorEntity[RDZDataUpdateCoordinator], SwitchEntity):
"""Representation of the RDZ HMI season switch."""
_attr_has_entity_name = True
_attr_translation_key = "season"
_attr_icon = "mdi:sun-snowflake-variant"
def __init__(
self,
coordinator: RDZDataUpdateCoordinator,
) -> None:
"""Initialize the switch entity."""
super().__init__(coordinator)
self._attr_unique_id = f"{DOMAIN}_{coordinator.client.host}_season_switch"
# Create a "System" device for system-level entities
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 summer mode, False if winter mode."""
if self.coordinator.data is None:
return None
return self.coordinator.data.get(DATA_SEASON)
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return extra state attributes."""
is_summer = self.is_on
if is_summer is None:
season_name = "Unknown"
elif is_summer:
season_name = "Summer"
else:
season_name = "Winter"
return {"season": season_name}
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on summer mode."""
success = await self.coordinator.async_set_season(True)
if not success:
_LOGGER.error("Failed to set season to summer")
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn on winter mode."""
success = await self.coordinator.async_set_season(False)
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()