fix: implement DKN Cloud NA authentication
This commit is contained in:
@@ -71,6 +71,7 @@ Each device exposes the following entities:
|
|||||||
|
|
||||||
## Known Limitations & Roadmap
|
## 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.
|
- **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).
|
- 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.
|
- 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.
|
"""DKN Cloud NA API client."""
|
||||||
|
|
||||||
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.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
from typing import Any
|
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):
|
class DknAuthError(Exception):
|
||||||
@@ -49,75 +54,153 @@ class DknCloudNaClient:
|
|||||||
self._password = None
|
self._password = None
|
||||||
|
|
||||||
async def login(self) -> 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.
|
data = await self._request(
|
||||||
Raises DknAuthError on bad credentials, DknConnectionError on network failure.
|
"POST",
|
||||||
"""
|
API_LOGIN,
|
||||||
# TODO: implement real POST to API_LOGIN
|
json_body={"email": self._username, "password": self._password},
|
||||||
LOGGER.debug("DknCloudNaClient.login() stub called for %s", self._username)
|
auth_error_statuses={400, 401, 403},
|
||||||
raise NotImplementedError("login() not yet implemented")
|
)
|
||||||
|
self._store_tokens(data)
|
||||||
|
|
||||||
async def is_logged_in(self) -> bool:
|
async def is_logged_in(self) -> bool:
|
||||||
"""Return True if the current token is still valid."""
|
"""Return True if the current token is still valid."""
|
||||||
# TODO: implement real GET to API_IS_LOGGED_IN
|
try:
|
||||||
raise NotImplementedError("is_logged_in() not yet implemented")
|
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:
|
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.
|
data = await self._request(
|
||||||
Raises DknAuthError if the refresh token is also expired.
|
"GET",
|
||||||
"""
|
API_REFRESH_TOKEN.format(refresh_token=self.refresh_token),
|
||||||
# TODO: implement real GET to API_REFRESH_TOKEN
|
require_auth=bool(self.token),
|
||||||
raise NotImplementedError("refresh_access_token() not yet implemented")
|
auth_error_statuses={400, 401, 403},
|
||||||
|
)
|
||||||
|
self._store_tokens(data)
|
||||||
|
|
||||||
async def fetch_installations(self) -> list[dict[str, Any]]:
|
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.
|
def _store_tokens(self, data: Any) -> None:
|
||||||
Returns stub data for scaffolding.
|
"""Persist access and refresh tokens from an API response."""
|
||||||
"""
|
if not isinstance(data, dict):
|
||||||
LOGGER.debug("DknCloudNaClient.fetch_installations() stub — returning fake data")
|
raise DknConnectionError("Unexpected authentication response")
|
||||||
return [
|
|
||||||
{
|
token = data.get("token")
|
||||||
"_id": "stub-installation-1",
|
refresh_token = data.get("refreshToken")
|
||||||
"name": "My Home",
|
if not isinstance(token, str) or not token:
|
||||||
"devices": [
|
raise DknConnectionError("Authentication response missing token")
|
||||||
{
|
if not isinstance(refresh_token, str) or not refresh_token:
|
||||||
"mac": "aa:bb:cc:dd:ee:ff",
|
raise DknConnectionError("Authentication response missing refresh token")
|
||||||
"name": "Living Room AC",
|
|
||||||
"power": False,
|
self.token = token
|
||||||
"mode": 1,
|
self.refresh_token = refresh_token
|
||||||
"real_mode": 1,
|
|
||||||
"work_temp": 22.0,
|
async def _request(
|
||||||
"ext_temp": 18.0,
|
self,
|
||||||
"units": 0,
|
method: str,
|
||||||
"setpoint_air_auto": 22.0,
|
path: str,
|
||||||
"setpoint_air_cool": 24.0,
|
*,
|
||||||
"setpoint_air_heat": 20.0,
|
json_body: dict[str, Any] | None = None,
|
||||||
"range_sp_auto_air_min": 16,
|
require_auth: bool = False,
|
||||||
"range_sp_auto_air_max": 32,
|
retry_on_auth: bool = False,
|
||||||
"range_sp_cool_air_min": 16,
|
auth_error_statuses: set[int] | None = None,
|
||||||
"range_sp_cool_air_max": 32,
|
) -> Any:
|
||||||
"range_sp_hot_air_min": 16,
|
"""Perform an API request and decode the response body."""
|
||||||
"range_sp_hot_air_max": 32,
|
headers = {
|
||||||
"speed_state": 0,
|
"Accept": "application/json",
|
||||||
"speed_available": [0, 2, 3, 4, 5, 6],
|
"User-Agent": USER_AGENT,
|
||||||
"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": "",
|
|
||||||
}
|
}
|
||||||
],
|
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:
|
def __repr__(self) -> str:
|
||||||
first = self._username[0] if self._username else "?"
|
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()
|
self._abort_if_unique_id_configured()
|
||||||
|
|
||||||
session = async_get_clientsession(self.hass)
|
session = async_get_clientsession(self.hass)
|
||||||
client = DknCloudNaClient(
|
client = DknCloudNaClient(email, session, password=password, token=None)
|
||||||
email, session, password=password, token=None
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
await asyncio.wait_for(client.login(), timeout=60.0)
|
await asyncio.wait_for(client.login(), timeout=60.0)
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
errors["base"] = "timeout"
|
errors["base"] = "timeout"
|
||||||
except DknAuthError:
|
except DknAuthError:
|
||||||
errors["base"] = "invalid_auth"
|
errors["base"] = "invalid_auth"
|
||||||
except (DknConnectionError, NotImplementedError):
|
except DknConnectionError:
|
||||||
# NotImplementedError: stub not yet implemented → treat as cannot_connect
|
|
||||||
errors["base"] = "cannot_connect"
|
errors["base"] = "cannot_connect"
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
_LOGGER.exception("Unexpected error during login")
|
_LOGGER.exception("Unexpected error during login")
|
||||||
@@ -132,9 +129,7 @@ class DknConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
|
|
||||||
schema = vol.Schema(
|
schema = vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Optional(
|
vol.Optional("access_token_display", default=self._token): cv.string,
|
||||||
"access_token_display", default=self._token
|
|
||||||
): cv.string,
|
|
||||||
vol.Optional(
|
vol.Optional(
|
||||||
"refresh_token_display", default=self._refresh_token
|
"refresh_token_display", default=self._refresh_token
|
||||||
): cv.string,
|
): cv.string,
|
||||||
@@ -195,7 +190,7 @@ class DknConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
errors["base"] = "timeout"
|
errors["base"] = "timeout"
|
||||||
except DknAuthError:
|
except DknAuthError:
|
||||||
errors["base"] = "invalid_auth"
|
errors["base"] = "invalid_auth"
|
||||||
except (DknConnectionError, NotImplementedError):
|
except DknConnectionError:
|
||||||
errors["base"] = "cannot_connect"
|
errors["base"] = "cannot_connect"
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
errors["base"] = "unknown"
|
errors["base"] = "unknown"
|
||||||
@@ -228,7 +223,9 @@ class DknOptionsFlow(config_entries.OptionsFlow):
|
|||||||
) -> config_entries.FlowResult:
|
) -> config_entries.FlowResult:
|
||||||
opts = self._entry.options
|
opts = self._entry.options
|
||||||
defaults = {
|
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)),
|
CONF_EXPOSE_PII: bool(opts.get(CONF_EXPOSE_PII, False)),
|
||||||
}
|
}
|
||||||
schema = vol.Schema(
|
schema = vol.Schema(
|
||||||
|
|||||||
Reference in New Issue
Block a user