283 lines
11 KiB
Python
283 lines
11 KiB
Python
"""Panel-plane API client for the NestQuest integration (Feature 18).
|
|
|
|
The integration talks to the NestQuest API service (Feature 16) over
|
|
HTTP instead of reading the database: this module is the ONE place that
|
|
speaks the API's panel plane — ``GET /api/v1/panel/snapshot`` (today's
|
|
household snapshot) and ``POST /api/v1/panel/instances/{id}/complete``
|
|
(completion as the tapped child profile) — authenticated with the
|
|
static panel service token as a Bearer credential on every request.
|
|
|
|
Transport-injected by construction: the constructor takes the base URL,
|
|
the panel token, and the HTTP session to use. The session speaks the
|
|
aiohttp ``ClientSession`` surface (``request(method, url, headers=...,
|
|
json=...)`` returning an async context manager whose response carries
|
|
``status`` and ``await text()``), so tests stub the transport with NO
|
|
network and NO aiohttp installed; production builds the client through
|
|
:func:`client_from_entry`, which hands in Home Assistant's shared
|
|
aiohttp session (``homeassistant.helpers.aiohttp_client
|
|
.async_get_clientsession``). The module itself imports NEITHER
|
|
homeassistant NOR aiohttp at runtime (aiohttp appears only behind
|
|
``TYPE_CHECKING`` for annotations), so the client is usable anywhere —
|
|
the HA coupling lives in the one production factory.
|
|
|
|
Every failure is typed: a transport failure (connection refused, DNS
|
|
failure, timeout — the exchange never produced a response) and every
|
|
non-2xx HTTP response raise :class:`NestQuestApiError`. The error
|
|
carries the HTTP status where there is one (so 401 and 404 are
|
|
distinguishable at the call site) and ``None`` when there is not; the
|
|
original transport error is chained as ``__cause__`` — nothing is
|
|
swallowed. There are NO retries and the client logs nothing: refresh
|
|
cadence is the coordinator's concern (the next task), and the panel
|
|
token is a secret that must never reach a log line or an error
|
|
message — the error texts name the method, path, and status only.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
from typing import TYPE_CHECKING, Any
|
|
from urllib.parse import urlparse
|
|
|
|
from .const import (
|
|
CONF_API_BASE_URL,
|
|
CONF_PANEL_TOKEN,
|
|
DEFAULT_API_BASE_URL,
|
|
DEFAULT_PANEL_TOKEN,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
import aiohttp
|
|
|
|
__all__ = [
|
|
"COMPLETE_INSTANCE_PATH",
|
|
"DEFAULT_TIMEOUT_SECONDS",
|
|
"NestQuestApiError",
|
|
"NestQuestApiClient",
|
|
"SNAPSHOT_PATH",
|
|
"client_from_entry",
|
|
"is_valid_base_url",
|
|
"resolve_api_config",
|
|
]
|
|
|
|
#: The panel plane's snapshot route (Feature 16).
|
|
SNAPSHOT_PATH = "/api/v1/panel/snapshot"
|
|
|
|
#: The panel plane's completion route template (Feature 16); the
|
|
#: ``{instance_id}`` placeholder is filled per call.
|
|
COMPLETE_INSTANCE_PATH = "/api/v1/panel/instances/{instance_id}/complete"
|
|
|
|
#: Request timeout, in seconds. One attempt, one timeout: a hung API
|
|
#: call surfaces as a typed error for the coordinator to report, never
|
|
#: a retry loop.
|
|
DEFAULT_TIMEOUT_SECONDS = 10.0
|
|
|
|
|
|
class NestQuestApiError(Exception):
|
|
"""A failed NestQuest API exchange, raised for every failure mode.
|
|
|
|
``status`` carries the HTTP status when a response arrived (a
|
|
non-2xx response — 401 for a bad token, 404 for an unknown
|
|
instance) and is ``None`` when the exchange failed at the transport
|
|
level (connection refused, DNS failure, timeout) — so callers
|
|
distinguish the two with ``is_transport_error``. The original
|
|
transport error, where there is one, is chained as ``__cause__``.
|
|
Messages carry the method, path, and status — never the request
|
|
headers, so the panel token can never leak through an error.
|
|
"""
|
|
|
|
def __init__(self, message: str, *, status: int | None = None) -> None:
|
|
super().__init__(message)
|
|
self.status = status
|
|
|
|
@property
|
|
def is_transport_error(self) -> bool:
|
|
"""True when the exchange failed before any response arrived."""
|
|
return self.status is None
|
|
|
|
|
|
def is_valid_base_url(value: object) -> bool:
|
|
"""Return True when ``value`` is a usable API base URL.
|
|
|
|
A full ``http://`` or ``https://`` URL with a host — the exact
|
|
shape the options flow validates (form ergonomics) and the client
|
|
constructor enforces (fail fast, never build requests against a
|
|
scheme-less or host-less string).
|
|
"""
|
|
if not isinstance(value, str):
|
|
return False
|
|
candidate = value.strip()
|
|
if not candidate:
|
|
return False
|
|
parsed = urlparse(candidate)
|
|
return parsed.scheme in ("http", "https") and bool(parsed.netloc)
|
|
|
|
|
|
def resolve_api_config(entry: Any) -> tuple[str, str]:
|
|
"""Resolve ``(base_url, panel_token)`` off a config entry.
|
|
|
|
Precedence mirrors the options flow's stored-values resolution:
|
|
``entry.options`` first (the live options-flow copy), then
|
|
``entry.data`` (the config-flow-era copy), then the const defaults.
|
|
The token is a secret: callers must never log the returned pair.
|
|
"""
|
|
options = getattr(entry, "options", None) or {}
|
|
data = getattr(entry, "data", None) or {}
|
|
|
|
def _pick(key: str, default: str) -> str:
|
|
if key in options:
|
|
return options[key]
|
|
if key in data:
|
|
return data[key]
|
|
return default
|
|
|
|
return (
|
|
_pick(CONF_API_BASE_URL, DEFAULT_API_BASE_URL),
|
|
_pick(CONF_PANEL_TOKEN, DEFAULT_PANEL_TOKEN),
|
|
)
|
|
|
|
|
|
def client_from_entry(hass: Any, entry: Any) -> NestQuestApiClient:
|
|
"""Build the production client for a config entry.
|
|
|
|
Reads the configured base URL and panel token off the entry
|
|
(:func:`resolve_api_config`) and hands the client Home Assistant's
|
|
SHARED aiohttp session (``homeassistant.helpers.aiohttp_client
|
|
.async_get_clientsession``) so panel calls ride HA's one connection
|
|
pool. The helper import is deferred into this function on purpose:
|
|
the test harness is mock-only and the module must import (and the
|
|
client must be stub-transport testable) without homeassistant or
|
|
aiohttp installed.
|
|
"""
|
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
|
|
|
base_url, panel_token = resolve_api_config(entry)
|
|
return NestQuestApiClient(
|
|
base_url, panel_token, async_get_clientsession(hass)
|
|
)
|
|
|
|
|
|
class NestQuestApiClient:
|
|
"""Async client for the NestQuest API service's panel plane.
|
|
|
|
ONE instance per config entry (the coordinator task wires it into
|
|
the refresh cycle). The client is deliberately thin: it adds the
|
|
Bearer credential and the base URL, enforces one timeout per
|
|
request, and turns every failure into :class:`NestQuestApiError` —
|
|
no business logic, no retries, no caching.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
base_url: str,
|
|
panel_token: str,
|
|
session: aiohttp.ClientSession,
|
|
*,
|
|
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
|
|
) -> None:
|
|
"""Store the connection settings; the session is injected.
|
|
|
|
``session`` is an aiohttp ``ClientSession`` in production and a
|
|
stub transport in tests. ``base_url`` must be a full http(s)
|
|
URL and ``panel_token`` must be non-empty — a client built
|
|
without either can only fail later at request time with a
|
|
less-explicable error, so construction fails fast instead.
|
|
"""
|
|
if not is_valid_base_url(base_url):
|
|
raise ValueError(
|
|
f"base_url must be a full http(s) URL, got {base_url!r}"
|
|
)
|
|
if not isinstance(panel_token, str) or not panel_token.strip():
|
|
raise ValueError("panel_token must be a non-empty string")
|
|
if (
|
|
not isinstance(timeout_seconds, (int, float))
|
|
or isinstance(timeout_seconds, bool)
|
|
or timeout_seconds <= 0
|
|
):
|
|
raise ValueError(
|
|
f"timeout_seconds must be a positive number, got {timeout_seconds!r}"
|
|
)
|
|
self._base_url = base_url.strip().rstrip("/")
|
|
self._panel_token = panel_token
|
|
self._session = session
|
|
self._timeout_seconds = float(timeout_seconds)
|
|
|
|
async def get_snapshot(self) -> dict[str, Any]:
|
|
"""Fetch and parse today's household snapshot.
|
|
|
|
Returns the JSON the snapshot route returns (``today_iso``,
|
|
``cycle_day``, and the per-child list — see
|
|
``api/routes_panel.py`` for the documented shape). Raises
|
|
:class:`NestQuestApiError` on transport failure, timeout, or a
|
|
non-2xx response.
|
|
"""
|
|
return await self._request_json("GET", SNAPSHOT_PATH)
|
|
|
|
async def complete_instance(
|
|
self, instance_id: int, actor_child_id: int
|
|
) -> dict[str, Any]:
|
|
"""Complete one quest instance as the tapped child profile.
|
|
|
|
POSTs ``{"actor_child_id": actor_child_id}`` to the complete
|
|
route and returns the parsed response body (the route answers
|
|
``{"status": ...}`` both for a first completion and for an
|
|
idempotent repeat, so a retried panel tap succeeds). Raises
|
|
:class:`NestQuestApiError` on transport failure, timeout, or a
|
|
non-2xx response — 401 for a wrong token, 404 for an unknown
|
|
instance.
|
|
"""
|
|
path = COMPLETE_INSTANCE_PATH.format(instance_id=instance_id)
|
|
return await self._request_json(
|
|
"POST", path, json_body={"actor_child_id": actor_child_id}
|
|
)
|
|
|
|
async def _request_json(
|
|
self,
|
|
method: str,
|
|
path: str,
|
|
*,
|
|
json_body: dict[str, Any] | None = None,
|
|
) -> Any:
|
|
"""Run ONE request and return the parsed JSON body.
|
|
|
|
The Bearer token rides the Authorization header only. A
|
|
non-2xx response raises :class:`NestQuestApiError` with its
|
|
status; anything the transport raises (connection, timeout) is
|
|
wrapped as a transport-level :class:`NestQuestApiError` with
|
|
the original error chained — never swallowed, never retried.
|
|
A 2xx body that is not valid JSON is a protocol failure and
|
|
raises typed too, carrying the status it arrived with.
|
|
"""
|
|
url = f"{self._base_url}{path}"
|
|
headers = {"Authorization": f"Bearer {self._panel_token}"}
|
|
try:
|
|
async with asyncio.timeout(self._timeout_seconds):
|
|
async with self._session.request(
|
|
method, url, headers=headers, json=json_body
|
|
) as response:
|
|
status = response.status
|
|
if not 200 <= status < 300:
|
|
raise NestQuestApiError(
|
|
f"NestQuest API {method} {path} failed with "
|
|
f"HTTP {status}",
|
|
status=status,
|
|
)
|
|
body_text = await response.text()
|
|
except asyncio.CancelledError:
|
|
# Cancellation is never converted: a shutting-down event
|
|
# loop must stop, not read a synthesized API error.
|
|
raise
|
|
except NestQuestApiError:
|
|
raise
|
|
except Exception as err:
|
|
raise NestQuestApiError(
|
|
f"NestQuest API {method} {path} transport failure: {err}",
|
|
status=None,
|
|
) from err
|
|
try:
|
|
return json.loads(body_text)
|
|
except ValueError as err:
|
|
raise NestQuestApiError(
|
|
f"NestQuest API {method} {path} returned malformed "
|
|
f"JSON: {err}",
|
|
status=status,
|
|
) from err |