224 files

This commit is contained in:
Home Assistant Version Control
2026-07-27 13:30:34 +00:00
parent b5723aa856
commit e36b8a1a22
224 changed files with 9967 additions and 2663 deletions
+158 -24
View File
@@ -32,6 +32,7 @@ import logging
import re
import time
import unicodedata
from collections.abc import Callable
from typing import Any
from homeassistant.core import HomeAssistant
@@ -167,6 +168,22 @@ class StoreClient:
_FS = "https://firestore.googleapis.com/v1"
_TOKEN = "https://securetoken.googleapis.com/v1/token"
# The community catalog (brands + device searches) is public and changes rarely, but
# the panel re-queries it every time the Settings/Store tab is (re-)opened. On the
# store's Firebase free tier (50k document reads/day) that brand-list query alone was
# the single largest read source. Cache these reads in memory for a short TTL so a
# burst of panel opens collapses to at most one Firestore query per key per window.
# The client is a long-lived singleton (one per manager) so the cache survives panel
# reloads. Writes that add brands/devices invalidate it (see _commit_create) so a
# freshly-contributed entry still appears immediately for the user who added it.
_CATALOG_CACHE_TTL_S = 900.0 # brands + device searches (15 min)
_CONFIG_CACHE_TTL_S = 3600.0 # config/site (maintenance flag + confirm threshold)
# Hard cap on distinct cached read keys. Device searches key on the brand term, so a
# long-lived session that issues many distinct searches would otherwise accumulate an
# unbounded number of (mostly expired) entries. When the cap is exceeded we evict the
# soonest-to-expire entries, keeping the live TTL guarantee while bounding memory.
_MAX_READ_CACHE_ENTRIES = 256
def __init__(
self,
hass: HomeAssistant,
@@ -183,6 +200,18 @@ class StoreClient:
self._id_token_rt: str | None = None # refresh token that produced the cached id_token
self._last_error: str | None = None # short reason for the last failed write, for the UI
self._base = f"{self._FS}/projects/{project_id}/databases/(default)/documents"
# key -> (expiry_epoch, value). Read-only catalog/config responses; see class docstring.
self._read_cache: dict[str, tuple[float, Any]] = {}
# Bumped on every catalog invalidation; a read captures it before its query and only
# caches the result if it is unchanged afterwards, so an in-flight read that spans an
# invalidation cannot re-cache a pre-write snapshot.
self._cache_gen: int = 0
# Single-flight: one in-flight (generation, task) per cache key so concurrent panel
# misses share a single Firestore read instead of each issuing one (matters on the
# free-tier budget). The generation is stored so a query started before an
# invalidation is not joined by a post-invalidation caller (which would receive a
# pre-write snapshot); such a caller starts a fresh query instead.
self._inflight: dict[str, "tuple[int, asyncio.Future[list[dict[str, Any]]]]"] = {}
def last_error(self) -> str | None:
return self._last_error
@@ -192,6 +221,35 @@ class StoreClient:
self._session = async_get_clientsession(self._hass)
return self._session
# ── read cache (public catalog/config; see class docstring) ─────────────────
def _cache_get(self, key: str) -> Any | None:
ent = self._read_cache.get(key)
if ent is None:
return None
expiry, value = ent
# Monotonic clock: immune to NTP/wall-clock steps that could otherwise extend or
# truncate the TTL (put + get must use the same clock).
if time.monotonic() >= expiry:
self._read_cache.pop(key, None)
return None
return value
def _cache_put(self, key: str, value: Any, ttl: float) -> None:
self._read_cache[key] = (time.monotonic() + ttl, value)
if len(self._read_cache) > self._MAX_READ_CACHE_ENTRIES:
# Drop the soonest-to-expire entries (expired ones first) to stay bounded.
overflow = len(self._read_cache) - self._MAX_READ_CACHE_ENTRIES
for k, _ in sorted(self._read_cache.items(), key=lambda kv: kv[1][0])[:overflow]:
self._read_cache.pop(k, None)
def _invalidate_catalog_cache(self) -> None:
"""Drop cached brand/device catalog reads (call after a create/upload/promote write so
a just-contributed or newly-approved entry appears immediately, not after the TTL)."""
self._cache_gen += 1
for key in [k for k in self._read_cache if k.startswith(("brands:", "devices:"))]:
self._read_cache.pop(key, None)
# ── auth ──────────────────────────────────────────────────────────────────
async def ensure_id_token(self, refresh_token: str) -> str | None:
@@ -231,7 +289,10 @@ class StoreClient:
# ── reads (public, no token) ────────────────────────────────────────────────
async def _run_query(self, sq: dict[str, Any], parent: str = "") -> list[dict[str, Any]]:
async def _run_query(self, sq: dict[str, Any], parent: str = "") -> list[dict[str, Any]] | None:
"""Run a structured query. Returns the decoded rows on success (possibly an empty
list), or ``None`` on any HTTP error / network failure so callers can distinguish a
genuinely-empty result from a transient failure and avoid caching the latter."""
url = f"{self._base}/{parent}:runQuery" if parent else f"{self._base}:runQuery"
try:
async with self._sess().post(url, json={"structuredQuery": sq}, timeout=15) as resp:
@@ -241,13 +302,53 @@ class StoreClient:
_LOGGER.warning("Store query HTTP %s: %s", resp.status, body)
except Exception:
_LOGGER.warning("Store query HTTP %s (no body)", resp.status)
return []
return None
rows = await resp.json()
except Exception as exc: # noqa: BLE001
_LOGGER.warning("Store query error: %s", exc)
return []
return None
return [_decode_doc(r["document"]) for r in rows if isinstance(r, dict) and "document" in r]
async def _cached_catalog_query(
self, key: str, build_sq: "Callable[[], dict[str, Any]]"
) -> list[dict[str, Any]]:
"""Shared cache-get -> (on miss) run query -> conditionally-cache flow for the public
catalog reads. ``build_sq`` is called only on a cache miss (kept lazy). A successful
result is cached only if no invalidation happened while the query was in flight (the
generation guard), so an in-flight read cannot re-cache a pre-write snapshot; a
transient failure (None) is never cached. Callers apply their own in-memory prefix
filter to the returned full list."""
cached = self._cache_get(key)
if cached is not None:
return cached
# Single-flight: join an in-flight query for the CURRENT generation; otherwise start
# a fresh one. An entry from an older generation (started before an invalidation) is
# deliberately not joined -- it may carry a pre-write snapshot.
entry = self._inflight.get(key)
if entry is None or entry[0] != self._cache_gen:
gen = self._cache_gen
entry = (gen, asyncio.ensure_future(self._fetch_and_cache(key, gen, build_sq)))
self._inflight[key] = entry
# Shield so one waiter's cancellation neither cancels the shared query nor the other
# waiters coalesced onto it.
return await asyncio.shield(entry[1])
async def _fetch_and_cache(
self, key: str, gen: int, build_sq: "Callable[[], dict[str, Any]]"
) -> list[dict[str, Any]]:
"""The shared single-flight body: run the query, cache only a successful, non-
superseded result, and clear the in-flight slot when done (if it is still ours)."""
try:
fetched = await self._run_query(build_sq())
result = fetched if fetched is not None else []
if fetched is not None and gen == self._cache_gen:
self._cache_put(key, result, self._CATALOG_CACHE_TTL_S)
return result
finally:
cur = self._inflight.get(key)
if cur is not None and cur[1] is asyncio.current_task():
self._inflight.pop(key, None)
@staticmethod
def _field_filter(field: str, op: str, value: Any) -> dict[str, Any]:
return {"fieldFilter": {"field": {"fieldPath": field}, "op": op, "value": _encode(value)}}
@@ -269,33 +370,50 @@ class StoreClient:
async def search_devices(
self, brand: str | None = None, appliance_type: str | None = None,
model_query: str | None = None, include_pending: bool = False, page_size: int = 60,
model_query: str | None = None, *, include_pending: bool = False, page_size: int = 500,
) -> list[dict[str, Any]]:
filters = [self._status_filter(include_pending)]
if appliance_type:
filters.append(self._field_filter("applianceType", "EQUAL", appliance_type))
if brand:
filters.append(self._field_filter("brand_lc", "EQUAL", brand.lower()))
sq = {
"from": [{"collectionId": "devices"}],
"where": self._where(filters),
"orderBy": [{"field": {"fieldPath": "favoriteCount"}, "direction": "DESCENDING"}],
"limit": page_size,
}
rows = await self._run_query(sq)
# Cache the full per-brand/type device list (the model_query prefix filter is applied
# in memory below), so one Firestore query serves every model prefix. page_size is a
# full-catalog ceiling, not a UI page size: the in-memory filter can only match what
# was fetched, so caching a truncated list would hide models past the limit for the
# whole TTL. A query reads only the docs that exist, so the high ceiling adds no reads
# for today's catalog while staying complete as it grows.
key = f"devices:{(brand or '').lower()}:{appliance_type or ''}:{int(include_pending)}:{page_size}"
def _build() -> dict[str, Any]:
filters = [self._status_filter(include_pending)]
if appliance_type:
filters.append(self._field_filter("applianceType", "EQUAL", appliance_type))
if brand:
filters.append(self._field_filter("brand_lc", "EQUAL", brand.lower()))
return {
"from": [{"collectionId": "devices"}],
"where": self._where(filters),
"orderBy": [{"field": {"fieldPath": "favoriteCount"}, "direction": "DESCENDING"}],
"limit": page_size,
}
rows = await self._cached_catalog_query(key, _build)
if model_query:
p = model_query.lower()
rows = [r for r in rows if str(r.get("model_lc", "")).startswith(p)]
return rows
async def list_brands(self, q: str | None = None, include_pending: bool = True, page_size: int = 60) -> list[dict[str, Any]]:
sq = {
async def list_brands(self, q: str | None = None, *, include_pending: bool = True, page_size: int = 500) -> list[dict[str, Any]]:
# Cache the unfiltered brand list (the q prefix filter is applied in memory below),
# so one Firestore query serves every search prefix for this key. The limit is a
# full-catalog ceiling (not a UI page size): the in-memory prefix filter can only
# match what was fetched, so a low cap would make brands past it unsearchable for
# the whole cache TTL. The brand collection is small with tiny docs, and a query
# only reads the docs that exist, so this ceiling does not add reads for today's
# catalog while staying correct as it grows.
key = f"brands:{int(include_pending)}:{page_size}"
rows = await self._cached_catalog_query(key, lambda: {
"from": [{"collectionId": "brands"}],
"where": self._where([self._status_filter(include_pending)]),
"orderBy": [{"field": {"fieldPath": "brand_lc"}, "direction": "ASCENDING"}],
"limit": page_size,
}
rows = await self._run_query(sq)
})
if q:
p = q.lower()
rows = [r for r in rows if str(r.get("brand_lc", "")).startswith(p)]
@@ -315,15 +433,24 @@ class StoreClient:
return _decode_doc(doc)
async def get_config(self) -> dict[str, Any]:
"""Public config/site (maintenance flag + confirmThreshold). {} on failure."""
"""Public config/site (maintenance flag + confirmThreshold). {} on failure.
Cached for a long TTL: this is read on every confirm_device to resolve the
confirmThreshold, which changes almost never. Only successful reads are cached,
so a transient failure never pins an empty config."""
cached = self._cache_get("config:site")
if cached is not None:
return cached
try:
async with self._sess().get(f"{self._base}/config/site", timeout=15) as resp:
if resp.status != 200:
return {}
return _decode_doc(await resp.json())
cfg = _decode_doc(await resp.json())
except Exception as exc: # noqa: BLE001
_LOGGER.debug("Store get_config error: %s", exc)
return {}
self._cache_put("config:site", cfg, self._CONFIG_CACHE_TTL_S)
return cfg
async def _rating_agg(self, parent_path: str) -> dict[str, Any]:
"""count + average over the `ratings` subcollection under ``parent_path``.
@@ -376,7 +503,7 @@ class StoreClient:
"orderBy": [{"field": {"fieldPath": "createdAt"}, "direction": "DESCENDING"}],
"limit": page_size,
}
return await self._run_query(sq)
return await self._run_query(sq) or []
async def device_profiles(self, brand: str, model: str, appliance_type: str) -> dict[str, Any]:
"""Resolve the store deviceId from brand/model/type and return its profiles
@@ -433,7 +560,7 @@ class StoreClient:
"orderBy": [{"field": {"fieldPath": "createdAt"}, "direction": "DESCENDING"}],
"limit": page_size,
}
cycles = [self._with_decoded_trace(c) for c in await self._run_query(sq)]
cycles = [self._with_decoded_trace(c) for c in (await self._run_query(sq) or [])]
# Attach each cycle's 5-star rating summary (info-only; the aggregation lives
# in a subcollection so it can't ride the list query). Bound concurrency with
# a semaphore so a large page can't fan out into dozens of simultaneous
@@ -513,6 +640,10 @@ class StoreClient:
timeout=15,
) as resp:
if resp.status == 200:
# A newly-created brand/device changes the catalog listing; drop the
# cached brand/device reads so it appears immediately for this user.
if path.split("/", 1)[0] in ("brands", "devices"):
self._invalidate_catalog_cache()
return (True, True)
body = await resp.text()
# Precondition failure => the doc already exists; that is fine (no-op).
@@ -785,6 +916,9 @@ class StoreClient:
}]
if (await self._commit(token, promote))[0]:
status = "approved"
# Promotion changes catalog visibility (pending -> approved); drop cached
# listings so the newly-approved device shows in approved-only searches now.
self._invalidate_catalog_cache()
return {"confirmed": True, "confirmCount": count, "status": status}
async def rate_device(self, refresh_token: str, uid: str, device_id: str, rating: int) -> bool: