Initial commit
This commit is contained in:
@@ -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
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user