Initial commit
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
buy_me_a_coffee: costea32
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
/.idea/
|
||||||
|
*.iml
|
||||||
|
/config/
|
||||||
|
/venv
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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