mirror of
https://github.com/home-assistant/core.git
synced 2026-08-24 10:13:52 -05:00
Add Xthings Cloud (#167885)
Co-authored-by: Joostlek <joostlek@outlook.com>
This commit is contained in:
Generated
+2
@@ -2026,6 +2026,8 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/xiaomi_miio/ @rytilahti @syssi @starkillerOG
|
||||
/homeassistant/components/xiaomi_tv/ @simse
|
||||
/homeassistant/components/xmpp/ @fabaff @flowolf
|
||||
/homeassistant/components/xthings_cloud/ @XthingsJacobs
|
||||
/tests/components/xthings_cloud/ @XthingsJacobs
|
||||
/homeassistant/components/yale/ @bdraco
|
||||
/tests/components/yale/ @bdraco
|
||||
/homeassistant/components/yale_smart_alarm/ @gjohansson-ST
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Xthings Cloud integration for Home Assistant."""
|
||||
|
||||
from ha_xthings_cloud import XthingsCloudApiClient
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import CONF_TOKEN, PLATFORMS
|
||||
from .coordinator import XthingsCloudConfigEntry, XthingsCloudCoordinator
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: XthingsCloudConfigEntry
|
||||
) -> bool:
|
||||
"""Set up config entry."""
|
||||
session = async_get_clientsession(hass)
|
||||
client = XthingsCloudApiClient(session, token=entry.data[CONF_TOKEN])
|
||||
|
||||
coordinator = XthingsCloudCoordinator(hass, client, entry)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
entry.runtime_data = coordinator
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
await coordinator.async_start_websocket()
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(
|
||||
hass: HomeAssistant, entry: XthingsCloudConfigEntry
|
||||
) -> bool:
|
||||
"""Unload config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||
await coordinator.async_stop_websocket()
|
||||
return unload_ok
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Config flow for Xthings Cloud."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from ha_xthings_cloud import (
|
||||
XthingsCloudApiClient,
|
||||
XthingsCloudApiError,
|
||||
XthingsCloudAuthError,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.instance_id import async_get as async_get_instance_id
|
||||
|
||||
from .const import (
|
||||
CONF_EMAIL,
|
||||
CONF_PASSWORD,
|
||||
CONF_REFRESH_TOKEN,
|
||||
CONF_TOKEN,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
)
|
||||
|
||||
ERROR_CODE_MAP: dict[int, str] = {
|
||||
20001: "token_invalid",
|
||||
21001: "email_empty",
|
||||
21002: "email_invalid",
|
||||
21004: "email_not_found",
|
||||
21011: "password_empty",
|
||||
21014: "password_wrong",
|
||||
21021: "user_disabled",
|
||||
21022: "user_not_logged_in",
|
||||
21023: "user_not_activated",
|
||||
20011: "token_invalid",
|
||||
20012: "token_expired",
|
||||
22001: "device_not_found",
|
||||
22003: "device_offline",
|
||||
}
|
||||
|
||||
|
||||
def _error_from_exception(err: XthingsCloudApiError) -> str:
|
||||
"""Return translation key from error code."""
|
||||
return ERROR_CODE_MAP.get(err.code, "unknown")
|
||||
|
||||
|
||||
class XthingsCloudConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Xthings Cloud config flow."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle user input step."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
instance_id = await async_get_instance_id(self.hass)
|
||||
session = async_get_clientsession(self.hass)
|
||||
client = XthingsCloudApiClient(session)
|
||||
try:
|
||||
token_data = await client.async_login(
|
||||
user_input[CONF_EMAIL],
|
||||
user_input[CONF_PASSWORD],
|
||||
client_id=instance_id,
|
||||
)
|
||||
except XthingsCloudAuthError as err:
|
||||
errors["base"] = _error_from_exception(err)
|
||||
except XthingsCloudApiError as err:
|
||||
errors["base"] = (
|
||||
_error_from_exception(err) if err.code else "cannot_connect"
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
LOGGER.exception("Unexpected error during login")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
await self.async_set_unique_id(token_data["user_id"])
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_EMAIL],
|
||||
data={
|
||||
CONF_EMAIL: user_input[CONF_EMAIL],
|
||||
CONF_TOKEN: token_data["token"],
|
||||
CONF_REFRESH_TOKEN: token_data["refresh_token"],
|
||||
},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_EMAIL): str,
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Constants for Xthings Cloud integration."""
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.const import Platform
|
||||
|
||||
DOMAIN = "xthings_cloud"
|
||||
LOGGER = logging.getLogger(__package__)
|
||||
|
||||
CONF_EMAIL = "email"
|
||||
CONF_PASSWORD = "password"
|
||||
CONF_TOKEN = "token"
|
||||
CONF_REFRESH_TOKEN = "refresh_token"
|
||||
CONF_CLIENT_ID = "client_id"
|
||||
CONF_INSTANCE_ID = "instance_id"
|
||||
|
||||
# Polling interval (seconds)
|
||||
DEFAULT_SCAN_INTERVAL = 1800
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.LIGHT]
|
||||
@@ -0,0 +1,128 @@
|
||||
"""DataUpdateCoordinator for Xthings Cloud."""
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
from ha_xthings_cloud import (
|
||||
XthingsCloudApiClient,
|
||||
XthingsCloudApiError,
|
||||
XthingsCloudAuthError,
|
||||
XthingsCloudWebSocket,
|
||||
)
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import CONF_REFRESH_TOKEN, CONF_TOKEN, DEFAULT_SCAN_INTERVAL, DOMAIN, LOGGER
|
||||
|
||||
type XthingsCloudConfigEntry = ConfigEntry["XthingsCloudCoordinator"]
|
||||
|
||||
|
||||
class XthingsCloudCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
"""Xthings Cloud data update coordinator."""
|
||||
|
||||
config_entry: XthingsCloudConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
client: XthingsCloudApiClient,
|
||||
entry: XthingsCloudConfigEntry,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
LOGGER,
|
||||
name=DOMAIN,
|
||||
update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL),
|
||||
config_entry=entry,
|
||||
)
|
||||
self.client = client
|
||||
self.websocket: XthingsCloudWebSocket | None = None
|
||||
|
||||
async def _async_ensure_token_valid(self) -> None:
|
||||
"""Ensure the token is valid, refresh if expired.
|
||||
|
||||
Raises ConfigEntryAuthFailed if refresh fails.
|
||||
"""
|
||||
if not self.client.is_token_expired():
|
||||
return
|
||||
try:
|
||||
token_data = await self.client.async_refresh_token(
|
||||
self.config_entry.data[CONF_REFRESH_TOKEN]
|
||||
)
|
||||
except XthingsCloudAuthError as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
"Token expired and refresh failed, re-authentication required"
|
||||
) from err
|
||||
self.hass.config_entries.async_update_entry(
|
||||
self.config_entry,
|
||||
data={
|
||||
**self.config_entry.data,
|
||||
CONF_TOKEN: token_data["token"],
|
||||
CONF_REFRESH_TOKEN: token_data["refresh_token"],
|
||||
},
|
||||
)
|
||||
|
||||
async def _async_update_data(self) -> dict[str, Any]:
|
||||
"""Fetch latest device data from cloud."""
|
||||
await self._async_ensure_token_valid()
|
||||
try:
|
||||
devices = await self.client.async_get_devices()
|
||||
except XthingsCloudAuthError as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
"Invalid token, re-authentication required"
|
||||
) from err
|
||||
except XthingsCloudApiError as err:
|
||||
raise UpdateFailed(f"Failed to fetch data: {err}") from err
|
||||
return {device["id"]: device for device in devices}
|
||||
|
||||
async def async_start_websocket(self) -> None:
|
||||
"""Start WebSocket connection."""
|
||||
if self.websocket:
|
||||
return
|
||||
session = async_get_clientsession(self.hass)
|
||||
token = self.config_entry.data[CONF_TOKEN]
|
||||
self.websocket = XthingsCloudWebSocket(
|
||||
session=session,
|
||||
token=token,
|
||||
on_device_status=self._handle_ws_device_status,
|
||||
on_token_expired=self._handle_ws_token_expired,
|
||||
)
|
||||
await self.websocket.async_start()
|
||||
|
||||
async def async_stop_websocket(self) -> None:
|
||||
"""Stop WebSocket connection."""
|
||||
if self.websocket:
|
||||
await self.websocket.async_stop()
|
||||
self.websocket = None
|
||||
|
||||
def _handle_ws_device_status(
|
||||
self, device_uuid: str, status: dict[str, Any]
|
||||
) -> None:
|
||||
"""Handle WebSocket device status update."""
|
||||
if not self.data or device_uuid not in self.data:
|
||||
LOGGER.debug(
|
||||
"WebSocket received status for unknown device: %s", device_uuid
|
||||
)
|
||||
return
|
||||
device_data = self.data[device_uuid]
|
||||
device_data.setdefault("status", {}).update(status)
|
||||
LOGGER.debug("WebSocket updated device status: %s", device_uuid)
|
||||
self.async_set_updated_data(self.data)
|
||||
|
||||
async def _handle_ws_token_expired(self) -> None:
|
||||
"""Handle WebSocket auth expiry, refresh token."""
|
||||
try:
|
||||
await self._async_ensure_token_valid()
|
||||
except ConfigEntryAuthFailed:
|
||||
LOGGER.error("WebSocket token refresh failed")
|
||||
return
|
||||
new_token = self.config_entry.data[CONF_TOKEN]
|
||||
self.client.token = new_token
|
||||
if self.websocket:
|
||||
self.websocket.token = new_token
|
||||
LOGGER.info("WebSocket token refreshed successfully")
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Base entity for Xthings Cloud."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import XthingsCloudCoordinator
|
||||
|
||||
|
||||
class XthingsCloudEntity(CoordinatorEntity[XthingsCloudCoordinator]):
|
||||
"""Xthings Cloud base entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: XthingsCloudCoordinator,
|
||||
device_id: str,
|
||||
device_data: dict[str, Any],
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(coordinator)
|
||||
self._device_id = device_id
|
||||
self._attr_unique_id = device_id
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, device_id)},
|
||||
name=device_data["name"],
|
||||
manufacturer="Xthings",
|
||||
model=device_data["model"],
|
||||
sw_version=device_data.get("version"),
|
||||
)
|
||||
|
||||
@property
|
||||
def device_data(self) -> dict[str, Any]:
|
||||
"""Return current device data."""
|
||||
return self.coordinator.data[self._device_id]
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return whether device is available (online)."""
|
||||
return (
|
||||
super().available
|
||||
and self._device_id in self.coordinator.data
|
||||
and self.device_data["online"]
|
||||
)
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Light platform for Xthings Cloud."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.light import (
|
||||
ATTR_BRIGHTNESS,
|
||||
ATTR_COLOR_TEMP_KELVIN,
|
||||
ATTR_HS_COLOR,
|
||||
ColorMode,
|
||||
LightEntity,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import XthingsCloudConfigEntry, XthingsCloudCoordinator
|
||||
from .entity import XthingsCloudEntity
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: XthingsCloudConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up light platform."""
|
||||
coordinator = entry.runtime_data
|
||||
entities = [
|
||||
XthingsCloudLight(coordinator, device_id, device_data)
|
||||
for device_id, device_data in coordinator.data.items()
|
||||
if device_data["type"] == "light"
|
||||
]
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class XthingsCloudLight(XthingsCloudEntity, LightEntity):
|
||||
"""Xthings Cloud light entity."""
|
||||
|
||||
_attr_min_color_temp_kelvin = 2000
|
||||
_attr_max_color_temp_kelvin = 6500
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: XthingsCloudCoordinator,
|
||||
device_id: str,
|
||||
device_data: dict[str, Any],
|
||||
) -> None:
|
||||
"""Initialize the light entity."""
|
||||
super().__init__(coordinator, device_id, device_data)
|
||||
# Determine supported color modes from device status
|
||||
status = device_data["status"]
|
||||
modes: set[ColorMode] = set()
|
||||
if "hue" in status or "saturation" in status:
|
||||
modes.add(ColorMode.HS)
|
||||
if "temperature" in status:
|
||||
modes.add(ColorMode.COLOR_TEMP)
|
||||
if not modes and "brightness" in status:
|
||||
modes.add(ColorMode.BRIGHTNESS)
|
||||
if not modes:
|
||||
modes.add(ColorMode.ONOFF)
|
||||
self._attr_supported_color_modes = modes
|
||||
|
||||
@property
|
||||
def color_mode(self) -> ColorMode:
|
||||
"""Return current color mode."""
|
||||
status = self.device_data["status"]
|
||||
color_type = status.get("color_type")
|
||||
modes = self._attr_supported_color_modes or set()
|
||||
if color_type == 0 and ColorMode.HS in modes:
|
||||
return ColorMode.HS
|
||||
if color_type == 1 and ColorMode.COLOR_TEMP in modes:
|
||||
return ColorMode.COLOR_TEMP
|
||||
if ColorMode.HS in modes:
|
||||
return ColorMode.HS
|
||||
if ColorMode.COLOR_TEMP in modes:
|
||||
return ColorMode.COLOR_TEMP
|
||||
if ColorMode.BRIGHTNESS in modes:
|
||||
return ColorMode.BRIGHTNESS
|
||||
return ColorMode.ONOFF
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if the light is on."""
|
||||
return self.device_data["status"]["on"]
|
||||
|
||||
@property
|
||||
def brightness(self) -> int | None:
|
||||
"""Return brightness (0-255)."""
|
||||
level = self.device_data["status"].get("brightness")
|
||||
if level is not None:
|
||||
return round(level * 255 / 100)
|
||||
return None
|
||||
|
||||
@property
|
||||
def hs_color(self) -> tuple[float, float] | None:
|
||||
"""Return the HS color value."""
|
||||
status = self.device_data["status"]
|
||||
hue = status.get("hue")
|
||||
saturation = status.get("saturation")
|
||||
if hue is not None and saturation is not None:
|
||||
return (hue, saturation)
|
||||
return None
|
||||
|
||||
@property
|
||||
def color_temp_kelvin(self) -> int | None:
|
||||
"""Return the color temperature in Kelvin."""
|
||||
return self.device_data["status"].get("temperature")
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn on light."""
|
||||
client = self.coordinator.client
|
||||
has_color = ATTR_HS_COLOR in kwargs or ATTR_COLOR_TEMP_KELVIN in kwargs
|
||||
has_brightness = ATTR_BRIGHTNESS in kwargs
|
||||
# Only send on command when no color/brightness adjustment
|
||||
if not has_color and not has_brightness:
|
||||
await client.async_brite_on(self._device_id)
|
||||
# Adjust brightness (standalone, no color change)
|
||||
if has_brightness and not has_color:
|
||||
brightness = round(kwargs[ATTR_BRIGHTNESS] * 100 / 255)
|
||||
await client.async_brite_brightness(self._device_id, brightness)
|
||||
# Adjust HS color
|
||||
if ATTR_HS_COLOR in kwargs:
|
||||
hue, saturation = kwargs[ATTR_HS_COLOR]
|
||||
status = self.device_data["status"]
|
||||
lightness = status.get("lightness", 50)
|
||||
cur_brightness = status.get("brightness", 100)
|
||||
if ATTR_BRIGHTNESS in kwargs:
|
||||
lightness = round(kwargs[ATTR_BRIGHTNESS] * 100 / 255)
|
||||
cur_brightness = lightness
|
||||
await client.async_brite_color(
|
||||
self._device_id,
|
||||
{
|
||||
"colortype": 0,
|
||||
"hue": round(hue),
|
||||
"saturation": round(saturation),
|
||||
"lightness": lightness,
|
||||
"brightness": cur_brightness,
|
||||
},
|
||||
)
|
||||
# Adjust color temperature
|
||||
if ATTR_COLOR_TEMP_KELVIN in kwargs:
|
||||
status = self.device_data["status"]
|
||||
cur_brightness = status.get("brightness", 100)
|
||||
if ATTR_BRIGHTNESS in kwargs:
|
||||
cur_brightness = round(kwargs[ATTR_BRIGHTNESS] * 100 / 255)
|
||||
await client.async_brite_color(
|
||||
self._device_id,
|
||||
{
|
||||
"colortype": 1,
|
||||
"temperature": kwargs[ATTR_COLOR_TEMP_KELVIN],
|
||||
"brightness": cur_brightness,
|
||||
},
|
||||
)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off light."""
|
||||
await self.coordinator.client.async_brite_off(self._device_id)
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"domain": "xthings_cloud",
|
||||
"name": "Xthings Cloud",
|
||||
"codeowners": ["@XthingsJacobs"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/xthings_cloud",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["ha_xthings_cloud"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["ha-xthings-cloud==1.0.5"]
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: No service actions implemented.
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow: done
|
||||
config-flow-test-coverage: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: No service actions implemented.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: No event-based entity setup.
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
config-entry-unloading: done
|
||||
log-when-unavailable:
|
||||
status: done
|
||||
comment: Offloaded to coordinator.
|
||||
entity-unavailable:
|
||||
status: done
|
||||
comment: Offloaded to coordinator.
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: No service actions implemented.
|
||||
reauthentication-flow: todo
|
||||
parallel-updates: todo
|
||||
test-coverage: todo
|
||||
integration-owner: done
|
||||
docs-installation-parameters: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: No options flow.
|
||||
|
||||
# Gold
|
||||
entity-translations:
|
||||
status: exempt
|
||||
comment: Entity uses has_entity_name with name set to None.
|
||||
entity-device-class:
|
||||
status: exempt
|
||||
comment: No platform with device classes.
|
||||
devices: done
|
||||
entity-category:
|
||||
status: exempt
|
||||
comment: No diagnostic or configuration entities.
|
||||
entity-disabled-by-default:
|
||||
status: exempt
|
||||
comment: No entities disabled by default.
|
||||
discovery: todo
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: Single config entry, devices managed by coordinator.
|
||||
diagnostics: todo
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: Devices are fetched from cloud on each update.
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: No discoverable entities.
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: No repair issues implemented.
|
||||
docs-use-cases: done
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-data-update: done
|
||||
docs-known-limitations: done
|
||||
docs-troubleshooting: done
|
||||
docs-examples:
|
||||
status: exempt
|
||||
comment: No automation examples needed.
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: todo
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"device_not_found": "Device not found.",
|
||||
"device_offline": "Device is offline.",
|
||||
"email_empty": "Email cannot be empty.",
|
||||
"email_invalid": "Invalid email format.",
|
||||
"email_not_found": "Email does not exist.",
|
||||
"password_empty": "Password cannot be empty.",
|
||||
"password_wrong": "Incorrect password.",
|
||||
"token_expired": "Token expired, please log in again.",
|
||||
"token_invalid": "Invalid token, please log in again.",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]",
|
||||
"user_disabled": "This account has been disabled.",
|
||||
"user_not_activated": "This account has not been activated.",
|
||||
"user_not_logged_in": "Session expired, please log in again."
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"email": "[%key:common::config_flow::data::email%]",
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"data_description": {
|
||||
"email": "The email address used to register your Xthings Cloud account.",
|
||||
"password": "Your Xthings Cloud account password."
|
||||
},
|
||||
"description": "Please enter your Xthings Cloud account credentials.",
|
||||
"title": "Xthings Cloud Login"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+1
@@ -847,6 +847,7 @@ FLOWS = {
|
||||
"xiaomi_aqara",
|
||||
"xiaomi_ble",
|
||||
"xiaomi_miio",
|
||||
"xthings_cloud",
|
||||
"yale",
|
||||
"yale_smart_alarm",
|
||||
"yalexs_ble",
|
||||
|
||||
@@ -8069,6 +8069,12 @@
|
||||
"config_flow": false,
|
||||
"iot_class": "local_polling"
|
||||
},
|
||||
"xthings_cloud": {
|
||||
"name": "Xthings Cloud",
|
||||
"integration_type": "hub",
|
||||
"config_flow": true,
|
||||
"iot_class": "cloud_push"
|
||||
},
|
||||
"yale": {
|
||||
"name": "Yale (non-US/Canada)",
|
||||
"integrations": {
|
||||
|
||||
Generated
+3
@@ -1200,6 +1200,9 @@ ha-philipsjs==3.2.4
|
||||
# homeassistant.components.homeassistant_hardware
|
||||
ha-silabs-firmware-client==0.3.0
|
||||
|
||||
# homeassistant.components.xthings_cloud
|
||||
ha-xthings-cloud==1.0.5
|
||||
|
||||
# homeassistant.components.habitica
|
||||
habiticalib==0.4.7
|
||||
|
||||
|
||||
Generated
+3
@@ -1076,6 +1076,9 @@ ha-philipsjs==3.2.4
|
||||
# homeassistant.components.homeassistant_hardware
|
||||
ha-silabs-firmware-client==0.3.0
|
||||
|
||||
# homeassistant.components.xthings_cloud
|
||||
ha-xthings-cloud==1.0.5
|
||||
|
||||
# homeassistant.components.habitica
|
||||
habiticalib==0.4.7
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Tests for the Xthings Cloud integration."""
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None:
|
||||
"""Fixture for setting up the integration."""
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
|
||||
def get_device_by_id(mock_api_client: AsyncMock, device_id: str) -> dict[str, Any]:
|
||||
"""Helper for getting the device."""
|
||||
for device in mock_api_client.async_get_devices.return_value:
|
||||
if device["id"] == device_id:
|
||||
return device
|
||||
raise ValueError(f"Device with ID {device_id} not found in mock API client.")
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Fixtures for Xthings Cloud tests."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.xthings_cloud.const import (
|
||||
CONF_EMAIL,
|
||||
CONF_REFRESH_TOKEN,
|
||||
CONF_TOKEN,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
from .const import MOCK_EMAIL, MOCK_REFRESH_TOKEN, MOCK_TOKEN, MOCK_USER_ID
|
||||
|
||||
from tests.common import MockConfigEntry, load_json_object_fixture
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config_entry() -> MockConfigEntry:
|
||||
"""Return a mock config entry."""
|
||||
return MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
title=MOCK_EMAIL,
|
||||
data={
|
||||
CONF_EMAIL: MOCK_EMAIL,
|
||||
CONF_TOKEN: MOCK_TOKEN,
|
||||
CONF_REFRESH_TOKEN: MOCK_REFRESH_TOKEN,
|
||||
},
|
||||
unique_id=MOCK_USER_ID,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_setup_entry() -> Generator[AsyncMock]:
|
||||
"""Override async_setup_entry."""
|
||||
with patch(
|
||||
"homeassistant.components.xthings_cloud.async_setup_entry", return_value=True
|
||||
) as mock_setup_entry:
|
||||
yield mock_setup_entry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def device_fixtures() -> list[str]:
|
||||
"""Fixtures for Xthings Cloud devices."""
|
||||
return [
|
||||
"XT-LT050",
|
||||
"XT-LT100",
|
||||
"XT-LT200",
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_api_client(
|
||||
device_fixtures: list[str], mock_websocket: AsyncMock
|
||||
) -> Generator[AsyncMock]:
|
||||
"""Mock the XthingsCloudApiClient."""
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.xthings_cloud.config_flow.XthingsCloudApiClient",
|
||||
autospec=True,
|
||||
) as mock_cls,
|
||||
patch(
|
||||
"homeassistant.components.xthings_cloud.XthingsCloudApiClient",
|
||||
new=mock_cls,
|
||||
),
|
||||
):
|
||||
client = mock_cls.return_value
|
||||
client.async_login.return_value = {
|
||||
"token": MOCK_TOKEN,
|
||||
"refresh_token": MOCK_REFRESH_TOKEN,
|
||||
"user_id": MOCK_USER_ID,
|
||||
"client_id": "mock_client_id",
|
||||
}
|
||||
client.async_get_devices.return_value = [
|
||||
load_json_object_fixture(f"{device_fixture}.json", DOMAIN)
|
||||
for device_fixture in device_fixtures
|
||||
]
|
||||
client.is_token_expired.return_value = False
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_websocket() -> Generator[AsyncMock]:
|
||||
"""Mock the XthingsCloudWebSocket."""
|
||||
with patch(
|
||||
"homeassistant.components.xthings_cloud.coordinator.XthingsCloudWebSocket",
|
||||
autospec=True,
|
||||
) as mock_ws_cls:
|
||||
yield mock_ws_cls
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_instance_id() -> Generator[None]:
|
||||
"""Mock the instance ID."""
|
||||
with patch(
|
||||
"homeassistant.components.xthings_cloud.config_flow.async_get_instance_id",
|
||||
return_value="mock_instance_id",
|
||||
):
|
||||
yield
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Constants for Xthings Cloud integration tests."""
|
||||
|
||||
MOCK_EMAIL = "test@example.com"
|
||||
MOCK_PASSWORD = "test_password"
|
||||
MOCK_TOKEN = "mock_token"
|
||||
MOCK_REFRESH_TOKEN = "mock_refresh_token"
|
||||
MOCK_USER_ID = "02c7badf2b3d44d953b48b579eb9eeb5"
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"id": "dev_light_003",
|
||||
"name": "Porch Light",
|
||||
"type": "light",
|
||||
"model": "XT-LT050",
|
||||
"version": "1.0.0",
|
||||
"online": true,
|
||||
"status": {
|
||||
"on": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"id": "dev_light_002",
|
||||
"name": "Hallway Light",
|
||||
"type": "light",
|
||||
"model": "XT-LT100",
|
||||
"version": "1.0.0",
|
||||
"online": true,
|
||||
"status": {
|
||||
"on": false,
|
||||
"brightness": 50
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"id": "dev_light_001",
|
||||
"name": "Bedroom Light",
|
||||
"type": "light",
|
||||
"model": "XT-LT200",
|
||||
"version": "2.0.1",
|
||||
"online": true,
|
||||
"status": {
|
||||
"on": true,
|
||||
"brightness": 75,
|
||||
"color_type": 0,
|
||||
"hue": 150,
|
||||
"saturation": 80,
|
||||
"lightness": 54,
|
||||
"temperature": 4000
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
# serializer version: 1
|
||||
# name: test_devices[XT-LT050]
|
||||
DeviceRegistryEntrySnapshot({
|
||||
'area_id': None,
|
||||
'config_entries': <ANY>,
|
||||
'config_entries_subentries': <ANY>,
|
||||
'configuration_url': None,
|
||||
'connections': set({
|
||||
}),
|
||||
'disabled_by': None,
|
||||
'entry_type': None,
|
||||
'hw_version': None,
|
||||
'id': <ANY>,
|
||||
'identifiers': set({
|
||||
tuple(
|
||||
'xthings_cloud',
|
||||
'dev_light_003',
|
||||
),
|
||||
}),
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': 'Xthings',
|
||||
'model': 'XT-LT050',
|
||||
'model_id': None,
|
||||
'name': 'Porch Light',
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': None,
|
||||
'sw_version': '1.0.0',
|
||||
'via_device_id': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_devices[XT-LT100]
|
||||
DeviceRegistryEntrySnapshot({
|
||||
'area_id': None,
|
||||
'config_entries': <ANY>,
|
||||
'config_entries_subentries': <ANY>,
|
||||
'configuration_url': None,
|
||||
'connections': set({
|
||||
}),
|
||||
'disabled_by': None,
|
||||
'entry_type': None,
|
||||
'hw_version': None,
|
||||
'id': <ANY>,
|
||||
'identifiers': set({
|
||||
tuple(
|
||||
'xthings_cloud',
|
||||
'dev_light_002',
|
||||
),
|
||||
}),
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': 'Xthings',
|
||||
'model': 'XT-LT100',
|
||||
'model_id': None,
|
||||
'name': 'Hallway Light',
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': None,
|
||||
'sw_version': '1.0.0',
|
||||
'via_device_id': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_devices[XT-LT200]
|
||||
DeviceRegistryEntrySnapshot({
|
||||
'area_id': None,
|
||||
'config_entries': <ANY>,
|
||||
'config_entries_subentries': <ANY>,
|
||||
'configuration_url': None,
|
||||
'connections': set({
|
||||
}),
|
||||
'disabled_by': None,
|
||||
'entry_type': None,
|
||||
'hw_version': None,
|
||||
'id': <ANY>,
|
||||
'identifiers': set({
|
||||
tuple(
|
||||
'xthings_cloud',
|
||||
'dev_light_001',
|
||||
),
|
||||
}),
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': 'Xthings',
|
||||
'model': 'XT-LT200',
|
||||
'model_id': None,
|
||||
'name': 'Bedroom Light',
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': None,
|
||||
'sw_version': '2.0.1',
|
||||
'via_device_id': None,
|
||||
})
|
||||
# ---
|
||||
@@ -0,0 +1,200 @@
|
||||
# serializer version: 1
|
||||
# name: test_lights[light.bedroom_light-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'max_color_temp_kelvin': 6500,
|
||||
'min_color_temp_kelvin': 2000,
|
||||
'supported_color_modes': list([
|
||||
<ColorMode.COLOR_TEMP: 'color_temp'>,
|
||||
<ColorMode.HS: 'hs'>,
|
||||
]),
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'light',
|
||||
'entity_category': None,
|
||||
'entity_id': 'light.bedroom_light',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': None,
|
||||
'platform': 'xthings_cloud',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': None,
|
||||
'unique_id': 'dev_light_001',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_lights[light.bedroom_light-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'brightness': 191,
|
||||
'color_mode': <ColorMode.HS: 'hs'>,
|
||||
'color_temp_kelvin': None,
|
||||
'friendly_name': 'Bedroom Light',
|
||||
'hs_color': tuple(
|
||||
150,
|
||||
80,
|
||||
),
|
||||
'max_color_temp_kelvin': 6500,
|
||||
'min_color_temp_kelvin': 2000,
|
||||
'rgb_color': tuple(
|
||||
51,
|
||||
255,
|
||||
153,
|
||||
),
|
||||
'supported_color_modes': list([
|
||||
<ColorMode.COLOR_TEMP: 'color_temp'>,
|
||||
<ColorMode.HS: 'hs'>,
|
||||
]),
|
||||
'supported_features': <LightEntityFeature: 0>,
|
||||
'xy_color': tuple(
|
||||
0.174,
|
||||
0.53,
|
||||
),
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'light.bedroom_light',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'on',
|
||||
})
|
||||
# ---
|
||||
# name: test_lights[light.hallway_light-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'supported_color_modes': list([
|
||||
<ColorMode.BRIGHTNESS: 'brightness'>,
|
||||
]),
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'light',
|
||||
'entity_category': None,
|
||||
'entity_id': 'light.hallway_light',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': None,
|
||||
'platform': 'xthings_cloud',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': None,
|
||||
'unique_id': 'dev_light_002',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_lights[light.hallway_light-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'brightness': None,
|
||||
'color_mode': None,
|
||||
'friendly_name': 'Hallway Light',
|
||||
'supported_color_modes': list([
|
||||
<ColorMode.BRIGHTNESS: 'brightness'>,
|
||||
]),
|
||||
'supported_features': <LightEntityFeature: 0>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'light.hallway_light',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'off',
|
||||
})
|
||||
# ---
|
||||
# name: test_lights[light.porch_light-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'supported_color_modes': list([
|
||||
<ColorMode.ONOFF: 'onoff'>,
|
||||
]),
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'light',
|
||||
'entity_category': None,
|
||||
'entity_id': 'light.porch_light',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': None,
|
||||
'platform': 'xthings_cloud',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': None,
|
||||
'unique_id': 'dev_light_003',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_lights[light.porch_light-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'color_mode': <ColorMode.ONOFF: 'onoff'>,
|
||||
'friendly_name': 'Porch Light',
|
||||
'supported_color_modes': list([
|
||||
<ColorMode.ONOFF: 'onoff'>,
|
||||
]),
|
||||
'supported_features': <LightEntityFeature: 0>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'light.porch_light',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'on',
|
||||
})
|
||||
# ---
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Tests for Xthings Cloud config flow."""
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from ha_xthings_cloud import XthingsCloudApiError, XthingsCloudAuthError
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.xthings_cloud.const import (
|
||||
CONF_EMAIL,
|
||||
CONF_PASSWORD,
|
||||
CONF_REFRESH_TOKEN,
|
||||
CONF_TOKEN,
|
||||
DOMAIN,
|
||||
)
|
||||
from homeassistant.config_entries import SOURCE_USER
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
|
||||
from .const import (
|
||||
MOCK_EMAIL,
|
||||
MOCK_PASSWORD,
|
||||
MOCK_REFRESH_TOKEN,
|
||||
MOCK_TOKEN,
|
||||
MOCK_USER_ID,
|
||||
)
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
async def test_user_flow_success(
|
||||
hass: HomeAssistant,
|
||||
mock_setup_entry: AsyncMock,
|
||||
mock_api_client: AsyncMock,
|
||||
) -> None:
|
||||
"""Test successful user login flow."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_EMAIL: MOCK_EMAIL, CONF_PASSWORD: MOCK_PASSWORD},
|
||||
)
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == MOCK_EMAIL
|
||||
assert result["result"].unique_id == MOCK_USER_ID
|
||||
assert result["data"] == {
|
||||
CONF_EMAIL: MOCK_EMAIL,
|
||||
CONF_TOKEN: MOCK_TOKEN,
|
||||
CONF_REFRESH_TOKEN: MOCK_REFRESH_TOKEN,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("side_effect", "expected_error"),
|
||||
[
|
||||
(XthingsCloudAuthError("Auth failed", code=21014), "password_wrong"),
|
||||
(XthingsCloudApiError("API error", code=22001), "device_not_found"),
|
||||
(XthingsCloudApiError("Connection failed", code=0), "cannot_connect"),
|
||||
(RuntimeError("unexpected"), "unknown"),
|
||||
],
|
||||
)
|
||||
async def test_user_flow_error_and_recover(
|
||||
hass: HomeAssistant,
|
||||
mock_setup_entry: AsyncMock,
|
||||
mock_api_client: AsyncMock,
|
||||
side_effect: Exception,
|
||||
expected_error: str,
|
||||
) -> None:
|
||||
"""Test user flow shows error then recovers on retry."""
|
||||
mock_api_client.async_login.side_effect = side_effect
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_EMAIL: MOCK_EMAIL, CONF_PASSWORD: MOCK_PASSWORD},
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"]["base"] == expected_error
|
||||
|
||||
# Recover: repatch to succeed
|
||||
mock_api_client.async_login.side_effect = None
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_EMAIL: MOCK_EMAIL, CONF_PASSWORD: MOCK_PASSWORD},
|
||||
)
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
|
||||
|
||||
async def test_user_flow_already_configured(
|
||||
hass: HomeAssistant,
|
||||
mock_setup_entry: AsyncMock,
|
||||
mock_api_client: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test user flow aborts if same account already configured."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_EMAIL: MOCK_EMAIL, CONF_PASSWORD: MOCK_PASSWORD},
|
||||
)
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Tests for the Xthings Cloud integration."""
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.xthings_cloud.const import DOMAIN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from . import setup_integration
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
async def test_devices(
|
||||
hass: HomeAssistant,
|
||||
snapshot: SnapshotAssertion,
|
||||
mock_api_client: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
) -> None:
|
||||
"""Test all devices."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
for device in mock_api_client.async_get_devices.return_value:
|
||||
device_entry = device_registry.async_get_device({(DOMAIN, device["id"])})
|
||||
|
||||
assert device_entry is not None
|
||||
assert device_entry == snapshot(name=device["model"])
|
||||
@@ -0,0 +1,265 @@
|
||||
"""Tests for Xthings Cloud light platform."""
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.light import (
|
||||
ATTR_BRIGHTNESS,
|
||||
ATTR_COLOR_MODE,
|
||||
ATTR_COLOR_TEMP_KELVIN,
|
||||
ATTR_HS_COLOR,
|
||||
DOMAIN as LIGHT_DOMAIN,
|
||||
ColorMode,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
SERVICE_TURN_OFF,
|
||||
SERVICE_TURN_ON,
|
||||
STATE_UNAVAILABLE,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from . import get_device_by_id, setup_integration
|
||||
|
||||
from tests.common import MockConfigEntry, snapshot_platform
|
||||
|
||||
|
||||
async def test_lights(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_api_client: AsyncMock,
|
||||
entity_registry: er.EntityRegistry,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test light entities are created correctly."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("service", "method"),
|
||||
[
|
||||
(SERVICE_TURN_ON, "async_brite_on"),
|
||||
(SERVICE_TURN_OFF, "async_brite_off"),
|
||||
],
|
||||
)
|
||||
async def test_light_turn_on_off(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_api_client: AsyncMock,
|
||||
service: str,
|
||||
method: str,
|
||||
) -> None:
|
||||
"""Test turning on and off a light."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
await hass.services.async_call(
|
||||
LIGHT_DOMAIN,
|
||||
service,
|
||||
{ATTR_ENTITY_ID: "light.bedroom_light"},
|
||||
blocking=True,
|
||||
)
|
||||
getattr(mock_api_client, method).assert_called_once_with("dev_light_001")
|
||||
|
||||
|
||||
async def test_light_turn_on_brightness(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_api_client: AsyncMock,
|
||||
) -> None:
|
||||
"""Test turning on with brightness."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
await hass.services.async_call(
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{
|
||||
ATTR_ENTITY_ID: "light.hallway_light",
|
||||
ATTR_BRIGHTNESS: 128,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
mock_api_client.async_brite_brightness.assert_called_once_with(
|
||||
"dev_light_002", round(128 * 100 / 255)
|
||||
)
|
||||
mock_api_client.async_brite_on.assert_not_called()
|
||||
|
||||
|
||||
async def test_light_turn_on_hs_color(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_api_client: AsyncMock,
|
||||
) -> None:
|
||||
"""Test turning on with HS color."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
await hass.services.async_call(
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{
|
||||
ATTR_ENTITY_ID: "light.bedroom_light",
|
||||
ATTR_HS_COLOR: (200, 90),
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
mock_api_client.async_brite_color.assert_called_once_with(
|
||||
"dev_light_001",
|
||||
{
|
||||
"colortype": 0,
|
||||
"hue": 200,
|
||||
"saturation": 90,
|
||||
"lightness": 54,
|
||||
"brightness": 75,
|
||||
},
|
||||
)
|
||||
mock_api_client.async_brite_on.assert_not_called()
|
||||
|
||||
|
||||
async def test_light_turn_on_color_temp(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_api_client: AsyncMock,
|
||||
) -> None:
|
||||
"""Test turning on with color temperature."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
await hass.services.async_call(
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{
|
||||
ATTR_ENTITY_ID: "light.bedroom_light",
|
||||
ATTR_COLOR_TEMP_KELVIN: 3000,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
mock_api_client.async_brite_color.assert_called_once_with(
|
||||
"dev_light_001",
|
||||
{
|
||||
"colortype": 1,
|
||||
"temperature": 3000,
|
||||
"brightness": 75,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def test_light_turn_on_hs_color_with_brightness(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_api_client: AsyncMock,
|
||||
) -> None:
|
||||
"""Test turning on with HS color and brightness."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
await hass.services.async_call(
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{
|
||||
ATTR_ENTITY_ID: "light.bedroom_light",
|
||||
ATTR_HS_COLOR: (100, 50),
|
||||
ATTR_BRIGHTNESS: 200,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
expected_level = round(200 * 100 / 255)
|
||||
mock_api_client.async_brite_color.assert_called_once_with(
|
||||
"dev_light_001",
|
||||
{
|
||||
"colortype": 0,
|
||||
"hue": 100,
|
||||
"saturation": 50,
|
||||
"lightness": expected_level,
|
||||
"brightness": expected_level,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def test_light_color_temp_with_brightness(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_api_client: AsyncMock,
|
||||
) -> None:
|
||||
"""Test color temp with brightness override."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
await hass.services.async_call(
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{
|
||||
ATTR_ENTITY_ID: "light.bedroom_light",
|
||||
ATTR_COLOR_TEMP_KELVIN: 5000,
|
||||
ATTR_BRIGHTNESS: 180,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
mock_api_client.async_brite_color.assert_called_once_with(
|
||||
"dev_light_001",
|
||||
{
|
||||
"colortype": 1,
|
||||
"temperature": 5000,
|
||||
"brightness": round(180 * 100 / 255),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def test_light_unavailable_when_offline(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_api_client: AsyncMock,
|
||||
) -> None:
|
||||
"""Test light shows unavailable when device is offline."""
|
||||
get_device_by_id(mock_api_client, "dev_light_001")["online"] = False
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
state = hass.states.get("light.bedroom_light")
|
||||
assert state is not None
|
||||
assert state.state == STATE_UNAVAILABLE
|
||||
|
||||
|
||||
async def test_light_color_mode_color_temp(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_api_client: AsyncMock,
|
||||
) -> None:
|
||||
"""Test color mode is COLOR_TEMP when color_type is 1."""
|
||||
get_device_by_id(mock_api_client, "dev_light_001")["status"]["color_type"] = 1
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
state = hass.states.get("light.bedroom_light")
|
||||
assert state is not None
|
||||
assert state.attributes[ATTR_COLOR_MODE] == ColorMode.COLOR_TEMP
|
||||
assert state.attributes[ATTR_COLOR_TEMP_KELVIN] == 4000
|
||||
|
||||
|
||||
async def test_updating_state(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_api_client: AsyncMock,
|
||||
mock_websocket: AsyncMock,
|
||||
) -> None:
|
||||
"""Test updating state."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
state = hass.states.get("light.bedroom_light")
|
||||
assert state is not None
|
||||
assert state.attributes[ATTR_BRIGHTNESS] == 191
|
||||
|
||||
mock_websocket.call_args[1]["on_device_status"](
|
||||
"dev_light_001",
|
||||
{
|
||||
"on": True,
|
||||
"brightness": 100,
|
||||
"color_type": 0,
|
||||
"hue": 150,
|
||||
"saturation": 80,
|
||||
"lightness": 54,
|
||||
"temperature": 4000,
|
||||
},
|
||||
)
|
||||
|
||||
state = hass.states.get("light.bedroom_light")
|
||||
assert state is not None
|
||||
assert state.attributes[ATTR_BRIGHTNESS] == 255
|
||||
Reference in New Issue
Block a user