The integration will consume the NestQuest API service (Feature 16) over HTTP instead of reading the database. This adds the one module that speaks the panel plane: api_client.py with get_snapshot() and complete_instance(), a typed NestQuestApiError for transport failures and non-2xx responses (carrying the HTTP status so 401/404 are distinguishable), one timeout per request and no retry loops. The HTTP session is injected, so tests exercise the client against a stub transport with no network and no aiohttp; the production factory hands in HA's shared aiohttp session and resolves the base URL and panel service token off the config entry (options, then entry data, then defaults). The two new settings are wired into the options flow like every other section (validation mirrored with the client's constructor rules); the token is a secret and is never logged or echoed into error messages. Coordinator wiring is the next task.
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 |