Skip to content

Commit 4112b2a

Browse files
Add Xthings Cloud (home-assistant#167885)
Co-authored-by: Joostlek <joostlek@outlook.com>
1 parent 944c0d7 commit 4112b2a

25 files changed

Lines changed: 1512 additions & 0 deletions

CODEOWNERS

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
"""Xthings Cloud integration for Home Assistant."""
2+
3+
from ha_xthings_cloud import XthingsCloudApiClient
4+
5+
from homeassistant.core import HomeAssistant
6+
from homeassistant.helpers.aiohttp_client import async_get_clientsession
7+
8+
from .const import CONF_TOKEN, PLATFORMS
9+
from .coordinator import XthingsCloudConfigEntry, XthingsCloudCoordinator
10+
11+
12+
async def async_setup_entry(
13+
hass: HomeAssistant, entry: XthingsCloudConfigEntry
14+
) -> bool:
15+
"""Set up config entry."""
16+
session = async_get_clientsession(hass)
17+
client = XthingsCloudApiClient(session, token=entry.data[CONF_TOKEN])
18+
19+
coordinator = XthingsCloudCoordinator(hass, client, entry)
20+
await coordinator.async_config_entry_first_refresh()
21+
22+
entry.runtime_data = coordinator
23+
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
24+
await coordinator.async_start_websocket()
25+
26+
return True
27+
28+
29+
async def async_unload_entry(
30+
hass: HomeAssistant, entry: XthingsCloudConfigEntry
31+
) -> bool:
32+
"""Unload config entry."""
33+
coordinator = entry.runtime_data
34+
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
35+
await coordinator.async_stop_websocket()
36+
return unload_ok
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
"""Config flow for Xthings Cloud."""
2+
3+
from typing import Any
4+
5+
from ha_xthings_cloud import (
6+
XthingsCloudApiClient,
7+
XthingsCloudApiError,
8+
XthingsCloudAuthError,
9+
)
10+
import voluptuous as vol
11+
12+
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
13+
from homeassistant.helpers.aiohttp_client import async_get_clientsession
14+
from homeassistant.helpers.instance_id import async_get as async_get_instance_id
15+
16+
from .const import (
17+
CONF_EMAIL,
18+
CONF_PASSWORD,
19+
CONF_REFRESH_TOKEN,
20+
CONF_TOKEN,
21+
DOMAIN,
22+
LOGGER,
23+
)
24+
25+
ERROR_CODE_MAP: dict[int, str] = {
26+
20001: "token_invalid",
27+
21001: "email_empty",
28+
21002: "email_invalid",
29+
21004: "email_not_found",
30+
21011: "password_empty",
31+
21014: "password_wrong",
32+
21021: "user_disabled",
33+
21022: "user_not_logged_in",
34+
21023: "user_not_activated",
35+
20011: "token_invalid",
36+
20012: "token_expired",
37+
22001: "device_not_found",
38+
22003: "device_offline",
39+
}
40+
41+
42+
def _error_from_exception(err: XthingsCloudApiError) -> str:
43+
"""Return translation key from error code."""
44+
return ERROR_CODE_MAP.get(err.code, "unknown")
45+
46+
47+
class XthingsCloudConfigFlow(ConfigFlow, domain=DOMAIN):
48+
"""Xthings Cloud config flow."""
49+
50+
VERSION = 1
51+
52+
async def async_step_user(
53+
self, user_input: dict[str, Any] | None = None
54+
) -> ConfigFlowResult:
55+
"""Handle user input step."""
56+
errors: dict[str, str] = {}
57+
58+
if user_input is not None:
59+
instance_id = await async_get_instance_id(self.hass)
60+
session = async_get_clientsession(self.hass)
61+
client = XthingsCloudApiClient(session)
62+
try:
63+
token_data = await client.async_login(
64+
user_input[CONF_EMAIL],
65+
user_input[CONF_PASSWORD],
66+
client_id=instance_id,
67+
)
68+
except XthingsCloudAuthError as err:
69+
errors["base"] = _error_from_exception(err)
70+
except XthingsCloudApiError as err:
71+
errors["base"] = (
72+
_error_from_exception(err) if err.code else "cannot_connect"
73+
)
74+
except Exception: # noqa: BLE001
75+
LOGGER.exception("Unexpected error during login")
76+
errors["base"] = "unknown"
77+
else:
78+
await self.async_set_unique_id(token_data["user_id"])
79+
self._abort_if_unique_id_configured()
80+
return self.async_create_entry(
81+
title=user_input[CONF_EMAIL],
82+
data={
83+
CONF_EMAIL: user_input[CONF_EMAIL],
84+
CONF_TOKEN: token_data["token"],
85+
CONF_REFRESH_TOKEN: token_data["refresh_token"],
86+
},
87+
)
88+
89+
return self.async_show_form(
90+
step_id="user",
91+
data_schema=vol.Schema(
92+
{
93+
vol.Required(CONF_EMAIL): str,
94+
vol.Required(CONF_PASSWORD): str,
95+
}
96+
),
97+
errors=errors,
98+
)
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
"""Constants for Xthings Cloud integration."""
2+
3+
import logging
4+
5+
from homeassistant.const import Platform
6+
7+
DOMAIN = "xthings_cloud"
8+
LOGGER = logging.getLogger(__package__)
9+
10+
CONF_EMAIL = "email"
11+
CONF_PASSWORD = "password"
12+
CONF_TOKEN = "token"
13+
CONF_REFRESH_TOKEN = "refresh_token"
14+
CONF_CLIENT_ID = "client_id"
15+
CONF_INSTANCE_ID = "instance_id"
16+
17+
# Polling interval (seconds)
18+
DEFAULT_SCAN_INTERVAL = 1800
19+
20+
PLATFORMS: list[Platform] = [Platform.LIGHT]
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
"""DataUpdateCoordinator for Xthings Cloud."""
2+
3+
from datetime import timedelta
4+
from typing import Any
5+
6+
from ha_xthings_cloud import (
7+
XthingsCloudApiClient,
8+
XthingsCloudApiError,
9+
XthingsCloudAuthError,
10+
XthingsCloudWebSocket,
11+
)
12+
13+
from homeassistant.config_entries import ConfigEntry
14+
from homeassistant.core import HomeAssistant
15+
from homeassistant.exceptions import ConfigEntryAuthFailed
16+
from homeassistant.helpers.aiohttp_client import async_get_clientsession
17+
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
18+
19+
from .const import CONF_REFRESH_TOKEN, CONF_TOKEN, DEFAULT_SCAN_INTERVAL, DOMAIN, LOGGER
20+
21+
type XthingsCloudConfigEntry = ConfigEntry["XthingsCloudCoordinator"]
22+
23+
24+
class XthingsCloudCoordinator(DataUpdateCoordinator[dict[str, Any]]):
25+
"""Xthings Cloud data update coordinator."""
26+
27+
config_entry: XthingsCloudConfigEntry
28+
29+
def __init__(
30+
self,
31+
hass: HomeAssistant,
32+
client: XthingsCloudApiClient,
33+
entry: XthingsCloudConfigEntry,
34+
) -> None:
35+
"""Initialize the coordinator."""
36+
super().__init__(
37+
hass,
38+
LOGGER,
39+
name=DOMAIN,
40+
update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL),
41+
config_entry=entry,
42+
)
43+
self.client = client
44+
self.websocket: XthingsCloudWebSocket | None = None
45+
46+
async def _async_ensure_token_valid(self) -> None:
47+
"""Ensure the token is valid, refresh if expired.
48+
49+
Raises ConfigEntryAuthFailed if refresh fails.
50+
"""
51+
if not self.client.is_token_expired():
52+
return
53+
try:
54+
token_data = await self.client.async_refresh_token(
55+
self.config_entry.data[CONF_REFRESH_TOKEN]
56+
)
57+
except XthingsCloudAuthError as err:
58+
raise ConfigEntryAuthFailed(
59+
"Token expired and refresh failed, re-authentication required"
60+
) from err
61+
self.hass.config_entries.async_update_entry(
62+
self.config_entry,
63+
data={
64+
**self.config_entry.data,
65+
CONF_TOKEN: token_data["token"],
66+
CONF_REFRESH_TOKEN: token_data["refresh_token"],
67+
},
68+
)
69+
70+
async def _async_update_data(self) -> dict[str, Any]:
71+
"""Fetch latest device data from cloud."""
72+
await self._async_ensure_token_valid()
73+
try:
74+
devices = await self.client.async_get_devices()
75+
except XthingsCloudAuthError as err:
76+
raise ConfigEntryAuthFailed(
77+
"Invalid token, re-authentication required"
78+
) from err
79+
except XthingsCloudApiError as err:
80+
raise UpdateFailed(f"Failed to fetch data: {err}") from err
81+
return {device["id"]: device for device in devices}
82+
83+
async def async_start_websocket(self) -> None:
84+
"""Start WebSocket connection."""
85+
if self.websocket:
86+
return
87+
session = async_get_clientsession(self.hass)
88+
token = self.config_entry.data[CONF_TOKEN]
89+
self.websocket = XthingsCloudWebSocket(
90+
session=session,
91+
token=token,
92+
on_device_status=self._handle_ws_device_status,
93+
on_token_expired=self._handle_ws_token_expired,
94+
)
95+
await self.websocket.async_start()
96+
97+
async def async_stop_websocket(self) -> None:
98+
"""Stop WebSocket connection."""
99+
if self.websocket:
100+
await self.websocket.async_stop()
101+
self.websocket = None
102+
103+
def _handle_ws_device_status(
104+
self, device_uuid: str, status: dict[str, Any]
105+
) -> None:
106+
"""Handle WebSocket device status update."""
107+
if not self.data or device_uuid not in self.data:
108+
LOGGER.debug(
109+
"WebSocket received status for unknown device: %s", device_uuid
110+
)
111+
return
112+
device_data = self.data[device_uuid]
113+
device_data.setdefault("status", {}).update(status)
114+
LOGGER.debug("WebSocket updated device status: %s", device_uuid)
115+
self.async_set_updated_data(self.data)
116+
117+
async def _handle_ws_token_expired(self) -> None:
118+
"""Handle WebSocket auth expiry, refresh token."""
119+
try:
120+
await self._async_ensure_token_valid()
121+
except ConfigEntryAuthFailed:
122+
LOGGER.error("WebSocket token refresh failed")
123+
return
124+
new_token = self.config_entry.data[CONF_TOKEN]
125+
self.client.token = new_token
126+
if self.websocket:
127+
self.websocket.token = new_token
128+
LOGGER.info("WebSocket token refreshed successfully")
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
"""Base entity for Xthings Cloud."""
2+
3+
from typing import Any
4+
5+
from homeassistant.helpers.device_registry import DeviceInfo
6+
from homeassistant.helpers.update_coordinator import CoordinatorEntity
7+
8+
from .const import DOMAIN
9+
from .coordinator import XthingsCloudCoordinator
10+
11+
12+
class XthingsCloudEntity(CoordinatorEntity[XthingsCloudCoordinator]):
13+
"""Xthings Cloud base entity."""
14+
15+
_attr_has_entity_name = True
16+
_attr_name = None
17+
18+
def __init__(
19+
self,
20+
coordinator: XthingsCloudCoordinator,
21+
device_id: str,
22+
device_data: dict[str, Any],
23+
) -> None:
24+
"""Initialize the entity."""
25+
super().__init__(coordinator)
26+
self._device_id = device_id
27+
self._attr_unique_id = device_id
28+
self._attr_device_info = DeviceInfo(
29+
identifiers={(DOMAIN, device_id)},
30+
name=device_data["name"],
31+
manufacturer="Xthings",
32+
model=device_data["model"],
33+
sw_version=device_data.get("version"),
34+
)
35+
36+
@property
37+
def device_data(self) -> dict[str, Any]:
38+
"""Return current device data."""
39+
return self.coordinator.data[self._device_id]
40+
41+
@property
42+
def available(self) -> bool:
43+
"""Return whether device is available (online)."""
44+
return (
45+
super().available
46+
and self._device_id in self.coordinator.data
47+
and self.device_data["online"]
48+
)

0 commit comments

Comments
 (0)