13 Commits
9 changed files with 667 additions and 15 deletions
+46
View File
@@ -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
@@ -15,13 +15,45 @@ 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_MODE_DEHUMIDIFICATION,
DATA_VENTILATION_MODE_INTEGRATION,
DATA_VENTILATION_MODE_RENEWAL,
DATA_VENTILATION_MODE_VENTILATION,
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"),
}
# Ventilation mode types with their data keys and display names
VENTILATION_MODE_TYPES: dict[str, tuple[str, str]] = {
"dehumidification": (DATA_VENTILATION_MODE_DEHUMIDIFICATION, "Dehumidification"),
"ventilation": (DATA_VENTILATION_MODE_VENTILATION, "Ventilation"),
"renewal": (DATA_VENTILATION_MODE_RENEWAL, "Renewal"),
"integration": (DATA_VENTILATION_MODE_INTEGRATION, "Integration"),
}
async def async_setup_entry(
hass: HomeAssistant,
@@ -30,6 +62,7 @@ async def async_setup_entry(
) -> 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] = []
@@ -37,6 +70,21 @@ async def async_setup_entry(
for pump_id in range(1, 9):
entities.append(RDZPumpActiveBinarySensor(coordinator, pump_id))
# Add ventilation mode binary sensors
for mode_type in VENTILATION_MODE_TYPES:
entities.append(RDZVentilationModeBinarySensor(coordinator, mode_type))
# 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)
@@ -85,3 +133,207 @@ class RDZPumpActiveBinarySensor(CoordinatorEntity[RDZDataUpdateCoordinator], Bin
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self.async_write_ha_state()
class RDZVentilationModeBinarySensor(CoordinatorEntity[RDZDataUpdateCoordinator], BinarySensorEntity):
"""Representation of an RDZ HMI ventilation mode binary sensor.
Indicates whether a ventilation unit work mode is active.
"""
_attr_has_entity_name = True
_attr_device_class = BinarySensorDeviceClass.RUNNING
def __init__(
self,
coordinator: RDZDataUpdateCoordinator,
mode_type: str,
) -> None:
"""Initialize the ventilation mode binary sensor entity.
Args:
coordinator: The data coordinator.
mode_type: The type of ventilation mode (key in VENTILATION_MODE_TYPES).
"""
super().__init__(coordinator)
self._mode_type = mode_type
self._data_key, self._display_name = VENTILATION_MODE_TYPES[mode_type]
self._attr_unique_id = f"{DOMAIN}_{coordinator.client.host}_ventilation_mode_{mode_type}"
self._attr_name = self._display_name
# 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 ventilation mode is active."""
if self.coordinator.data is None:
return None
return self.coordinator.data.get(self._data_key)
@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()
+105 -9
View File
@@ -23,22 +23,43 @@ from .const import (
CONF_ZONE_NAME,
CONF_ZONE_TYPE,
CONF_ZONES,
DATA_CALCULATED_SETPOINTS,
DATA_SEASON,
DATA_SUMMER_SETPOINTS,
DATA_TEMPERATURES,
DATA_WINTER_SETPOINTS,
DATA_ZONE_ACTIVITY,
DATA_ZONE_MODES,
DOMAIN,
MAX_TEMP,
MIN_TEMP,
TEMP_STEP,
THERMOSTAT_TYPE_REAL,
THERMOSTAT_TYPE_UNCONFIGURED,
THERMOSTAT_TYPE_VIRTUAL,
ZONE_MODE_MAN,
ZONE_MODE_OFF,
ZONE_MODE_OPTIONS,
ZONE_MODE_PGM,
ZONE_MODE_PGM_MAN,
)
from .coordinator import RDZDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
# Preset modes that map to zone modes
PRESET_OFF = "Off"
PRESET_MAN = "Man"
PRESET_PGM = "Pgm"
PRESET_PGM_MAN = "Pgm/Man"
PRESET_MODE_TO_ZONE_MODE = {
PRESET_OFF: ZONE_MODE_OFF,
PRESET_MAN: ZONE_MODE_MAN,
PRESET_PGM: ZONE_MODE_PGM,
PRESET_PGM_MAN: ZONE_MODE_PGM_MAN,
}
async def async_setup_entry(
hass: HomeAssistant,
@@ -66,6 +87,7 @@ class RDZClimateEntity(CoordinatorEntity[RDZDataUpdateCoordinator], ClimateEntit
_attr_min_temp = MIN_TEMP
_attr_max_temp = MAX_TEMP
_attr_target_temperature_step = TEMP_STEP
_attr_preset_modes = [PRESET_OFF, PRESET_MAN, PRESET_PGM, PRESET_PGM_MAN]
_enable_turn_on_off_backwards_compat = False
def __init__(
@@ -93,16 +115,19 @@ class RDZClimateEntity(CoordinatorEntity[RDZDataUpdateCoordinator], ClimateEntit
# Set supported features and HVAC modes based on configuration
if zone_type == THERMOSTAT_TYPE_UNCONFIGURED:
self._attr_supported_features = ClimateEntityFeature(0)
self._attr_supported_features = ClimateEntityFeature.PRESET_MODE
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_supported_features = ClimateEntityFeature.PRESET_MODE
self._attr_hvac_modes = [HVACMode.OFF]
else: # THERMOSTAT_TYPE_REAL
# Real thermostats support temperature control
# Real thermostats support temperature control and preset mode
# HVAC mode is determined by season switch (read-only)
self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE
self._attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.PRESET_MODE
)
self._attr_hvac_modes = [HVACMode.HEAT, HVACMode.COOL]
# Update device info
@@ -125,6 +150,26 @@ class RDZClimateEntity(CoordinatorEntity[RDZDataUpdateCoordinator], ClimateEntit
else: # Winter or unknown
return HVACMode.HEAT
def _get_effective_zone_id_for_preset(self) -> int:
"""Get the zone ID to use for preset operations based on season.
For real thermostats with linked virtual zone:
- Summer mode: use linked virtual zone ID (presets control cooling)
- Winter mode: use this zone ID (presets control heating)
For all other thermostats: use own zone ID.
"""
zone_type = self._zone_data.get(CONF_ZONE_TYPE, THERMOSTAT_TYPE_UNCONFIGURED)
if zone_type == THERMOSTAT_TYPE_REAL:
linked_virtual = self._zone_data.get(CONF_LINKED_VIRTUAL_ZONE)
if linked_virtual is not None:
current_mode = self._get_season_based_hvac_mode()
if current_mode == HVACMode.COOL: # Summer
return linked_virtual
return self._zone_id
@property
def current_temperature(self) -> float | None:
"""Return the current temperature."""
@@ -135,19 +180,31 @@ class RDZClimateEntity(CoordinatorEntity[RDZDataUpdateCoordinator], ClimateEntit
@property
def target_temperature(self) -> float | None:
"""Return the target temperature based on season (summer=cooling setpoint, winter=heating setpoint)."""
"""Return the target temperature based on zone mode and season.
In 'Man' mode: return manual setpoint (winter or summer based on season)
In other modes: return calculated setpoint from program
"""
if self.coordinator.data is None:
return None
current_mode = self._get_season_based_hvac_mode()
effective_zone_id = self._get_effective_zone_id_for_preset()
zone_modes = self.coordinator.data.get(DATA_ZONE_MODES, {})
current_zone_mode = zone_modes.get(effective_zone_id)
if current_mode == HVACMode.COOL:
# Only use manual setpoints in Man mode
if current_zone_mode == ZONE_MODE_MAN:
current_hvac_mode = self._get_season_based_hvac_mode()
if current_hvac_mode == HVACMode.COOL:
setpoints = self.coordinator.data.get(DATA_SUMMER_SETPOINTS, {})
else: # HEAT
else:
setpoints = self.coordinator.data.get(DATA_WINTER_SETPOINTS, {})
return setpoints.get(self._zone_id)
# All other modes: use calculated setpoints from effective zone
calculated = self.coordinator.data.get(DATA_CALCULATED_SETPOINTS, {})
return calculated.get(effective_zone_id)
@property
def hvac_mode(self) -> HVACMode:
"""Return current HVAC mode based on season switch (read-only)."""
@@ -188,6 +245,33 @@ class RDZClimateEntity(CoordinatorEntity[RDZDataUpdateCoordinator], ClimateEntit
return HVACAction.IDLE
@property
def preset_mode(self) -> str | None:
"""Return the current preset mode based on zone mode."""
if self.coordinator.data is None:
return None
effective_zone_id = self._get_effective_zone_id_for_preset()
zone_modes = self.coordinator.data.get(DATA_ZONE_MODES, {})
mode_value = zone_modes.get(effective_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_set_preset_mode(self, preset_mode: str) -> None:
"""Set the preset mode by writing to the appropriate zone mode register."""
mode_value = PRESET_MODE_TO_ZONE_MODE.get(preset_mode)
if mode_value is None:
_LOGGER.error("Invalid preset mode: %s", preset_mode)
return
effective_zone_id = self._get_effective_zone_id_for_preset()
success = await self.coordinator.async_set_zone_mode(effective_zone_id, mode_value)
if not success:
_LOGGER.error("Failed to set preset for zone %d", self._zone_id)
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""HVAC mode is read-only - determined by season switch."""
_LOGGER.debug(
@@ -215,6 +299,18 @@ class RDZClimateEntity(CoordinatorEntity[RDZDataUpdateCoordinator], ClimateEntit
)
return
# Check if in Man mode using effective zone ID
if self.coordinator.data:
effective_zone_id = self._get_effective_zone_id_for_preset()
zone_modes = self.coordinator.data.get(DATA_ZONE_MODES, {})
if zone_modes.get(effective_zone_id) != ZONE_MODE_MAN:
_LOGGER.warning(
"Cannot set temperature for zone %d - not in manual mode. "
"Change preset to 'Man' first.",
self._zone_id,
)
return
temperature = kwargs.get(ATTR_TEMPERATURE)
if temperature is None:
return
@@ -23,6 +23,22 @@ 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
@@ -35,6 +51,10 @@ REGISTER_DEHUMIDIFICATION_SETPOINT_COUNT = 64
REGISTER_DEW_POINT_START = 2828
REGISTER_DEW_POINT_COUNT = 64
# Calculated setpoint registers (program-calculated target temperatures)
REGISTER_CALCULATED_SETPOINT_START = 2764
REGISTER_CALCULATED_SETPOINT_COUNT = 64
# Zone mode registers
REGISTER_ZONE_MODE_START = 5301
REGISTER_ZONE_MODE_COUNT = 64
@@ -75,6 +95,11 @@ 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"
@@ -115,6 +140,12 @@ REGISTER_CALCULATED_WATER_TEMP_COUNT = 8
# Pump active bitmask register
REGISTER_PUMP_ACTIVE = 7615
# Ventilation mode registers (LSB = on/off)
REGISTER_VENTILATION_MODE_DEHUMIDIFICATION = 8201
REGISTER_VENTILATION_MODE_VENTILATION = 8202
REGISTER_VENTILATION_MODE_RENEWAL = 8203
REGISTER_VENTILATION_MODE_INTEGRATION = 8204
# Data keys for new features
DATA_OUTSIDE_TEMP = "outside_temperature"
DATA_TIME_SETTINGS = "time_settings"
@@ -122,3 +153,8 @@ DATA_SYSTEM_ACTIVATION = "system_activation"
DATA_DELIVERY_WATER_TEMPS = "delivery_water_temps"
DATA_CALCULATED_WATER_TEMPS = "calculated_water_temps"
DATA_PUMP_ACTIVE = "pump_active"
DATA_CALCULATED_SETPOINTS = "calculated_setpoints"
DATA_VENTILATION_MODE_DEHUMIDIFICATION = "ventilation_mode_dehumidification"
DATA_VENTILATION_MODE_VENTILATION = "ventilation_mode_ventilation"
DATA_VENTILATION_MODE_RENEWAL = "ventilation_mode_renewal"
DATA_VENTILATION_MODE_INTEGRATION = "ventilation_mode_integration"
@@ -17,18 +17,28 @@ from .const import (
CONF_PORT,
CONF_ZONE_TYPE,
CONF_ZONES,
DATA_CALCULATED_SETPOINTS,
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_MODE_DEHUMIDIFICATION,
DATA_VENTILATION_MODE_INTEGRATION,
DATA_VENTILATION_MODE_RENEWAL,
DATA_VENTILATION_MODE_VENTILATION,
DATA_VENTILATION_REQUEST,
DATA_WINTER_SETPOINTS,
DATA_ZONE_ACTIVITY,
DATA_ZONE_MODES,
@@ -78,6 +88,7 @@ class RDZDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
temperatures = await self.client.read_temperatures()
winter_setpoints = await self.client.read_winter_setpoints()
summer_setpoints = await self.client.read_summer_setpoints()
calculated_setpoints = await self.client.read_calculated_setpoints()
season = await self.client.read_season()
zone_activity = await self.client.read_zone_activity()
humidity = await self.client.read_humidity()
@@ -93,6 +104,16 @@ class RDZDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
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()
# Read ventilation mode registers
ventilation_modes = await self.client.read_ventilation_modes()
if temperatures is None or winter_setpoints is None or summer_setpoints is None:
raise UpdateFailed("Failed to read data from Modbus device")
@@ -106,6 +127,7 @@ class RDZDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
DATA_TEMPERATURES: temperatures,
DATA_WINTER_SETPOINTS: winter_setpoints,
DATA_SUMMER_SETPOINTS: summer_setpoints,
DATA_CALCULATED_SETPOINTS: calculated_setpoints,
DATA_SEASON: season, # True = summer, False = winter, None = unknown
DATA_ZONE_ACTIVITY: zone_activity, # dict of zone_id -> is_active
DATA_HUMIDITY: humidity,
@@ -119,6 +141,17 @@ class RDZDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
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,
# Ventilation mode states
DATA_VENTILATION_MODE_DEHUMIDIFICATION: ventilation_modes.get("dehumidification") if ventilation_modes else None,
DATA_VENTILATION_MODE_VENTILATION: ventilation_modes.get("ventilation") if ventilation_modes else None,
DATA_VENTILATION_MODE_RENEWAL: ventilation_modes.get("renewal") if ventilation_modes else None,
DATA_VENTILATION_MODE_INTEGRATION: ventilation_modes.get("integration") if ventilation_modes else None,
}
except Exception as ex:
@@ -8,5 +8,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["pymodbus>=3.6.0"],
"version": "1.0.0"
"version": "1.1.5"
}
@@ -12,8 +12,12 @@ from .const import (
COIL_SYSTEM_ACTIVATION_COUNT,
REGISTER_ACTIVITY_COUNT,
REGISTER_ACTIVITY_START,
REGISTER_CALCULATED_SETPOINT_COUNT,
REGISTER_CALCULATED_SETPOINT_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,
@@ -21,9 +25,15 @@ from .const import (
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,
@@ -31,6 +41,8 @@ from .const import (
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,
@@ -216,6 +228,20 @@ class RDZModbusClient:
return {i: value / TEMP_SCALE_FACTOR for i, value in enumerate(registers)}
async def read_calculated_setpoints(self) -> dict[int, float] | None:
"""Read all calculated setpoint registers.
These are program-calculated target temperatures used in non-manual modes.
Values are stored as integer * 10 (e.g., 21.5°C = 215).
"""
registers = await self.read_registers(
REGISTER_CALCULATED_SETPOINT_START, REGISTER_CALCULATED_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.
@@ -351,7 +377,10 @@ class RDZModbusClient:
registers = await self.read_registers(REGISTER_OUTSIDE_TEMP, 1)
if registers is None:
return None
return registers[0] / TEMP_SCALE_FACTOR
raw = registers[0]
if raw > 32767:
raw -= 65536
return raw / TEMP_SCALE_FACTOR
async def read_time_settings(self) -> dict[str, int] | None:
"""Read time settings from registers 5009-5013.
@@ -468,3 +497,83 @@ class RDZModbusClient:
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
)
async def read_ventilation_modes(self) -> dict[str, bool] | None:
"""Read ventilation mode registers 8201-8204.
Returns dict with keys: dehumidification, ventilation, renewal, integration.
LSB (bit 0) of each register indicates on/off state.
"""
registers = await self.read_registers(8201, 4)
if registers is None:
return None
return {
"dehumidification": bool(registers[0] & 1),
"ventilation": bool(registers[1] & 1),
"renewal": bool(registers[2] & 1),
"integration": bool(registers[3] & 1),
}
+81 -1
View File
@@ -11,7 +11,7 @@ from homeassistant.components.sensor import (
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory, UnitOfTemperature
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -23,6 +23,7 @@ from .const import (
DATA_CALCULATED_WATER_TEMPS,
DATA_DELIVERY_WATER_TEMPS,
DATA_DEW_POINTS,
DATA_HUMIDITY,
DATA_OUTSIDE_TEMP,
DATA_SUMMER_SETPOINTS,
DATA_WINTER_SETPOINTS,
@@ -58,6 +59,10 @@ async def async_setup_entry(
entities.append(
RDZDewPointSensor(coordinator, zone_id, zone_data)
)
# Create humidity sensor for each zone
entities.append(
RDZHumiditySensor(coordinator, zone_id, zone_data)
)
# System-level sensors
# Outside temperature sensor
@@ -234,6 +239,81 @@ class RDZDewPointSensor(CoordinatorEntity[RDZDataUpdateCoordinator], SensorEntit
self.async_write_ha_state()
class RDZHumiditySensor(CoordinatorEntity[RDZDataUpdateCoordinator], SensorEntity):
"""Representation of an RDZ HMI humidity sensor."""
_attr_has_entity_name = True
_attr_device_class = SensorDeviceClass.HUMIDITY
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_native_unit_of_measurement = PERCENTAGE
def __init__(
self,
coordinator: RDZDataUpdateCoordinator,
zone_id: int,
zone_data: dict[str, Any],
) -> None:
"""Initialize the humidity 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}_humidity"
)
self._attr_translation_key = "humidity"
self._attr_name = "Humidity"
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
humidity = self.coordinator.data.get(DATA_HUMIDITY, {})
return humidity.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."""
+1 -1
View File
@@ -45,7 +45,7 @@ class RDZSeasonSwitch(CoordinatorEntity[RDZDataUpdateCoordinator], SwitchEntity)
"""Representation of the RDZ HMI season switch."""
_attr_has_entity_name = True
_attr_translation_key = "season"
_attr_name = "Summer Mode"
_attr_icon = "mdi:sun-snowflake-variant"
def __init__(