Skip to content

Commit

Permalink
Support HitachiAirToAirHeatPump (hlrrwifi:HLinkMainController) in Ove…
Browse files Browse the repository at this point in the history
…rkiz (#103803)
  • Loading branch information
dotvav committed Nov 24, 2023
1 parent 65a2f5b commit a1701f0
Show file tree
Hide file tree
Showing 3 changed files with 302 additions and 1 deletion.
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]
)
9 changes: 9 additions & 0 deletions homeassistant/components/overkiz/climate_entities/__init__.py
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
)

0 comments on commit a1701f0

Please sign in to comment.