Treat 404 as auth error and persist refreshed tokens
The DKN Cloud NA API returns 404 (not 401) when tokens are expired, which caused the integration to silently fail with UpdateFailed indefinitely instead of attempting a token refresh or triggering reauth. Refreshed tokens were also only held in memory, so they were lost on Home Assistant restart. - Add 404 to auth_error_statuses on is_logged_in, refresh_access_token, and fetch_installations. - Persist refreshed access + refresh tokens back to the config entry after each successful coordinator update. - Skip the entry reload listener for token-only option updates to avoid a reload loop on every refresh. - Log API 4xx responses at WARNING with the body so failures are visible in HA logs without enabling debug logging.
This commit is contained in:
@@ -9,9 +9,18 @@ from homeassistant.exceptions import ConfigEntryAuthFailed
|
|||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
|
|
||||||
from .api import DknCloudNaClient
|
from .api import DknCloudNaClient
|
||||||
from .const import CONF_REFRESH_TOKEN, CONF_USER_TOKEN, DOMAIN, LOGGER
|
from .const import (
|
||||||
|
CONF_EXPOSE_PII,
|
||||||
|
CONF_REFRESH_TOKEN,
|
||||||
|
CONF_SCAN_INTERVAL,
|
||||||
|
CONF_USER_TOKEN,
|
||||||
|
DOMAIN,
|
||||||
|
LOGGER,
|
||||||
|
)
|
||||||
from .coordinator import DknCoordinator
|
from .coordinator import DknCoordinator
|
||||||
|
|
||||||
|
_RELOAD_KEYS = {CONF_SCAN_INTERVAL, CONF_EXPOSE_PII}
|
||||||
|
|
||||||
PLATFORMS: list[Platform] = [
|
PLATFORMS: list[Platform] = [
|
||||||
Platform.CLIMATE,
|
Platform.CLIMATE,
|
||||||
Platform.SENSOR,
|
Platform.SENSOR,
|
||||||
@@ -44,6 +53,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
hass.data[DOMAIN][entry.entry_id] = {
|
hass.data[DOMAIN][entry.entry_id] = {
|
||||||
"coordinator": coordinator,
|
"coordinator": coordinator,
|
||||||
"client": client,
|
"client": client,
|
||||||
|
"_prev_options": dict(entry.options),
|
||||||
}
|
}
|
||||||
|
|
||||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||||
@@ -74,5 +84,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
async def _async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
async def _async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||||
"""Reload entry when options change."""
|
"""Reload entry when user-facing options change (not token refreshes)."""
|
||||||
await hass.config_entries.async_reload(entry.entry_id)
|
domain_data = hass.data.get(DOMAIN, {}).get(entry.entry_id, {})
|
||||||
|
prev_opts = domain_data.get("_prev_options", {})
|
||||||
|
if any(entry.options.get(k) != prev_opts.get(k) for k in _RELOAD_KEYS):
|
||||||
|
await hass.config_entries.async_reload(entry.entry_id)
|
||||||
|
domain_data["_prev_options"] = dict(entry.options)
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ class DknCloudNaClient:
|
|||||||
"GET",
|
"GET",
|
||||||
API_IS_LOGGED_IN,
|
API_IS_LOGGED_IN,
|
||||||
require_auth=True,
|
require_auth=True,
|
||||||
auth_error_statuses={401, 403},
|
auth_error_statuses={401, 403, 404},
|
||||||
)
|
)
|
||||||
except DknAuthError:
|
except DknAuthError:
|
||||||
return False
|
return False
|
||||||
@@ -106,7 +106,7 @@ class DknCloudNaClient:
|
|||||||
"GET",
|
"GET",
|
||||||
API_REFRESH_TOKEN.format(refresh_token=self.refresh_token),
|
API_REFRESH_TOKEN.format(refresh_token=self.refresh_token),
|
||||||
require_auth=bool(self.token),
|
require_auth=bool(self.token),
|
||||||
auth_error_statuses={400, 401, 403},
|
auth_error_statuses={400, 401, 403, 404},
|
||||||
)
|
)
|
||||||
self._store_tokens(data)
|
self._store_tokens(data)
|
||||||
|
|
||||||
@@ -117,7 +117,7 @@ class DknCloudNaClient:
|
|||||||
API_INSTALLATIONS,
|
API_INSTALLATIONS,
|
||||||
require_auth=True,
|
require_auth=True,
|
||||||
retry_on_auth=True,
|
retry_on_auth=True,
|
||||||
auth_error_statuses={401, 403},
|
auth_error_statuses={401, 403, 404},
|
||||||
)
|
)
|
||||||
if not isinstance(data, list):
|
if not isinstance(data, list):
|
||||||
raise DknConnectionError("Unexpected installations response")
|
raise DknConnectionError("Unexpected installations response")
|
||||||
@@ -397,10 +397,20 @@ class DknCloudNaClient:
|
|||||||
except ClientError as err:
|
except ClientError as err:
|
||||||
raise DknConnectionError(str(err) or type(err).__name__) from 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 >= 400:
|
||||||
|
LOGGER.warning(
|
||||||
|
"DKN API error %s %s status=%s body=%s",
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
response.status,
|
||||||
|
data if not isinstance(data, str) or len(data) < 200 else data[:200],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
LOGGER.debug("DKN response %s %s status=%s", method, url, response.status)
|
||||||
|
|
||||||
if response.status in (auth_error_statuses or set()):
|
if response.status in (auth_error_statuses or set()):
|
||||||
if retry_on_auth and self.refresh_token:
|
if retry_on_auth and self.refresh_token:
|
||||||
|
LOGGER.info("DKN token expired, attempting refresh")
|
||||||
await self.refresh_access_token()
|
await self.refresh_access_token()
|
||||||
return await self._request(
|
return await self._request(
|
||||||
method,
|
method,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed
|
|||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
|
|
||||||
from .api import DknAuthError, DknCloudNaClient, DknConnectionError
|
from .api import DknAuthError, DknCloudNaClient, DknConnectionError
|
||||||
from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, LOGGER
|
from .const import CONF_REFRESH_TOKEN, CONF_USER_TOKEN, DEFAULT_SCAN_INTERVAL, DOMAIN, LOGGER
|
||||||
|
|
||||||
|
|
||||||
class DknCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]):
|
class DknCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]):
|
||||||
@@ -64,6 +64,8 @@ class DknCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]):
|
|||||||
except Exception as err: # noqa: BLE001
|
except Exception as err: # noqa: BLE001
|
||||||
raise UpdateFailed(f"Unexpected error: {type(err).__name__}") from err
|
raise UpdateFailed(f"Unexpected error: {type(err).__name__}") from err
|
||||||
|
|
||||||
|
self._persist_tokens_if_changed()
|
||||||
|
|
||||||
devices: dict[str, dict[str, Any]] = {}
|
devices: dict[str, dict[str, Any]] = {}
|
||||||
existing = self.data or {}
|
existing = self.data or {}
|
||||||
for installation in installations or []:
|
for installation in installations or []:
|
||||||
@@ -80,6 +82,25 @@ class DknCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]):
|
|||||||
|
|
||||||
return devices
|
return devices
|
||||||
|
|
||||||
|
def _persist_tokens_if_changed(self) -> None:
|
||||||
|
"""Save refreshed tokens back to the config entry so they survive restarts."""
|
||||||
|
opts = self._entry.options
|
||||||
|
stored_token = opts.get(CONF_USER_TOKEN)
|
||||||
|
stored_refresh = opts.get(CONF_REFRESH_TOKEN)
|
||||||
|
if (
|
||||||
|
self.client.token
|
||||||
|
and self.client.refresh_token
|
||||||
|
and (
|
||||||
|
self.client.token != stored_token
|
||||||
|
or self.client.refresh_token != stored_refresh
|
||||||
|
)
|
||||||
|
):
|
||||||
|
new_opts = dict(opts)
|
||||||
|
new_opts[CONF_USER_TOKEN] = self.client.token
|
||||||
|
new_opts[CONF_REFRESH_TOKEN] = self.client.refresh_token
|
||||||
|
self.hass.config_entries.async_update_entry(self._entry, options=new_opts)
|
||||||
|
LOGGER.debug("DKN tokens persisted after refresh")
|
||||||
|
|
||||||
async def async_handle_socket_device_data(
|
async def async_handle_socket_device_data(
|
||||||
self, mac: str, data: dict[str, Any]
|
self, mac: str, data: dict[str, Any]
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user