mirror of
https://github.com/home-assistant/core.git
synced 2026-09-12 11:38:47 +01:00
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:
co-authored by
Robert Resch
Norbert Rittel
Copilot
Owais Amin
Owais Amin
Maria Nadeem
parent
65491372c2
commit
a7fd763570
Generated
+2
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+1
@@ -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": {
|
||||
|
||||
Generated
+3
@@ -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
|
||||
|
||||
|
||||
Generated
+3
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Tests for the Cielo Home integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user