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
+1
View File
@@ -0,0 +1 @@
buy_me_a_coffee: costea32
+4
View File
@@ -0,0 +1,4 @@
/.idea/
*.iml
/config/
/venv
+148
View File
@@ -0,0 +1,148 @@
# RDZ HMI Control - Home Assistant Integration
A custom Home Assistant integration for controlling RDZ HMI thermostats via Modbus TCP.
## Overview
This integration connects to RDZ HMI HVAC control systems over Modbus TCP and exposes thermostats as Home Assistant climate entities. The system supports up to 64 zones with separate heating (winter) and cooling (summer) setpoints.
## Key Concepts
### Season Mode (Global)
The system operates in one of two modes controlled by a global season switch:
- **Winter Mode**: System is in heating mode, thermostats show heating action
- **Summer Mode**: System is in cooling mode, thermostats show cooling action
The season switch controls Modbus coil 2 and affects all thermostats globally. Individual thermostats cannot override the season - their HVAC mode/action is derived from this switch.
### Thermostat Types
Zones can be configured as one of three types:
1. **Real Thermostat** (`real`): Physical thermostat with temperature sensor
- Supports setting target temperature
- HVAC mode is read-only (derived from season switch)
- Can be linked to a virtual thermostat for setpoint syncing
2. **Virtual Thermostat** (`virtual`): Software-controlled zone without physical thermostat
- Read-only (no direct control)
- Can be linked from a real thermostat to auto-sync setpoints
3. **Unconfigured** (`unconfigured`): Detected zone not yet configured
- Shows as OFF, no control available
### Setpoints
Each zone has two temperature setpoints stored in separate Modbus registers:
- **Winter Setpoint** (registers 300-363): Used when system is in heating mode
- **Summer Setpoint** (registers 364-427): Used when system is in cooling mode
When you change the target temperature, it writes to the appropriate setpoint based on the current season.
## Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ Home Assistant │
├─────────────────────────────────────────────────────────────┤
│ __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 │
│ modbus_client.py Async Modbus TCP communication │
│ config_flow.py UI configuration wizard │
│ const.py Constants and register addresses │
└─────────────────────────────────────────────────────────────┘
│
│ Modbus TCP (default port 8000)
▼
┌─────────────────┐
│ RDZ HMI │
│ Controller │
└─────────────────┘
```
## File Structure
| File | Purpose |
|------|---------|
| `__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 |
| `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
| 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 2700-2763 | 2700 + zone_id | Current temperatures (value * 10, read-only) |
| Registers 2892-2895 | 2892 + (zone_id / 16) | Zone activity bitmasks (16-bit, read-only) |
Temperature values are stored as integers with a scale factor of 10 (e.g., 21.5°C = 215).
### Zone Activity Bitmasks
Registers 2892-2895 contain 16-bit bitmasks indicating which zones are actively heating/cooling:
- **Register 2892**: Zones 0-15 (bit 0 = zone 0, bit 15 = zone 15)
- **Register 2893**: Zones 16-31 (bit 0 = zone 16, bit 15 = zone 31)
- **Register 2894**: Zones 32-47 (bit 0 = zone 32, bit 15 = zone 47)
- **Register 2895**: Zones 48-63 (bit 0 = zone 48, bit 15 = zone 63)
Bit = 1: Zone is actively heating/cooling
Bit = 0: Zone is idle
## Data Flow
### Reading Data (Coordinator)
1. Every 30 seconds, coordinator polls:
- All temperature registers (2700-2763)
- Winter setpoints (300-363)
- Summer setpoints (364-427)
- Season coil (2)
- Zone activity bitmasks (2892-2895)
2. Data stored in `coordinator.data` dictionary
3. Entities update via `_handle_coordinator_update()`
### Setting Temperature
1. User changes target temperature on climate entity
2. `async_set_temperature()` determines current season from coordinator data
3. Writes to winter (HEAT) or summer (COOL) setpoint register
4. If real thermostat is linked to virtual, coordinator syncs on next poll
### Changing Season
1. User toggles season switch
2. `async_turn_on()`/`async_turn_off()` writes to coil 2
3. All climate entities update their HVAC mode accordingly
### HVAC Action Detection
The HVAC action (heating/cooling/idle) is determined from the zone activity bitmasks:
For **real thermostats**:
- **HEATING**: The zone's bit is set (1) in the activity bitmask
- **COOLING**: The linked virtual zone's bit is set (1) in the activity bitmask
- **IDLE**: Neither this zone nor linked virtual zone is active
For **virtual/unconfigured thermostats**: Always shows OFF
## Entities Created
For each configured zone:
- 1x Climate entity (thermostat)
- 1x Winter setpoint sensor (diagnostic)
- 1x Summer setpoint sensor (diagnostic)
System-level:
- 1x Season switch
## Configuration
The integration is configured through the Home Assistant UI:
1. Add integration, enter Modbus host/port
2. Integration discovers active zones (non-zero temperature readings)
3. Configure each zone: name, type (real/virtual/unconfigured)
4. Optionally link real thermostats to virtual zones for setpoint syncing
+133
View File
@@ -0,0 +1,133 @@
# RDZ HMI Control for Home Assistant
A custom Home Assistant integration for controlling RDZ Graphic Tablet (HMI) thermostats via Modbus TCP. This allows you to remotely monitor and control your RDZ HVAC system directly from Home Assistant.
## Features
- Control up to 64 thermostat zones
- Support for both heating (winter) and cooling (summer) modes
- Global season switch to toggle between heating and cooling
- Automatic setpoint synchronization between real and virtual thermostats
- Real-time temperature monitoring
## Prerequisites
- RDZ Graphic Tablet (HMI) connected to your network
- Home Assistant installation
- Network access from Home Assistant to the RDZ tablet on port 8000
- **Important**: All thermostats must be set to **manual mode** on the physical devices
## Installation
### Manual Installation
1. Download the `custom_components/rdz_hmi_control` folder from this repository
2. Copy it to your Home Assistant `config/custom_components/` directory
3. Restart Home Assistant
## Setup
Setting up the integration requires identifying and mapping each discovered zone to your physical thermostats. Follow these steps carefully.
### Step 1: Add the Integration
1. Go to **Settings** > **Devices & Services** > **Add Integration**
2. Search for "RDZ HMI Control"
3. Enter the IP address of your RDZ Graphic Tablet
4. Enter port `8000` (default Modbus TCP port for RDZ)
5. Click **Submit**
The integration will discover all active zones from your RDZ system.
### Step 2: Identify Your Thermostats
Since discovered zones are identified only by number, you need to map them to your actual rooms. The easiest way to do this is by using unique setpoint temperatures:
1. Go to each thermostat in rdz tablet
2. Set a unique target temperature for each one:
- Thermostat 1: 17.1°C
- Thermostat 2: 17.2°C
- Thermostat 3: 17.3°C
- And so on...
3. In Home Assistant, look at the discovered devices:
- If it's **winter/heating season**: Check the **Winter Setpoint** sensor value
- If it's **summer/cooling season**: Check the **Summer Setpoint** sensor value
4. Match the setpoint values to identify which zone corresponds to which room
### Step 3: Configure Virtual Thermostats First
Virtual thermostats are used for cooling control. Configure these before real thermostats because you'll need to link them later.
1. Go to **Settings** > **Devices & Services**
2. Find "RDZ HMI Control" and click **Configure**
3. For each virtual thermostat zone:
- Select the device from the list
- Set the **Name** to match your room (e.g., "Living Room Cooling")
- Set **Type** to `virtual`
- Click **Submit**
4. Repeat for all virtual thermostats
### Step 4: Configure Real Thermostats
Now configure the physical thermostats and link them to their corresponding virtual zones:
1. Go to **Settings** > **Devices & Services**
2. Find "RDZ HMI Control" and click **Configure**
3. For each real thermostat zone:
- Select the device from the list
- Set the **Name** to match your room (e.g., "Living Room")
- Set **Type** to `real`
- Select the **Linked Virtual Thermostat** (e.g., link "Living Room" to "Living Room Cooling")
- Click **Submit**
4. Repeat for all real thermostats
### Step 5: Verify Setup
After configuration:
1. The **Season Switch** entity controls whether the system is in heating or cooling mode
2. Each thermostat should display the current temperature
3. Setting a target temperature will:
- Write to the **winter setpoint** when in heating mode
- Write to the **summer setpoint** when in cooling mode
4. Linked thermostats will automatically synchronize their setpoints
## Understanding the System
### Season Mode
The RDZ system operates globally in either heating (winter) or cooling (summer) mode. This is controlled by a single switch that affects all thermostats. Individual thermostats cannot override this setting.
### Real vs Virtual Thermostats
- **Real Thermostats**: Physical devices with temperature sensors. These control both heating and cooling but need a linked virtual thermostat for cooling setpoint synchronization.
- **Virtual Thermostats**: Software-controlled zones without physical sensors. These are typically used for cooling-only zones and receive their setpoints from linked real thermostats.
### Setpoint Linking
When you link a real thermostat to a virtual one:
- Changing the temperature on the real thermostat updates both setpoints
- The virtual thermostat's setpoint stays synchronized automatically
- This allows you to use the physical thermostat dial to control cooling temperature
## Troubleshooting
### Thermostats show "Unavailable"
- Verify network connectivity to the RDZ tablet
- Ensure port 8000 is not blocked by a firewall
- Check that the tablet is powered on
### Temperature changes don't take effect
- Confirm thermostats are in **manual mode** on the physical devices
- Check that you're changing the correct setpoint for the current season
### Wrong thermostat responds to commands
- Re-verify the zone mapping using the unique temperature method
- Check that virtual and real thermostats are correctly linked
## Support
For issues and feature requests, please open an issue on the GitHub repository.
@@ -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()
+10
View File
@@ -0,0 +1,10 @@
python3 -m venv venv
source venv/bin/activate
pip install homeassistant
# Create config directory structure
mkdir -p config/custom_components
ln -s $(pwd)/custom_components/rdz_hmi_control config/custom_components/
# Run Home Assistant
hass -c config