diff --git a/CODEOWNERS b/CODEOWNERS index dc2eb6367493..5777712c1e8c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -294,6 +294,8 @@ CLAUDE.md @home-assistant/core /tests/components/chacon_dio/ @cnico /homeassistant/components/chess_com/ @joostlek /tests/components/chess_com/ @joostlek +/homeassistant/components/cielo_home/ @ihsan-cielo @mudasar-cielo +/tests/components/cielo_home/ @ihsan-cielo @mudasar-cielo /homeassistant/components/cisco_ios/ @fbradyirl /homeassistant/components/cisco_mobility_express/ @fbradyirl /homeassistant/components/cisco_webex_teams/ @fbradyirl diff --git a/homeassistant/components/cielo_home/__init__.py b/homeassistant/components/cielo_home/__init__.py new file mode 100644 index 000000000000..b6fe3adfd760 --- /dev/null +++ b/homeassistant/components/cielo_home/__init__.py @@ -0,0 +1,26 @@ +"""Integration for Cielo Home.""" + +from __future__ import annotations + +from homeassistant.core import HomeAssistant + +from .const import PLATFORMS +from .coordinator import CieloDataUpdateCoordinator, CieloHomeConfigEntry + + +async def async_setup_entry(hass: HomeAssistant, entry: CieloHomeConfigEntry) -> bool: + """Set up Cielo Home from a config entry.""" + coordinator = CieloDataUpdateCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: CieloHomeConfigEntry) -> bool: + """Unload a config entry.""" + coordinator = entry.runtime_data + await coordinator.async_shutdown() + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/cielo_home/climate.py b/homeassistant/components/cielo_home/climate.py new file mode 100644 index 000000000000..00c56a9d9986 --- /dev/null +++ b/homeassistant/components/cielo_home/climate.py @@ -0,0 +1,313 @@ +"""Support for Cielo home thermostats and Smart AC Controllers.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable, Coroutine +from typing import Any, Concatenate, ParamSpec, TypeVar + +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, UnitOfTemperature +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 + +_T = TypeVar("_T", bound="CieloDeviceEntity") +_P = ParamSpec("_P") + +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( + 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(*args: Any, **kwargs: Any) -> None: + """Wrap services for api calls.""" + entity: _T = args[0] + res: Any = None + + try: + async with asyncio.timeout(TIMEOUT): + res = await function(*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 + def temperature_unit(self) -> str: + """Return the unit of temperature in Home Assistant format. + + It can change over time based on the device settings, so we fetch it dynamically from the client. + """ + unit = self.client.temperature_unit() + + if not unit: + return UnitOfTemperature.CELSIUS + + normalized = unit.strip().lower() + + if normalized in {"c", "°c", "celsius"}: + return UnitOfTemperature.CELSIUS + if normalized in {"f", "°f", "fahrenheit"}: + return UnitOfTemperature.FAHRENHEIT + + return UnitOfTemperature.CELSIUS + + @property + 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 + def current_humidity(self) -> int | None: + """Return the current humidity, if available.""" + if self.device_data: + return self.device_data.humidity + return None + + @property + 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 + 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 + 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 + 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 + def current_temperature(self) -> float | None: + """Return the current indoor temperature.""" + return self.client.current_temperature() + + @property + def target_temperature(self) -> float | None: + """Return the target temperature.""" + return self.client.target_temperature() + + @property + def min_temp(self) -> float: + """Return the minimum possible target temperature.""" + return self.client.min_temp() + + @property + def max_temp(self) -> float: + """Return the maximum possible target temperature.""" + return self.client.max_temp() + + @property + def target_temperature_step(self) -> float | None: + """Return the precision of the thermostat.""" + return self.client.target_temperature_step(self.temperature_unit) + + @property + def fan_mode(self) -> str | None: + """Return the current fan mode.""" + return self.client.fan_mode() + + @property + 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 + 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 + def preset_mode(self) -> str | None: + """Return the current preset mode.""" + return self.client.preset_mode() + + @property + 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 + def swing_mode(self) -> str | None: + """Return the current swing mode.""" + return self.device_data.swing_mode if self.device_data else None + + @property + def precision(self) -> float: + """Return the precision of the thermostat.""" + return self.client.precision(self.temperature_unit) + + @async_handle_api_call + 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 + 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 + 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 + 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 + 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) + + 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") + + async def async_turn_off(self) -> None: + """Turn the climate device off.""" + await self.async_set_hvac_mode(HVACMode.OFF) diff --git a/homeassistant/components/cielo_home/config_flow.py b/homeassistant/components/cielo_home/config_flow.py new file mode 100644 index 000000000000..856bc2d7e7bb --- /dev/null +++ b/homeassistant/components/cielo_home/config_flow.py @@ -0,0 +1,101 @@ +"""Config Flow for Cielo integration.""" + +from __future__ import annotations + +from typing import Any, Final + +from aiohttp import ClientError +from cieloconnectapi import CieloClient +from cieloconnectapi.exceptions import AuthenticationError, CieloError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.config_entries import ConfigFlowResult +from homeassistant.const import CONF_API_KEY, CONF_TOKEN +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import DEFAULT_NAME, DOMAIN, LOGGER, TIMEOUT + +DATA_SCHEMA: Final = vol.Schema( + { + vol.Required(CONF_API_KEY): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), + } +) + + +class CieloConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Cielo integration.""" + + VERSION = 1 + MINOR_VERSION = 1 + + async def _async_validate_api_key( + self, api_key: str + ) -> tuple[str | None, dict[str, str]]: + """Validate the API key, initialize the client, and return errors or token.""" + client = CieloClient( + api_key=api_key, + timeout=TIMEOUT, + session=async_get_clientsession(self.hass), + ) + + try: + token = await client.get_or_refresh_token() + + devices = await client.get_devices_data() + if not devices.parsed: + return None, {"base": "no_devices"} + + except AuthenticationError: + return None, {"base": "invalid_auth"} + except ConnectionError, TimeoutError, ClientError, CieloError: + return None, {"base": "cannot_connect"} + except Exception: # noqa: BLE001 + LOGGER.exception("Unexpected exception during config flow validation") + return None, {"base": "unknown"} + + return client.user_id, {CONF_TOKEN: token} + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + + if user_input: + api_key = user_input[CONF_API_KEY].strip() + + user_id, validation_result = await self._async_validate_api_key(api_key) + + if "base" in validation_result: + errors = validation_result + else: + token: str = validation_result[CONF_TOKEN] + + user_input[CONF_API_KEY] = api_key + user_input[CONF_TOKEN] = token + + await self.async_set_unique_id(user_id) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=DEFAULT_NAME, + data=user_input, + ) + + # Show the user form + return self.async_show_form( + step_id="user", + data_schema=DATA_SCHEMA, + errors=errors, + description_placeholders={ + "url": "https://www.home-assistant.io/integrations/cielo_home" + }, + ) diff --git a/homeassistant/components/cielo_home/const.py b/homeassistant/components/cielo_home/const.py new file mode 100644 index 000000000000..dbc3d68d342a --- /dev/null +++ b/homeassistant/components/cielo_home/const.py @@ -0,0 +1,24 @@ +"""Constants for the Cielo Home integration.""" + +import logging +from typing import Final + +from aiohttp import ClientError +from cieloconnectapi.exceptions import CieloError + +from homeassistant.const import Platform + +DOMAIN: Final = "cielo_home" +PLATFORMS: Final[list[Platform]] = [ + Platform.CLIMATE, +] +DEFAULT_NAME: Final = "Cielo Home" +DEFAULT_SCAN_INTERVAL: Final[int] = 2 * 60 +TIMEOUT: Final[int] = 20 +LOGGER: Final = logging.getLogger(__package__) + +CIELO_ERRORS: Final[tuple] = ( + ClientError, + TimeoutError, + CieloError, +) diff --git a/homeassistant/components/cielo_home/coordinator.py b/homeassistant/components/cielo_home/coordinator.py new file mode 100644 index 000000000000..3f53291b840d --- /dev/null +++ b/homeassistant/components/cielo_home/coordinator.py @@ -0,0 +1,109 @@ +"""Coordinator for Cielo integration.""" + +from __future__ import annotations + +from copy import copy +from dataclasses import dataclass +from datetime import timedelta +from typing import Any, Final + +from aiohttp import ClientError +from cieloconnectapi import CieloClient +from cieloconnectapi.exceptions import AuthenticationError, CieloError +from cieloconnectapi.model import CieloDevice + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, CONF_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.debounce import Debouncer +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, LOGGER, TIMEOUT + +REQUEST_REFRESH_DELAY: Final[int] = 2 * 60 + + +@dataclass(slots=True) +class CieloData: + """Data structure for the coordinator.""" + + raw: dict[str, Any] + parsed: dict[str, CieloDevice] + + +class CieloDataUpdateCoordinator(DataUpdateCoordinator[CieloData]): + """Cielo Data Update Coordinator.""" + + config_entry: CieloHomeConfigEntry + + def __init__(self, hass: HomeAssistant, entry: CieloHomeConfigEntry) -> None: + """Initialize the coordinator.""" + self.client = CieloClient( + api_key=entry.data[CONF_API_KEY], + timeout=TIMEOUT, + token=entry.data[CONF_TOKEN], + session=async_get_clientsession(hass), + ) + + super().__init__( + hass, + LOGGER, + name=DOMAIN, + config_entry=entry, + update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), + # The debouncer prevents multiple rapid refresh requests from triggering repeated full data fetches from the backend. + request_refresh_debouncer=Debouncer( + hass, LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=False + ), + ) + + async def _async_update_data(self) -> CieloData: + """Fetch data from the API.""" + try: + data = await self.client.get_devices_data() + except AuthenticationError as err: + raise ConfigEntryAuthFailed from err + except (TimeoutError, ConnectionError, CieloError, ClientError) as err: + raise UpdateFailed(err) from err + + return CieloData(raw=data.raw, parsed=data.parsed) + + async def async_apply_action_result( + self, device_id: str, data: dict[str, Any] + ) -> None: + """Apply an optimistic update from an API action response. + + This updates the affected device locally in the coordinator state so the + UI reflects the change immediately without requiring a full backend refresh. + + Performing a coordinator refresh after every action would fetch all devices + for the account, even when only a single device was updated. This is not + optimal from an API usage/cost perspective. + + Instead, the coordinator applies the action result locally for the affected + device and schedules a later refresh to reconcile with the backend state. + """ + if not self.data or not self.data.parsed or device_id not in self.data.parsed: + await self.async_request_refresh() + return + + new_parsed = dict(self.data.parsed) + dev = copy(new_parsed[device_id]) + + try: + dev.apply_update(data) + except KeyError, ValueError, TypeError: + await self.async_request_refresh() + return + + new_parsed[device_id] = dev + self.async_set_updated_data(CieloData(raw=self.data.raw, parsed=new_parsed)) + + # Request a debounced refresh to reconcile with the backend state. + await self.async_request_refresh() + + +# Define the ConfigEntry type here to avoid circular imports +type CieloHomeConfigEntry = ConfigEntry[CieloDataUpdateCoordinator] diff --git a/homeassistant/components/cielo_home/entity.py b/homeassistant/components/cielo_home/entity.py new file mode 100644 index 000000000000..ed82d59d4009 --- /dev/null +++ b/homeassistant/components/cielo_home/entity.py @@ -0,0 +1,78 @@ +"""Base entity for Cielo integration.""" + +from __future__ import annotations + +from cieloconnectapi.device import CieloDeviceAPI +from cieloconnectapi.model import CieloDevice + +from homeassistant.helpers.device_registry import ( + CONNECTION_NETWORK_MAC, + DeviceInfo, + format_mac, +) +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import CieloDataUpdateCoordinator + + +class CieloBaseEntity(CoordinatorEntity[CieloDataUpdateCoordinator]): + """Representation of a Cielo base entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: CieloDataUpdateCoordinator, + device_id: str, + ) -> None: + """Initialize the Cielo base entity.""" + super().__init__(coordinator) + self._device_id = device_id + self.client = CieloDeviceAPI( + coordinator.client, coordinator.data.parsed[device_id] + ) + + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + if (dev := self.device_data) is not None: + self.client.device_data = dev + super()._handle_coordinator_update() + + @property + def device_data(self) -> CieloDevice | None: + """Return the device data from the coordinator.""" + return self.coordinator.data.parsed.get(self._device_id) + + @property + def available(self) -> bool: + """Return if the device is available and online.""" + if not (super().available and self._device_id in self.coordinator.data.parsed): + return False + + dev = self.device_data + return bool(dev and dev.device_status) + + +class CieloDeviceEntity(CieloBaseEntity): + """Representation of a Cielo Device.""" + + def __init__( + self, + coordinator: CieloDataUpdateCoordinator, + device_id: str, + ) -> None: + """Initialize the device entity.""" + super().__init__(coordinator, device_id) + self.device_id = device_id + + device = coordinator.data.parsed[device_id] + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.id)}, + name=device.name, + connections={(CONNECTION_NETWORK_MAC, format_mac(device.mac_address))}, + manufacturer="Cielo", + configuration_url="https://home.cielowigle.com/", + suggested_area=device.name, + ) diff --git a/homeassistant/components/cielo_home/manifest.json b/homeassistant/components/cielo_home/manifest.json new file mode 100644 index 000000000000..9e233aa89d38 --- /dev/null +++ b/homeassistant/components/cielo_home/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "cielo_home", + "name": "Cielo Home", + "codeowners": ["@ihsan-cielo", "@mudasar-cielo"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/cielo_home", + "integration_type": "hub", + "iot_class": "cloud_polling", + "loggers": ["cieloconnectapi"], + "quality_scale": "bronze", + "requirements": ["cielo-connect-api==1.0.6"] +} diff --git a/homeassistant/components/cielo_home/quality_scale.yaml b/homeassistant/components/cielo_home/quality_scale.yaml new file mode 100644 index 000000000000..a7af9f8bb940 --- /dev/null +++ b/homeassistant/components/cielo_home/quality_scale.yaml @@ -0,0 +1,60 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: done + integration-owner: done + log-when-unavailable: todo + parallel-updates: done + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: todo + entity-device-class: todo + entity-disabled-by-default: todo + entity-translations: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/cielo_home/strings.json b/homeassistant/components/cielo_home/strings.json new file mode 100644 index 000000000000..a7dba1479250 --- /dev/null +++ b/homeassistant/components/cielo_home/strings.json @@ -0,0 +1,69 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "Invalid or expired API key; generate a new one", + "no_devices": "No devices found; make sure devices are set up in the Cielo Home app", + "no_user_id": "No valid user information found for the API key", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "The API key from your Cielo Home account" + }, + "description": "Sign in with your Cielo Home API key. Follow the [documentation]({url}) to learn how to get your API key.", + "title": "Connect to Cielo Home" + } + } + }, + "entity": { + "climate": { + "climate_device": { + "state_attributes": { + "fan_mode": { + "state": { + "auto": "[%key:common::state::auto%]", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "quiet": "Quiet", + "super_high": "Super high", + "ultra_high": "Ultra high" + } + }, + "swing_mode": { + "state": { + "adjust": "Adjust", + "auto": "[%key:common::state::auto%]", + "auto_stop": "Auto Stop", + "pos1": "Position 1", + "pos10": "Position 10", + "pos11": "Position 11", + "pos12": "Position 12", + "pos13": "Position 13", + "pos14": "Position 14", + "pos15": "Position 15", + "pos2": "Position 2", + "pos3": "Position 3", + "pos4": "Position 4", + "pos5": "Position 5", + "pos6": "Position 6", + "pos7": "Position 7", + "pos8": "Position 8", + "pos9": "Position 9", + "swing": "Swing" + } + } + } + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index e32493a1de0e..d876cc517d00 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -124,6 +124,7 @@ FLOWS = { "cert_expiry", "chacon_dio", "chess_com", + "cielo_home", "cloudflare", "cloudflare_r2", "co2signal", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 9d8e36587bac..3400242cf292 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1014,6 +1014,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "cielo_home": { + "name": "Cielo Home", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "cisco": { "name": "Cisco", "integrations": { diff --git a/requirements_all.txt b/requirements_all.txt index df9014437937..0761f6edf209 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -735,6 +735,9 @@ caldav==2.1.0 # homeassistant.components.chess_com chess-com-api==1.1.0 +# homeassistant.components.cielo_home +cielo-connect-api==1.0.6 + # homeassistant.components.cisco_mobility_express ciscomobilityexpress==0.3.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 72043228c921..090c5b8a0f8b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -659,6 +659,9 @@ caldav==2.1.0 # homeassistant.components.chess_com chess-com-api==1.1.0 +# homeassistant.components.cielo_home +cielo-connect-api==1.0.6 + # homeassistant.components.coinbase coinbase-advanced-py==1.2.2 diff --git a/tests/components/cielo_home/__init__.py b/tests/components/cielo_home/__init__.py new file mode 100644 index 000000000000..83871e19b039 --- /dev/null +++ b/tests/components/cielo_home/__init__.py @@ -0,0 +1,3 @@ +"""Tests for the Cielo Home integration.""" + +from __future__ import annotations diff --git a/tests/components/cielo_home/conftest.py b/tests/components/cielo_home/conftest.py new file mode 100644 index 000000000000..9fb9df783180 --- /dev/null +++ b/tests/components/cielo_home/conftest.py @@ -0,0 +1,91 @@ +"""Common fixtures for the Cielo Home tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.cielo_home.const import DOMAIN +from homeassistant.components.climate import HVACMode +from homeassistant.const import CONF_API_KEY, CONF_TOKEN + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry for the Cielo Home integration.""" + with patch( + "homeassistant.components.cielo_home.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_cielo_client() -> Generator[MagicMock]: + """Mock the CieloClient to prevent actual API calls during init.""" + with ( + patch( + "homeassistant.components.cielo_home.coordinator.CieloClient", autospec=True + ) as mock_client_cls, + patch( + "homeassistant.components.cielo_home.config_flow.CieloClient", + autospec=True, + ), + ): + client = mock_client_cls.return_value + + # Fake device + dev = MagicMock() + dev.id = "device_1" + dev.name = "Living Room" + dev.mac_address = "AA:BB:CC:DD:EE:FF" + dev.device_status = True + dev.preset_modes = ["sleep"] + dev.humidity = 40 + + mock_data = MagicMock() + mock_data.raw = {} + mock_data.parsed = {"device_1": dev} + + client.get_devices_data = AsyncMock(return_value=mock_data) + + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Create a mock config entry for the Cielo Home integration.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id="test-user-1", + data={CONF_API_KEY: "test-api-key-", CONF_TOKEN: "valid-test-token"}, + ) + + +@pytest.fixture +def mock_cielo_device_api() -> Generator[MagicMock]: + """Mock the CieloDeviceAPI to prevent actual device API calls.""" + device_api = MagicMock() + device_api.temperature_unit.return_value = "°C" + device_api.min_temp.return_value = 10 + device_api.max_temp.return_value = 35 + device_api.target_temperature_step.return_value = 1 + device_api.hvac_mode.return_value = HVACMode.COOL + device_api.hvac_modes.return_value = [HVACMode.OFF, HVACMode.COOL] + device_api.mode_supports_temperature.return_value = True + device_api.mode_caps.return_value = {"fan_levels": True, "swing": True} + device_api.current_temperature.return_value = 22 + device_api.target_temperature.return_value = 24 + device_api.fan_modes.return_value = ["auto", "low", "high"] + device_api.preset_modes.return_value = ["home", "away"] + device_api.swing_modes.return_value = ["auto", "pos1", "pos2"] + device_api.async_set_temperature = AsyncMock( + return_value={"data": {"target_temperature": 25}} + ) + + with patch( + "homeassistant.components.cielo_home.entity.CieloDeviceAPI", + return_value=device_api, + ): + yield device_api diff --git a/tests/components/cielo_home/test_climate.py b/tests/components/cielo_home/test_climate.py new file mode 100644 index 000000000000..6b9634f86759 --- /dev/null +++ b/tests/components/cielo_home/test_climate.py @@ -0,0 +1,35 @@ +"""Common tests for the Cielo Home climate.""" + +from unittest.mock import MagicMock + +from homeassistant.const import UnitOfTemperature +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_climate_set_temperature_calls_library( + hass: HomeAssistant, + mock_cielo_client: MagicMock, + mock_config_entry: MockConfigEntry, + mock_cielo_device_api: MagicMock, +) -> None: + """Test setting temperature calls into the library client/device API.""" + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entity_id = "climate.living_room" + + await hass.services.async_call( + "climate", + "set_temperature", + {"entity_id": entity_id, "temperature": 25}, + blocking=True, + ) + + mock_cielo_device_api.async_set_temperature.assert_awaited_once_with( + UnitOfTemperature.CELSIUS, + temperature=25.0, + ) diff --git a/tests/components/cielo_home/test_config_flow.py b/tests/components/cielo_home/test_config_flow.py new file mode 100644 index 000000000000..d678b4ff9d10 --- /dev/null +++ b/tests/components/cielo_home/test_config_flow.py @@ -0,0 +1,161 @@ +"""Tests for Cielo config flow.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +from cieloconnectapi.exceptions import AuthenticationError +import pytest + +from homeassistant.components.cielo_home.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_API_KEY, CONF_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +MOCK_TOKEN = "valid-test-token" +MOCK_CIELO_CLIENT_CTOR = "homeassistant.components.cielo_home.config_flow.CieloClient" + + +def _devices_payload(parsed: dict | None) -> MagicMock: + """Return a mock object shaped like cieloconnectapi get_devices_data() result.""" + payload = MagicMock() + payload.raw = {} + payload.parsed = parsed + return payload + + +async def test_full_config_flow_success(hass: HomeAssistant) -> None: + """Test successful config flow with valid API key.""" + mock_client = MagicMock() + mock_client.user_id = "test-user" + mock_client.get_or_refresh_token = AsyncMock(return_value=MOCK_TOKEN) + mock_client.get_devices_data = AsyncMock( + return_value=_devices_payload({"dev1": MagicMock()}) + ) + + with patch(MOCK_CIELO_CLIENT_CTOR, return_value=mock_client): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_API_KEY: " test-api-key "}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Cielo Home" + assert result["data"] == { + CONF_API_KEY: "test-api-key", + CONF_TOKEN: MOCK_TOKEN, + } + assert result["result"].unique_id == "test-user" + + +async def test_full_config_flow_abort_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test the flow aborts when the account is already configured.""" + mock_config_entry.add_to_hass(hass) + mock_client = MagicMock() + mock_client.user_id = mock_config_entry.unique_id + mock_client.get_or_refresh_token = AsyncMock(return_value=MOCK_TOKEN) + mock_client.get_devices_data = AsyncMock( + return_value=_devices_payload({"dev1": MagicMock()}) + ) + + with patch(MOCK_CIELO_CLIENT_CTOR, return_value=mock_client): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_API_KEY: "test-api-key"}, + ) + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("api_error", "flow_error_key"), + [ + (ConnectionError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_form_error_mapping( + hass: HomeAssistant, api_error: type[Exception], flow_error_key: str +) -> None: + """Test we handle various API errors correctly. + + These errors may occur from either token retrieval or device fetch + (your flow does both in validation). + """ + mock_client = MagicMock() + mock_client.user_id = "test-user" + mock_client.get_or_refresh_token = AsyncMock(return_value=MOCK_TOKEN) + mock_client.get_devices_data = AsyncMock(side_effect=api_error) + + with patch(MOCK_CIELO_CLIENT_CTOR, return_value=mock_client): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_API_KEY: "test-api-key"}, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["errors"]["base"] == flow_error_key + + # simulate recovery (next call succeeds) + mock_client.get_devices_data = AsyncMock(return_value=MagicMock(parsed=True)) + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_API_KEY: "test-api-key"}, + ) + + assert result3["type"] is FlowResultType.CREATE_ENTRY + + +async def test_form_error_mapping_invalid_auth(hass: HomeAssistant) -> None: + """Test AuthenticationError maps to invalid_auth.""" + + mock_client = MagicMock() + mock_client.user_id = "test-user" + mock_client.get_or_refresh_token = AsyncMock(side_effect=AuthenticationError) + mock_client.get_devices_data = AsyncMock( + return_value=_devices_payload({"dev1": MagicMock()}) + ) + + with patch(MOCK_CIELO_CLIENT_CTOR, return_value=mock_client): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_API_KEY: "test-api-key"}, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["errors"]["base"] == "invalid_auth" + + mock_client.get_or_refresh_token = AsyncMock(return_value=MOCK_TOKEN) + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_API_KEY: "test-api-key"}, + ) + + assert result3["type"] is FlowResultType.CREATE_ENTRY diff --git a/tests/components/cielo_home/test_init.py b/tests/components/cielo_home/test_init.py new file mode 100644 index 000000000000..24fed47063c9 --- /dev/null +++ b/tests/components/cielo_home/test_init.py @@ -0,0 +1,38 @@ +"""Common tests for the Cielo Home.""" + +from unittest.mock import MagicMock + +from homeassistant.components.cielo_home.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + + +async def test_async_setup_and_unload_entry( + hass: HomeAssistant, + mock_cielo_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting up and unloading the integration.""" + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert mock_config_entry.runtime_data is not None + + entity_reg = er.async_get(hass) + entities = [ + e + for e in entity_reg.entities.values() + if e.platform == DOMAIN and e.domain == "climate" + ] + assert len(entities) == 1 + + # Unload + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED