"""Support for the Daikin HVAC.""" from collections.abc import Sequence import logging from typing import Any, override from pydaikin.daikin_base import Appliance from homeassistant.components.climate import ( ATTR_FAN_MODE, ATTR_HVAC_MODE, ATTR_PRESET_MODE, ATTR_SWING_MODE, PRESET_AWAY, PRESET_BOOST, PRESET_ECO, PRESET_NONE, ClimateEntity, ClimateEntityFeature, HVACAction, HVACMode, ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( ATTR_INSIDE_TEMPERATURE, ATTR_OUTSIDE_TEMPERATURE, ATTR_STATE_OFF, ATTR_STATE_ON, ATTR_TARGET_TEMPERATURE, DOMAIN, ZONE_NAME_UNCONFIGURED, ) from .coordinator import DaikinConfigEntry, DaikinCoordinator from .entity import DaikinEntity _LOGGER = logging.getLogger(__name__) type DaikinZone = Sequence[str | int] DAIKIN_ZONE_TEMP_HEAT = "lztemp_h" DAIKIN_ZONE_TEMP_COOL = "lztemp_c" HA_STATE_TO_DAIKIN = { HVACMode.FAN_ONLY: "fan", HVACMode.DRY: "dry", HVACMode.COOL: "cool", HVACMode.HEAT: "hot", HVACMode.HEAT_COOL: "auto", HVACMode.OFF: "off", } DAIKIN_TO_HA_STATE = { "fan": HVACMode.FAN_ONLY, "dry": HVACMode.DRY, "cool": HVACMode.COOL, "hot": HVACMode.HEAT, "auto": HVACMode.HEAT_COOL, "off": HVACMode.OFF, } HA_STATE_TO_CURRENT_HVAC = { HVACMode.COOL: HVACAction.COOLING, HVACMode.HEAT: HVACAction.HEATING, HVACMode.OFF: HVACAction.OFF, } HA_PRESET_TO_DAIKIN = { PRESET_AWAY: "on", PRESET_NONE: "off", PRESET_BOOST: "powerful", PRESET_ECO: "econo", } HA_ATTR_TO_DAIKIN = { ATTR_PRESET_MODE: "en_hol", ATTR_HVAC_MODE: "mode", ATTR_FAN_MODE: "f_rate", ATTR_SWING_MODE: "f_dir", ATTR_INSIDE_TEMPERATURE: "htemp", ATTR_OUTSIDE_TEMPERATURE: "otemp", ATTR_TARGET_TEMPERATURE: "stemp", } DAIKIN_ATTR_ADVANCED = "adv" ZONE_TEMPERATURE_WINDOW = 2 def _zone_error( translation_key: str, placeholders: dict[str, str] | None = None ) -> HomeAssistantError: """Return a Home Assistant error with Daikin translation info.""" return HomeAssistantError( translation_domain=DOMAIN, translation_key=translation_key, translation_placeholders=placeholders, ) def _zone_is_configured(zone: DaikinZone) -> bool: """Return True if the Daikin zone represents a configured zone.""" if not zone: return False return zone[0] != ZONE_NAME_UNCONFIGURED def _zone_temperature_lists(device: Appliance) -> tuple[list[str], list[str]]: """Return the decoded zone temperature lists.""" values = device.values if DAIKIN_ZONE_TEMP_HEAT not in values or DAIKIN_ZONE_TEMP_COOL not in values: return ([], []) heating = device.represent(DAIKIN_ZONE_TEMP_HEAT)[1] cooling = device.represent(DAIKIN_ZONE_TEMP_COOL)[1] return (list(heating or []), list(cooling or [])) def _supports_zone_temperature_control(device: Appliance) -> bool: """Return True if the device exposes zone temperature settings.""" zones = device.zones if not zones: return False heating, cooling = _zone_temperature_lists(device) return bool( heating and cooling and len(heating) >= len(zones) and len(cooling) >= len(zones) ) def _system_target_temperature(device: Appliance) -> float | None: """Return the system target temperature when available.""" target = device.target_temperature if target is None: return None try: return float(target) except TypeError, ValueError: return None def _zone_temperature_from_list(values: list[str], zone_id: int) -> float | None: """Return the parsed temperature for a zone from a Daikin list.""" if zone_id >= len(values): return None try: return float(values[zone_id]) except TypeError, ValueError: return None async def async_setup_entry( hass: HomeAssistant, entry: DaikinConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Daikin climate based on config_entry.""" coordinator = entry.runtime_data entities: list[ClimateEntity] = [DaikinClimate(coordinator)] if _supports_zone_temperature_control(coordinator.device): zones = coordinator.device.zones or [] entities.extend( DaikinZoneClimate(coordinator, zone_id) for zone_id, zone in enumerate(zones) if _zone_is_configured(zone) ) async_add_entities(entities) def format_target_temperature(target_temperature: float) -> str: """Format target temperature to be sent to the Daikin unit. Rounds to nearest half degree. """ return str(round(float(target_temperature) * 2, 0) / 2).rstrip("0").rstrip(".") class DaikinClimate(DaikinEntity, ClimateEntity): """Representation of a Daikin HVAC.""" _attr_name = None _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_hvac_modes = list(HA_STATE_TO_DAIKIN) _attr_target_temperature_step = 1 _attr_fan_modes: list[str] _attr_swing_modes: list[str] def __init__(self, coordinator: DaikinCoordinator) -> None: """Initialize the climate device.""" super().__init__(coordinator) self._attr_fan_modes = self.device.fan_rate self._attr_swing_modes = self.device.swing_modes self._list: dict[str, list[Any]] = { ATTR_HVAC_MODE: self._attr_hvac_modes, ATTR_FAN_MODE: self._attr_fan_modes, ATTR_SWING_MODE: self._attr_swing_modes, } self._attr_supported_features = ( ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TARGET_TEMPERATURE ) if self.device.support_away_mode or self.device.support_advanced_modes: self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE if self.device.support_fan_rate: self._attr_supported_features |= ClimateEntityFeature.FAN_MODE if self.device.support_swing_mode: self._attr_supported_features |= ClimateEntityFeature.SWING_MODE async def _set(self, settings: dict[str, Any]) -> None: """Set device settings using API.""" values: dict[str, Any] = {} for attr in (ATTR_TEMPERATURE, ATTR_FAN_MODE, ATTR_SWING_MODE, ATTR_HVAC_MODE): if (value := settings.get(attr)) is None: continue if (daikin_attr := HA_ATTR_TO_DAIKIN.get(attr)) is not None: if attr == ATTR_HVAC_MODE: values[daikin_attr] = HA_STATE_TO_DAIKIN[value] elif value in self._list[attr]: values[daikin_attr] = value.lower() else: _LOGGER.error("Invalid value %s for %s", attr, value) # temperature elif attr == ATTR_TEMPERATURE: try: values[HA_ATTR_TO_DAIKIN[ATTR_TARGET_TEMPERATURE]] = ( format_target_temperature(value) ) except ValueError: _LOGGER.error("Invalid temperature %s", value) if values: await self.device.set(values) await self.coordinator.async_refresh() @property @override def unique_id(self) -> str: """Return a unique ID.""" return self.device.mac @property @override def current_temperature(self) -> float | None: """Return the current temperature.""" return self.device.inside_temperature @property @override def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" return self.device.target_temperature @override async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" await self._set(kwargs) @property @override def hvac_action(self) -> HVACAction | None: """Return the current state.""" ret = HA_STATE_TO_CURRENT_HVAC.get(self.hvac_mode) if ( ret in (HVACAction.COOLING, HVACAction.HEATING) and self.device.support_compressor_frequency and self.device.compressor_frequency == 0 ): return HVACAction.IDLE return ret @property @override def hvac_mode(self) -> HVACMode: """Return current operation ie. heat, cool, idle.""" daikin_mode = self.device.represent(HA_ATTR_TO_DAIKIN[ATTR_HVAC_MODE])[1] return DAIKIN_TO_HA_STATE.get(daikin_mode, HVACMode.HEAT_COOL) @override async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set HVAC mode.""" await self._set({ATTR_HVAC_MODE: hvac_mode}) @property @override def fan_mode(self) -> str: """Return the fan setting.""" return self.device.represent(HA_ATTR_TO_DAIKIN[ATTR_FAN_MODE])[1].title() @override async def async_set_fan_mode(self, fan_mode: str) -> None: """Set fan mode.""" await self._set({ATTR_FAN_MODE: fan_mode}) @property @override def swing_mode(self) -> str: """Return the fan setting.""" return self.device.represent(HA_ATTR_TO_DAIKIN[ATTR_SWING_MODE])[1].title() @override async def async_set_swing_mode(self, swing_mode: str) -> None: """Set new target temperature.""" await self._set({ATTR_SWING_MODE: swing_mode}) @property @override def preset_mode(self) -> str: """Return the preset_mode.""" if ( self.device.represent(HA_ATTR_TO_DAIKIN[ATTR_PRESET_MODE])[1] == HA_PRESET_TO_DAIKIN[PRESET_AWAY] ): return PRESET_AWAY if ( HA_PRESET_TO_DAIKIN[PRESET_BOOST] in self.device.represent(DAIKIN_ATTR_ADVANCED)[1] ): return PRESET_BOOST if ( HA_PRESET_TO_DAIKIN[PRESET_ECO] in self.device.represent(DAIKIN_ATTR_ADVANCED)[1] ): return PRESET_ECO return PRESET_NONE @override async def async_set_preset_mode(self, preset_mode: str) -> None: """Set preset mode.""" if preset_mode == PRESET_AWAY: await self.device.set_holiday(ATTR_STATE_ON) elif preset_mode == PRESET_BOOST: await self.device.set_advanced_mode( HA_PRESET_TO_DAIKIN[PRESET_BOOST], ATTR_STATE_ON ) elif preset_mode == PRESET_ECO: await self.device.set_advanced_mode( HA_PRESET_TO_DAIKIN[PRESET_ECO], ATTR_STATE_ON ) elif self.preset_mode == PRESET_AWAY: await self.device.set_holiday(ATTR_STATE_OFF) elif self.preset_mode == PRESET_BOOST: await self.device.set_advanced_mode( HA_PRESET_TO_DAIKIN[PRESET_BOOST], ATTR_STATE_OFF ) elif self.preset_mode == PRESET_ECO: await self.device.set_advanced_mode( HA_PRESET_TO_DAIKIN[PRESET_ECO], ATTR_STATE_OFF ) await self.coordinator.async_refresh() @property @override def preset_modes(self) -> list[str]: """List of available preset modes.""" ret = [PRESET_NONE] if self.device.support_away_mode: ret.append(PRESET_AWAY) if self.device.support_advanced_modes: ret += [PRESET_ECO, PRESET_BOOST] return ret @override async def async_turn_on(self) -> None: """Turn device on.""" await self.device.set({}) await self.coordinator.async_refresh() @override async def async_turn_off(self) -> None: """Turn device off.""" await self.device.set( {HA_ATTR_TO_DAIKIN[ATTR_HVAC_MODE]: HA_STATE_TO_DAIKIN[HVACMode.OFF]} ) await self.coordinator.async_refresh() class DaikinZoneClimate(DaikinEntity, ClimateEntity): """Representation of a Daikin zone temperature controller.""" _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_has_entity_name = True _attr_supported_features = ( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON ) _attr_target_temperature_step = 1 def __init__(self, coordinator: DaikinCoordinator, zone_id: int) -> None: """Initialize the zone climate entity.""" super().__init__(coordinator) self._zone_id = zone_id self._attr_unique_id = f"{self.device.mac}-zone{zone_id}-temperature" zone_name = self.device.zones[self._zone_id][0] self._attr_name = f"{zone_name} temperature" @property def _main_hvac_mode(self) -> HVACMode: """Return the main unit HVAC mode.""" daikin_mode = self.device.represent(HA_ATTR_TO_DAIKIN[ATTR_HVAC_MODE])[1] return DAIKIN_TO_HA_STATE.get(daikin_mode, HVACMode.HEAT_COOL) @property @override def hvac_modes(self) -> list[HVACMode]: """Return the hvac modes (mirrors the main unit).""" return [self._main_hvac_mode] @property @override def hvac_mode(self) -> HVACMode: """Return the current HVAC mode.""" main_mode = self._main_hvac_mode if main_mode == HVACMode.OFF or self.device.zones[self._zone_id][1] != "1": return HVACMode.OFF return main_mode @property @override def hvac_action(self) -> HVACAction | None: """Return the current HVAC action.""" return HA_STATE_TO_CURRENT_HVAC.get(self.hvac_mode) @property @override def target_temperature(self) -> float | None: """Return the zone target temperature for the active mode.""" heating, cooling = _zone_temperature_lists(self.device) mode = self._main_hvac_mode if mode == HVACMode.HEAT: return _zone_temperature_from_list(heating, self._zone_id) if mode == HVACMode.COOL: return _zone_temperature_from_list(cooling, self._zone_id) return None @property @override def min_temp(self) -> float: """Return the minimum selectable temperature.""" target = _system_target_temperature(self.device) if target is None: return super().min_temp return target - ZONE_TEMPERATURE_WINDOW @property @override def max_temp(self) -> float: """Return the maximum selectable temperature.""" target = _system_target_temperature(self.device) if target is None: return super().max_temp return target + ZONE_TEMPERATURE_WINDOW @property @override def available(self) -> bool: """Return if the entity is available.""" return ( super().available and _supports_zone_temperature_control(self.device) and _system_target_temperature(self.device) is not None ) @property @override def extra_state_attributes(self) -> dict[str, Any]: """Return additional metadata.""" return {"zone_id": self._zone_id} @override async def async_set_temperature(self, **kwargs: Any) -> None: """Set the zone temperature.""" if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="zone_temperature_missing", ) zones = self.device.zones if not zones or not _supports_zone_temperature_control(self.device): raise _zone_error("zone_parameters_unavailable") try: zone = zones[self._zone_id] except (IndexError, TypeError) as err: raise _zone_error( "zone_missing", { "zone_id": str(self._zone_id), "max_zone": str(len(zones) - 1), }, ) from err if not _zone_is_configured(zone): raise _zone_error("zone_inactive", {"zone_id": str(self._zone_id)}) temperature_value = float(temperature) target = _system_target_temperature(self.device) if target is None: raise _zone_error("zone_parameters_unavailable") mode = self._main_hvac_mode if mode == HVACMode.HEAT: zone_key = DAIKIN_ZONE_TEMP_HEAT elif mode == HVACMode.COOL: zone_key = DAIKIN_ZONE_TEMP_COOL else: raise _zone_error("zone_hvac_mode_unsupported") zone_value = str(round(temperature_value)) try: await self.device.set_zone(self._zone_id, zone_key, zone_value) except (AttributeError, KeyError, NotImplementedError, TypeError) as err: raise _zone_error("zone_set_failed") from err await self.coordinator.async_request_refresh() @override async def async_turn_on(self) -> None: """Turn the zone on.""" await self.device.set_zone(self._zone_id, "zone_onoff", "1") await self.coordinator.async_refresh() @override async def async_turn_off(self) -> None: """Turn the zone off.""" await self.device.set_zone(self._zone_id, "zone_onoff", "0") await self.coordinator.async_refresh() @override async def async_toggle(self) -> None: """Toggle the zone.""" if self.device.zones[self._zone_id][1] == "1": await self.async_turn_off() else: await self.async_turn_on() @override async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Disallow changing HVAC mode via zone climate.""" raise HomeAssistantError( translation_domain=DOMAIN, translation_key="zone_hvac_read_only", )