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."""
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](

View file

@ -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: {

View file

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

View file

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

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
)