Follow real AtlanticPassAPCZoneControlZone physical mode on Overkiz (HEAT, COOL or HEAT_COOL) (#111830)

* Support HEAT_COOL when mode is Auto on overkiz AtlanticPassAPCZoneControlZone

* Refactor ZoneControlZone to simplify usic by only using a single hvac mode

* Fix linting issues

* Makes more sense to use halves there

* Fix PR feedback
This commit is contained in:
Jeremy TRUFIER 2024-03-29 14:51:44 +01:00 committed by GitHub
parent dc557fca1e
commit 5b4452a579
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 325 additions and 98 deletions

View File

@ -7,6 +7,7 @@ from typing import cast
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import HomeAssistantOverkizData
@ -27,15 +28,16 @@ async def async_setup_entry(
"""Set up the Overkiz climate from a config entry."""
data: HomeAssistantOverkizData = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
# Match devices based on the widget.
entities_based_on_widget: list[Entity] = [
WIDGET_TO_CLIMATE_ENTITY[device.widget](device.device_url, data.coordinator)
for device in data.platforms[Platform.CLIMATE]
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(
# Match devices based on the widget and controllableName.
# ie Atlantic APC
entities_based_on_widget_and_controllable: list[Entity] = [
WIDGET_AND_CONTROLLABLE_TO_CLIMATE_ENTITY[device.widget][
cast(Controllable, device.controllable_name)
](device.device_url, data.coordinator)
@ -43,14 +45,21 @@ async def async_setup_entry(
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(
# Match devices based on the widget and protocol.
# #ie Hitachi Air To Air Heat Pumps
entities_based_on_widget_and_protocol: list[Entity] = [
WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY[device.widget][device.protocol](
device.device_url, data.coordinator
)
for device in data.platforms[Platform.CLIMATE]
if device.widget in WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY
and device.protocol in WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY[device.widget]
]
async_add_entities(
entities_based_on_widget
+ entities_based_on_widget_and_controllable
+ entities_based_on_widget_and_protocol
)

View File

@ -159,7 +159,7 @@ class AtlanticPassAPCHeatingZone(OverkizEntity, ClimateEntity):
await self.async_set_heating_mode(PRESET_MODES_TO_OVERKIZ[preset_mode])
@property
def preset_mode(self) -> str:
def preset_mode(self) -> str | None:
"""Return the current preset mode, e.g., home, away, temp."""
heating_mode = cast(
str, self.executor.select_state(OverkizState.IO_PASS_APC_HEATING_MODE)
@ -179,7 +179,7 @@ class AtlanticPassAPCHeatingZone(OverkizEntity, ClimateEntity):
return OVERKIZ_TO_PRESET_MODES[heating_mode]
@property
def target_temperature(self) -> float:
def target_temperature(self) -> float | None:
"""Return hvac target temperature."""
current_heating_profile = self.current_heating_profile
if current_heating_profile in OVERKIZ_TEMPERATURE_STATE_BY_PROFILE:

View File

@ -3,16 +3,24 @@
from __future__ import annotations
from asyncio import sleep
from functools import cached_property
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 homeassistant.components.climate import (
ATTR_TARGET_TEMP_HIGH,
ATTR_TARGET_TEMP_LOW,
PRESET_NONE,
ClimateEntityFeature,
HVACAction,
HVACMode,
)
from homeassistant.const import ATTR_TEMPERATURE, PRECISION_HALVES
from ..coordinator import OverkizDataUpdateCoordinator
from ..executor import OverkizExecutor
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"
@ -24,32 +32,127 @@ OVERKIZ_MODE_TO_PRESET_MODES: dict[str, str] = {
PRESET_MODES_TO_OVERKIZ = {v: k for k, v in OVERKIZ_MODE_TO_PRESET_MODES.items()}
TEMPERATURE_ZONECONTROL_DEVICE_INDEX = 20
# Maps the HVAC current ZoneControl system operating mode.
OVERKIZ_TO_HVAC_ACTION: dict[str, HVACAction] = {
OverkizCommandParam.COOLING: HVACAction.COOLING,
OverkizCommandParam.DRYING: HVACAction.DRYING,
OverkizCommandParam.HEATING: HVACAction.HEATING,
# There is no known way to differentiate OFF from Idle.
OverkizCommandParam.STOP: HVACAction.OFF,
}
HVAC_ACTION_TO_OVERKIZ_PROFILE_STATE: dict[HVACAction, OverkizState] = {
HVACAction.COOLING: OverkizState.IO_PASS_APC_COOLING_PROFILE,
HVACAction.HEATING: OverkizState.IO_PASS_APC_HEATING_PROFILE,
}
HVAC_ACTION_TO_OVERKIZ_MODE_STATE: dict[HVACAction, OverkizState] = {
HVACAction.COOLING: OverkizState.IO_PASS_APC_COOLING_MODE,
HVACAction.HEATING: OverkizState.IO_PASS_APC_HEATING_MODE,
}
TEMPERATURE_ZONECONTROL_DEVICE_INDEX = 1
SUPPORTED_FEATURES: ClimateEntityFeature = (
ClimateEntityFeature.PRESET_MODE
| ClimateEntityFeature.TURN_OFF
| ClimateEntityFeature.TURN_ON
)
OVERKIZ_THERMAL_CONFIGURATION_TO_HVAC_MODE: dict[
OverkizCommandParam, tuple[HVACMode, ClimateEntityFeature]
] = {
OverkizCommandParam.COOLING: (
HVACMode.COOL,
SUPPORTED_FEATURES | ClimateEntityFeature.TARGET_TEMPERATURE,
),
OverkizCommandParam.HEATING: (
HVACMode.HEAT,
SUPPORTED_FEATURES | ClimateEntityFeature.TARGET_TEMPERATURE,
),
OverkizCommandParam.HEATING_AND_COOLING: (
HVACMode.HEAT_COOL,
SUPPORTED_FEATURES | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE,
),
}
# Those device depends on a main probe that choose the operating mode (heating, cooling, ...)
# 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."""
_attr_target_temperature_step = PRECISION_HALVES
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 = []
# When using derogated temperature, we fallback to legacy behavior.
if self.is_using_derogated_temperature_fallback:
return
# Those are available and tested presets on Shogun.
self._attr_preset_modes = [*PRESET_MODES_TO_OVERKIZ]
self._attr_hvac_modes = []
self._attr_supported_features = ClimateEntityFeature(0)
# Modes depends on device capabilities.
if (thermal_configuration := self.thermal_configuration) is not None:
(
device_hvac_mode,
climate_entity_feature,
) = thermal_configuration
self._attr_hvac_modes = [device_hvac_mode, HVACMode.OFF]
self._attr_supported_features = climate_entity_feature
# 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
self.zone_control_executor: OverkizExecutor | None = None
if (
zone_control_device := self.executor.linked_device(
TEMPERATURE_ZONECONTROL_DEVICE_INDEX
)
) is not None:
self.zone_control_executor = OverkizExecutor(
zone_control_device.device_url,
coordinator,
)
@cached_property
def thermal_configuration(self) -> tuple[HVACMode, ClimateEntityFeature] | None:
"""Retrieve thermal configuration for this devices."""
if (
(
state_thermal_configuration := cast(
OverkizCommandParam | None,
self.executor.select_state(OverkizState.CORE_THERMAL_CONFIGURATION),
)
)
is not None
and state_thermal_configuration
in OVERKIZ_THERMAL_CONFIGURATION_TO_HVAC_MODE
):
return OVERKIZ_THERMAL_CONFIGURATION_TO_HVAC_MODE[
state_thermal_configuration
]
return None
@cached_property
def device_hvac_mode(self) -> HVACMode | None:
"""ZoneControlZone device has a single possible mode."""
return (
None
if self.thermal_configuration is None
else self.thermal_configuration[0]
)
@property
@ -61,21 +164,37 @@ class AtlanticPassAPCZoneControlZone(AtlanticPassAPCHeatingZone):
)
@property
def zone_control_hvac_mode(self) -> HVACMode:
def zone_control_hvac_action(self) -> HVACAction:
"""Return hvac operation ie. heat, cool, dry, off mode."""
if (
self.zone_control_device is not None
and (
state := self.zone_control_device.states[
if self.zone_control_executor is not None and (
(
state := self.zone_control_executor.select_state(
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
return OVERKIZ_TO_HVAC_ACTION[cast(str, state)]
return HVACAction.OFF
@property
def hvac_action(self) -> HVACAction | None:
"""Return the current running hvac operation."""
# When ZoneControl action is heating/cooling but Zone is stopped, means the zone is idle.
if (
hvac_action := self.zone_control_hvac_action
) in HVAC_ACTION_TO_OVERKIZ_PROFILE_STATE and cast(
str,
self.executor.select_state(
HVAC_ACTION_TO_OVERKIZ_PROFILE_STATE[hvac_action]
),
) == OverkizCommandParam.STOP:
return HVACAction.IDLE
return hvac_action
@property
def hvac_mode(self) -> HVACMode:
@ -84,30 +203,32 @@ class AtlanticPassAPCZoneControlZone(AtlanticPassAPCHeatingZone):
if self.is_using_derogated_temperature_fallback:
return super().hvac_mode
zone_control_hvac_mode = self.zone_control_hvac_mode
if (device_hvac_mode := self.device_hvac_mode) is None:
return HVACMode.OFF
# Should be same, because either thermostat or this integration change both.
on_off_state = cast(
cooling_is_off = cast(
str,
self.executor.select_state(
OverkizState.CORE_COOLING_ON_OFF
if zone_control_hvac_mode == HVACMode.COOL
else OverkizState.CORE_HEATING_ON_OFF
),
)
self.executor.select_state(OverkizState.CORE_COOLING_ON_OFF),
) in (OverkizCommandParam.OFF, None)
heating_is_off = cast(
str,
self.executor.select_state(OverkizState.CORE_HEATING_ON_OFF),
) in (OverkizCommandParam.OFF, None)
# 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
if (
(device_hvac_mode == HVACMode.COOL and cooling_is_off)
or (device_hvac_mode == HVACMode.HEAT and heating_is_off)
or (
device_hvac_mode == HVACMode.HEAT_COOL
and cooling_is_off
and heating_is_off
)
):
return HVACMode.OFF
# 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
return device_hvac_mode
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target hvac mode."""
@ -118,46 +239,49 @@ class AtlanticPassAPCZoneControlZone(AtlanticPassAPCHeatingZone):
# 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,
)
on_off_target_command_param = (
OverkizCommandParam.OFF
if hvac_mode == HVACMode.OFF
else OverkizCommandParam.ON
)
await self.executor.async_execute_command(
OverkizCommand.SET_COOLING_ON_OFF,
on_off_target_command_param,
)
await self.executor.async_execute_command(
OverkizCommand.SET_HEATING_ON_OFF,
on_off_target_command_param,
)
await self.async_refresh_modes()
@property
def preset_mode(self) -> str:
def preset_mode(self) -> str | None:
"""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
),
if (
self.zone_control_hvac_action in HVAC_ACTION_TO_OVERKIZ_MODE_STATE
and (
mode_state := HVAC_ACTION_TO_OVERKIZ_MODE_STATE[
self.zone_control_hvac_action
]
)
]
and (
(
mode := OVERKIZ_MODE_TO_PRESET_MODES[
cast(str, self.executor.select_state(mode_state))
]
)
is not None
)
):
return mode
return mode if mode is not None else PRESET_NONE
return PRESET_NONE
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set new preset mode."""
@ -178,13 +302,18 @@ class AtlanticPassAPCZoneControlZone(AtlanticPassAPCHeatingZone):
await self.async_refresh_modes()
@property
def target_temperature(self) -> float:
def target_temperature(self) -> float | None:
"""Return hvac target temperature."""
if self.is_using_derogated_temperature_fallback:
return super().target_temperature
if self.zone_control_hvac_mode == HVACMode.COOL:
device_hvac_mode = self.device_hvac_mode
if device_hvac_mode == HVACMode.HEAT_COOL:
return None
if device_hvac_mode == HVACMode.COOL:
return cast(
float,
self.executor.select_state(
@ -192,7 +321,7 @@ class AtlanticPassAPCZoneControlZone(AtlanticPassAPCHeatingZone):
),
)
if self.zone_control_hvac_mode == HVACMode.HEAT:
if device_hvac_mode == HVACMode.HEAT:
return cast(
float,
self.executor.select_state(
@ -204,32 +333,73 @@ class AtlanticPassAPCZoneControlZone(AtlanticPassAPCHeatingZone):
float, self.executor.select_state(OverkizState.CORE_TARGET_TEMPERATURE)
)
@property
def target_temperature_high(self) -> float | None:
"""Return the highbound target temperature we try to reach (cooling)."""
if self.device_hvac_mode != HVACMode.HEAT_COOL:
return None
return cast(
float,
self.executor.select_state(OverkizState.CORE_COOLING_TARGET_TEMPERATURE),
)
@property
def target_temperature_low(self) -> float | None:
"""Return the lowbound target temperature we try to reach (heating)."""
if self.device_hvac_mode != HVACMode.HEAT_COOL:
return None
return cast(
float,
self.executor.select_state(OverkizState.CORE_HEATING_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]
target_temperature = kwargs.get(ATTR_TEMPERATURE)
target_temp_low = kwargs.get(ATTR_TARGET_TEMP_LOW)
target_temp_high = kwargs.get(ATTR_TARGET_TEMP_HIGH)
hvac_mode = self.hvac_mode
if hvac_mode == HVACMode.HEAT_COOL:
if target_temp_low is not None:
await self.executor.async_execute_command(
OverkizCommand.SET_HEATING_TARGET_TEMPERATURE,
target_temp_low,
)
if target_temp_high is not None:
await self.executor.async_execute_command(
OverkizCommand.SET_COOLING_TARGET_TEMPERATURE,
target_temp_high,
)
elif target_temperature is not None:
if hvac_mode == HVACMode.HEAT:
await self.executor.async_execute_command(
OverkizCommand.SET_HEATING_TARGET_TEMPERATURE,
target_temperature,
)
elif hvac_mode == HVACMode.COOL:
await self.executor.async_execute_command(
OverkizCommand.SET_COOLING_TARGET_TEMPERATURE,
target_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,
OverkizCommandParam.ON,
)
# Target temperature may take up to 1 minute to get refreshed.
await self.executor.async_execute_command(
OverkizCommand.REFRESH_TARGET_TEMPERATURE
)
await self.async_refresh_modes()
async def async_refresh_modes(self) -> None:
"""Refresh the device modes to have new states."""
@ -256,3 +426,51 @@ class AtlanticPassAPCZoneControlZone(AtlanticPassAPCHeatingZone):
await self.executor.async_execute_command(
OverkizCommand.REFRESH_TARGET_TEMPERATURE
)
@property
def min_temp(self) -> float:
"""Return Minimum Temperature for AC of this group."""
device_hvac_mode = self.device_hvac_mode
if device_hvac_mode in (HVACMode.HEAT, HVACMode.HEAT_COOL):
return cast(
float,
self.executor.select_state(
OverkizState.CORE_MINIMUM_HEATING_TARGET_TEMPERATURE
),
)
if device_hvac_mode == HVACMode.COOL:
return cast(
float,
self.executor.select_state(
OverkizState.CORE_MINIMUM_COOLING_TARGET_TEMPERATURE
),
)
return super().min_temp
@property
def max_temp(self) -> float:
"""Return Max Temperature for AC of this group."""
device_hvac_mode = self.device_hvac_mode
if device_hvac_mode == HVACMode.HEAT:
return cast(
float,
self.executor.select_state(
OverkizState.CORE_MAXIMUM_HEATING_TARGET_TEMPERATURE
),
)
if device_hvac_mode in (HVACMode.COOL, HVACMode.HEAT_COOL):
return cast(
float,
self.executor.select_state(
OverkizState.CORE_MAXIMUM_COOLING_TARGET_TEMPERATURE
),
)
return super().max_temp