Add Xthings Cloud (#167885)

Co-authored-by: Joostlek <joostlek@outlook.com>
This commit is contained in:
zhangluofeng
2026-05-12 22:07:39 +02:00
committed by GitHub
co-authored by Joostlek
parent 944c0d7ed2
commit 4112b2af07
25 changed files with 1512 additions and 0 deletions
Generated
+2
View File
@@ -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"
}
}
}
}
+1
View File
@@ -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": {
+3
View File
@@ -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
+3
View File
@@ -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.")
+101
View File
@@ -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
+7
View File
@@ -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