2 Commits
Author SHA1 Message Date
github-actions[bot] c6fde73e7a Bump version to 1.1.4 2026-02-02 12:09:28 +00:00
Constantin Pascal f704dfc5ce Fixing Setpoint displayed when in PGM mode 2026-02-02 14:07:05 +02:00
5 changed files with 132 additions and 12 deletions
+107 -11
View File
@@ -23,22 +23,43 @@ from .const import (
CONF_ZONE_NAME, CONF_ZONE_NAME,
CONF_ZONE_TYPE, CONF_ZONE_TYPE,
CONF_ZONES, CONF_ZONES,
DATA_CALCULATED_SETPOINTS,
DATA_SEASON, DATA_SEASON,
DATA_SUMMER_SETPOINTS, DATA_SUMMER_SETPOINTS,
DATA_TEMPERATURES, DATA_TEMPERATURES,
DATA_WINTER_SETPOINTS, DATA_WINTER_SETPOINTS,
DATA_ZONE_ACTIVITY, DATA_ZONE_ACTIVITY,
DATA_ZONE_MODES,
DOMAIN, DOMAIN,
MAX_TEMP, MAX_TEMP,
MIN_TEMP, MIN_TEMP,
TEMP_STEP, TEMP_STEP,
THERMOSTAT_TYPE_REAL,
THERMOSTAT_TYPE_UNCONFIGURED, THERMOSTAT_TYPE_UNCONFIGURED,
THERMOSTAT_TYPE_VIRTUAL, THERMOSTAT_TYPE_VIRTUAL,
ZONE_MODE_MAN,
ZONE_MODE_OFF,
ZONE_MODE_OPTIONS,
ZONE_MODE_PGM,
ZONE_MODE_PGM_MAN,
) )
from .coordinator import RDZDataUpdateCoordinator from .coordinator import RDZDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__) _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( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
@@ -66,6 +87,7 @@ class RDZClimateEntity(CoordinatorEntity[RDZDataUpdateCoordinator], ClimateEntit
_attr_min_temp = MIN_TEMP _attr_min_temp = MIN_TEMP
_attr_max_temp = MAX_TEMP _attr_max_temp = MAX_TEMP
_attr_target_temperature_step = TEMP_STEP _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 _enable_turn_on_off_backwards_compat = False
def __init__( def __init__(
@@ -93,16 +115,19 @@ class RDZClimateEntity(CoordinatorEntity[RDZDataUpdateCoordinator], ClimateEntit
# Set supported features and HVAC modes based on configuration # Set supported features and HVAC modes based on configuration
if zone_type == THERMOSTAT_TYPE_UNCONFIGURED: 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] self._attr_hvac_modes = [HVACMode.OFF]
elif zone_type == THERMOSTAT_TYPE_VIRTUAL: elif zone_type == THERMOSTAT_TYPE_VIRTUAL:
# Virtual thermostats are read-only (synced from real thermostats) # 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] self._attr_hvac_modes = [HVACMode.OFF]
else: # THERMOSTAT_TYPE_REAL 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) # 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] self._attr_hvac_modes = [HVACMode.HEAT, HVACMode.COOL]
# Update device info # Update device info
@@ -125,6 +150,26 @@ class RDZClimateEntity(CoordinatorEntity[RDZDataUpdateCoordinator], ClimateEntit
else: # Winter or unknown else: # Winter or unknown
return HVACMode.HEAT 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 @property
def current_temperature(self) -> float | None: def current_temperature(self) -> float | None:
"""Return the current temperature.""" """Return the current temperature."""
@@ -135,18 +180,30 @@ class RDZClimateEntity(CoordinatorEntity[RDZDataUpdateCoordinator], ClimateEntit
@property @property
def target_temperature(self) -> float | None: 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: if self.coordinator.data is None:
return 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
setpoints = self.coordinator.data.get(DATA_SUMMER_SETPOINTS, {}) if current_zone_mode == ZONE_MODE_MAN:
else: # HEAT current_hvac_mode = self._get_season_based_hvac_mode()
setpoints = self.coordinator.data.get(DATA_WINTER_SETPOINTS, {}) if current_hvac_mode == HVACMode.COOL:
setpoints = self.coordinator.data.get(DATA_SUMMER_SETPOINTS, {})
else:
setpoints = self.coordinator.data.get(DATA_WINTER_SETPOINTS, {})
return setpoints.get(self._zone_id)
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 @property
def hvac_mode(self) -> HVACMode: def hvac_mode(self) -> HVACMode:
@@ -188,6 +245,33 @@ class RDZClimateEntity(CoordinatorEntity[RDZDataUpdateCoordinator], ClimateEntit
return HVACAction.IDLE 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: async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""HVAC mode is read-only - determined by season switch.""" """HVAC mode is read-only - determined by season switch."""
_LOGGER.debug( _LOGGER.debug(
@@ -215,6 +299,18 @@ class RDZClimateEntity(CoordinatorEntity[RDZDataUpdateCoordinator], ClimateEntit
) )
return 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) temperature = kwargs.get(ATTR_TEMPERATURE)
if temperature is None: if temperature is None:
return return
@@ -51,6 +51,10 @@ REGISTER_DEHUMIDIFICATION_SETPOINT_COUNT = 64
REGISTER_DEW_POINT_START = 2828 REGISTER_DEW_POINT_START = 2828
REGISTER_DEW_POINT_COUNT = 64 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 # Zone mode registers
REGISTER_ZONE_MODE_START = 5301 REGISTER_ZONE_MODE_START = 5301
REGISTER_ZONE_MODE_COUNT = 64 REGISTER_ZONE_MODE_COUNT = 64
@@ -143,3 +147,4 @@ DATA_SYSTEM_ACTIVATION = "system_activation"
DATA_DELIVERY_WATER_TEMPS = "delivery_water_temps" DATA_DELIVERY_WATER_TEMPS = "delivery_water_temps"
DATA_CALCULATED_WATER_TEMPS = "calculated_water_temps" DATA_CALCULATED_WATER_TEMPS = "calculated_water_temps"
DATA_PUMP_ACTIVE = "pump_active" DATA_PUMP_ACTIVE = "pump_active"
DATA_CALCULATED_SETPOINTS = "calculated_setpoints"
@@ -17,6 +17,7 @@ from .const import (
CONF_PORT, CONF_PORT,
CONF_ZONE_TYPE, CONF_ZONE_TYPE,
CONF_ZONES, CONF_ZONES,
DATA_CALCULATED_SETPOINTS,
DATA_CALCULATED_WATER_TEMPS, DATA_CALCULATED_WATER_TEMPS,
DATA_DEHUMIDIFICATION_PUMP, DATA_DEHUMIDIFICATION_PUMP,
DATA_DEHUMIDIFICATION_SETPOINTS, DATA_DEHUMIDIFICATION_SETPOINTS,
@@ -83,6 +84,7 @@ class RDZDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
temperatures = await self.client.read_temperatures() temperatures = await self.client.read_temperatures()
winter_setpoints = await self.client.read_winter_setpoints() winter_setpoints = await self.client.read_winter_setpoints()
summer_setpoints = await self.client.read_summer_setpoints() summer_setpoints = await self.client.read_summer_setpoints()
calculated_setpoints = await self.client.read_calculated_setpoints()
season = await self.client.read_season() season = await self.client.read_season()
zone_activity = await self.client.read_zone_activity() zone_activity = await self.client.read_zone_activity()
humidity = await self.client.read_humidity() humidity = await self.client.read_humidity()
@@ -118,6 +120,7 @@ class RDZDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
DATA_TEMPERATURES: temperatures, DATA_TEMPERATURES: temperatures,
DATA_WINTER_SETPOINTS: winter_setpoints, DATA_WINTER_SETPOINTS: winter_setpoints,
DATA_SUMMER_SETPOINTS: summer_setpoints, DATA_SUMMER_SETPOINTS: summer_setpoints,
DATA_CALCULATED_SETPOINTS: calculated_setpoints,
DATA_SEASON: season, # True = summer, False = winter, None = unknown DATA_SEASON: season, # True = summer, False = winter, None = unknown
DATA_ZONE_ACTIVITY: zone_activity, # dict of zone_id -> is_active DATA_ZONE_ACTIVITY: zone_activity, # dict of zone_id -> is_active
DATA_HUMIDITY: humidity, DATA_HUMIDITY: humidity,
@@ -8,5 +8,5 @@
"integration_type": "device", "integration_type": "device",
"iot_class": "local_polling", "iot_class": "local_polling",
"requirements": ["pymodbus>=3.6.0"], "requirements": ["pymodbus>=3.6.0"],
"version": "1.1.3" "version": "1.1.4"
} }
@@ -12,6 +12,8 @@ from .const import (
COIL_SYSTEM_ACTIVATION_COUNT, COIL_SYSTEM_ACTIVATION_COUNT,
REGISTER_ACTIVITY_COUNT, REGISTER_ACTIVITY_COUNT,
REGISTER_ACTIVITY_START, REGISTER_ACTIVITY_START,
REGISTER_CALCULATED_SETPOINT_COUNT,
REGISTER_CALCULATED_SETPOINT_START,
REGISTER_CALCULATED_WATER_TEMP_COUNT, REGISTER_CALCULATED_WATER_TEMP_COUNT,
REGISTER_CALCULATED_WATER_TEMP_START, REGISTER_CALCULATED_WATER_TEMP_START,
REGISTER_DEHUMIDIFICATION_PUMP_COUNT, REGISTER_DEHUMIDIFICATION_PUMP_COUNT,
@@ -226,6 +228,20 @@ class RDZModbusClient:
return {i: value / TEMP_SCALE_FACTOR for i, value in enumerate(registers)} 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: async def read_zone_activity(self) -> dict[int, bool] | None:
"""Read zone activity bitmasks and return dict of zone_id -> is_active. """Read zone activity bitmasks and return dict of zone_id -> is_active.