From af625a6696badf72e7f5de64339fda6c053f748c Mon Sep 17 00:00:00 2001 From: dotvav Date: Thu, 29 Feb 2024 19:18:59 +0100 Subject: [PATCH] Support HitachiAirToAirHeatPump (ovp:HLinkMainController) in Overkiz integration (#102159) * Support OVP devices Support OVP devices * Fix coding style * Fix coding style and unnecessary constants * Move fanmodes inside class * Apply suggestions from code review Co-authored-by: Joost Lekkerkerker * Remove duplicate widget * Update homeassistant/components/overkiz/climate_entities/hitachi_air_to_air_heat_pump_ovp.py Co-authored-by: Joost Lekkerkerker * Format ruff * Fix mypy --------- Co-authored-by: Mick Vleeshouwer Co-authored-by: Joost Lekkerkerker --- .../overkiz/climate_entities/__init__.py | 2 + .../hitachi_air_to_air_heat_pump_ovp.py | 357 ++++++++++++++++++ 2 files changed, 359 insertions(+) create mode 100644 homeassistant/components/overkiz/climate_entities/hitachi_air_to_air_heat_pump_ovp.py diff --git a/homeassistant/components/overkiz/climate_entities/__init__.py b/homeassistant/components/overkiz/climate_entities/__init__.py index 331823c594a..72230c99a05 100644 --- a/homeassistant/components/overkiz/climate_entities/__init__.py +++ b/homeassistant/components/overkiz/climate_entities/__init__.py @@ -14,6 +14,7 @@ from .atlantic_pass_apc_heating_zone import AtlanticPassAPCHeatingZone from .atlantic_pass_apc_zone_control import AtlanticPassAPCZoneControl from .atlantic_pass_apc_zone_control_zone import AtlanticPassAPCZoneControlZone from .hitachi_air_to_air_heat_pump_hlrrwifi import HitachiAirToAirHeatPumpHLRRWIFI +from .hitachi_air_to_air_heat_pump_ovp import HitachiAirToAirHeatPumpOVP from .somfy_heating_temperature_interface import SomfyHeatingTemperatureInterface from .somfy_thermostat import SomfyThermostat from .valve_heating_temperature_interface import ValveHeatingTemperatureInterface @@ -56,5 +57,6 @@ WIDGET_AND_CONTROLLABLE_TO_CLIMATE_ENTITY = { WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY = { UIWidget.HITACHI_AIR_TO_AIR_HEAT_PUMP: { Protocol.HLRR_WIFI: HitachiAirToAirHeatPumpHLRRWIFI, + Protocol.OVP: HitachiAirToAirHeatPumpOVP, }, } diff --git a/homeassistant/components/overkiz/climate_entities/hitachi_air_to_air_heat_pump_ovp.py b/homeassistant/components/overkiz/climate_entities/hitachi_air_to_air_heat_pump_ovp.py new file mode 100644 index 00000000000..bf6bb5f95d5 --- /dev/null +++ b/homeassistant/components/overkiz/climate_entities/hitachi_air_to_air_heat_pump_ovp.py @@ -0,0 +1,357 @@ +"""Support for HitachiAirToAirHeatPump.""" +from __future__ import annotations + +from typing import Any + +from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState + +from homeassistant.components.climate import ( + FAN_AUTO, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + PRESET_NONE, + SWING_BOTH, + SWING_HORIZONTAL, + SWING_OFF, + SWING_VERTICAL, + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature + +from ..const import DOMAIN +from ..coordinator import OverkizDataUpdateCoordinator +from ..entity import OverkizEntity + +PRESET_HOLIDAY_MODE = "holiday_mode" +FAN_SILENT = "silent" +TEMP_MIN = 16 +TEMP_MAX = 32 +TEMP_AUTO_MIN = 22 +TEMP_AUTO_MAX = 28 +AUTO_PIVOT_TEMPERATURE = 25 +AUTO_TEMPERATURE_CHANGE_MIN = TEMP_AUTO_MIN - AUTO_PIVOT_TEMPERATURE +AUTO_TEMPERATURE_CHANGE_MAX = TEMP_AUTO_MAX - AUTO_PIVOT_TEMPERATURE + +OVERKIZ_TO_HVAC_MODES: dict[str, HVACMode] = { + OverkizCommandParam.AUTOHEATING: HVACMode.AUTO, + OverkizCommandParam.AUTOCOOLING: HVACMode.AUTO, + OverkizCommandParam.ON: HVACMode.HEAT, + OverkizCommandParam.OFF: HVACMode.OFF, + OverkizCommandParam.HEATING: HVACMode.HEAT, + OverkizCommandParam.FAN: HVACMode.FAN_ONLY, + OverkizCommandParam.DEHUMIDIFY: HVACMode.DRY, + OverkizCommandParam.COOLING: HVACMode.COOL, +} + +HVAC_MODES_TO_OVERKIZ: dict[HVACMode, str] = { + HVACMode.AUTO: OverkizCommandParam.AUTO, + HVACMode.HEAT: OverkizCommandParam.HEATING, + HVACMode.OFF: OverkizCommandParam.HEATING, + HVACMode.FAN_ONLY: OverkizCommandParam.FAN, + HVACMode.DRY: OverkizCommandParam.DEHUMIDIFY, + HVACMode.COOL: OverkizCommandParam.COOLING, +} + +OVERKIZ_TO_SWING_MODES: dict[str, str] = { + OverkizCommandParam.BOTH: SWING_BOTH, + OverkizCommandParam.HORIZONTAL: SWING_HORIZONTAL, + OverkizCommandParam.STOP: SWING_OFF, + OverkizCommandParam.VERTICAL: SWING_VERTICAL, +} + +SWING_MODES_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_SWING_MODES.items()} + +OVERKIZ_TO_FAN_MODES: dict[str, str] = { + OverkizCommandParam.AUTO: FAN_AUTO, + OverkizCommandParam.HIGH: FAN_HIGH, # fallback, state can be exposed as HIGH, new state = hi + OverkizCommandParam.HI: FAN_HIGH, + OverkizCommandParam.LOW: FAN_LOW, + OverkizCommandParam.LO: FAN_LOW, + OverkizCommandParam.MEDIUM: FAN_MEDIUM, # fallback, state can be exposed as MEDIUM, new state = med + OverkizCommandParam.MED: FAN_MEDIUM, + OverkizCommandParam.SILENT: OverkizCommandParam.SILENT, +} + +FAN_MODES_TO_OVERKIZ: dict[str, str] = { + FAN_AUTO: OverkizCommandParam.AUTO, + FAN_HIGH: OverkizCommandParam.HI, + FAN_LOW: OverkizCommandParam.LO, + FAN_MEDIUM: OverkizCommandParam.MED, + FAN_SILENT: OverkizCommandParam.SILENT, +} + + +class HitachiAirToAirHeatPumpOVP(OverkizEntity, ClimateEntity): + """Representation of Hitachi Air To Air HeatPump.""" + + _attr_hvac_modes = [*HVAC_MODES_TO_OVERKIZ] + _attr_fan_modes = [*FAN_MODES_TO_OVERKIZ] + _attr_preset_modes = [PRESET_NONE, PRESET_HOLIDAY_MODE] + _attr_swing_modes = [*SWING_MODES_TO_OVERKIZ] + _attr_target_temperature_step = 1.0 + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_translation_key = DOMAIN + + def __init__( + self, device_url: str, coordinator: OverkizDataUpdateCoordinator + ) -> None: + """Init method.""" + super().__init__(device_url, coordinator) + + self._attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.PRESET_MODE + ) + + if self.device.states.get(OverkizState.OVP_SWING): + self._attr_supported_features |= ClimateEntityFeature.SWING_MODE + + if self._attr_device_info: + self._attr_device_info["manufacturer"] = "Hitachi" + + @property + def hvac_mode(self) -> HVACMode: + """Return hvac operation ie. heat, cool mode.""" + if ( + main_op_state := self.device.states[OverkizState.OVP_MAIN_OPERATION] + ) and main_op_state.value_as_str: + if main_op_state.value_as_str.lower() == OverkizCommandParam.OFF: + return HVACMode.OFF + + if ( + mode_change_state := self.device.states[OverkizState.OVP_MODE_CHANGE] + ) and mode_change_state.value_as_str: + # The OVP protocol has 'auto cooling' and 'auto heating' values + # that are equivalent to the HLRRWIFI protocol without spaces + sanitized_value = mode_change_state.value_as_str.replace(" ", "").lower() + return OVERKIZ_TO_HVAC_MODES[sanitized_value] + + return HVACMode.OFF + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target hvac mode.""" + if hvac_mode == HVACMode.OFF: + await self._global_control(main_operation=OverkizCommandParam.OFF) + else: + await self._global_control( + main_operation=OverkizCommandParam.ON, + hvac_mode=HVAC_MODES_TO_OVERKIZ[hvac_mode], + ) + + @property + def fan_mode(self) -> str | None: + """Return the fan setting.""" + if ( + state := self.device.states[OverkizState.OVP_FAN_SPEED] + ) and state.value_as_str: + return OVERKIZ_TO_FAN_MODES[state.value_as_str] + + return None + + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set new target fan mode.""" + await self._global_control(fan_mode=FAN_MODES_TO_OVERKIZ[fan_mode]) + + @property + def swing_mode(self) -> str | None: + """Return the swing setting.""" + if (state := self.device.states[OverkizState.OVP_SWING]) and state.value_as_str: + return OVERKIZ_TO_SWING_MODES[state.value_as_str] + + return None + + async def async_set_swing_mode(self, swing_mode: str) -> None: + """Set new target swing operation.""" + await self._global_control(swing_mode=SWING_MODES_TO_OVERKIZ[swing_mode]) + + @property + def target_temperature(self) -> int | None: + """Return the target temperature.""" + if ( + temperature := self.device.states[OverkizState.CORE_TARGET_TEMPERATURE] + ) and temperature.value_as_int: + return temperature.value_as_int + + return None + + @property + def current_temperature(self) -> int | None: + """Return current temperature.""" + if ( + state := self.device.states[OverkizState.OVP_ROOM_TEMPERATURE] + ) and state.value_as_int: + return state.value_as_int + + return None + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new temperature.""" + await self._global_control(target_temperature=int(kwargs[ATTR_TEMPERATURE])) + + @property + def preset_mode(self) -> str | None: + """Return the current preset mode, e.g., home, away, temp.""" + if ( + state := self.device.states[OverkizState.CORE_HOLIDAYS_MODE] + ) and state.value_as_str: + if state.value_as_str == OverkizCommandParam.ON: + return PRESET_HOLIDAY_MODE + + if state.value_as_str == OverkizCommandParam.OFF: + return PRESET_NONE + + return None + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + if preset_mode == PRESET_HOLIDAY_MODE: + await self.executor.async_execute_command( + OverkizCommand.SET_HOLIDAYS, + OverkizCommandParam.ON, + ) + if preset_mode == PRESET_NONE: + await self.executor.async_execute_command( + OverkizCommand.SET_HOLIDAYS, + OverkizCommandParam.OFF, + ) + + # OVP has this property to control the unit's timer mode + @property + def auto_manu_mode(self) -> str | None: + """Return auto/manu mode.""" + if ( + state := self.device.states[OverkizState.CORE_AUTO_MANU_MODE] + ) and state.value_as_str: + return state.value_as_str + return None + + # OVP has this property to control the target temperature delta in auto mode + @property + def temperature_change(self) -> int | None: + """Return temperature change state.""" + if ( + state := self.device.states[OverkizState.OVP_TEMPERATURE_CHANGE] + ) and state.value_as_int: + return state.value_as_int + + return None + + @property + def min_temp(self) -> float: + """Return the minimum temperature.""" + if self.hvac_mode == HVACMode.AUTO: + return TEMP_AUTO_MIN + return TEMP_MIN + + @property + def max_temp(self) -> float: + """Return the maximum temperature.""" + if self.hvac_mode == HVACMode.AUTO: + return TEMP_AUTO_MAX + return TEMP_MAX + + def _control_backfill( + self, value: str | None, state_name: str, fallback_value: str + ) -> str: + """Return a parameter value which will be accepted in a command by Overkiz. + + Overkiz doesn't accept commands with undefined parameters. This function + is guaranteed to return a `str` which is the provided `value` if set, or + the current device state if set, or the provided `fallback_value` otherwise. + """ + if value: + return value + if (state := self.device.states[state_name]) is not None and ( + value := state.value_as_str + ) is not None: + return value + return fallback_value + + async def _global_control( + self, + main_operation: str | None = None, + target_temperature: int | None = None, + fan_mode: str | None = None, + hvac_mode: str | None = None, + swing_mode: str | None = None, + leave_home: str | None = None, + ) -> None: + """Execute globalControl command with all parameters. + + There is no option to only set a single parameter, without passing + all other values. + """ + + main_operation = self._control_backfill( + main_operation, OverkizState.OVP_MAIN_OPERATION, OverkizCommandParam.ON + ) + fan_mode = self._control_backfill( + fan_mode, + OverkizState.OVP_FAN_SPEED, + OverkizCommandParam.AUTO, + ) + hvac_mode = self._control_backfill( + hvac_mode, + OverkizState.OVP_MODE_CHANGE, + OverkizCommandParam.AUTO, + ).lower() # Overkiz returns uppercase states that are not acceptable commands + if hvac_mode.replace(" ", "") in [ + # Overkiz returns compound states like 'auto cooling' or 'autoHeating' + # that are not valid commands and need to be mapped to 'auto' + OverkizCommandParam.AUTOCOOLING, + OverkizCommandParam.AUTOHEATING, + ]: + hvac_mode = OverkizCommandParam.AUTO + + swing_mode = self._control_backfill( + swing_mode, + OverkizState.OVP_SWING, + OverkizCommandParam.STOP, + ) + + # AUTO_MANU parameter is not controlled by HA and is turned "off" when the device is on Holiday mode + auto_manu_mode = self._control_backfill( + None, OverkizState.CORE_AUTO_MANU_MODE, OverkizCommandParam.MANU + ) + if self.preset_mode == PRESET_HOLIDAY_MODE: + auto_manu_mode = OverkizCommandParam.OFF + + # In all the hvac modes except AUTO, the temperature command parameter is the target temperature + temperature_command = None + target_temperature = target_temperature or self.target_temperature + if hvac_mode == OverkizCommandParam.AUTO: + # In hvac mode AUTO, the temperature command parameter is a temperature_change + # which is the delta between a pivot temperature (25) and the target temperature + temperature_change = 0 + + if target_temperature: + temperature_change = target_temperature - AUTO_PIVOT_TEMPERATURE + elif self.temperature_change: + temperature_change = self.temperature_change + + # Keep temperature_change in the API accepted range + temperature_change = min( + max(temperature_change, AUTO_TEMPERATURE_CHANGE_MIN), + AUTO_TEMPERATURE_CHANGE_MAX, + ) + + temperature_command = temperature_change + else: + # In other modes, the temperature command is the target temperature + temperature_command = target_temperature + + command_data = [ + main_operation, # Main Operation + temperature_command, # Temperature Command + fan_mode, # Fan Mode + hvac_mode, # Mode + auto_manu_mode, # Auto Manu Mode + ] + + await self.executor.async_execute_command( + OverkizCommand.GLOBAL_CONTROL, command_data + )