fix: implement DKN Cloud NA authentication
This commit is contained in:
@@ -71,6 +71,7 @@ Each device exposes the following entities:
|
||||
|
||||
## Known Limitations & Roadmap
|
||||
|
||||
- **Login and device discovery are implemented.** The integration authenticates against the DKN Cloud NA API and discovers configured devices.
|
||||
- **Device control is not yet implemented.** This release establishes the integration scaffolding and entity structure. Setting temperature, mode, fan speed, and swing will be implemented in a future release.
|
||||
- Real-time updates via Socket.IO are not used; the integration polls the cloud API on a configurable interval (default 60s).
|
||||
- Temperature units follow what the device reports; Fahrenheit devices are converted to Celsius for Home Assistant.
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
"""DKN Cloud NA API client stub.
|
||||
|
||||
Real HTTP calls are implemented in a future phase. This stub defines the
|
||||
interface and returns hardcoded data so the rest of the integration can be
|
||||
developed and tested without live credentials.
|
||||
"""
|
||||
"""DKN Cloud NA API client."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import ClientSession
|
||||
from aiohttp import ClientError, ClientResponse, ClientSession, ContentTypeError
|
||||
|
||||
from .const import LOGGER
|
||||
from .const import (
|
||||
API_INSTALLATIONS,
|
||||
API_IS_LOGGED_IN,
|
||||
API_LOGIN,
|
||||
API_REFRESH_TOKEN,
|
||||
BASE_URL,
|
||||
LOGGER,
|
||||
REQUEST_TIMEOUT,
|
||||
USER_AGENT,
|
||||
)
|
||||
|
||||
|
||||
class DknAuthError(Exception):
|
||||
@@ -49,75 +54,153 @@ class DknCloudNaClient:
|
||||
self._password = None
|
||||
|
||||
async def login(self) -> None:
|
||||
"""Exchange email+password for access and refresh tokens.
|
||||
"""Exchange email+password for access and refresh tokens."""
|
||||
if not self._password:
|
||||
raise DknAuthError("Missing password")
|
||||
|
||||
Sets self.token and self.refresh_token on success.
|
||||
Raises DknAuthError on bad credentials, DknConnectionError on network failure.
|
||||
"""
|
||||
# TODO: implement real POST to API_LOGIN
|
||||
LOGGER.debug("DknCloudNaClient.login() stub called for %s", self._username)
|
||||
raise NotImplementedError("login() not yet implemented")
|
||||
data = await self._request(
|
||||
"POST",
|
||||
API_LOGIN,
|
||||
json_body={"email": self._username, "password": self._password},
|
||||
auth_error_statuses={400, 401, 403},
|
||||
)
|
||||
self._store_tokens(data)
|
||||
|
||||
async def is_logged_in(self) -> bool:
|
||||
"""Return True if the current token is still valid."""
|
||||
# TODO: implement real GET to API_IS_LOGGED_IN
|
||||
raise NotImplementedError("is_logged_in() not yet implemented")
|
||||
try:
|
||||
await self._request(
|
||||
"GET",
|
||||
API_IS_LOGGED_IN,
|
||||
require_auth=True,
|
||||
auth_error_statuses={401, 403},
|
||||
)
|
||||
except DknAuthError:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
async def refresh_access_token(self) -> None:
|
||||
"""Use the refresh token to obtain a new access token.
|
||||
"""Use the refresh token to obtain a new access token."""
|
||||
if not self.refresh_token:
|
||||
raise DknAuthError("Missing refresh token")
|
||||
|
||||
Updates self.token on success.
|
||||
Raises DknAuthError if the refresh token is also expired.
|
||||
"""
|
||||
# TODO: implement real GET to API_REFRESH_TOKEN
|
||||
raise NotImplementedError("refresh_access_token() not yet implemented")
|
||||
data = await self._request(
|
||||
"GET",
|
||||
API_REFRESH_TOKEN.format(refresh_token=self.refresh_token),
|
||||
require_auth=bool(self.token),
|
||||
auth_error_statuses={400, 401, 403},
|
||||
)
|
||||
self._store_tokens(data)
|
||||
|
||||
async def fetch_installations(self) -> list[dict[str, Any]]:
|
||||
"""Return all installations and their devices.
|
||||
"""Return all installations and their devices."""
|
||||
data = await self._request(
|
||||
"GET",
|
||||
API_INSTALLATIONS,
|
||||
require_auth=True,
|
||||
retry_on_auth=True,
|
||||
auth_error_statuses={401, 403},
|
||||
)
|
||||
if not isinstance(data, list):
|
||||
raise DknConnectionError("Unexpected installations response")
|
||||
return data
|
||||
|
||||
Each installation contains a list of DeviceInfo dicts.
|
||||
Returns stub data for scaffolding.
|
||||
"""
|
||||
LOGGER.debug("DknCloudNaClient.fetch_installations() stub — returning fake data")
|
||||
return [
|
||||
{
|
||||
"_id": "stub-installation-1",
|
||||
"name": "My Home",
|
||||
"devices": [
|
||||
{
|
||||
"mac": "aa:bb:cc:dd:ee:ff",
|
||||
"name": "Living Room AC",
|
||||
"power": False,
|
||||
"mode": 1,
|
||||
"real_mode": 1,
|
||||
"work_temp": 22.0,
|
||||
"ext_temp": 18.0,
|
||||
"units": 0,
|
||||
"setpoint_air_auto": 22.0,
|
||||
"setpoint_air_cool": 24.0,
|
||||
"setpoint_air_heat": 20.0,
|
||||
"range_sp_auto_air_min": 16,
|
||||
"range_sp_auto_air_max": 32,
|
||||
"range_sp_cool_air_min": 16,
|
||||
"range_sp_cool_air_max": 32,
|
||||
"range_sp_hot_air_min": 16,
|
||||
"range_sp_hot_air_max": 32,
|
||||
"speed_state": 0,
|
||||
"speed_available": [0, 2, 3, 4, 5, 6],
|
||||
"slats_vertical_1": 0,
|
||||
"machineready": True,
|
||||
"isConnected": True,
|
||||
"tsensor_error": False,
|
||||
"stat_rssi": -65,
|
||||
"stat_ssid": "MyWiFi",
|
||||
"version": "1.0.0",
|
||||
"error_value": 0,
|
||||
"error_ascii1": "",
|
||||
"error_ascii2": "",
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
def _store_tokens(self, data: Any) -> None:
|
||||
"""Persist access and refresh tokens from an API response."""
|
||||
if not isinstance(data, dict):
|
||||
raise DknConnectionError("Unexpected authentication response")
|
||||
|
||||
token = data.get("token")
|
||||
refresh_token = data.get("refreshToken")
|
||||
if not isinstance(token, str) or not token:
|
||||
raise DknConnectionError("Authentication response missing token")
|
||||
if not isinstance(refresh_token, str) or not refresh_token:
|
||||
raise DknConnectionError("Authentication response missing refresh token")
|
||||
|
||||
self.token = token
|
||||
self.refresh_token = refresh_token
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
json_body: dict[str, Any] | None = None,
|
||||
require_auth: bool = False,
|
||||
retry_on_auth: bool = False,
|
||||
auth_error_statuses: set[int] | None = None,
|
||||
) -> Any:
|
||||
"""Perform an API request and decode the response body."""
|
||||
headers = {
|
||||
"Accept": "application/json",
|
||||
"User-Agent": USER_AGENT,
|
||||
}
|
||||
if json_body is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
if require_auth:
|
||||
if not self.token:
|
||||
raise DknAuthError("Missing access token")
|
||||
headers["Authorization"] = f"Bearer {self.token}"
|
||||
|
||||
url = f"{BASE_URL}{path}"
|
||||
safe_body = {
|
||||
key: ("***" if key == "password" else value)
|
||||
for key, value in (json_body or {}).items()
|
||||
}
|
||||
LOGGER.debug("DKN request %s %s body=%s", method, url, safe_body or None)
|
||||
|
||||
try:
|
||||
async with self._session.request(
|
||||
method,
|
||||
url,
|
||||
json=json_body,
|
||||
headers=headers,
|
||||
timeout=REQUEST_TIMEOUT,
|
||||
) as response:
|
||||
data = await self._read_response(response)
|
||||
except asyncio.TimeoutError as err:
|
||||
raise DknConnectionError("Request timed out") from err
|
||||
except ClientError as err:
|
||||
raise DknConnectionError(str(err) or type(err).__name__) from err
|
||||
|
||||
LOGGER.debug("DKN response %s %s status=%s", method, url, response.status)
|
||||
|
||||
if response.status in (auth_error_statuses or set()):
|
||||
if retry_on_auth and self.refresh_token:
|
||||
await self.refresh_access_token()
|
||||
return await self._request(
|
||||
method,
|
||||
path,
|
||||
json_body=json_body,
|
||||
require_auth=require_auth,
|
||||
retry_on_auth=False,
|
||||
auth_error_statuses=auth_error_statuses,
|
||||
)
|
||||
raise DknAuthError(self._error_message(data, response))
|
||||
|
||||
if response.status >= 400:
|
||||
raise DknConnectionError(self._error_message(data, response))
|
||||
|
||||
return data
|
||||
|
||||
async def _read_response(self, response: ClientResponse) -> Any:
|
||||
"""Decode a JSON response body, falling back to text."""
|
||||
try:
|
||||
return await response.json(content_type=None)
|
||||
except ContentTypeError:
|
||||
return await response.text()
|
||||
|
||||
def _error_message(self, data: Any, response: ClientResponse) -> str:
|
||||
"""Build a useful error message from an API response."""
|
||||
if isinstance(data, dict):
|
||||
for key in ("message", "error", "detail"):
|
||||
value = data.get(key)
|
||||
if isinstance(value, str) and value:
|
||||
return value
|
||||
if isinstance(data, str) and data:
|
||||
return data
|
||||
return f"HTTP {response.status}: {response.reason}"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
first = self._username[0] if self._username else "?"
|
||||
|
||||
@@ -82,17 +82,14 @@ class DknConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
session = async_get_clientsession(self.hass)
|
||||
client = DknCloudNaClient(
|
||||
email, session, password=password, token=None
|
||||
)
|
||||
client = DknCloudNaClient(email, session, password=password, token=None)
|
||||
try:
|
||||
await asyncio.wait_for(client.login(), timeout=60.0)
|
||||
except TimeoutError:
|
||||
errors["base"] = "timeout"
|
||||
except DknAuthError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except (DknConnectionError, NotImplementedError):
|
||||
# NotImplementedError: stub not yet implemented → treat as cannot_connect
|
||||
except DknConnectionError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception: # noqa: BLE001
|
||||
_LOGGER.exception("Unexpected error during login")
|
||||
@@ -132,9 +129,7 @@ class DknConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
|
||||
schema = vol.Schema(
|
||||
{
|
||||
vol.Optional(
|
||||
"access_token_display", default=self._token
|
||||
): cv.string,
|
||||
vol.Optional("access_token_display", default=self._token): cv.string,
|
||||
vol.Optional(
|
||||
"refresh_token_display", default=self._refresh_token
|
||||
): cv.string,
|
||||
@@ -195,7 +190,7 @@ class DknConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
errors["base"] = "timeout"
|
||||
except DknAuthError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except (DknConnectionError, NotImplementedError):
|
||||
except DknConnectionError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception: # noqa: BLE001
|
||||
errors["base"] = "unknown"
|
||||
@@ -228,7 +223,9 @@ class DknOptionsFlow(config_entries.OptionsFlow):
|
||||
) -> config_entries.FlowResult:
|
||||
opts = self._entry.options
|
||||
defaults = {
|
||||
CONF_SCAN_INTERVAL: int(opts.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)),
|
||||
CONF_SCAN_INTERVAL: int(
|
||||
opts.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)
|
||||
),
|
||||
CONF_EXPOSE_PII: bool(opts.get(CONF_EXPOSE_PII, False)),
|
||||
}
|
||||
schema = vol.Schema(
|
||||
|
||||
Reference in New Issue
Block a user