Add Cielo Home integration (#158511)

Co-authored-by: Robert Resch <robert@resch.dev>
Co-authored-by: Norbert Rittel <norbert@rittel.de>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Owais Amin <141307092+owais-cielo@users.noreply.github.com>
Co-authored-by: Owais Amin <owais@cielowigle.com>
Co-authored-by: Maria Nadeem <maria@cielowigle.com>
This commit is contained in:
Muhammad Ihsan
2026-05-07 23:12:19 +02:00
committed by GitHub
co-authored by Robert Resch Norbert Rittel Copilot Owais Amin Owais Amin Maria Nadeem
parent 65491372c2
commit a7fd763570
19 changed files with 1135 additions and 0 deletions
Generated
+2
View File
@@ -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
@@ -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)
@@ -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)
@@ -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"
},
)
@@ -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,
)
@@ -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]
@@ -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,
)
@@ -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"]
}
@@ -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
@@ -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"
}
}
}
}
}
}
}
+1
View File
@@ -124,6 +124,7 @@ FLOWS = {
"cert_expiry",
"chacon_dio",
"chess_com",
"cielo_home",
"cloudflare",
"cloudflare_r2",
"co2signal",
@@ -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": {
+3
View File
@@ -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
+3
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
"""Tests for the Cielo Home integration."""
from __future__ import annotations
+91
View File
@@ -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
@@ -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,
)
@@ -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
+38
View File
@@ -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