Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6692bfe984 | ||
|
|
439f2a46f0 | ||
|
|
a54fa78a5f | ||
|
|
9079a9868c | ||
|
|
d71a434a85 | ||
|
|
4e8e20a5ac | ||
|
|
9580df558a | ||
|
|
932baef234 | ||
|
|
ebcaa500c3 |
@@ -0,0 +1,46 @@
|
||||
name: Create Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Release version (e.g., 1.1.5)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Update manifest.json
|
||||
run: |
|
||||
sed -i 's/"version": ".*"/"version": "${{ github.event.inputs.version }}"/' custom_components/rdz_hmi_control/manifest.json
|
||||
|
||||
- name: Commit manifest change
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add custom_components/rdz_hmi_control/manifest.json
|
||||
git commit -m "Bump version to ${{ github.event.inputs.version }}"
|
||||
git push
|
||||
|
||||
- name: Create tag
|
||||
run: |
|
||||
git tag v${{ github.event.inputs.version }}
|
||||
git push origin v${{ github.event.inputs.version }}
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: actions/create-release@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
tag_name: v${{ github.event.inputs.version }}
|
||||
release_name: v${{ github.event.inputs.version }}
|
||||
draft: true
|
||||
prerelease: false
|
||||
@@ -46,8 +46,12 @@ When you change the target temperature, it writes to the appropriate setpoint ba
|
||||
│ __init__.py Entry point, platform setup │
|
||||
│ coordinator.py Data polling, setpoint syncing │
|
||||
│ climate.py Climate entities (thermostats) │
|
||||
│ switch.py Season switch entity │
|
||||
│ sensor.py Setpoint diagnostic sensors │
|
||||
│ humidifier.py Dehumidifier entities (humidity ctrl) │
|
||||
│ switch.py Season + system activation switches │
|
||||
│ select.py Zone mode select entities │
|
||||
│ sensor.py Setpoint, temperature, dew point │
|
||||
│ binary_sensor.py Pump active sensors │
|
||||
│ number.py Time setting entities │
|
||||
│ modbus_client.py Async Modbus TCP communication │
|
||||
│ config_flow.py UI configuration wizard │
|
||||
│ const.py Constants and register addresses │
|
||||
@@ -68,21 +72,42 @@ When you change the target temperature, it writes to the appropriate setpoint ba
|
||||
| `__init__.py` | Integration setup, platform loading, lifecycle management |
|
||||
| `coordinator.py` | `RDZDataUpdateCoordinator` - polls Modbus every 30s, syncs linked thermostats |
|
||||
| `climate.py` | `RDZClimateEntity` - thermostat entities with season-based HVAC mode |
|
||||
| `switch.py` | `RDZSeasonSwitch` - global summer/winter mode control |
|
||||
| `sensor.py` | `RDZSetpointSensor` - diagnostic sensors showing both setpoints |
|
||||
| `humidifier.py` | `RDZDehumidifierEntity` - dehumidifier control with humidity setpoints |
|
||||
| `switch.py` | `RDZSeasonSwitch` + `RDZSystemActivationSwitch` - season and system control |
|
||||
| `select.py` | `RDZZoneModeSelect` - zone mode selection (Off/Man/Pgm/Pgm-Man) |
|
||||
| `sensor.py` | `RDZSetpointSensor`, `RDZDewPointSensor`, `RDZOutsideTemperatureSensor`, `RDZWaterTemperatureSensor` |
|
||||
| `binary_sensor.py` | `RDZPumpActiveBinarySensor` - pump running status |
|
||||
| `number.py` | `RDZTimeSettingNumber` - system time configuration |
|
||||
| `modbus_client.py` | `RDZModbusClient` - async Modbus TCP wrapper with connection management |
|
||||
| `config_flow.py` | Configuration UI for adding/configuring the integration |
|
||||
| `const.py` | Constants: register addresses, defaults, config keys |
|
||||
|
||||
## Modbus Register Map
|
||||
|
||||
### Zone Registers
|
||||
|
||||
| Register/Coil | Address | Description |
|
||||
|---------------|---------|-------------|
|
||||
| Coil 2 | 2 | Season (0=winter, 1=summer) |
|
||||
| Registers 300-363 | 300 + zone_id | Winter setpoints (value * 10) |
|
||||
| Registers 364-427 | 364 + zone_id | Summer setpoints (value * 10) |
|
||||
| Registers 428-491 | 428 + zone_id | Dehumidification setpoints (raw %, no scaling) |
|
||||
| Registers 2700-2763 | 2700 + zone_id | Current temperatures (value * 10, read-only) |
|
||||
| Registers 2828-2891 | 2828 + zone_id | Dew point temperatures (value * 10, read-only) |
|
||||
| Registers 2892-2895 | 2892 + (zone_id / 16) | Zone activity bitmasks (16-bit, read-only) |
|
||||
| Registers 5301-5364 | 5301 + zone_id | Zone mode (0=Off, 1=Man, 2=Pgm, 3=Pgm/Man) |
|
||||
| Registers 7701-7764 | 7701 + zone_id | Current humidity (value * 10, read-only) |
|
||||
|
||||
### System Registers
|
||||
|
||||
| Register/Coil | Address | Description |
|
||||
|---------------|---------|-------------|
|
||||
| Coils 100-107 | 100 + (system_id - 1) | System activation (1-8) |
|
||||
| Register 2600 | 2600 | Outside temperature (value * 10, read-only) |
|
||||
| Registers 2650-2657 | 2650 + (system_id - 1) | Delivery water temperatures 1-8 (value * 10, read-only) |
|
||||
| Registers 2658-2665 | 2658 + (system_id - 1) | Calculated water temperatures 1-8 (value * 10, read-only) |
|
||||
| Registers 5009-5013 | 5009-5013 | Time settings: Day, Month, Year, Hour, Minute |
|
||||
| Register 7615 | 7615 | Pump active bitmask (bit 0 = pump 1, read-only) |
|
||||
|
||||
Temperature values are stored as integers with a scale factor of 10 (e.g., 21.5°C = 215).
|
||||
|
||||
@@ -96,6 +121,16 @@ Registers 2892-2895 contain 16-bit bitmasks indicating which zones are actively
|
||||
Bit = 1: Zone is actively heating/cooling
|
||||
Bit = 0: Zone is idle
|
||||
|
||||
### Pump Active Bitmask
|
||||
Register 7615 contains an 8-bit bitmask indicating which pumps are currently running:
|
||||
- Bit 0: Pump 1 active
|
||||
- Bit 1: Pump 2 active
|
||||
- ...
|
||||
- Bit 7: Pump 8 active
|
||||
|
||||
Bit = 1: Pump is running
|
||||
Bit = 0: Pump is idle
|
||||
|
||||
## Data Flow
|
||||
|
||||
### Reading Data (Coordinator)
|
||||
@@ -103,8 +138,18 @@ Bit = 0: Zone is idle
|
||||
- All temperature registers (2700-2763)
|
||||
- Winter setpoints (300-363)
|
||||
- Summer setpoints (364-427)
|
||||
- Dehumidification setpoints (428-491)
|
||||
- Humidity values (7701-7764)
|
||||
- Dew point temperatures (2828-2891)
|
||||
- Zone modes (5301-5364)
|
||||
- Season coil (2)
|
||||
- Zone activity bitmasks (2892-2895)
|
||||
- Outside temperature (2600)
|
||||
- Time settings (5009-5013)
|
||||
- System activation coils (100-107)
|
||||
- Delivery water temperatures (2650-2657)
|
||||
- Calculated water temperatures (2658-2665)
|
||||
- Pump active bitmask (7615)
|
||||
2. Data stored in `coordinator.data` dictionary
|
||||
3. Entities update via `_handle_coordinator_update()`
|
||||
|
||||
@@ -135,9 +180,16 @@ For each configured zone:
|
||||
- 1x Climate entity (thermostat)
|
||||
- 1x Winter setpoint sensor (diagnostic)
|
||||
- 1x Summer setpoint sensor (diagnostic)
|
||||
- 1x Dew point sensor (diagnostic)
|
||||
|
||||
System-level:
|
||||
- 1x Season switch
|
||||
System-level (on the "RDZ HMI System" device):
|
||||
- 1x Season switch (summer/winter mode)
|
||||
- 8x System activation switches (System 1-8)
|
||||
- 1x Outside temperature sensor
|
||||
- 8x Delivery water temperature sensors (1-8)
|
||||
- 8x Calculated water temperature sensors (1-8)
|
||||
- 8x Pump active binary sensors (1-8)
|
||||
- 5x Time setting number entities (Day, Month, Year, Hour, Minute)
|
||||
|
||||
## Configuration
|
||||
|
||||
|
||||
@@ -11,6 +11,11 @@ A custom Home Assistant integration for controlling RDZ Graphic Tablet (HMI) the
|
||||
- Global season switch to toggle between heating and cooling
|
||||
- Automatic setpoint synchronization between real and virtual thermostats
|
||||
- Real-time temperature monitoring
|
||||
- Outside temperature sensor
|
||||
- Water temperature monitoring (delivery and calculated temperatures for 8 systems)
|
||||
- Pump status monitoring (8 pumps)
|
||||
- System activation control (8 systems)
|
||||
- System time configuration (day, month, year, hour, minute)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -125,6 +130,25 @@ When you link a real thermostat to a virtual one:
|
||||
- The virtual thermostat's setpoint stays synchronized automatically
|
||||
- This allows you to use the physical thermostat dial to control cooling temperature
|
||||
|
||||
### System-Level Entities
|
||||
|
||||
The integration creates a "RDZ HMI System" device with the following entities:
|
||||
|
||||
**Sensors:**
|
||||
- **Outside Temperature**: Current outdoor temperature from the system
|
||||
- **Delivery Water Temperature 1-8**: Actual water temperatures being delivered by each system
|
||||
- **Calculated Water Temperature 1-8**: Target water temperatures calculated by each system
|
||||
|
||||
**Binary Sensors:**
|
||||
- **Pump 1-8 Active**: Shows whether each circulation pump is currently running
|
||||
|
||||
**Switches:**
|
||||
- **Season Switch**: Controls global heating/cooling mode
|
||||
- **System 1-8**: Enable/disable each heating/cooling system
|
||||
|
||||
**Number Entities:**
|
||||
- **Day, Month, Year, Hour, Minute**: Configure the RDZ system's internal clock
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Thermostats show "Unavailable"
|
||||
|
||||
@@ -13,7 +13,15 @@ from .coordinator import RDZDataUpdateCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.SENSOR, Platform.SWITCH]
|
||||
PLATFORMS: list[Platform] = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.CLIMATE,
|
||||
Platform.HUMIDIFIER,
|
||||
Platform.NUMBER,
|
||||
Platform.SELECT,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
"""Binary sensor platform for RDZ HMI Control integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntity,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import (
|
||||
CONF_LINKED_VIRTUAL_ZONE,
|
||||
CONF_ZONE_NAME,
|
||||
CONF_ZONE_TYPE,
|
||||
CONF_ZONES,
|
||||
DATA_DEHUMIDIFICATION_PUMP,
|
||||
DATA_HUMIDITY_REQUEST,
|
||||
DATA_INTEGRATION_REQUEST,
|
||||
DATA_PUMP_ACTIVE,
|
||||
DATA_RENEWAL_REQUEST,
|
||||
DATA_VENTILATION_REQUEST,
|
||||
DATA_ZONE_ACTIVITY,
|
||||
DOMAIN,
|
||||
THERMOSTAT_TYPE_REAL,
|
||||
)
|
||||
from .coordinator import RDZDataUpdateCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Zone request types with their data keys and display names
|
||||
ZONE_REQUEST_TYPES: dict[str, tuple[str, str]] = {
|
||||
"humidity_request": (DATA_HUMIDITY_REQUEST, "Humidity request"),
|
||||
"ventilation_request": (DATA_VENTILATION_REQUEST, "Ventilation request"),
|
||||
"renewal_request": (DATA_RENEWAL_REQUEST, "Renewal request"),
|
||||
"integration_request": (DATA_INTEGRATION_REQUEST, "Integration request"),
|
||||
"dehumidification_pump": (DATA_DEHUMIDIFICATION_PUMP, "Dehumidification pump"),
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up RDZ HMI binary sensor entities from a config entry."""
|
||||
coordinator: RDZDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
|
||||
zones_config = config_entry.data.get(CONF_ZONES, {})
|
||||
|
||||
entities: list[BinarySensorEntity] = []
|
||||
|
||||
# Add pump active binary sensors (1-8)
|
||||
for pump_id in range(1, 9):
|
||||
entities.append(RDZPumpActiveBinarySensor(coordinator, pump_id))
|
||||
|
||||
# Add zone pump binary sensors for each configured zone
|
||||
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(RDZZonePumpBinarySensor(coordinator, zone_id, zone_data))
|
||||
|
||||
# Add zone request binary sensors (5 per zone)
|
||||
for request_type in ZONE_REQUEST_TYPES:
|
||||
entities.append(
|
||||
RDZZoneRequestBinarySensor(coordinator, zone_id, zone_data, request_type)
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class RDZPumpActiveBinarySensor(CoordinatorEntity[RDZDataUpdateCoordinator], BinarySensorEntity):
|
||||
"""Representation of an RDZ HMI pump active binary sensor."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_device_class = BinarySensorDeviceClass.RUNNING
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: RDZDataUpdateCoordinator,
|
||||
pump_id: int,
|
||||
) -> None:
|
||||
"""Initialize the pump active binary sensor entity.
|
||||
|
||||
Args:
|
||||
coordinator: The data coordinator.
|
||||
pump_id: The pump ID (1-8).
|
||||
"""
|
||||
super().__init__(coordinator)
|
||||
self._pump_id = pump_id
|
||||
|
||||
self._attr_unique_id = f"{DOMAIN}_{coordinator.client.host}_pump_{pump_id}_active"
|
||||
self._attr_translation_key = f"pump_{pump_id}_active"
|
||||
self._attr_name = f"Pump {pump_id} active"
|
||||
|
||||
# Place on the System device
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, f"{coordinator.client.host}_system")},
|
||||
name="RDZ HMI System",
|
||||
manufacturer="RDZ",
|
||||
model="HMI Control System",
|
||||
sw_version="1.0",
|
||||
)
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return True if the pump is active."""
|
||||
if self.coordinator.data is None:
|
||||
return None
|
||||
pump_active = self.coordinator.data.get(DATA_PUMP_ACTIVE, {})
|
||||
return pump_active.get(self._pump_id)
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
class RDZZonePumpBinarySensor(CoordinatorEntity[RDZDataUpdateCoordinator], BinarySensorEntity):
|
||||
"""Representation of an RDZ HMI zone pump binary sensor.
|
||||
|
||||
Indicates whether the zone is actively heating or cooling.
|
||||
"""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_device_class = BinarySensorDeviceClass.RUNNING
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: RDZDataUpdateCoordinator,
|
||||
zone_id: int,
|
||||
zone_data: dict,
|
||||
) -> None:
|
||||
"""Initialize the zone pump binary sensor entity."""
|
||||
super().__init__(coordinator)
|
||||
self._zone_id = zone_id
|
||||
self._zone_data = zone_data
|
||||
|
||||
zone_name = zone_data.get(CONF_ZONE_NAME, f"Zone {zone_id}")
|
||||
|
||||
self._attr_unique_id = f"{DOMAIN}_{coordinator.client.host}_{zone_id}_zone_pump"
|
||||
self._attr_name = "Zone pump"
|
||||
|
||||
# Place on the zone's device
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, f"{coordinator.client.host}_{zone_id}")},
|
||||
name=zone_name,
|
||||
manufacturer="RDZ",
|
||||
model="HMI Thermostat",
|
||||
sw_version="1.0",
|
||||
)
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return True if the zone is actively heating or cooling.
|
||||
|
||||
Uses the same logic as hvac_action in climate.py:
|
||||
- For real thermostats: True if this zone or linked virtual zone is active
|
||||
- For virtual/unconfigured: Always False
|
||||
"""
|
||||
zone_type = self._zone_data.get(CONF_ZONE_TYPE)
|
||||
|
||||
if zone_type != THERMOSTAT_TYPE_REAL:
|
||||
return False
|
||||
|
||||
if self.coordinator.data is None:
|
||||
return None
|
||||
|
||||
zone_activity = self.coordinator.data.get(DATA_ZONE_ACTIVITY, {})
|
||||
|
||||
# Check if this zone is active
|
||||
if zone_activity.get(self._zone_id, False):
|
||||
return True
|
||||
|
||||
# Check if linked virtual zone is active
|
||||
linked_virtual = self._zone_data.get(CONF_LINKED_VIRTUAL_ZONE)
|
||||
if linked_virtual is not None and zone_activity.get(linked_virtual, False):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
# Update zone_data in case config changed
|
||||
zones_config = self.coordinator.config_entry.data.get(CONF_ZONES, {})
|
||||
self._zone_data = zones_config.get(str(self._zone_id), self._zone_data)
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
class RDZZoneRequestBinarySensor(CoordinatorEntity[RDZDataUpdateCoordinator], BinarySensorEntity):
|
||||
"""Representation of an RDZ HMI zone request binary sensor.
|
||||
|
||||
Indicates whether the zone has an active request (humidity, ventilation,
|
||||
renewal, integration, or dehumidification pump).
|
||||
"""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_device_class = BinarySensorDeviceClass.RUNNING
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: RDZDataUpdateCoordinator,
|
||||
zone_id: int,
|
||||
zone_data: dict,
|
||||
request_type: str,
|
||||
) -> None:
|
||||
"""Initialize the zone request binary sensor entity.
|
||||
|
||||
Args:
|
||||
coordinator: The data coordinator.
|
||||
zone_id: The zone ID (0-63).
|
||||
zone_data: Zone configuration data.
|
||||
request_type: The type of request (key in ZONE_REQUEST_TYPES).
|
||||
"""
|
||||
super().__init__(coordinator)
|
||||
self._zone_id = zone_id
|
||||
self._zone_data = zone_data
|
||||
self._request_type = request_type
|
||||
self._data_key, self._display_name = ZONE_REQUEST_TYPES[request_type]
|
||||
|
||||
zone_name = zone_data.get(CONF_ZONE_NAME, f"Zone {zone_id}")
|
||||
|
||||
self._attr_unique_id = f"{DOMAIN}_{coordinator.client.host}_{zone_id}_{request_type}"
|
||||
self._attr_name = self._display_name
|
||||
|
||||
# Place on the zone's device
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, f"{coordinator.client.host}_{zone_id}")},
|
||||
name=zone_name,
|
||||
manufacturer="RDZ",
|
||||
model="HMI Thermostat",
|
||||
sw_version="1.0",
|
||||
)
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return True if the zone has the request active.
|
||||
|
||||
Uses the same logic as RDZZonePumpBinarySensor:
|
||||
- For real thermostats: True if this zone or linked virtual zone is active
|
||||
- For virtual/unconfigured: Always False
|
||||
"""
|
||||
zone_type = self._zone_data.get(CONF_ZONE_TYPE)
|
||||
|
||||
if zone_type != THERMOSTAT_TYPE_REAL:
|
||||
return False
|
||||
|
||||
if self.coordinator.data is None:
|
||||
return None
|
||||
|
||||
request_data = self.coordinator.data.get(self._data_key, {})
|
||||
|
||||
# Check if this zone has the request active
|
||||
if request_data.get(self._zone_id, False):
|
||||
return True
|
||||
|
||||
# Check if linked virtual zone has the request active
|
||||
linked_virtual = self._zone_data.get(CONF_LINKED_VIRTUAL_ZONE)
|
||||
if linked_virtual is not None and request_data.get(linked_virtual, False):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
# Update zone_data in case config changed
|
||||
zones_config = self.coordinator.config_entry.data.get(CONF_ZONES, {})
|
||||
self._zone_data = zones_config.get(str(self._zone_id), self._zone_data)
|
||||
self.async_write_ha_state()
|
||||
@@ -31,6 +31,11 @@ from .modbus_client import RDZModbusClient
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _format_zone_id(zone_id: int | str) -> str:
|
||||
"""Format zone ID as zero-padded two-digit string for consistent ordering."""
|
||||
return f"{int(zone_id):02d}"
|
||||
|
||||
|
||||
class RDZHMIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for RDZ HMI Control."""
|
||||
|
||||
@@ -68,8 +73,9 @@ class RDZHMIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
# Build initial zone config (all unconfigured)
|
||||
# Use zero-padded string keys for consistent ordering
|
||||
zones_config = {
|
||||
zone_id: {
|
||||
_format_zone_id(zone_id): {
|
||||
CONF_ZONE_ID: zone_id,
|
||||
CONF_ZONE_NAME: f"Zone {zone_id}",
|
||||
CONF_ZONE_TYPE: THERMOSTAT_TYPE_UNCONFIGURED,
|
||||
@@ -127,6 +133,8 @@ class RDZHMIOptionsFlow(config_entries.OptionsFlow):
|
||||
) -> FlowResult:
|
||||
"""Handle the initial options step - select zone to configure."""
|
||||
zones_config: dict = self._config_entry.data.get(CONF_ZONES, {})
|
||||
# Normalize keys to zero-padded strings for consistent ordering
|
||||
zones_config = {_format_zone_id(k): v for k, v in zones_config.items()}
|
||||
|
||||
if not zones_config:
|
||||
return self.async_abort(reason="no_zones")
|
||||
@@ -139,7 +147,7 @@ class RDZHMIOptionsFlow(config_entries.OptionsFlow):
|
||||
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_name = f"[Zone {zone_id}] "+zone_data.get(CONF_ZONE_NAME, "")
|
||||
zone_type = zone_data.get(CONF_ZONE_TYPE, THERMOSTAT_TYPE_UNCONFIGURED)
|
||||
type_label = {
|
||||
THERMOSTAT_TYPE_REAL: "Real",
|
||||
@@ -148,7 +156,7 @@ class RDZHMIOptionsFlow(config_entries.OptionsFlow):
|
||||
}.get(zone_type, "Unknown")
|
||||
zone_options.append(
|
||||
selector.SelectOptionDict(
|
||||
value=str(zone_id),
|
||||
value=_format_zone_id(zone_id),
|
||||
label=f"{zone_name} ({type_label})",
|
||||
)
|
||||
)
|
||||
@@ -172,7 +180,9 @@ class RDZHMIOptionsFlow(config_entries.OptionsFlow):
|
||||
) -> FlowResult:
|
||||
"""Handle zone configuration step."""
|
||||
zones_config: dict = dict(self._config_entry.data.get(CONF_ZONES, {}))
|
||||
zone_id_str = str(self._selected_zone_id)
|
||||
# Normalize keys to zero-padded strings for consistent ordering
|
||||
zones_config = {_format_zone_id(k): v for k, v in zones_config.items()}
|
||||
zone_id_str = _format_zone_id(self._selected_zone_id)
|
||||
current_zone = zones_config.get(zone_id_str, {})
|
||||
|
||||
if user_input is not None:
|
||||
@@ -207,16 +217,28 @@ class RDZHMIOptionsFlow(config_entries.OptionsFlow):
|
||||
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)
|
||||
# Collect virtual zones already linked to other real thermostats
|
||||
already_linked_virtuals = set()
|
||||
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_REAL:
|
||||
linked = zone_data.get(CONF_LINKED_VIRTUAL_ZONE)
|
||||
if linked is not None:
|
||||
already_linked_virtuals.add(linked)
|
||||
|
||||
# Build virtual zone options for linking (exclude current zone and already-linked virtuals)
|
||||
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:
|
||||
# Skip if already linked to another real thermostat
|
||||
if zid in already_linked_virtuals:
|
||||
continue
|
||||
zone_name = zone_data.get(CONF_ZONE_NAME, f"Zone {zid}")
|
||||
virtual_zone_options.append(
|
||||
selector.SelectOptionDict(value=str(zid), label=zone_name)
|
||||
selector.SelectOptionDict(value=_format_zone_id(zid), label=zone_name)
|
||||
)
|
||||
|
||||
# Build schema
|
||||
@@ -240,11 +262,12 @@ class RDZHMIOptionsFlow(config_entries.OptionsFlow):
|
||||
),
|
||||
}
|
||||
|
||||
# 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:
|
||||
# Show linked virtual zone option when virtual zones are available
|
||||
# (selection is only used if user chooses "Real" type - handled in user_input processing)
|
||||
if len(virtual_zone_options) > 1:
|
||||
schema_dict[vol.Optional(
|
||||
CONF_LINKED_VIRTUAL_ZONE,
|
||||
default=str(current_linked) if current_linked else "none"
|
||||
default=_format_zone_id(current_linked) if current_linked else "none"
|
||||
)] = selector.SelectSelector(
|
||||
selector.SelectSelectorConfig(
|
||||
options=virtual_zone_options,
|
||||
|
||||
@@ -23,6 +23,51 @@ COIL_SEASON = 2 # 0 = winter, 1 = summer
|
||||
REGISTER_ACTIVITY_START = 2892
|
||||
REGISTER_ACTIVITY_COUNT = 4
|
||||
|
||||
# Zone request bitmask registers (4 registers each, 16 bits = 64 zones)
|
||||
REGISTER_HUMIDITY_REQUEST_START = 2896
|
||||
REGISTER_HUMIDITY_REQUEST_COUNT = 4
|
||||
|
||||
REGISTER_VENTILATION_REQUEST_START = 2900
|
||||
REGISTER_VENTILATION_REQUEST_COUNT = 4
|
||||
|
||||
REGISTER_RENEWAL_REQUEST_START = 2904
|
||||
REGISTER_RENEWAL_REQUEST_COUNT = 4
|
||||
|
||||
REGISTER_INTEGRATION_REQUEST_START = 2908
|
||||
REGISTER_INTEGRATION_REQUEST_COUNT = 4
|
||||
|
||||
REGISTER_DEHUMIDIFICATION_PUMP_START = 2912
|
||||
REGISTER_DEHUMIDIFICATION_PUMP_COUNT = 4
|
||||
|
||||
# Humidity registers
|
||||
REGISTER_HUMIDITY_START = 7701
|
||||
REGISTER_HUMIDITY_COUNT = 64
|
||||
|
||||
# Summer dehumidification setpoint registers
|
||||
REGISTER_DEHUMIDIFICATION_SETPOINT_START = 428
|
||||
REGISTER_DEHUMIDIFICATION_SETPOINT_COUNT = 64
|
||||
|
||||
# Dew point registers
|
||||
REGISTER_DEW_POINT_START = 2828
|
||||
REGISTER_DEW_POINT_COUNT = 64
|
||||
|
||||
# Zone mode registers
|
||||
REGISTER_ZONE_MODE_START = 5301
|
||||
REGISTER_ZONE_MODE_COUNT = 64
|
||||
|
||||
# Zone mode values
|
||||
ZONE_MODE_OFF = 0
|
||||
ZONE_MODE_MAN = 1
|
||||
ZONE_MODE_PGM = 2
|
||||
ZONE_MODE_PGM_MAN = 3
|
||||
|
||||
ZONE_MODE_OPTIONS = {
|
||||
ZONE_MODE_OFF: "Off",
|
||||
ZONE_MODE_MAN: "Man",
|
||||
ZONE_MODE_PGM: "Pgm",
|
||||
ZONE_MODE_PGM_MAN: "Pgm/Man",
|
||||
}
|
||||
|
||||
# Config keys
|
||||
CONF_HOST = "host"
|
||||
CONF_PORT = "port"
|
||||
@@ -46,6 +91,15 @@ DATA_WINTER_SETPOINTS = "winter_setpoints"
|
||||
DATA_SUMMER_SETPOINTS = "summer_setpoints"
|
||||
DATA_SEASON = "season" # True = summer, False = winter
|
||||
DATA_ZONE_ACTIVITY = "zone_activity" # dict[int, bool] - zone_id -> is_active
|
||||
DATA_HUMIDITY_REQUEST = "humidity_request"
|
||||
DATA_VENTILATION_REQUEST = "ventilation_request"
|
||||
DATA_RENEWAL_REQUEST = "renewal_request"
|
||||
DATA_INTEGRATION_REQUEST = "integration_request"
|
||||
DATA_DEHUMIDIFICATION_PUMP = "dehumidification_pump"
|
||||
DATA_HUMIDITY = "humidity"
|
||||
DATA_DEHUMIDIFICATION_SETPOINTS = "dehumidification_setpoints"
|
||||
DATA_DEW_POINTS = "dew_points"
|
||||
DATA_ZONE_MODES = "zone_modes"
|
||||
|
||||
# Temperature scale factor (Modbus returns values * 10)
|
||||
TEMP_SCALE_FACTOR = 10.0
|
||||
@@ -54,3 +108,38 @@ TEMP_SCALE_FACTOR = 10.0
|
||||
MIN_TEMP = 5.0
|
||||
MAX_TEMP = 35.0
|
||||
TEMP_STEP = 0.5
|
||||
|
||||
# Humidity limits
|
||||
MIN_HUMIDITY = 30
|
||||
MAX_HUMIDITY = 90
|
||||
|
||||
# Outside temperature register
|
||||
REGISTER_OUTSIDE_TEMP = 2600
|
||||
|
||||
# Time settings registers (Day, Month, Year, Hour, Minute)
|
||||
REGISTER_TIME_DAY = 5009
|
||||
REGISTER_TIME_MONTH = 5010
|
||||
REGISTER_TIME_YEAR = 5011
|
||||
REGISTER_TIME_HOUR = 5012
|
||||
REGISTER_TIME_MINUTE = 5013
|
||||
|
||||
# System activation coils
|
||||
COIL_SYSTEM_ACTIVATION_START = 100
|
||||
COIL_SYSTEM_ACTIVATION_COUNT = 8
|
||||
|
||||
# Water temperature registers
|
||||
REGISTER_DELIVERY_WATER_TEMP_START = 2650
|
||||
REGISTER_DELIVERY_WATER_TEMP_COUNT = 8
|
||||
REGISTER_CALCULATED_WATER_TEMP_START = 2658
|
||||
REGISTER_CALCULATED_WATER_TEMP_COUNT = 8
|
||||
|
||||
# Pump active bitmask register
|
||||
REGISTER_PUMP_ACTIVE = 7615
|
||||
|
||||
# Data keys for new features
|
||||
DATA_OUTSIDE_TEMP = "outside_temperature"
|
||||
DATA_TIME_SETTINGS = "time_settings"
|
||||
DATA_SYSTEM_ACTIVATION = "system_activation"
|
||||
DATA_DELIVERY_WATER_TEMPS = "delivery_water_temps"
|
||||
DATA_CALCULATED_WATER_TEMPS = "calculated_water_temps"
|
||||
DATA_PUMP_ACTIVE = "pump_active"
|
||||
|
||||
@@ -17,11 +17,26 @@ from .const import (
|
||||
CONF_PORT,
|
||||
CONF_ZONE_TYPE,
|
||||
CONF_ZONES,
|
||||
DATA_CALCULATED_WATER_TEMPS,
|
||||
DATA_DEHUMIDIFICATION_PUMP,
|
||||
DATA_DEHUMIDIFICATION_SETPOINTS,
|
||||
DATA_DELIVERY_WATER_TEMPS,
|
||||
DATA_DEW_POINTS,
|
||||
DATA_HUMIDITY,
|
||||
DATA_HUMIDITY_REQUEST,
|
||||
DATA_INTEGRATION_REQUEST,
|
||||
DATA_OUTSIDE_TEMP,
|
||||
DATA_PUMP_ACTIVE,
|
||||
DATA_RENEWAL_REQUEST,
|
||||
DATA_SEASON,
|
||||
DATA_SUMMER_SETPOINTS,
|
||||
DATA_SYSTEM_ACTIVATION,
|
||||
DATA_TEMPERATURES,
|
||||
DATA_TIME_SETTINGS,
|
||||
DATA_VENTILATION_REQUEST,
|
||||
DATA_WINTER_SETPOINTS,
|
||||
DATA_ZONE_ACTIVITY,
|
||||
DATA_ZONE_MODES,
|
||||
DEFAULT_PORT,
|
||||
DEFAULT_SCAN_INTERVAL,
|
||||
DOMAIN,
|
||||
@@ -70,6 +85,25 @@ class RDZDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
summer_setpoints = await self.client.read_summer_setpoints()
|
||||
season = await self.client.read_season()
|
||||
zone_activity = await self.client.read_zone_activity()
|
||||
humidity = await self.client.read_humidity()
|
||||
dehumidification_setpoints = await self.client.read_dehumidification_setpoints()
|
||||
dew_points = await self.client.read_dew_points()
|
||||
zone_modes = await self.client.read_zone_modes()
|
||||
|
||||
# Read new system-level data
|
||||
outside_temp = await self.client.read_outside_temperature()
|
||||
time_settings = await self.client.read_time_settings()
|
||||
system_activation = await self.client.read_system_activation()
|
||||
delivery_water_temps = await self.client.read_delivery_water_temps()
|
||||
calculated_water_temps = await self.client.read_calculated_water_temps()
|
||||
pump_active = await self.client.read_pump_active()
|
||||
|
||||
# Read zone request bitmasks
|
||||
humidity_request = await self.client.read_humidity_request()
|
||||
ventilation_request = await self.client.read_ventilation_request()
|
||||
renewal_request = await self.client.read_renewal_request()
|
||||
integration_request = await self.client.read_integration_request()
|
||||
dehumidification_pump = await self.client.read_dehumidification_pump()
|
||||
|
||||
if temperatures is None or winter_setpoints is None or summer_setpoints is None:
|
||||
raise UpdateFailed("Failed to read data from Modbus device")
|
||||
@@ -86,6 +120,23 @@ class RDZDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
DATA_SUMMER_SETPOINTS: summer_setpoints,
|
||||
DATA_SEASON: season, # True = summer, False = winter, None = unknown
|
||||
DATA_ZONE_ACTIVITY: zone_activity, # dict of zone_id -> is_active
|
||||
DATA_HUMIDITY: humidity,
|
||||
DATA_DEHUMIDIFICATION_SETPOINTS: dehumidification_setpoints,
|
||||
DATA_DEW_POINTS: dew_points,
|
||||
DATA_ZONE_MODES: zone_modes,
|
||||
# New system-level data
|
||||
DATA_OUTSIDE_TEMP: outside_temp,
|
||||
DATA_TIME_SETTINGS: time_settings,
|
||||
DATA_SYSTEM_ACTIVATION: system_activation,
|
||||
DATA_DELIVERY_WATER_TEMPS: delivery_water_temps,
|
||||
DATA_CALCULATED_WATER_TEMPS: calculated_water_temps,
|
||||
DATA_PUMP_ACTIVE: pump_active,
|
||||
# Zone request bitmasks
|
||||
DATA_HUMIDITY_REQUEST: humidity_request,
|
||||
DATA_VENTILATION_REQUEST: ventilation_request,
|
||||
DATA_RENEWAL_REQUEST: renewal_request,
|
||||
DATA_INTEGRATION_REQUEST: integration_request,
|
||||
DATA_DEHUMIDIFICATION_PUMP: dehumidification_pump,
|
||||
}
|
||||
|
||||
except Exception as ex:
|
||||
@@ -177,6 +228,64 @@ class RDZDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
|
||||
return success
|
||||
|
||||
async def async_set_dehumidification_setpoint(
|
||||
self, zone_id: int, humidity: float
|
||||
) -> bool:
|
||||
"""Set the dehumidification setpoint for a zone."""
|
||||
success = await self.client.write_dehumidification_setpoint(zone_id, humidity)
|
||||
if success:
|
||||
await self.async_request_refresh()
|
||||
return success
|
||||
|
||||
async def async_set_zone_mode(self, zone_id: int, mode: int) -> bool:
|
||||
"""Set the zone mode for a zone."""
|
||||
success = await self.client.write_zone_mode(zone_id, mode)
|
||||
if success:
|
||||
await self.async_request_refresh()
|
||||
return success
|
||||
|
||||
async def async_set_system_activation(self, system_id: int, is_on: bool) -> bool:
|
||||
"""Set system activation for a system (1-8)."""
|
||||
success = await self.client.write_system_activation(system_id, is_on)
|
||||
if success:
|
||||
await self.async_request_refresh()
|
||||
return success
|
||||
|
||||
async def async_set_time_day(self, day: int) -> bool:
|
||||
"""Set the day value."""
|
||||
success = await self.client.write_time_day(day)
|
||||
if success:
|
||||
await self.async_request_refresh()
|
||||
return success
|
||||
|
||||
async def async_set_time_month(self, month: int) -> bool:
|
||||
"""Set the month value."""
|
||||
success = await self.client.write_time_month(month)
|
||||
if success:
|
||||
await self.async_request_refresh()
|
||||
return success
|
||||
|
||||
async def async_set_time_year(self, year: int) -> bool:
|
||||
"""Set the year value."""
|
||||
success = await self.client.write_time_year(year)
|
||||
if success:
|
||||
await self.async_request_refresh()
|
||||
return success
|
||||
|
||||
async def async_set_time_hour(self, hour: int) -> bool:
|
||||
"""Set the hour value."""
|
||||
success = await self.client.write_time_hour(hour)
|
||||
if success:
|
||||
await self.async_request_refresh()
|
||||
return success
|
||||
|
||||
async def async_set_time_minute(self, minute: int) -> bool:
|
||||
"""Set the minute value."""
|
||||
success = await self.client.write_time_minute(minute)
|
||||
if success:
|
||||
await self.async_request_refresh()
|
||||
return success
|
||||
|
||||
async def async_shutdown(self) -> None:
|
||||
"""Disconnect from the Modbus device."""
|
||||
await self.client.disconnect()
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
"""Humidifier platform for RDZ HMI Control integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.humidifier import (
|
||||
HumidifierDeviceClass,
|
||||
HumidifierEntity,
|
||||
HumidifierEntityFeature,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import (
|
||||
CONF_ZONE_NAME,
|
||||
CONF_ZONES,
|
||||
DATA_DEHUMIDIFICATION_SETPOINTS,
|
||||
DATA_HUMIDITY,
|
||||
DOMAIN,
|
||||
MAX_HUMIDITY,
|
||||
MIN_HUMIDITY,
|
||||
)
|
||||
from .coordinator import RDZDataUpdateCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up RDZ HMI dehumidifier entities from a config entry."""
|
||||
coordinator: RDZDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
|
||||
zones_config = config_entry.data.get(CONF_ZONES, {})
|
||||
|
||||
entities = []
|
||||
for zone_id_str, zone_data in zones_config.items():
|
||||
zone_id = int(zone_id_str) if isinstance(zone_id_str, str) else zone_id_str
|
||||
entities.append(RDZDehumidifierEntity(coordinator, zone_id, zone_data))
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class RDZDehumidifierEntity(
|
||||
CoordinatorEntity[RDZDataUpdateCoordinator], HumidifierEntity
|
||||
):
|
||||
"""Representation of an RDZ HMI dehumidifier entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_device_class = HumidifierDeviceClass.DEHUMIDIFIER
|
||||
_attr_supported_features = HumidifierEntityFeature(0)
|
||||
_attr_min_humidity = MIN_HUMIDITY
|
||||
_attr_max_humidity = MAX_HUMIDITY
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: RDZDataUpdateCoordinator,
|
||||
zone_id: int,
|
||||
zone_data: dict[str, Any],
|
||||
) -> None:
|
||||
"""Initialize the dehumidifier entity."""
|
||||
super().__init__(coordinator)
|
||||
self._zone_id = zone_id
|
||||
self._zone_data = zone_data
|
||||
|
||||
self._attr_unique_id = (
|
||||
f"{DOMAIN}_{coordinator.client.host}_{zone_id}_dehumidifier"
|
||||
)
|
||||
self._attr_translation_key = "dehumidifier"
|
||||
self._attr_name = "Dehumidifier"
|
||||
|
||||
self._update_device_info()
|
||||
|
||||
def _update_device_info(self) -> None:
|
||||
"""Update device info from zone data."""
|
||||
zones_config = self.coordinator.config_entry.data.get(CONF_ZONES, {})
|
||||
zone_data = zones_config.get(str(self._zone_id), self._zone_data)
|
||||
self._zone_data = zone_data
|
||||
|
||||
zone_name = zone_data.get(CONF_ZONE_NAME, f"Zone {self._zone_id}")
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, f"{self.coordinator.client.host}_{self._zone_id}")},
|
||||
name=zone_name,
|
||||
manufacturer="RDZ",
|
||||
model="HMI Thermostat",
|
||||
sw_version="1.0",
|
||||
)
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return True if the dehumidifier is on.
|
||||
|
||||
The dehumidifier is always considered 'on' as it's system-controlled.
|
||||
"""
|
||||
return True
|
||||
|
||||
@property
|
||||
def current_humidity(self) -> float | None:
|
||||
"""Return the current humidity."""
|
||||
if self.coordinator.data is None:
|
||||
return None
|
||||
|
||||
humidity_data = self.coordinator.data.get(DATA_HUMIDITY, {})
|
||||
return humidity_data.get(self._zone_id)
|
||||
|
||||
@property
|
||||
def target_humidity(self) -> float | None:
|
||||
"""Return the target humidity (dehumidification setpoint)."""
|
||||
if self.coordinator.data is None:
|
||||
return None
|
||||
|
||||
setpoints = self.coordinator.data.get(DATA_DEHUMIDIFICATION_SETPOINTS, {})
|
||||
return setpoints.get(self._zone_id)
|
||||
|
||||
async def async_set_humidity(self, humidity: int) -> None:
|
||||
"""Set the target humidity (dehumidification setpoint)."""
|
||||
success = await self.coordinator.async_set_dehumidification_setpoint(
|
||||
self._zone_id, float(humidity)
|
||||
)
|
||||
if not success:
|
||||
_LOGGER.error(
|
||||
"Failed to set dehumidification setpoint for zone %d", self._zone_id
|
||||
)
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn on the dehumidifier.
|
||||
|
||||
The dehumidifier is system-controlled, so this is a no-op.
|
||||
"""
|
||||
_LOGGER.debug(
|
||||
"Dehumidifier turn_on called for zone %d, but system is auto-controlled",
|
||||
self._zone_id,
|
||||
)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off the dehumidifier.
|
||||
|
||||
The dehumidifier is system-controlled, so this is a no-op.
|
||||
"""
|
||||
_LOGGER.debug(
|
||||
"Dehumidifier turn_off called for zone %d, but system is auto-controlled",
|
||||
self._zone_id,
|
||||
)
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
self._update_device_info()
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""When entity is added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
# Listen for config entry updates
|
||||
self.async_on_remove(
|
||||
self.coordinator.config_entry.add_update_listener(
|
||||
self._async_config_entry_updated
|
||||
)
|
||||
)
|
||||
|
||||
async def _async_config_entry_updated(
|
||||
self, hass: HomeAssistant, entry: ConfigEntry
|
||||
) -> None:
|
||||
"""Handle config entry update."""
|
||||
self._update_device_info()
|
||||
self.async_write_ha_state()
|
||||
@@ -8,5 +8,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["pymodbus>=3.6.0"],
|
||||
"version": "1.0.0"
|
||||
"version": "1.1.2"
|
||||
}
|
||||
|
||||
@@ -8,14 +8,45 @@ from pymodbus.exceptions import ModbusException
|
||||
|
||||
from .const import (
|
||||
COIL_SEASON,
|
||||
COIL_SYSTEM_ACTIVATION_START,
|
||||
COIL_SYSTEM_ACTIVATION_COUNT,
|
||||
REGISTER_ACTIVITY_COUNT,
|
||||
REGISTER_ACTIVITY_START,
|
||||
REGISTER_CALCULATED_WATER_TEMP_COUNT,
|
||||
REGISTER_CALCULATED_WATER_TEMP_START,
|
||||
REGISTER_DEHUMIDIFICATION_PUMP_COUNT,
|
||||
REGISTER_DEHUMIDIFICATION_PUMP_START,
|
||||
REGISTER_DEHUMIDIFICATION_SETPOINT_COUNT,
|
||||
REGISTER_DEHUMIDIFICATION_SETPOINT_START,
|
||||
REGISTER_DELIVERY_WATER_TEMP_COUNT,
|
||||
REGISTER_DELIVERY_WATER_TEMP_START,
|
||||
REGISTER_DEW_POINT_COUNT,
|
||||
REGISTER_DEW_POINT_START,
|
||||
REGISTER_HUMIDITY_COUNT,
|
||||
REGISTER_HUMIDITY_REQUEST_COUNT,
|
||||
REGISTER_HUMIDITY_REQUEST_START,
|
||||
REGISTER_HUMIDITY_START,
|
||||
REGISTER_INTEGRATION_REQUEST_COUNT,
|
||||
REGISTER_INTEGRATION_REQUEST_START,
|
||||
REGISTER_OUTSIDE_TEMP,
|
||||
REGISTER_PUMP_ACTIVE,
|
||||
REGISTER_RENEWAL_REQUEST_COUNT,
|
||||
REGISTER_RENEWAL_REQUEST_START,
|
||||
REGISTER_TEMP_START,
|
||||
REGISTER_TEMP_COUNT,
|
||||
REGISTER_TIME_DAY,
|
||||
REGISTER_TIME_HOUR,
|
||||
REGISTER_TIME_MINUTE,
|
||||
REGISTER_TIME_MONTH,
|
||||
REGISTER_TIME_YEAR,
|
||||
REGISTER_VENTILATION_REQUEST_COUNT,
|
||||
REGISTER_VENTILATION_REQUEST_START,
|
||||
REGISTER_WINTER_SETPOINT_START,
|
||||
REGISTER_WINTER_SETPOINT_COUNT,
|
||||
REGISTER_SUMMER_SETPOINT_START,
|
||||
REGISTER_SUMMER_SETPOINT_COUNT,
|
||||
REGISTER_ZONE_MODE_COUNT,
|
||||
REGISTER_ZONE_MODE_START,
|
||||
TEMP_SCALE_FACTOR,
|
||||
)
|
||||
|
||||
@@ -231,6 +262,81 @@ class RDZModbusClient:
|
||||
value = int(temperature * TEMP_SCALE_FACTOR)
|
||||
return await self.write_register(address, value)
|
||||
|
||||
async def read_humidity(self) -> dict[int, float] | None:
|
||||
"""Read all humidity registers and return dict of zone_id -> humidity %.
|
||||
|
||||
Values are stored as integer * 10 (e.g., 55.0% = 550).
|
||||
"""
|
||||
registers = await self.read_registers(
|
||||
REGISTER_HUMIDITY_START, REGISTER_HUMIDITY_COUNT
|
||||
)
|
||||
if registers is None:
|
||||
return None
|
||||
|
||||
return {i: value / TEMP_SCALE_FACTOR for i, value in enumerate(registers)}
|
||||
|
||||
async def read_dehumidification_setpoints(self) -> dict[int, int] | None:
|
||||
"""Read all dehumidification setpoint registers.
|
||||
|
||||
Values are stored as raw percentage (e.g., 50% = 50).
|
||||
"""
|
||||
registers = await self.read_registers(
|
||||
REGISTER_DEHUMIDIFICATION_SETPOINT_START,
|
||||
REGISTER_DEHUMIDIFICATION_SETPOINT_COUNT,
|
||||
)
|
||||
if registers is None:
|
||||
return None
|
||||
|
||||
return {i: value for i, value in enumerate(registers)}
|
||||
|
||||
async def write_dehumidification_setpoint(
|
||||
self, zone_id: int, humidity: int
|
||||
) -> bool:
|
||||
"""Write a dehumidification setpoint for a zone.
|
||||
|
||||
Args:
|
||||
zone_id: The zone ID (0-63).
|
||||
humidity: The target humidity percentage (e.g., 50).
|
||||
"""
|
||||
address = REGISTER_DEHUMIDIFICATION_SETPOINT_START + zone_id
|
||||
return await self.write_register(address, humidity)
|
||||
|
||||
async def read_dew_points(self) -> dict[int, float] | None:
|
||||
"""Read all dew point registers.
|
||||
|
||||
Values are stored as integer * 10 (e.g., 15.5°C = 155).
|
||||
"""
|
||||
registers = await self.read_registers(
|
||||
REGISTER_DEW_POINT_START, REGISTER_DEW_POINT_COUNT
|
||||
)
|
||||
if registers is None:
|
||||
return None
|
||||
|
||||
return {i: value / TEMP_SCALE_FACTOR for i, value in enumerate(registers)}
|
||||
|
||||
async def read_zone_modes(self) -> dict[int, int] | None:
|
||||
"""Read all zone mode registers.
|
||||
|
||||
Returns dict of zone_id -> mode (0=Off, 1=Man, 2=Pgm, 3=Pgm/Man).
|
||||
"""
|
||||
registers = await self.read_registers(
|
||||
REGISTER_ZONE_MODE_START, REGISTER_ZONE_MODE_COUNT
|
||||
)
|
||||
if registers is None:
|
||||
return None
|
||||
|
||||
return {i: value for i, value in enumerate(registers)}
|
||||
|
||||
async def write_zone_mode(self, zone_id: int, mode: int) -> bool:
|
||||
"""Write a zone mode.
|
||||
|
||||
Args:
|
||||
zone_id: The zone ID (0-63).
|
||||
mode: The mode value (0=Off, 1=Man, 2=Pgm, 3=Pgm/Man).
|
||||
"""
|
||||
address = REGISTER_ZONE_MODE_START + zone_id
|
||||
return await self.write_register(address, mode)
|
||||
|
||||
async def discover_zones(self) -> list[int]:
|
||||
"""Discover active zones by reading temperature registers."""
|
||||
temperatures = await self.read_temperatures()
|
||||
@@ -246,3 +352,193 @@ class RDZModbusClient:
|
||||
# Try reading temperature registers as a connection test
|
||||
result = await self.read_registers(REGISTER_TEMP_START, 1)
|
||||
return result is not None
|
||||
|
||||
async def read_outside_temperature(self) -> float | None:
|
||||
"""Read the outside temperature from register 2600.
|
||||
|
||||
Value is stored as integer * 10 (e.g., 21.5°C = 215).
|
||||
"""
|
||||
registers = await self.read_registers(REGISTER_OUTSIDE_TEMP, 1)
|
||||
if registers is None:
|
||||
return None
|
||||
return registers[0] / TEMP_SCALE_FACTOR
|
||||
|
||||
async def read_time_settings(self) -> dict[str, int] | None:
|
||||
"""Read time settings from registers 5009-5013.
|
||||
|
||||
Returns dict with keys: day, month, year, hour, minute
|
||||
"""
|
||||
registers = await self.read_registers(REGISTER_TIME_DAY, 5)
|
||||
if registers is None:
|
||||
return None
|
||||
return {
|
||||
"day": registers[0],
|
||||
"month": registers[1],
|
||||
"year": registers[2],
|
||||
"hour": registers[3],
|
||||
"minute": registers[4],
|
||||
}
|
||||
|
||||
async def write_time_day(self, day: int) -> bool:
|
||||
"""Write the day value to register 5009."""
|
||||
return await self.write_register(REGISTER_TIME_DAY, day)
|
||||
|
||||
async def write_time_month(self, month: int) -> bool:
|
||||
"""Write the month value to register 5010."""
|
||||
return await self.write_register(REGISTER_TIME_MONTH, month)
|
||||
|
||||
async def write_time_year(self, year: int) -> bool:
|
||||
"""Write the year value to register 5011."""
|
||||
return await self.write_register(REGISTER_TIME_YEAR, year)
|
||||
|
||||
async def write_time_hour(self, hour: int) -> bool:
|
||||
"""Write the hour value to register 5012."""
|
||||
return await self.write_register(REGISTER_TIME_HOUR, hour)
|
||||
|
||||
async def write_time_minute(self, minute: int) -> bool:
|
||||
"""Write the minute value to register 5013."""
|
||||
return await self.write_register(REGISTER_TIME_MINUTE, minute)
|
||||
|
||||
async def read_coils(self, address: int, count: int) -> list[bool] | None:
|
||||
"""Read multiple coils from the Modbus device."""
|
||||
async with self._lock:
|
||||
if not await self._connect_unlocked():
|
||||
return None
|
||||
|
||||
try:
|
||||
result = await self._client.read_coils(address=address, count=count)
|
||||
if result.isError():
|
||||
_LOGGER.error("Error reading coils at %d: %s", address, result)
|
||||
return None
|
||||
return list(result.bits[:count])
|
||||
except ModbusException as ex:
|
||||
_LOGGER.error("Modbus exception reading coils at %d: %s", address, ex)
|
||||
return None
|
||||
except Exception as ex:
|
||||
_LOGGER.error("Unexpected error reading coils at %d: %s", address, ex)
|
||||
return None
|
||||
|
||||
async def read_system_activation(self) -> dict[int, bool] | None:
|
||||
"""Read system activation coils 100-107.
|
||||
|
||||
Returns dict of system_id (1-8) -> is_on (True/False).
|
||||
"""
|
||||
coils = await self.read_coils(COIL_SYSTEM_ACTIVATION_START, COIL_SYSTEM_ACTIVATION_COUNT)
|
||||
if coils is None:
|
||||
return None
|
||||
return {i + 1: coils[i] for i in range(COIL_SYSTEM_ACTIVATION_COUNT)}
|
||||
|
||||
async def write_system_activation(self, system_id: int, is_on: bool) -> bool:
|
||||
"""Write a system activation coil.
|
||||
|
||||
Args:
|
||||
system_id: The system ID (1-8).
|
||||
is_on: Whether to activate (True) or deactivate (False).
|
||||
"""
|
||||
if not 1 <= system_id <= 8:
|
||||
_LOGGER.error("Invalid system_id %d, must be 1-8", system_id)
|
||||
return False
|
||||
address = COIL_SYSTEM_ACTIVATION_START + (system_id - 1)
|
||||
return await self.write_coil(address, is_on)
|
||||
|
||||
async def read_delivery_water_temps(self) -> dict[int, float] | None:
|
||||
"""Read delivery water temperatures from registers 2650-2657.
|
||||
|
||||
Returns dict of system_id (1-8) -> temperature.
|
||||
Values are stored as integer * 10.
|
||||
"""
|
||||
registers = await self.read_registers(
|
||||
REGISTER_DELIVERY_WATER_TEMP_START, REGISTER_DELIVERY_WATER_TEMP_COUNT
|
||||
)
|
||||
if registers is None:
|
||||
return None
|
||||
return {i + 1: value / TEMP_SCALE_FACTOR for i, value in enumerate(registers)}
|
||||
|
||||
async def read_calculated_water_temps(self) -> dict[int, float] | None:
|
||||
"""Read calculated water temperatures from registers 2658-2665.
|
||||
|
||||
Returns dict of system_id (1-8) -> temperature.
|
||||
Values are stored as integer * 10.
|
||||
"""
|
||||
registers = await self.read_registers(
|
||||
REGISTER_CALCULATED_WATER_TEMP_START, REGISTER_CALCULATED_WATER_TEMP_COUNT
|
||||
)
|
||||
if registers is None:
|
||||
return None
|
||||
return {i + 1: value / TEMP_SCALE_FACTOR for i, value in enumerate(registers)}
|
||||
|
||||
async def read_pump_active(self) -> dict[int, bool] | None:
|
||||
"""Read pump active status from register 7615 bitmask.
|
||||
|
||||
Returns dict of pump_id (1-8) -> is_active (True/False).
|
||||
Bit 0 = pump 1, bit 7 = pump 8.
|
||||
"""
|
||||
registers = await self.read_registers(REGISTER_PUMP_ACTIVE, 1)
|
||||
if registers is None:
|
||||
return None
|
||||
bitmask = registers[0]
|
||||
return {i + 1: bool(bitmask & (1 << i)) for i in range(8)}
|
||||
|
||||
async def _read_zone_bitmask(self, start_register: int, count: int) -> dict[int, bool] | None:
|
||||
"""Generic zone bitmask reader for 64 zones across 4 registers.
|
||||
|
||||
Each register contains 16 bits, with bit 0 of register 0 being zone 0,
|
||||
bit 15 of register 0 being zone 15, bit 0 of register 1 being zone 16, etc.
|
||||
|
||||
Returns dict of zone_id (0-63) -> is_active (True/False).
|
||||
"""
|
||||
registers = await self.read_registers(start_register, count)
|
||||
if registers is None:
|
||||
return None
|
||||
|
||||
result: dict[int, bool] = {}
|
||||
for reg_idx, reg_value in enumerate(registers):
|
||||
for bit in range(16):
|
||||
zone_id = reg_idx * 16 + bit
|
||||
result[zone_id] = bool(reg_value & (1 << bit))
|
||||
return result
|
||||
|
||||
async def read_humidity_request(self) -> dict[int, bool] | None:
|
||||
"""Read humidity request bitmasks (registers 2896-2899).
|
||||
|
||||
Returns dict of zone_id (0-63) -> is_requesting (True/False).
|
||||
"""
|
||||
return await self._read_zone_bitmask(
|
||||
REGISTER_HUMIDITY_REQUEST_START, REGISTER_HUMIDITY_REQUEST_COUNT
|
||||
)
|
||||
|
||||
async def read_ventilation_request(self) -> dict[int, bool] | None:
|
||||
"""Read ventilation request bitmasks (registers 2900-2903).
|
||||
|
||||
Returns dict of zone_id (0-63) -> is_requesting (True/False).
|
||||
"""
|
||||
return await self._read_zone_bitmask(
|
||||
REGISTER_VENTILATION_REQUEST_START, REGISTER_VENTILATION_REQUEST_COUNT
|
||||
)
|
||||
|
||||
async def read_renewal_request(self) -> dict[int, bool] | None:
|
||||
"""Read renewal request bitmasks (registers 2904-2907).
|
||||
|
||||
Returns dict of zone_id (0-63) -> is_requesting (True/False).
|
||||
"""
|
||||
return await self._read_zone_bitmask(
|
||||
REGISTER_RENEWAL_REQUEST_START, REGISTER_RENEWAL_REQUEST_COUNT
|
||||
)
|
||||
|
||||
async def read_integration_request(self) -> dict[int, bool] | None:
|
||||
"""Read integration request bitmasks (registers 2908-2911).
|
||||
|
||||
Returns dict of zone_id (0-63) -> is_requesting (True/False).
|
||||
"""
|
||||
return await self._read_zone_bitmask(
|
||||
REGISTER_INTEGRATION_REQUEST_START, REGISTER_INTEGRATION_REQUEST_COUNT
|
||||
)
|
||||
|
||||
async def read_dehumidification_pump(self) -> dict[int, bool] | None:
|
||||
"""Read dehumidification pump bitmasks (registers 2912-2915).
|
||||
|
||||
Returns dict of zone_id (0-63) -> is_active (True/False).
|
||||
"""
|
||||
return await self._read_zone_bitmask(
|
||||
REGISTER_DEHUMIDIFICATION_PUMP_START, REGISTER_DEHUMIDIFICATION_PUMP_COUNT
|
||||
)
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Number platform for RDZ HMI Control integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.number import NumberEntity, NumberMode
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import (
|
||||
DATA_TIME_SETTINGS,
|
||||
DOMAIN,
|
||||
)
|
||||
from .coordinator import RDZDataUpdateCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Time setting configurations: (key, name, min, max)
|
||||
TIME_SETTINGS = [
|
||||
("day", "Day", 1, 31),
|
||||
("month", "Month", 1, 12),
|
||||
("year", "Year", 2000, 2099),
|
||||
("hour", "Hour", 0, 23),
|
||||
("minute", "Minute", 0, 59),
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up RDZ HMI number entities from a config entry."""
|
||||
coordinator: RDZDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
|
||||
|
||||
entities: list[NumberEntity] = []
|
||||
|
||||
# Add time setting number entities
|
||||
for key, name, min_val, max_val in TIME_SETTINGS:
|
||||
entities.append(
|
||||
RDZTimeSettingNumber(coordinator, key, name, min_val, max_val)
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class RDZTimeSettingNumber(CoordinatorEntity[RDZDataUpdateCoordinator], NumberEntity):
|
||||
"""Representation of an RDZ HMI time setting number entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_mode = NumberMode.BOX
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: RDZDataUpdateCoordinator,
|
||||
setting_key: str,
|
||||
setting_name: str,
|
||||
min_value: int,
|
||||
max_value: int,
|
||||
) -> None:
|
||||
"""Initialize the time setting number entity.
|
||||
|
||||
Args:
|
||||
coordinator: The data coordinator.
|
||||
setting_key: The key in the time settings dict (day, month, year, hour, minute).
|
||||
setting_name: The display name for the entity.
|
||||
min_value: The minimum allowed value.
|
||||
max_value: The maximum allowed value.
|
||||
"""
|
||||
super().__init__(coordinator)
|
||||
self._setting_key = setting_key
|
||||
|
||||
self._attr_unique_id = f"{DOMAIN}_{coordinator.client.host}_time_{setting_key}"
|
||||
self._attr_translation_key = f"time_{setting_key}"
|
||||
self._attr_name = setting_name
|
||||
self._attr_native_min_value = min_value
|
||||
self._attr_native_max_value = max_value
|
||||
self._attr_native_step = 1
|
||||
|
||||
# Place on the System device
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, f"{coordinator.client.host}_system")},
|
||||
name="RDZ HMI System",
|
||||
manufacturer="RDZ",
|
||||
model="HMI Control System",
|
||||
sw_version="1.0",
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | None:
|
||||
"""Return the current value."""
|
||||
if self.coordinator.data is None:
|
||||
return None
|
||||
time_settings = self.coordinator.data.get(DATA_TIME_SETTINGS, {})
|
||||
if time_settings is None:
|
||||
return None
|
||||
return time_settings.get(self._setting_key)
|
||||
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
"""Set the value."""
|
||||
int_value = int(value)
|
||||
|
||||
# Call the appropriate setter method on the coordinator
|
||||
setter_methods = {
|
||||
"day": self.coordinator.async_set_time_day,
|
||||
"month": self.coordinator.async_set_time_month,
|
||||
"year": self.coordinator.async_set_time_year,
|
||||
"hour": self.coordinator.async_set_time_hour,
|
||||
"minute": self.coordinator.async_set_time_minute,
|
||||
}
|
||||
|
||||
setter = setter_methods.get(self._setting_key)
|
||||
if setter is None:
|
||||
_LOGGER.error("Unknown time setting key: %s", self._setting_key)
|
||||
return
|
||||
|
||||
success = await setter(int_value)
|
||||
if not success:
|
||||
_LOGGER.error("Failed to set time %s to %d", self._setting_key, int_value)
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
self.async_write_ha_state()
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Select platform for RDZ HMI Control integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.select import SelectEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import (
|
||||
CONF_ZONE_NAME,
|
||||
CONF_ZONES,
|
||||
DATA_ZONE_MODES,
|
||||
DOMAIN,
|
||||
ZONE_MODE_MAN,
|
||||
ZONE_MODE_OFF,
|
||||
ZONE_MODE_OPTIONS,
|
||||
ZONE_MODE_PGM,
|
||||
ZONE_MODE_PGM_MAN,
|
||||
)
|
||||
from .coordinator import RDZDataUpdateCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Reverse mapping from option string to mode value
|
||||
ZONE_MODE_VALUES = {v: k for k, v in ZONE_MODE_OPTIONS.items()}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up RDZ HMI zone mode select entities from a config entry."""
|
||||
coordinator: RDZDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
|
||||
zones_config = config_entry.data.get(CONF_ZONES, {})
|
||||
|
||||
entities = []
|
||||
for zone_id_str, zone_data in zones_config.items():
|
||||
zone_id = int(zone_id_str) if isinstance(zone_id_str, str) else zone_id_str
|
||||
entities.append(RDZZoneModeSelect(coordinator, zone_id, zone_data))
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class RDZZoneModeSelect(CoordinatorEntity[RDZDataUpdateCoordinator], SelectEntity):
|
||||
"""Representation of an RDZ HMI zone mode select entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_options = list(ZONE_MODE_OPTIONS.values())
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: RDZDataUpdateCoordinator,
|
||||
zone_id: int,
|
||||
zone_data: dict[str, Any],
|
||||
) -> None:
|
||||
"""Initialize the zone mode select entity."""
|
||||
super().__init__(coordinator)
|
||||
self._zone_id = zone_id
|
||||
self._zone_data = zone_data
|
||||
|
||||
self._attr_unique_id = (
|
||||
f"{DOMAIN}_{coordinator.client.host}_{zone_id}_zone_mode"
|
||||
)
|
||||
self._attr_translation_key = "zone_mode"
|
||||
self._attr_name = "Zone mode"
|
||||
|
||||
self._update_device_info()
|
||||
|
||||
def _update_device_info(self) -> None:
|
||||
"""Update device info from zone data."""
|
||||
zones_config = self.coordinator.config_entry.data.get(CONF_ZONES, {})
|
||||
zone_data = zones_config.get(str(self._zone_id), self._zone_data)
|
||||
self._zone_data = zone_data
|
||||
|
||||
zone_name = zone_data.get(CONF_ZONE_NAME, f"Zone {self._zone_id}")
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, f"{self.coordinator.client.host}_{self._zone_id}")},
|
||||
name=zone_name,
|
||||
manufacturer="RDZ",
|
||||
model="HMI Thermostat",
|
||||
sw_version="1.0",
|
||||
)
|
||||
|
||||
@property
|
||||
def current_option(self) -> str | None:
|
||||
"""Return the currently selected option."""
|
||||
if self.coordinator.data is None:
|
||||
return None
|
||||
|
||||
zone_modes = self.coordinator.data.get(DATA_ZONE_MODES, {})
|
||||
mode_value = zone_modes.get(self._zone_id)
|
||||
|
||||
if mode_value is None:
|
||||
return None
|
||||
|
||||
return ZONE_MODE_OPTIONS.get(mode_value, ZONE_MODE_OPTIONS[ZONE_MODE_OFF])
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Change the selected option."""
|
||||
mode_value = ZONE_MODE_VALUES.get(option)
|
||||
|
||||
if mode_value is None:
|
||||
_LOGGER.error("Invalid zone mode option: %s", option)
|
||||
return
|
||||
|
||||
success = await self.coordinator.async_set_zone_mode(self._zone_id, mode_value)
|
||||
if not success:
|
||||
_LOGGER.error("Failed to set zone mode for zone %d", self._zone_id)
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
self._update_device_info()
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""When entity is added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
# Listen for config entry updates
|
||||
self.async_on_remove(
|
||||
self.coordinator.config_entry.add_update_listener(
|
||||
self._async_config_entry_updated
|
||||
)
|
||||
)
|
||||
|
||||
async def _async_config_entry_updated(
|
||||
self, hass: HomeAssistant, entry: ConfigEntry
|
||||
) -> None:
|
||||
"""Handle config entry update."""
|
||||
self._update_device_info()
|
||||
self.async_write_ha_state()
|
||||
@@ -20,6 +20,10 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from .const import (
|
||||
CONF_ZONE_NAME,
|
||||
CONF_ZONES,
|
||||
DATA_CALCULATED_WATER_TEMPS,
|
||||
DATA_DELIVERY_WATER_TEMPS,
|
||||
DATA_DEW_POINTS,
|
||||
DATA_OUTSIDE_TEMP,
|
||||
DATA_SUMMER_SETPOINTS,
|
||||
DATA_WINTER_SETPOINTS,
|
||||
DOMAIN,
|
||||
@@ -38,7 +42,9 @@ async def async_setup_entry(
|
||||
coordinator: RDZDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
|
||||
zones_config = config_entry.data.get(CONF_ZONES, {})
|
||||
|
||||
entities = []
|
||||
entities: list[SensorEntity] = []
|
||||
|
||||
# Zone-level sensors
|
||||
for zone_id_str, zone_data in zones_config.items():
|
||||
zone_id = int(zone_id_str) if isinstance(zone_id_str, str) else zone_id_str
|
||||
# Create winter and summer setpoint sensors for each zone
|
||||
@@ -48,6 +54,19 @@ async def async_setup_entry(
|
||||
entities.append(
|
||||
RDZSetpointSensor(coordinator, zone_id, zone_data, "summer")
|
||||
)
|
||||
# Create dew point sensor for each zone
|
||||
entities.append(
|
||||
RDZDewPointSensor(coordinator, zone_id, zone_data)
|
||||
)
|
||||
|
||||
# System-level sensors
|
||||
# Outside temperature sensor
|
||||
entities.append(RDZOutsideTemperatureSensor(coordinator))
|
||||
|
||||
# Water temperature sensors (1-8 for each type)
|
||||
for system_id in range(1, 9):
|
||||
entities.append(RDZWaterTemperatureSensor(coordinator, system_id, "delivery"))
|
||||
entities.append(RDZWaterTemperatureSensor(coordinator, system_id, "calculated"))
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
@@ -137,3 +156,186 @@ class RDZSetpointSensor(CoordinatorEntity[RDZDataUpdateCoordinator], SensorEntit
|
||||
"""Handle config entry update."""
|
||||
self._update_device_info()
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
class RDZDewPointSensor(CoordinatorEntity[RDZDataUpdateCoordinator], SensorEntity):
|
||||
"""Representation of an RDZ HMI dew point sensor."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_device_class = SensorDeviceClass.TEMPERATURE
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
_attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: RDZDataUpdateCoordinator,
|
||||
zone_id: int,
|
||||
zone_data: dict[str, Any],
|
||||
) -> None:
|
||||
"""Initialize the dew point sensor entity."""
|
||||
super().__init__(coordinator)
|
||||
self._zone_id = zone_id
|
||||
self._zone_data = zone_data
|
||||
|
||||
self._attr_unique_id = (
|
||||
f"{DOMAIN}_{coordinator.client.host}_{zone_id}_dew_point"
|
||||
)
|
||||
self._attr_translation_key = "dew_point"
|
||||
self._attr_name = "Dew point"
|
||||
|
||||
self._update_device_info()
|
||||
|
||||
def _update_device_info(self) -> None:
|
||||
"""Update device info from zone data."""
|
||||
zones_config = self.coordinator.config_entry.data.get(CONF_ZONES, {})
|
||||
zone_data = zones_config.get(str(self._zone_id), self._zone_data)
|
||||
self._zone_data = zone_data
|
||||
|
||||
zone_name = zone_data.get(CONF_ZONE_NAME, f"Zone {self._zone_id}")
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, f"{self.coordinator.client.host}_{self._zone_id}")},
|
||||
name=zone_name,
|
||||
manufacturer="RDZ",
|
||||
model="HMI Thermostat",
|
||||
sw_version="1.0",
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | None:
|
||||
"""Return the sensor value."""
|
||||
if self.coordinator.data is None:
|
||||
return None
|
||||
|
||||
dew_points = self.coordinator.data.get(DATA_DEW_POINTS, {})
|
||||
return dew_points.get(self._zone_id)
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
self._update_device_info()
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""When entity is added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
# Listen for config entry updates
|
||||
self.async_on_remove(
|
||||
self.coordinator.config_entry.add_update_listener(self._async_config_entry_updated)
|
||||
)
|
||||
|
||||
async def _async_config_entry_updated(
|
||||
self, hass: HomeAssistant, entry: ConfigEntry
|
||||
) -> None:
|
||||
"""Handle config entry update."""
|
||||
self._update_device_info()
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
class RDZOutsideTemperatureSensor(CoordinatorEntity[RDZDataUpdateCoordinator], SensorEntity):
|
||||
"""Representation of an RDZ HMI outside temperature sensor."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_device_class = SensorDeviceClass.TEMPERATURE
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
_attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
|
||||
_attr_translation_key = "outside_temperature"
|
||||
_attr_name = "Outside temperature"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: RDZDataUpdateCoordinator,
|
||||
) -> None:
|
||||
"""Initialize the outside temperature sensor entity."""
|
||||
super().__init__(coordinator)
|
||||
|
||||
self._attr_unique_id = (
|
||||
f"{DOMAIN}_{coordinator.client.host}_outside_temperature"
|
||||
)
|
||||
|
||||
# Place on the System device
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, f"{coordinator.client.host}_system")},
|
||||
name="RDZ HMI System",
|
||||
manufacturer="RDZ",
|
||||
model="HMI Control System",
|
||||
sw_version="1.0",
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | None:
|
||||
"""Return the sensor value."""
|
||||
if self.coordinator.data is None:
|
||||
return None
|
||||
return self.coordinator.data.get(DATA_OUTSIDE_TEMP)
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
class RDZWaterTemperatureSensor(CoordinatorEntity[RDZDataUpdateCoordinator], SensorEntity):
|
||||
"""Representation of an RDZ HMI water temperature sensor."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_device_class = SensorDeviceClass.TEMPERATURE
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
_attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: RDZDataUpdateCoordinator,
|
||||
system_id: int,
|
||||
temp_type: str,
|
||||
) -> None:
|
||||
"""Initialize the water temperature sensor entity.
|
||||
|
||||
Args:
|
||||
coordinator: The data coordinator.
|
||||
system_id: The system ID (1-8).
|
||||
temp_type: Either "delivery" or "calculated".
|
||||
"""
|
||||
super().__init__(coordinator)
|
||||
self._system_id = system_id
|
||||
self._temp_type = temp_type
|
||||
|
||||
self._attr_unique_id = (
|
||||
f"{DOMAIN}_{coordinator.client.host}_{temp_type}_water_temp_{system_id}"
|
||||
)
|
||||
|
||||
if temp_type == "delivery":
|
||||
self._attr_translation_key = f"delivery_water_temp_{system_id}"
|
||||
self._attr_name = f"Delivery water temperature {system_id}"
|
||||
else:
|
||||
self._attr_translation_key = f"calculated_water_temp_{system_id}"
|
||||
self._attr_name = f"Calculated water temperature {system_id}"
|
||||
|
||||
# Place on the System device
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, f"{coordinator.client.host}_system")},
|
||||
name="RDZ HMI System",
|
||||
manufacturer="RDZ",
|
||||
model="HMI Control System",
|
||||
sw_version="1.0",
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | None:
|
||||
"""Return the sensor value."""
|
||||
if self.coordinator.data is None:
|
||||
return None
|
||||
|
||||
if self._temp_type == "delivery":
|
||||
temps = self.coordinator.data.get(DATA_DELIVERY_WATER_TEMPS, {})
|
||||
else:
|
||||
temps = self.coordinator.data.get(DATA_CALCULATED_WATER_TEMPS, {})
|
||||
|
||||
return temps.get(self._system_id)
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
self.async_write_ha_state()
|
||||
@@ -14,6 +14,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import (
|
||||
DATA_SEASON,
|
||||
DATA_SYSTEM_ACTIVATION,
|
||||
DOMAIN,
|
||||
)
|
||||
from .coordinator import RDZDataUpdateCoordinator
|
||||
@@ -29,10 +30,14 @@ async def async_setup_entry(
|
||||
"""Set up RDZ HMI switch entities from a config entry."""
|
||||
coordinator: RDZDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
|
||||
|
||||
entities = [
|
||||
entities: list[SwitchEntity] = [
|
||||
RDZSeasonSwitch(coordinator),
|
||||
]
|
||||
|
||||
# Add system activation switches (1-8)
|
||||
for system_id in range(1, 9):
|
||||
entities.append(RDZSystemActivationSwitch(coordinator, system_id))
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
@@ -91,6 +96,65 @@ class RDZSeasonSwitch(CoordinatorEntity[RDZDataUpdateCoordinator], SwitchEntity)
|
||||
if not success:
|
||||
_LOGGER.error("Failed to set season to winter")
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
class RDZSystemActivationSwitch(CoordinatorEntity[RDZDataUpdateCoordinator], SwitchEntity):
|
||||
"""Representation of an RDZ HMI system activation switch."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: RDZDataUpdateCoordinator,
|
||||
system_id: int,
|
||||
) -> None:
|
||||
"""Initialize the system activation switch entity.
|
||||
|
||||
Args:
|
||||
coordinator: The data coordinator.
|
||||
system_id: The system ID (1-8).
|
||||
"""
|
||||
super().__init__(coordinator)
|
||||
self._system_id = system_id
|
||||
|
||||
self._attr_unique_id = f"{DOMAIN}_{coordinator.client.host}_system_{system_id}"
|
||||
self._attr_translation_key = f"system_{system_id}"
|
||||
self._attr_name = f"System {system_id}"
|
||||
self._attr_icon = "mdi:power"
|
||||
|
||||
# Place on the System device
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, f"{coordinator.client.host}_system")},
|
||||
name="RDZ HMI System",
|
||||
manufacturer="RDZ",
|
||||
model="HMI Control System",
|
||||
sw_version="1.0",
|
||||
)
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return True if the system is active."""
|
||||
if self.coordinator.data is None:
|
||||
return None
|
||||
activation = self.coordinator.data.get(DATA_SYSTEM_ACTIVATION, {})
|
||||
return activation.get(self._system_id)
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn on the system."""
|
||||
success = await self.coordinator.async_set_system_activation(self._system_id, True)
|
||||
if not success:
|
||||
_LOGGER.error("Failed to activate system %d", self._system_id)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off the system."""
|
||||
success = await self.coordinator.async_set_system_activation(self._system_id, False)
|
||||
if not success:
|
||||
_LOGGER.error("Failed to deactivate system %d", self._system_id)
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
|
||||
Reference in New Issue
Block a user