From 25a36c1588034e3ce0ebe4fa73752a657262cacd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexey=20ALERT=20Rubash=D1=91ff?= Date: Wed, 26 Mar 2025 00:05:14 +0200 Subject: [PATCH] Add AtlanticDomesticHotWaterProductionV2IOComponent to Overkiz (#139524) --- .../overkiz/water_heater/__init__.py | 4 + ...ic_hot_water_production_v2_io_component.py | 332 ++++++++++++++++++ 2 files changed, 336 insertions(+) create mode 100644 homeassistant/components/overkiz/water_heater/atlantic_domestic_hot_water_production_v2_io_component.py diff --git a/homeassistant/components/overkiz/water_heater/__init__.py b/homeassistant/components/overkiz/water_heater/__init__.py index 9895ea84c2c..2960cefe10c 100644 --- a/homeassistant/components/overkiz/water_heater/__init__.py +++ b/homeassistant/components/overkiz/water_heater/__init__.py @@ -13,6 +13,9 @@ from ..entity import OverkizEntity from .atlantic_domestic_hot_water_production_mlb_component import ( AtlanticDomesticHotWaterProductionMBLComponent, ) +from .atlantic_domestic_hot_water_production_v2_io_component import ( + AtlanticDomesticHotWaterProductionV2IOComponent, +) from .atlantic_pass_apc_dhw import AtlanticPassAPCDHW from .domestic_hot_water_production import DomesticHotWaterProduction from .hitachi_dhw import HitachiDHW @@ -52,4 +55,5 @@ WIDGET_TO_WATER_HEATER_ENTITY = { CONTROLLABLE_NAME_TO_WATER_HEATER_ENTITY = { "modbuslink:AtlanticDomesticHotWaterProductionMBLComponent": AtlanticDomesticHotWaterProductionMBLComponent, + "io:AtlanticDomesticHotWaterProductionV2_CV4E_IOComponent": AtlanticDomesticHotWaterProductionV2IOComponent, } diff --git a/homeassistant/components/overkiz/water_heater/atlantic_domestic_hot_water_production_v2_io_component.py b/homeassistant/components/overkiz/water_heater/atlantic_domestic_hot_water_production_v2_io_component.py new file mode 100644 index 00000000000..7e7db07f847 --- /dev/null +++ b/homeassistant/components/overkiz/water_heater/atlantic_domestic_hot_water_production_v2_io_component.py @@ -0,0 +1,332 @@ +"""Support for AtlanticDomesticHotWaterProductionV2IOComponent.""" + +from typing import Any, cast + +from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState + +from homeassistant.components.water_heater import ( + STATE_ECO, + STATE_ELECTRIC, + STATE_HEAT_PUMP, + STATE_PERFORMANCE, + WaterHeaterEntity, + WaterHeaterEntityFeature, +) +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature + +from ..entity import OverkizEntity + +DEFAULT_MIN_TEMP: float = 50.0 +DEFAULT_MAX_TEMP: float = 62.0 +MAX_BOOST_MODE_DURATION: int = 7 + +DHWP_AWAY_MODES = [ + OverkizCommandParam.ABSENCE, + OverkizCommandParam.AWAY, + OverkizCommandParam.FROSTPROTECTION, +] + + +class AtlanticDomesticHotWaterProductionV2IOComponent(OverkizEntity, WaterHeaterEntity): + """Representation of AtlanticDomesticHotWaterProductionV2IOComponent (io).""" + + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_supported_features = ( + WaterHeaterEntityFeature.TARGET_TEMPERATURE + | WaterHeaterEntityFeature.OPERATION_MODE + | WaterHeaterEntityFeature.AWAY_MODE + | WaterHeaterEntityFeature.ON_OFF + ) + _attr_operation_list = [ + STATE_ECO, + STATE_PERFORMANCE, + STATE_HEAT_PUMP, + STATE_ELECTRIC, + ] + + @property + def min_temp(self) -> float: + """Return the minimum temperature.""" + + min_temp = self.device.states[OverkizState.CORE_MINIMAL_TEMPERATURE_MANUAL_MODE] + if min_temp: + return cast(float, min_temp.value_as_float) + return DEFAULT_MIN_TEMP + + @property + def max_temp(self) -> float: + """Return the maximum temperature.""" + + max_temp = self.device.states[OverkizState.CORE_MAXIMAL_TEMPERATURE_MANUAL_MODE] + if max_temp: + return cast(float, max_temp.value_as_float) + return DEFAULT_MAX_TEMP + + @property + def current_temperature(self) -> float: + """Return the current temperature.""" + + return cast( + float, + self.executor.select_state( + OverkizState.IO_MIDDLE_WATER_TEMPERATURE, + ), + ) + + @property + def target_temperature(self) -> float: + """Return the temperature corresponding to the PRESET.""" + + return cast( + float, + self.executor.select_state(OverkizState.CORE_TARGET_TEMPERATURE), + ) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new temperature.""" + + temperature = kwargs.get(ATTR_TEMPERATURE) + await self.executor.async_execute_command( + OverkizCommand.SET_TARGET_TEMPERATURE, temperature, refresh_afterwards=False + ) + await self.executor.async_execute_command( + OverkizCommand.REFRESH_TARGET_TEMPERATURE, refresh_afterwards=False + ) + await self.coordinator.async_refresh() + + @property + def is_state_eco(self) -> bool: + """Return true if eco mode is on.""" + + return ( + self.executor.select_state(OverkizState.IO_DHW_MODE) + == OverkizCommandParam.MANUAL_ECO_ACTIVE + ) + + @property + def is_state_performance(self) -> bool: + """Return true if performance mode is on.""" + + return ( + self.executor.select_state(OverkizState.IO_DHW_MODE) + == OverkizCommandParam.AUTO_MODE + ) + + @property + def is_state_heat_pump(self) -> bool: + """Return true if heat pump mode is on.""" + + return ( + self.executor.select_state(OverkizState.IO_DHW_MODE) + == OverkizCommandParam.MANUAL_ECO_INACTIVE + ) + + @property + def is_away_mode_on(self) -> bool: + """Return true if away mode is on.""" + + away_mode_duration = cast( + str, self.executor.select_state(OverkizState.IO_AWAY_MODE_DURATION) + ) + # away_mode_duration can be either a Literal["always"] + if away_mode_duration == OverkizCommandParam.ALWAYS: + return True + + # Or an int of 0 to 7 days. But it still is a string. + if away_mode_duration.isdecimal() and int(away_mode_duration) > 0: + return True + + return False + + @property + def current_operation(self) -> str | None: + """Return current operation.""" + + # The Away Mode leaves the current operation unchanged + if self.is_boost_mode_on: + return STATE_ELECTRIC + + if self.is_state_eco: + return STATE_ECO + + if self.is_state_performance: + return STATE_PERFORMANCE + + if self.is_state_heat_pump: + return STATE_HEAT_PUMP + + return None + + @property + def is_boost_mode_on(self) -> bool: + """Return true if boost mode is on.""" + + return ( + cast( + int, + self.executor.select_state(OverkizState.CORE_BOOST_MODE_DURATION), + ) + > 0 + ) + + async def async_set_operation_mode(self, operation_mode: str) -> None: + """Set new operation mode.""" + + if operation_mode == STATE_ECO: + if self.is_boost_mode_on: + await self.async_turn_boost_mode_off(refresh_afterwards=False) + + if self.is_away_mode_on: + await self.async_turn_away_mode_off(refresh_afterwards=False) + + await self.executor.async_execute_command( + OverkizCommand.SET_DHW_MODE, + OverkizCommandParam.MANUAL_ECO_ACTIVE, + refresh_afterwards=False, + ) + # ECO changes the target temperature so we have to refresh it + await self.executor.async_execute_command( + OverkizCommand.REFRESH_TARGET_TEMPERATURE, refresh_afterwards=False + ) + await self.coordinator.async_refresh() + + elif operation_mode == STATE_PERFORMANCE: + if self.is_boost_mode_on: + await self.async_turn_boost_mode_off(refresh_afterwards=False) + if self.is_away_mode_on: + await self.async_turn_away_mode_off(refresh_afterwards=False) + + await self.executor.async_execute_command( + OverkizCommand.SET_DHW_MODE, + OverkizCommandParam.AUTO_MODE, + refresh_afterwards=False, + ) + + await self.coordinator.async_refresh() + + elif operation_mode == STATE_HEAT_PUMP: + refresh_target_temp = False + if self.is_state_performance: + # Switching from STATE_PERFORMANCE to STATE_HEAT_PUMP + # changes the target temperature and requires a target temperature refresh + refresh_target_temp = True + + if self.is_boost_mode_on: + await self.async_turn_boost_mode_off(refresh_afterwards=False) + if self.is_away_mode_on: + await self.async_turn_away_mode_off(refresh_afterwards=False) + + await self.executor.async_execute_command( + OverkizCommand.SET_DHW_MODE, + OverkizCommandParam.MANUAL_ECO_INACTIVE, + refresh_afterwards=False, + ) + + if refresh_target_temp: + await self.executor.async_execute_command( + OverkizCommand.REFRESH_TARGET_TEMPERATURE, + refresh_afterwards=False, + ) + + await self.coordinator.async_refresh() + + elif operation_mode == STATE_ELECTRIC: + if self.is_away_mode_on: + await self.async_turn_away_mode_off(refresh_afterwards=False) + if not self.is_boost_mode_on: + await self.async_turn_boost_mode_on(refresh_afterwards=False) + await self.coordinator.async_refresh() + + async def async_turn_away_mode_on(self, refresh_afterwards: bool = True) -> None: + """Turn away mode on.""" + + await self.executor.async_execute_command( + OverkizCommand.SET_CURRENT_OPERATING_MODE, + { + OverkizCommandParam.RELAUNCH: OverkizCommandParam.OFF, + OverkizCommandParam.ABSENCE: OverkizCommandParam.ON, + }, + refresh_afterwards=False, + ) + # Toggling the AWAY mode changes away mode duration so we have to refresh it + await self.executor.async_execute_command( + OverkizCommand.REFRESH_AWAY_MODE_DURATION, + refresh_afterwards=False, + ) + if refresh_afterwards: + await self.coordinator.async_refresh() + + async def async_turn_away_mode_off(self, refresh_afterwards: bool = True) -> None: + """Turn away mode off.""" + + await self.executor.async_execute_command( + OverkizCommand.SET_CURRENT_OPERATING_MODE, + { + OverkizCommandParam.RELAUNCH: OverkizCommandParam.OFF, + OverkizCommandParam.ABSENCE: OverkizCommandParam.OFF, + }, + refresh_afterwards=False, + ) + # Toggling the AWAY mode changes away mode duration so we have to refresh it + await self.executor.async_execute_command( + OverkizCommand.REFRESH_AWAY_MODE_DURATION, + refresh_afterwards=False, + ) + if refresh_afterwards: + await self.coordinator.async_refresh() + + async def async_turn_boost_mode_on(self, refresh_afterwards: bool = True) -> None: + """Turn boost mode on.""" + + refresh_target_temp = False + if self.is_state_performance: + # Switching from STATE_PERFORMANCE to BOOST requires a target temperature refresh + refresh_target_temp = True + + await self.executor.async_execute_command( + OverkizCommand.SET_BOOST_MODE_DURATION, + MAX_BOOST_MODE_DURATION, + refresh_afterwards=False, + ) + + await self.executor.async_execute_command( + OverkizCommand.SET_CURRENT_OPERATING_MODE, + { + OverkizCommandParam.RELAUNCH: OverkizCommandParam.ON, + OverkizCommandParam.ABSENCE: OverkizCommandParam.OFF, + }, + refresh_afterwards=False, + ) + + await self.executor.async_execute_command( + OverkizCommand.REFRESH_BOOST_MODE_DURATION, + refresh_afterwards=False, + ) + + if refresh_target_temp: + await self.executor.async_execute_command( + OverkizCommand.REFRESH_TARGET_TEMPERATURE, refresh_afterwards=False + ) + + if refresh_afterwards: + await self.coordinator.async_refresh() + + async def async_turn_boost_mode_off(self, refresh_afterwards: bool = True) -> None: + """Turn boost mode off.""" + + await self.executor.async_execute_command( + OverkizCommand.SET_CURRENT_OPERATING_MODE, + { + OverkizCommandParam.RELAUNCH: OverkizCommandParam.OFF, + OverkizCommandParam.ABSENCE: OverkizCommandParam.OFF, + }, + refresh_afterwards=False, + ) + # Toggling the BOOST mode changes boost mode duration so we have to refresh it + await self.executor.async_execute_command( + OverkizCommand.REFRESH_BOOST_MODE_DURATION, + refresh_afterwards=False, + ) + + if refresh_afterwards: + await self.coordinator.async_refresh()