diff --git a/homeassistant/components/overkiz/climate.py b/homeassistant/components/overkiz/climate.py index b6d31a8e685..2c24ca4f832 100644 --- a/homeassistant/components/overkiz/climate.py +++ b/homeassistant/components/overkiz/climate.py @@ -1,6 +1,8 @@ """Support for Overkiz climate devices.""" from __future__ import annotations +from typing import cast + from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -8,8 +10,10 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import HomeAssistantOverkizData from .climate_entities import ( + WIDGET_AND_CONTROLLABLE_TO_CLIMATE_ENTITY, WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY, WIDGET_TO_CLIMATE_ENTITY, + Controllable, ) from .const import DOMAIN @@ -28,6 +32,18 @@ async def async_setup_entry( if device.widget in WIDGET_TO_CLIMATE_ENTITY ) + # Match devices based on the widget and controllableName + # This is for example used for Atlantic APC, where devices with different functionality share the same uiClass and widget. + async_add_entities( + WIDGET_AND_CONTROLLABLE_TO_CLIMATE_ENTITY[device.widget][ + cast(Controllable, device.controllable_name) + ](device.device_url, data.coordinator) + for device in data.platforms[Platform.CLIMATE] + if device.widget in WIDGET_AND_CONTROLLABLE_TO_CLIMATE_ENTITY + and device.controllable_name + in WIDGET_AND_CONTROLLABLE_TO_CLIMATE_ENTITY[device.widget] + ) + # Hitachi Air To Air Heat Pumps async_add_entities( WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY[device.widget][device.protocol]( diff --git a/homeassistant/components/overkiz/climate_entities/__init__.py b/homeassistant/components/overkiz/climate_entities/__init__.py index c74ff2829cc..331823c594a 100644 --- a/homeassistant/components/overkiz/climate_entities/__init__.py +++ b/homeassistant/components/overkiz/climate_entities/__init__.py @@ -1,4 +1,6 @@ """Climate entities for the Overkiz (by Somfy) integration.""" +from enum import StrEnum, unique + from pyoverkiz.enums import Protocol from pyoverkiz.enums.ui import UIWidget @@ -10,18 +12,30 @@ from .atlantic_electrical_towel_dryer import AtlanticElectricalTowelDryer from .atlantic_heat_recovery_ventilation import AtlanticHeatRecoveryVentilation 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 .somfy_heating_temperature_interface import SomfyHeatingTemperatureInterface from .somfy_thermostat import SomfyThermostat from .valve_heating_temperature_interface import ValveHeatingTemperatureInterface + +@unique +class Controllable(StrEnum): + """Enum for widget controllables.""" + + IO_ATLANTIC_PASS_APC_HEATING_AND_COOLING_ZONE = ( + "io:AtlanticPassAPCHeatingAndCoolingZoneComponent" + ) + IO_ATLANTIC_PASS_APC_ZONE_CONTROL_ZONE = ( + "io:AtlanticPassAPCZoneControlZoneComponent" + ) + + WIDGET_TO_CLIMATE_ENTITY = { UIWidget.ATLANTIC_ELECTRICAL_HEATER: AtlanticElectricalHeater, UIWidget.ATLANTIC_ELECTRICAL_HEATER_WITH_ADJUSTABLE_TEMPERATURE_SETPOINT: AtlanticElectricalHeaterWithAdjustableTemperatureSetpoint, UIWidget.ATLANTIC_ELECTRICAL_TOWEL_DRYER: AtlanticElectricalTowelDryer, UIWidget.ATLANTIC_HEAT_RECOVERY_VENTILATION: AtlanticHeatRecoveryVentilation, - # ATLANTIC_PASS_APC_HEATING_AND_COOLING_ZONE works exactly the same as ATLANTIC_PASS_APC_HEATING_ZONE - UIWidget.ATLANTIC_PASS_APC_HEATING_AND_COOLING_ZONE: AtlanticPassAPCHeatingZone, UIWidget.ATLANTIC_PASS_APC_HEATING_ZONE: AtlanticPassAPCHeatingZone, UIWidget.ATLANTIC_PASS_APC_ZONE_CONTROL: AtlanticPassAPCZoneControl, UIWidget.SOMFY_HEATING_TEMPERATURE_INTERFACE: SomfyHeatingTemperatureInterface, @@ -29,6 +43,15 @@ WIDGET_TO_CLIMATE_ENTITY = { UIWidget.VALVE_HEATING_TEMPERATURE_INTERFACE: ValveHeatingTemperatureInterface, } +# For Atlantic APC, some devices are standalone and control themselves, some others needs to be +# managed by a ZoneControl device. Widget name is the same in the two cases. +WIDGET_AND_CONTROLLABLE_TO_CLIMATE_ENTITY = { + UIWidget.ATLANTIC_PASS_APC_HEATING_AND_COOLING_ZONE: { + Controllable.IO_ATLANTIC_PASS_APC_HEATING_AND_COOLING_ZONE: AtlanticPassAPCHeatingZone, + Controllable.IO_ATLANTIC_PASS_APC_ZONE_CONTROL_ZONE: AtlanticPassAPCZoneControlZone, + } +} + # Hitachi air-to-air heatpumps come in 2 flavors (HLRRWIFI and OVP) that are separated in 2 classes WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY = { UIWidget.HITACHI_AIR_TO_AIR_HEAT_PUMP: { diff --git a/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_heating_zone.py b/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_heating_zone.py index 25dab7c1d7e..157ec72a249 100644 --- a/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_heating_zone.py +++ b/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_heating_zone.py @@ -49,7 +49,15 @@ OVERKIZ_TO_PRESET_MODES: dict[str, str] = { OverkizCommandParam.INTERNAL_SCHEDULING: PRESET_HOME, } -PRESET_MODES_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_PRESET_MODES.items()} +PRESET_MODES_TO_OVERKIZ: dict[str, str] = { + PRESET_COMFORT: OverkizCommandParam.COMFORT, + PRESET_AWAY: OverkizCommandParam.ABSENCE, + PRESET_ECO: OverkizCommandParam.ECO, + PRESET_FROST_PROTECTION: OverkizCommandParam.FROSTPROTECTION, + PRESET_EXTERNAL: OverkizCommandParam.EXTERNAL_SCHEDULING, + PRESET_HOME: OverkizCommandParam.INTERNAL_SCHEDULING, +} + OVERKIZ_TO_PROFILE_MODES: dict[str, str] = { OverkizCommandParam.OFF: PRESET_SLEEP, diff --git a/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_zone_control.py b/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_zone_control.py index fe9f20b05fc..cfb92067875 100644 --- a/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_zone_control.py +++ b/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_zone_control.py @@ -10,6 +10,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import UnitOfTemperature +from ..coordinator import OverkizDataUpdateCoordinator from ..entity import OverkizEntity OVERKIZ_TO_HVAC_MODE: dict[str, HVACMode] = { @@ -25,16 +26,48 @@ HVAC_MODE_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_HVAC_MODE.items()} class AtlanticPassAPCZoneControl(OverkizEntity, ClimateEntity): """Representation of Atlantic Pass APC Zone Control.""" - _attr_hvac_modes = [*HVAC_MODE_TO_OVERKIZ] _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_supported_features = ( ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON ) _enable_turn_on_off_backwards_compatibility = False + def __init__( + self, device_url: str, coordinator: OverkizDataUpdateCoordinator + ) -> None: + """Init method.""" + super().__init__(device_url, coordinator) + + self._attr_hvac_modes = [*HVAC_MODE_TO_OVERKIZ] + + # Cooling is supported by a separate command + if self.is_auto_hvac_mode_available: + self._attr_hvac_modes.append(HVACMode.AUTO) + + @property + def is_auto_hvac_mode_available(self) -> bool: + """Check if auto mode is available on the ZoneControl.""" + + return self.executor.has_command( + OverkizCommand.SET_HEATING_COOLING_AUTO_SWITCH + ) and self.executor.has_state(OverkizState.CORE_HEATING_COOLING_AUTO_SWITCH) + @property def hvac_mode(self) -> HVACMode: """Return hvac operation ie. heat, cool mode.""" + + if ( + self.is_auto_hvac_mode_available + and cast( + str, + self.executor.select_state( + OverkizState.CORE_HEATING_COOLING_AUTO_SWITCH + ), + ) + == OverkizCommandParam.ON + ): + return HVACMode.AUTO + return OVERKIZ_TO_HVAC_MODE[ cast( str, self.executor.select_state(OverkizState.IO_PASS_APC_OPERATING_MODE) @@ -43,6 +76,18 @@ class AtlanticPassAPCZoneControl(OverkizEntity, ClimateEntity): async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" + + if self.is_auto_hvac_mode_available: + await self.executor.async_execute_command( + OverkizCommand.SET_HEATING_COOLING_AUTO_SWITCH, + OverkizCommandParam.ON + if hvac_mode == HVACMode.AUTO + else OverkizCommandParam.OFF, + ) + + if hvac_mode == HVACMode.AUTO: + return + await self.executor.async_execute_command( OverkizCommand.SET_PASS_APC_OPERATING_MODE, HVAC_MODE_TO_OVERKIZ[hvac_mode] ) diff --git a/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_zone_control_zone.py b/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_zone_control_zone.py new file mode 100644 index 00000000000..a30cb93f287 --- /dev/null +++ b/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_zone_control_zone.py @@ -0,0 +1,252 @@ +"""Support for Atlantic Pass APC Heating Control.""" +from __future__ import annotations + +from asyncio import sleep +from typing import Any, cast + +from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState + +from homeassistant.components.climate import PRESET_NONE, HVACMode +from homeassistant.const import ATTR_TEMPERATURE + +from ..coordinator import OverkizDataUpdateCoordinator +from .atlantic_pass_apc_heating_zone import AtlanticPassAPCHeatingZone +from .atlantic_pass_apc_zone_control import OVERKIZ_TO_HVAC_MODE + +PRESET_SCHEDULE = "schedule" +PRESET_MANUAL = "manual" + +OVERKIZ_MODE_TO_PRESET_MODES: dict[str, str] = { + OverkizCommandParam.MANU: PRESET_MANUAL, + OverkizCommandParam.INTERNAL_SCHEDULING: PRESET_SCHEDULE, +} + +PRESET_MODES_TO_OVERKIZ = {v: k for k, v in OVERKIZ_MODE_TO_PRESET_MODES.items()} + +TEMPERATURE_ZONECONTROL_DEVICE_INDEX = 1 + + +# Those device depends on a main probe that choose the operating mode (heating, cooling, ...) +class AtlanticPassAPCZoneControlZone(AtlanticPassAPCHeatingZone): + """Representation of Atlantic Pass APC Heating And Cooling Zone Control.""" + + def __init__( + self, device_url: str, coordinator: OverkizDataUpdateCoordinator + ) -> None: + """Init method.""" + super().__init__(device_url, coordinator) + + # There is less supported functions, because they depend on the ZoneControl. + if not self.is_using_derogated_temperature_fallback: + # Modes are not configurable, they will follow current HVAC Mode of Zone Control. + self._attr_hvac_modes = [] + + # Those are available and tested presets on Shogun. + self._attr_preset_modes = [*PRESET_MODES_TO_OVERKIZ] + + # Those APC Heating and Cooling probes depends on the zone control device (main probe). + # Only the base device (#1) can be used to get/set some states. + # Like to retrieve and set the current operating mode (heating, cooling, drying, off). + self.zone_control_device = self.executor.linked_device( + TEMPERATURE_ZONECONTROL_DEVICE_INDEX + ) + + @property + def is_using_derogated_temperature_fallback(self) -> bool: + """Check if the device behave like the Pass APC Heating Zone.""" + + return self.executor.has_command( + OverkizCommand.SET_DEROGATED_TARGET_TEMPERATURE + ) + + @property + def zone_control_hvac_mode(self) -> HVACMode: + """Return hvac operation ie. heat, cool, dry, off mode.""" + + if ( + state := self.zone_control_device.states[ + OverkizState.IO_PASS_APC_OPERATING_MODE + ] + ) is not None and (value := state.value_as_str) is not None: + return OVERKIZ_TO_HVAC_MODE[value] + return HVACMode.OFF + + @property + def hvac_mode(self) -> HVACMode: + """Return hvac operation ie. heat, cool, dry, off mode.""" + + if self.is_using_derogated_temperature_fallback: + return super().hvac_mode + + zone_control_hvac_mode = self.zone_control_hvac_mode + + # Should be same, because either thermostat or this integration change both. + on_off_state = cast( + str, + self.executor.select_state( + OverkizState.CORE_COOLING_ON_OFF + if zone_control_hvac_mode == HVACMode.COOL + else OverkizState.CORE_HEATING_ON_OFF + ), + ) + + # Device is Stopped, it means the air flux is flowing but its venting door is closed. + if on_off_state == OverkizCommandParam.OFF: + hvac_mode = HVACMode.OFF + else: + hvac_mode = zone_control_hvac_mode + + # It helps keep it consistent with the Zone Control, within the interface. + if self._attr_hvac_modes != [zone_control_hvac_mode, HVACMode.OFF]: + self._attr_hvac_modes = [zone_control_hvac_mode, HVACMode.OFF] + self.async_write_ha_state() + + return hvac_mode + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target hvac mode.""" + + if self.is_using_derogated_temperature_fallback: + return await super().async_set_hvac_mode(hvac_mode) + + # They are mainly managed by the Zone Control device + # However, it make sense to map the OFF Mode to the Overkiz STOP Preset + + if hvac_mode == HVACMode.OFF: + await self.executor.async_execute_command( + OverkizCommand.SET_COOLING_ON_OFF, + OverkizCommandParam.OFF, + ) + await self.executor.async_execute_command( + OverkizCommand.SET_HEATING_ON_OFF, + OverkizCommandParam.OFF, + ) + else: + await self.executor.async_execute_command( + OverkizCommand.SET_COOLING_ON_OFF, + OverkizCommandParam.ON, + ) + await self.executor.async_execute_command( + OverkizCommand.SET_HEATING_ON_OFF, + OverkizCommandParam.ON, + ) + + await self.async_refresh_modes() + + @property + def preset_mode(self) -> str: + """Return the current preset mode, e.g., schedule, manual.""" + + if self.is_using_derogated_temperature_fallback: + return super().preset_mode + + mode = OVERKIZ_MODE_TO_PRESET_MODES[ + cast( + str, + self.executor.select_state( + OverkizState.IO_PASS_APC_COOLING_MODE + if self.zone_control_hvac_mode == HVACMode.COOL + else OverkizState.IO_PASS_APC_HEATING_MODE + ), + ) + ] + + return mode if mode is not None else PRESET_NONE + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + + if self.is_using_derogated_temperature_fallback: + return await super().async_set_preset_mode(preset_mode) + + mode = PRESET_MODES_TO_OVERKIZ[preset_mode] + + # For consistency, it is better both are synced like on the Thermostat. + await self.executor.async_execute_command( + OverkizCommand.SET_PASS_APC_HEATING_MODE, mode + ) + await self.executor.async_execute_command( + OverkizCommand.SET_PASS_APC_COOLING_MODE, mode + ) + + await self.async_refresh_modes() + + @property + def target_temperature(self) -> float: + """Return hvac target temperature.""" + + if self.is_using_derogated_temperature_fallback: + return super().target_temperature + + if self.zone_control_hvac_mode == HVACMode.COOL: + return cast( + float, + self.executor.select_state( + OverkizState.CORE_COOLING_TARGET_TEMPERATURE + ), + ) + + if self.zone_control_hvac_mode == HVACMode.HEAT: + return cast( + float, + self.executor.select_state( + OverkizState.CORE_HEATING_TARGET_TEMPERATURE + ), + ) + + return cast( + float, self.executor.select_state(OverkizState.CORE_TARGET_TEMPERATURE) + ) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new temperature.""" + + if self.is_using_derogated_temperature_fallback: + return await super().async_set_temperature(**kwargs) + + temperature = kwargs[ATTR_TEMPERATURE] + + # Change both (heating/cooling) temperature is a good way to have consistency + await self.executor.async_execute_command( + OverkizCommand.SET_HEATING_TARGET_TEMPERATURE, + temperature, + ) + await self.executor.async_execute_command( + OverkizCommand.SET_COOLING_TARGET_TEMPERATURE, + temperature, + ) + await self.executor.async_execute_command( + OverkizCommand.SET_DEROGATION_ON_OFF_STATE, + OverkizCommandParam.OFF, + ) + + # Target temperature may take up to 1 minute to get refreshed. + await self.executor.async_execute_command( + OverkizCommand.REFRESH_TARGET_TEMPERATURE + ) + + async def async_refresh_modes(self) -> None: + """Refresh the device modes to have new states.""" + + # The device needs a bit of time to update everything before a refresh. + await sleep(2) + + await self.executor.async_execute_command( + OverkizCommand.REFRESH_PASS_APC_HEATING_MODE + ) + + await self.executor.async_execute_command( + OverkizCommand.REFRESH_PASS_APC_HEATING_PROFILE + ) + + await self.executor.async_execute_command( + OverkizCommand.REFRESH_PASS_APC_COOLING_MODE + ) + + await self.executor.async_execute_command( + OverkizCommand.REFRESH_PASS_APC_COOLING_PROFILE + ) + + await self.executor.async_execute_command( + OverkizCommand.REFRESH_TARGET_TEMPERATURE + )