Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support HitachiAirToAirHeatPump (hlrrwifi:HLinkMainController) in Overkiz #103803

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
15 changes: 14 additions & 1 deletion homeassistant/components/overkiz/climate.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@
from homeassistant.helpers.entity_platform import AddEntitiesCallback

from . import HomeAssistantOverkizData
from .climate_entities import WIDGET_TO_CLIMATE_ENTITY
from .climate_entities import (
WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY,
WIDGET_TO_CLIMATE_ENTITY,
)
from .const import DOMAIN


Expand All @@ -24,3 +27,13 @@ async def async_setup_entry(
for device in data.platforms[Platform.CLIMATE]
if device.widget in WIDGET_TO_CLIMATE_ENTITY
)

# Hitachi Air To Air Heat Pumps
async_add_entities(
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]
)
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Climate entities for the Overkiz (by Somfy) integration."""
from pyoverkiz.enums import Protocol
from pyoverkiz.enums.ui import UIWidget

from .atlantic_electrical_heater import AtlanticElectricalHeater
Expand All @@ -9,6 +10,7 @@
from .atlantic_heat_recovery_ventilation import AtlanticHeatRecoveryVentilation
from .atlantic_pass_apc_heating_zone import AtlanticPassAPCHeatingZone
from .atlantic_pass_apc_zone_control import AtlanticPassAPCZoneControl
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
Expand All @@ -26,3 +28,10 @@
UIWidget.SOMFY_THERMOSTAT: SomfyThermostat,
UIWidget.VALVE_HEATING_TEMPERATURE_INTERFACE: ValveHeatingTemperatureInterface,
}

# 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: {
Protocol.HLRR_WIFI: HitachiAirToAirHeatPumpHLRRWIFI,
},
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,279 @@
"""Support for HitachiAirToAirHeatPump."""
from __future__ import annotations

from typing import Any, cast

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"
FAN_SPEED_STATE = OverkizState.HLRRWIFI_FAN_SPEED
LEAVE_HOME_STATE = OverkizState.HLRRWIFI_LEAVE_HOME
MAIN_OPERATION_STATE = OverkizState.HLRRWIFI_MAIN_OPERATION
MODE_CHANGE_STATE = OverkizState.HLRRWIFI_MODE_CHANGE
ROOM_TEMPERATURE_STATE = OverkizState.HLRRWIFI_ROOM_TEMPERATURE
SWING_STATE = OverkizState.HLRRWIFI_SWING

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,
OverkizCommandParam.AUTO: HVACMode.AUTO,
}

HVAC_MODES_TO_OVERKIZ: dict[HVACMode, str] = {
HVACMode.AUTO: OverkizCommandParam.AUTO,
HVACMode.HEAT: OverkizCommandParam.HEATING,
HVACMode.OFF: OverkizCommandParam.AUTO,
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,
OverkizCommandParam.LOW: FAN_LOW,
OverkizCommandParam.MEDIUM: FAN_MEDIUM,
OverkizCommandParam.SILENT: FAN_SILENT,
}

FAN_MODES_TO_OVERKIZ: dict[str, str] = {
FAN_AUTO: OverkizCommandParam.AUTO,
FAN_HIGH: OverkizCommandParam.HIGH,
FAN_LOW: OverkizCommandParam.LOW,
FAN_MEDIUM: OverkizCommandParam.MEDIUM,
FAN_SILENT: OverkizCommandParam.SILENT,
}


class HitachiAirToAirHeatPumpHLRRWIFI(OverkizEntity, ClimateEntity):
"""Representation of Hitachi Air To Air HeatPump."""

_attr_hvac_modes = [*HVAC_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(SWING_STATE):
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[MAIN_OPERATION_STATE]
) 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[MODE_CHANGE_STATE]
) and mode_change_state.value_as_str:
sanitized_value = mode_change_state.value_as_str.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[FAN_SPEED_STATE]) and state.value_as_str:
return OVERKIZ_TO_FAN_MODES[state.value_as_str]

return None

@property
def fan_modes(self) -> list[str] | None:
"""Return the list of available fan modes."""
return [*FAN_MODES_TO_OVERKIZ]

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[SWING_STATE]) 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 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[ROOM_TEMPERATURE_STATE]) and state.value_as_int:
return state.value_as_int

return None

async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new temperature."""
temperature = cast(float, kwargs.get(ATTR_TEMPERATURE))
await self._global_control(target_temperature=int(temperature))

@property
def preset_mode(self) -> str | None:
"""Return the current preset mode, e.g., home, away, temp."""
if (state := self.device.states[LEAVE_HOME_STATE]) 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._global_control(leave_home=OverkizCommandParam.ON)

if preset_mode == PRESET_NONE:
await self._global_control(leave_home=OverkizCommandParam.OFF)

def _control_backfill(
self, value: str | None, state_name: str, fallback_value: str
) -> str:
"""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
state = self.device.states[state_name]
if state and state.value_as_str:
return state.value_as_str
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, MAIN_OPERATION_STATE, OverkizCommandParam.ON
)
target_temperature = target_temperature or self.target_temperature

fan_mode = self._control_backfill(
fan_mode,
FAN_SPEED_STATE,
OverkizCommandParam.AUTO,
)
hvac_mode = self._control_backfill(
hvac_mode,
MODE_CHANGE_STATE,
OverkizCommandParam.AUTO,
).lower() # Overkiz can return states that have uppercase characters which are not accepted back as commands
if hvac_mode.replace(
" ", ""
) in [ # Overkiz can return states like 'auto cooling' or 'autoHeating' that are not valid commands and need to be converted to 'auto'
OverkizCommandParam.AUTOCOOLING,
OverkizCommandParam.AUTOHEATING,
]:
hvac_mode = OverkizCommandParam.AUTO

swing_mode = self._control_backfill(
swing_mode,
SWING_STATE,
OverkizCommandParam.STOP,
)

leave_home = self._control_backfill(
leave_home,
LEAVE_HOME_STATE,
OverkizCommandParam.OFF,
)

command_data = [
main_operation, # Main Operation
target_temperature, # Target Temperature
fan_mode, # Fan Mode
hvac_mode, # Mode
swing_mode, # Swing Mode
leave_home, # Leave Home
]

await self.executor.async_execute_command(
OverkizCommand.GLOBAL_CONTROL, *command_data
)