mirror of
https://github.com/home-assistant/core.git
synced 2026-09-09 15:11:40 +01:00
313 lines
10 KiB
Python
313 lines
10 KiB
Python
"""Support for Cielo home thermostats and Smart AC Controllers."""
|
|
|
|
import asyncio
|
|
from collections.abc import Callable, Coroutine
|
|
from typing import Any, Concatenate, override
|
|
|
|
from cieloconnectapi.exceptions import AuthenticationError
|
|
|
|
from homeassistant.components.climate import (
|
|
ATTR_TARGET_TEMP_HIGH,
|
|
ATTR_TARGET_TEMP_LOW,
|
|
ClimateEntity,
|
|
ClimateEntityFeature,
|
|
HVACMode,
|
|
)
|
|
from homeassistant.const import ATTR_TEMPERATURE
|
|
from homeassistant.core import HomeAssistant
|
|
from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
|
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
|
|
|
from .const import CIELO_ERRORS, LOGGER, TIMEOUT
|
|
from .coordinator import CieloDataUpdateCoordinator, CieloHomeConfigEntry
|
|
from .entity import CieloDeviceEntity
|
|
|
|
PARALLEL_UPDATES = 0
|
|
|
|
CIELO_TO_HA_HVAC: dict[str, HVACMode] = {
|
|
"cool": HVACMode.COOL,
|
|
"heat": HVACMode.HEAT,
|
|
"fan": HVACMode.FAN_ONLY,
|
|
"dry": HVACMode.DRY,
|
|
"auto": HVACMode.AUTO,
|
|
"heat_cool": HVACMode.HEAT_COOL,
|
|
"off": HVACMode.OFF,
|
|
}
|
|
HA_TO_CIELO_HVAC: dict[HVACMode, str] = {v: k for k, v in CIELO_TO_HA_HVAC.items()}
|
|
|
|
|
|
async def async_setup_entry(
|
|
hass: HomeAssistant,
|
|
entry: CieloHomeConfigEntry,
|
|
async_add_entities: AddConfigEntryEntitiesCallback,
|
|
) -> None:
|
|
"""Set up the Cielo climate platform."""
|
|
coordinator = entry.runtime_data
|
|
devices = coordinator.data.parsed
|
|
async_add_entities([CieloClimate(coordinator, dev_id) for dev_id in devices])
|
|
|
|
|
|
def async_handle_api_call[_T: CieloDeviceEntity, **_P](
|
|
function: Callable[Concatenate[_T, _P], Coroutine[Any, Any, Any]],
|
|
) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, Any]]:
|
|
"""Decorate api calls to handle exceptions and update state."""
|
|
|
|
async def wrap_api_call(entity: _T, *args: _P.args, **kwargs: _P.kwargs) -> None:
|
|
"""Wrap services for api calls."""
|
|
res: Any = None
|
|
|
|
try:
|
|
async with asyncio.timeout(TIMEOUT):
|
|
res = await function(entity, *args, **kwargs)
|
|
except AuthenticationError as err:
|
|
raise ConfigEntryAuthFailed from err
|
|
|
|
except CIELO_ERRORS as err:
|
|
if isinstance(err, TimeoutError):
|
|
raise HomeAssistantError("API call timed out") from err
|
|
raise HomeAssistantError("Unable to perform API call") from err
|
|
|
|
LOGGER.debug(
|
|
"API call result for entity %s: type=%s keys=%s",
|
|
entity.entity_id,
|
|
type(res),
|
|
list(res.keys()) if isinstance(res, dict) else None,
|
|
)
|
|
|
|
if not isinstance(res, dict):
|
|
LOGGER.error(
|
|
"API function did not return a dictionary for entity %s, got %s",
|
|
entity.entity_id,
|
|
type(res),
|
|
)
|
|
raise HomeAssistantError("Invalid API response format")
|
|
|
|
data: dict[str, Any] | None = res.get("data")
|
|
|
|
if not data:
|
|
raise HomeAssistantError("API response contained no data payload")
|
|
|
|
await entity.coordinator.async_apply_action_result(entity.device_id, data)
|
|
|
|
return wrap_api_call
|
|
|
|
|
|
class CieloClimate(CieloDeviceEntity, ClimateEntity):
|
|
"""Representation of a Cielo Smart AC Controller."""
|
|
|
|
_attr_name = None
|
|
_attr_translation_key = "climate_device"
|
|
|
|
def __init__(self, coordinator: CieloDataUpdateCoordinator, device_id: str) -> None:
|
|
"""Initialize the climate device."""
|
|
super().__init__(coordinator, device_id)
|
|
self._attr_unique_id = device_id
|
|
|
|
@property
|
|
@override
|
|
def supported_features(self) -> ClimateEntityFeature:
|
|
"""Return dynamic feature flags based on the current mode."""
|
|
flags = ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON
|
|
|
|
if self.hvac_mode == HVACMode.HEAT_COOL:
|
|
flags |= ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
|
|
elif self.client.mode_supports_temperature():
|
|
flags |= ClimateEntityFeature.TARGET_TEMPERATURE
|
|
|
|
caps = self.client.mode_caps()
|
|
|
|
if caps.get("fan_levels"):
|
|
flags |= ClimateEntityFeature.FAN_MODE
|
|
|
|
if caps.get("swing"):
|
|
flags |= ClimateEntityFeature.SWING_MODE
|
|
|
|
if self.device_data and self.device_data.preset_modes:
|
|
flags |= ClimateEntityFeature.PRESET_MODE
|
|
|
|
return flags
|
|
|
|
@property
|
|
@override
|
|
def current_humidity(self) -> int | None:
|
|
"""Return the current humidity, if available."""
|
|
if self.device_data:
|
|
return self.device_data.humidity
|
|
return None
|
|
|
|
@property
|
|
@override
|
|
def target_temperature_low(self) -> float | None:
|
|
"""Return the low target temperature for HEAT_COOL mode."""
|
|
return self.client.target_temperature_low(self.temperature_unit)
|
|
|
|
@property
|
|
@override
|
|
def target_temperature_high(self) -> float | None:
|
|
"""Return the high target temperature for HEAT_COOL mode."""
|
|
return self.client.target_temperature_high(self.temperature_unit)
|
|
|
|
@property
|
|
@override
|
|
def hvac_mode(self) -> HVACMode | None:
|
|
"""Return the current HVAC mode."""
|
|
mode = self.client.hvac_mode()
|
|
return CIELO_TO_HA_HVAC.get(mode, mode)
|
|
|
|
@property
|
|
@override
|
|
def hvac_modes(self) -> list[HVACMode]:
|
|
"""Return the list of available HVAC modes."""
|
|
modes = self.client.hvac_modes() or []
|
|
return [CIELO_TO_HA_HVAC.get(m, m) for m in modes]
|
|
|
|
@property
|
|
@override
|
|
def current_temperature(self) -> float | None:
|
|
"""Return the current indoor temperature."""
|
|
return self.client.current_temperature()
|
|
|
|
@property
|
|
@override
|
|
def target_temperature(self) -> float | None:
|
|
"""Return the target temperature."""
|
|
return self.client.target_temperature()
|
|
|
|
@property
|
|
@override
|
|
def min_temp(self) -> float:
|
|
"""Return the minimum possible target temperature."""
|
|
return self.client.min_temp()
|
|
|
|
@property
|
|
@override
|
|
def max_temp(self) -> float:
|
|
"""Return the maximum possible target temperature."""
|
|
return self.client.max_temp()
|
|
|
|
@property
|
|
@override
|
|
def target_temperature_step(self) -> float | None:
|
|
"""Return the precision of the thermostat."""
|
|
return self.client.target_temperature_step(self.temperature_unit)
|
|
|
|
@property
|
|
@override
|
|
def fan_mode(self) -> str | None:
|
|
"""Return the current fan mode."""
|
|
return self.client.fan_mode()
|
|
|
|
@property
|
|
@override
|
|
def fan_modes(self) -> list[str] | None:
|
|
"""Return the list of available fan modes.
|
|
|
|
Fan modes are normalized in the backend to snake_case values that
|
|
match Home Assistant expectations (e.g. "low", "medium", "high", "auto").
|
|
This allows HA to translate and display icons correctly using the
|
|
integration strings definitions.
|
|
"""
|
|
return self.client.fan_modes()
|
|
|
|
@property
|
|
@override
|
|
def swing_modes(self) -> list[str] | None:
|
|
"""Return the list of available swing modes.
|
|
|
|
Swing modes are normalized in the backend to snake_case values
|
|
compatible with Home Assistant (e.g. "auto", "swing").
|
|
These values align with the integration translations so HA can display
|
|
proper labels and icons.
|
|
"""
|
|
return self.client.swing_modes()
|
|
|
|
@property
|
|
@override
|
|
def preset_mode(self) -> str | None:
|
|
"""Return the current preset mode."""
|
|
return self.client.preset_mode()
|
|
|
|
@property
|
|
@override
|
|
def preset_modes(self) -> list[str] | None:
|
|
"""Return the list of available preset modes.
|
|
|
|
Preset modes are normalized in the backend to snake_case values that
|
|
match Home Assistant expectations (e.g. "home", "away", "sleep", "pets").
|
|
This allows HA to translate and display icons correctly using the
|
|
integration strings definitions.
|
|
"""
|
|
return self.client.preset_modes()
|
|
|
|
@property
|
|
@override
|
|
def swing_mode(self) -> str | None:
|
|
"""Return the current swing mode."""
|
|
return self.device_data.swing_mode if self.device_data else None
|
|
|
|
@property
|
|
@override
|
|
def precision(self) -> float:
|
|
"""Return the precision of the thermostat."""
|
|
return self.client.precision(self.temperature_unit)
|
|
|
|
@async_handle_api_call
|
|
@override
|
|
async def async_set_temperature(self, **kwargs: Any) -> None:
|
|
"""Set new target temperature."""
|
|
if self.hvac_mode == HVACMode.HEAT_COOL:
|
|
return await self.client.async_set_temperature(
|
|
self.temperature_unit,
|
|
**{
|
|
ATTR_TARGET_TEMP_LOW: kwargs.get(ATTR_TARGET_TEMP_LOW),
|
|
ATTR_TARGET_TEMP_HIGH: kwargs.get(ATTR_TARGET_TEMP_HIGH),
|
|
},
|
|
)
|
|
return await self.client.async_set_temperature(
|
|
self.temperature_unit,
|
|
**{ATTR_TEMPERATURE: kwargs.get(ATTR_TEMPERATURE)},
|
|
)
|
|
|
|
@async_handle_api_call
|
|
@override
|
|
async def async_set_fan_mode(self, fan_mode: str) -> None:
|
|
"""Set new fan mode."""
|
|
return await self.client.async_set_fan_mode(fan_mode)
|
|
|
|
@async_handle_api_call
|
|
@override
|
|
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
|
"""Set new preset mode."""
|
|
return await self.client.async_set_preset_mode(preset_mode)
|
|
|
|
@async_handle_api_call
|
|
@override
|
|
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
|
"""Set new HVAC mode."""
|
|
cielo_mode = HA_TO_CIELO_HVAC.get(hvac_mode)
|
|
return await self.client.async_set_hvac_mode(cielo_mode)
|
|
|
|
@async_handle_api_call
|
|
@override
|
|
async def async_set_swing_mode(self, swing_mode: str) -> None:
|
|
"""Set new swing mode."""
|
|
return await self.client.async_set_swing_mode(swing_mode)
|
|
|
|
@override
|
|
async def async_turn_on(self) -> None:
|
|
"""Turn the climate device on."""
|
|
modes = self.hvac_modes or []
|
|
|
|
# Select the first supported non-off mode when turning on
|
|
for mode in modes:
|
|
if mode != HVACMode.OFF:
|
|
await self.async_set_hvac_mode(mode)
|
|
return
|
|
|
|
raise HomeAssistantError("No non-off HVAC modes available to turn on device")
|
|
|
|
@override
|
|
async def async_turn_off(self) -> None:
|
|
"""Turn the climate device off."""
|
|
await self.async_set_hvac_mode(HVACMode.OFF)
|