Add overkiz support for Atlantic Shogun ZoneControl 2.0 (AtlanticPassAPCHeatingAndCoolingZone) (#110510)

* Add Overkiz support for AtlanticPassAPCHeatingAndCoolingZone widget

* Add support for AUTO HVAC mode for Atlantic Pass APC ZC devices that support it

* Add support for multiple IO controllers for same widget (mainly for Atlantic APC)

* Implement PR feedback

* Small PR fixes

* Fix constant inversion typo
This commit is contained in:
Jeremy TRUFIER 2024-02-28 23:16:03 +01:00 committed by GitHub
parent fb10ef9ac0
commit eeb87247e9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 348 additions and 4 deletions

View File

@ -1,6 +1,8 @@
"""Support for Overkiz climate devices.""" """Support for Overkiz climate devices."""
from __future__ import annotations from __future__ import annotations
from typing import cast
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform from homeassistant.const import Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@ -8,8 +10,10 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import HomeAssistantOverkizData from . import HomeAssistantOverkizData
from .climate_entities import ( from .climate_entities import (
WIDGET_AND_CONTROLLABLE_TO_CLIMATE_ENTITY,
WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY, WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY,
WIDGET_TO_CLIMATE_ENTITY, WIDGET_TO_CLIMATE_ENTITY,
Controllable,
) )
from .const import DOMAIN from .const import DOMAIN
@ -28,6 +32,18 @@ async def async_setup_entry(
if device.widget in WIDGET_TO_CLIMATE_ENTITY 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 # Hitachi Air To Air Heat Pumps
async_add_entities( async_add_entities(
WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY[device.widget][device.protocol]( WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY[device.widget][device.protocol](

View File

@ -1,4 +1,6 @@
"""Climate entities for the Overkiz (by Somfy) integration.""" """Climate entities for the Overkiz (by Somfy) integration."""
from enum import StrEnum, unique
from pyoverkiz.enums import Protocol from pyoverkiz.enums import Protocol
from pyoverkiz.enums.ui import UIWidget 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_heat_recovery_ventilation import AtlanticHeatRecoveryVentilation
from .atlantic_pass_apc_heating_zone import AtlanticPassAPCHeatingZone from .atlantic_pass_apc_heating_zone import AtlanticPassAPCHeatingZone
from .atlantic_pass_apc_zone_control import AtlanticPassAPCZoneControl 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_hlrrwifi import HitachiAirToAirHeatPumpHLRRWIFI
from .somfy_heating_temperature_interface import SomfyHeatingTemperatureInterface from .somfy_heating_temperature_interface import SomfyHeatingTemperatureInterface
from .somfy_thermostat import SomfyThermostat from .somfy_thermostat import SomfyThermostat
from .valve_heating_temperature_interface import ValveHeatingTemperatureInterface 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 = { WIDGET_TO_CLIMATE_ENTITY = {
UIWidget.ATLANTIC_ELECTRICAL_HEATER: AtlanticElectricalHeater, UIWidget.ATLANTIC_ELECTRICAL_HEATER: AtlanticElectricalHeater,
UIWidget.ATLANTIC_ELECTRICAL_HEATER_WITH_ADJUSTABLE_TEMPERATURE_SETPOINT: AtlanticElectricalHeaterWithAdjustableTemperatureSetpoint, UIWidget.ATLANTIC_ELECTRICAL_HEATER_WITH_ADJUSTABLE_TEMPERATURE_SETPOINT: AtlanticElectricalHeaterWithAdjustableTemperatureSetpoint,
UIWidget.ATLANTIC_ELECTRICAL_TOWEL_DRYER: AtlanticElectricalTowelDryer, UIWidget.ATLANTIC_ELECTRICAL_TOWEL_DRYER: AtlanticElectricalTowelDryer,
UIWidget.ATLANTIC_HEAT_RECOVERY_VENTILATION: AtlanticHeatRecoveryVentilation, 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_HEATING_ZONE: AtlanticPassAPCHeatingZone,
UIWidget.ATLANTIC_PASS_APC_ZONE_CONTROL: AtlanticPassAPCZoneControl, UIWidget.ATLANTIC_PASS_APC_ZONE_CONTROL: AtlanticPassAPCZoneControl,
UIWidget.SOMFY_HEATING_TEMPERATURE_INTERFACE: SomfyHeatingTemperatureInterface, UIWidget.SOMFY_HEATING_TEMPERATURE_INTERFACE: SomfyHeatingTemperatureInterface,
@ -29,6 +43,15 @@ WIDGET_TO_CLIMATE_ENTITY = {
UIWidget.VALVE_HEATING_TEMPERATURE_INTERFACE: ValveHeatingTemperatureInterface, 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 # Hitachi air-to-air heatpumps come in 2 flavors (HLRRWIFI and OVP) that are separated in 2 classes
WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY = { WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY = {
UIWidget.HITACHI_AIR_TO_AIR_HEAT_PUMP: { UIWidget.HITACHI_AIR_TO_AIR_HEAT_PUMP: {

View File

@ -49,7 +49,15 @@ OVERKIZ_TO_PRESET_MODES: dict[str, str] = {
OverkizCommandParam.INTERNAL_SCHEDULING: PRESET_HOME, 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] = { OVERKIZ_TO_PROFILE_MODES: dict[str, str] = {
OverkizCommandParam.OFF: PRESET_SLEEP, OverkizCommandParam.OFF: PRESET_SLEEP,

View File

@ -10,6 +10,7 @@ from homeassistant.components.climate import (
) )
from homeassistant.const import UnitOfTemperature from homeassistant.const import UnitOfTemperature
from ..coordinator import OverkizDataUpdateCoordinator
from ..entity import OverkizEntity from ..entity import OverkizEntity
OVERKIZ_TO_HVAC_MODE: dict[str, HVACMode] = { 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): class AtlanticPassAPCZoneControl(OverkizEntity, ClimateEntity):
"""Representation of Atlantic Pass APC Zone Control.""" """Representation of Atlantic Pass APC Zone Control."""
_attr_hvac_modes = [*HVAC_MODE_TO_OVERKIZ]
_attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_supported_features = ( _attr_supported_features = (
ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON
) )
_enable_turn_on_off_backwards_compatibility = False _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 @property
def hvac_mode(self) -> HVACMode: def hvac_mode(self) -> HVACMode:
"""Return hvac operation ie. heat, cool mode.""" """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[ return OVERKIZ_TO_HVAC_MODE[
cast( cast(
str, self.executor.select_state(OverkizState.IO_PASS_APC_OPERATING_MODE) 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: async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target hvac mode.""" """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( await self.executor.async_execute_command(
OverkizCommand.SET_PASS_APC_OPERATING_MODE, HVAC_MODE_TO_OVERKIZ[hvac_mode] OverkizCommand.SET_PASS_APC_OPERATING_MODE, HVAC_MODE_TO_OVERKIZ[hvac_mode]
) )

View File

@ -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
)