Files
HomeAssistantVS/custom_components/ha_washdata/store_client.py
T

996 lines
49 KiB
Python

# WashData - Home Assistant integration for appliance cycle monitoring via smart plugs.
# Copyright (C) 2026 Lukas Bandura
# SPDX-License-Identifier: AGPL-3.0-or-later
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""Async Firestore-REST client for the WashData community store (v2 hierarchy).
Reads (approved brands/devices/profiles/cycles) are public and need no token. Writes
(upload a reference cycle) use the signed-in user's Firebase ID token, obtained by
exchanging the refresh token handed over by the store's connect page.
No Firebase SDK, no new dependency: plain aiohttp via Home Assistant's shared session.
Never raises into the event loop - failures return ``None``/empty and are logged.
"""
from __future__ import annotations
import asyncio
import hashlib
import json
import logging
import re
import time
import unicodedata
from collections.abc import Callable
from typing import Any
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.util import dt as dt_util
from .const import (
SHAREABLE_SETTING_KEYS,
STORE_API_KEY,
STORE_PROJECT_ID,
SUPPORTED_CYCLE_SCHEMA_VERSIONS,
)
_LOGGER = logging.getLogger(__name__)
_APPLIANCE_TYPES = {"washer", "dryer", "dishwasher", "washer_dryer"}
# Max concurrent per-cycle rating aggregations when listing a profile's cycles.
_RATING_FANOUT_LIMIT = 8
# Max profiles hydrated concurrently when downloading a whole-device bundle. Each
# profile's get_cycles adds its own (rating) fan-out, so the effective ceiling is
# roughly this x (1 + _RATING_FANOUT_LIMIT); kept small to stay well under the
# store's rate limiter on devices that carry many profiles.
_BUNDLE_HYDRATE_LIMIT = 4
# ── deterministic ids (must match the store's lib/ids.js exactly) ──────────────
def normalize_token(s: Any) -> str:
"""lowercase -> NFKD -> collapse non-alphanumerics to '-' -> trim '-'."""
text = unicodedata.normalize("NFKD", str(s if s is not None else "").lower())
text = re.sub(r"[^a-z0-9]+", "-", text)
return text.strip("-")
def device_id(appliance_type: str, brand: str, model: str) -> str:
return "__".join((normalize_token(appliance_type), normalize_token(brand), normalize_token(model)))
def profile_id(dev_id: str, program: str) -> str:
return f"{dev_id}__{normalize_token(program)}"
def brand_id(brand: str) -> str:
return str(brand or "").lower()
# ── typed-value encode/decode (Firestore REST) ─────────────────────────────────
def _encode(v: Any) -> dict[str, Any]:
if v is None:
return {"nullValue": None}
if isinstance(v, bool):
return {"booleanValue": v}
if isinstance(v, int):
return {"integerValue": str(v)}
if isinstance(v, float):
return {"doubleValue": v}
if isinstance(v, str):
return {"stringValue": v}
if isinstance(v, (list, tuple)):
return {"arrayValue": {"values": [_encode(x) for x in v]}}
if isinstance(v, dict):
return {"mapValue": {"fields": {k: _encode(x) for k, x in v.items()}}}
return {"stringValue": str(v)}
def _decode(v: dict[str, Any]) -> Any:
if "stringValue" in v:
return v["stringValue"]
if "integerValue" in v:
return int(v["integerValue"])
if "doubleValue" in v:
return float(v["doubleValue"])
if "booleanValue" in v:
return v["booleanValue"]
if "nullValue" in v:
return None
if "timestampValue" in v:
return v["timestampValue"]
if "arrayValue" in v:
return [_decode(x) for x in v["arrayValue"].get("values", [])]
if "mapValue" in v:
return {k: _decode(x) for k, x in v["mapValue"].get("fields", {}).items()}
return None
def _decode_doc(doc: dict[str, Any]) -> dict[str, Any]:
out = {k: _decode(x) for k, x in doc.get("fields", {}).items()}
name = doc.get("name", "")
out["id"] = name.rsplit("/", 1)[-1] if "/" in name else name
return out
# Firestore forbids directly-nested arrays, so a trace can't be stored as
# [[offset, watts], ...]. On the wire we store an array of {o, w} maps and convert
# to/from [[offset, watts], ...] pairs at the boundary (matches lib/trace.js).
def pack_points(pairs: list[list[float]]) -> list[dict[str, float]]:
return [{"o": float(p[0]), "w": float(p[1])} for p in pairs if len(p) >= 2]
def unpack_points(points: Any) -> list[list[float]]:
out: list[list[float]] = []
if not isinstance(points, list):
return out
for p in points:
if isinstance(p, dict):
out.append([p.get("o", 0), p.get("w", 0)])
elif isinstance(p, (list, tuple)) and len(p) >= 2:
out.append([p[0], p[1]])
return out
def trace_hash(profile_id_: str, pts: list[list[float]]) -> str:
"""Deterministic content hash for a reference-cycle trace, scoped to its profile.
Used as the store cycle's document id so an identical trace re-uploaded to the
same program collides on the same id and is refused server-side (the create
precondition), making share idempotent. Two DIFFERENT recordings of the same
program hash differently, so genuine multi-instance contributions are preserved.
Offsets are rounded to whole seconds and watts to 1 decimal so trivial float
formatting differences do not change the hash.
"""
norm = [[int(round(float(p[0]))), round(float(p[1]), 1)] for p in pts if len(p) >= 2]
payload = f"{profile_id_}|{json.dumps(norm, separators=(',', ':'))}"
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
class StoreClient:
"""Read/write client for the store. One per manager; safe to keep for the entry."""
_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,
project_id: str = STORE_PROJECT_ID,
api_key: str = STORE_API_KEY,
session: Any | None = None,
) -> None:
self._hass = hass
self._pid = project_id
self._key = api_key
self._session = session
self._id_token: str | None = None
self._id_token_exp: float = 0.0
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
def _sess(self) -> Any:
if self._session is None:
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:
"""Exchange the refresh token for a (cached) Firebase ID token."""
now = time.time()
# The cache is only valid for the same refresh token that produced it -- after
# a disconnect/reconnect (or a different global account) the previous account's
# token must not be returned even if it is still unexpired.
if (
self._id_token
and self._id_token_rt == refresh_token
and now < self._id_token_exp - 60
):
return self._id_token
try:
async with self._sess().post(
f"{self._TOKEN}?key={self._key}",
data={"grant_type": "refresh_token", "refresh_token": refresh_token},
timeout=15,
) as resp:
if resp.status != 200:
_LOGGER.warning("Store token exchange failed: HTTP %s", resp.status)
self._last_error = f"sign-in expired (HTTP {resp.status}) - reconnect GitHub in the gear"
return None
body = await resp.json()
except Exception as exc: # noqa: BLE001 - never raise into the loop
_LOGGER.warning("Store token exchange error: %s", exc)
self._last_error = "could not reach the sign-in service"
return None
self._id_token = body.get("id_token")
self._id_token_rt = refresh_token
try:
self._id_token_exp = now + float(body.get("expires_in", 3600))
except (TypeError, ValueError):
self._id_token_exp = now + 3600
return self._id_token
# ── reads (public, no token) ────────────────────────────────────────────────
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:
if resp.status != 200:
try:
body = await resp.json()
_LOGGER.warning("Store query HTTP %s: %s", resp.status, body)
except Exception:
_LOGGER.warning("Store query HTTP %s (no body)", resp.status)
return None
rows = await resp.json()
except Exception as exc: # noqa: BLE001
_LOGGER.warning("Store query error: %s", exc)
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)}}
def _where(self, filters: list[dict[str, Any]]) -> dict[str, Any]:
if len(filters) == 1:
return filters[0]
return {"compositeFilter": {"op": "AND", "filters": filters}}
def _status_filter(self, include_pending: bool) -> dict[str, Any]:
"""status == approved, or status IN [approved, pending] when browsing the
community catalog (pending entries are publicly readable, shown with a tag)."""
if include_pending:
return {"fieldFilter": {
"field": {"fieldPath": "status"}, "op": "IN",
"value": _encode(["approved", "pending"]),
}}
return self._field_filter("status", "EQUAL", "approved")
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 = 500,
) -> list[dict[str, Any]]:
# 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 = 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,
})
if q:
p = q.lower()
rows = [r for r in rows if str(r.get("brand_lc", "")).startswith(p)]
return rows
async def get_device(self, device_id: str) -> dict[str, Any] | None:
try:
async with self._sess().get(f"{self._base}/devices/{device_id}", timeout=15) as resp:
if resp.status in (403, 404):
return None
if resp.status != 200:
return None
doc = await resp.json()
except Exception as exc: # noqa: BLE001
_LOGGER.debug("Store get_device error: %s", exc)
return None
return _decode_doc(doc)
async def get_config(self) -> dict[str, Any]:
"""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 {}
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``.
Public (unauthenticated) aggregation -- ratings are world-readable. Returns
``{"avg": float|None, "count": int}`` and never raises.
"""
body = {"structuredAggregationQuery": {
"structuredQuery": {"from": [{"collectionId": "ratings"}]},
"aggregations": [
{"alias": "cnt", "count": {}},
# NB: the Firestore aggregation operator is `avg`, NOT `average` --
# the wrong name 400s the whole query and silently zeroes ratings.
{"alias": "avg", "avg": {"field": {"fieldPath": "rating"}}},
],
}}
try:
async with self._sess().post(
f"{self._base}/{parent_path}:runAggregationQuery",
json=body, timeout=15,
) as resp:
if resp.status != 200:
return {"avg": None, "count": 0}
rows = await resp.json()
except Exception as exc: # noqa: BLE001
_LOGGER.debug("Store rating aggregation error (%s): %s", parent_path, exc)
return {"avg": None, "count": 0}
agg = next((r["result"]["aggregateFields"] for r in rows if isinstance(r, dict) and "result" in r), None)
if not agg:
return {"avg": None, "count": 0}
cnt = _decode(agg["cnt"]) if "cnt" in agg else 0
avg = _decode(agg["avg"]) if ("avg" in agg and "nullValue" not in agg["avg"]) else None
return {"avg": avg if (cnt and avg is not None) else None, "count": cnt or 0}
async def get_device_quality(self, device_id: str) -> dict[str, Any]:
"""count + average of the device's 5-star quality ratings (info only)."""
return await self._rating_agg(f"devices/{device_id}")
async def cycle_rating(self, cycle_id: str) -> dict[str, Any]:
"""count + average of a reference cycle's 5-star ratings (info only)."""
return await self._rating_agg(f"cycles/{cycle_id}")
async def get_profiles(self, dev_id: str, include_pending: bool = False, page_size: int = 100) -> list[dict[str, Any]]:
sq = {
"from": [{"collectionId": "profiles"}],
"where": self._where([
self._field_filter("deviceId", "EQUAL", dev_id),
self._status_filter(include_pending),
]),
"orderBy": [{"field": {"fieldPath": "createdAt"}, "direction": "DESCENDING"}],
"limit": page_size,
}
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
(approved + the caller's own pending), for the Share dialog's profile picker."""
dev_id = device_id(appliance_type, brand, model)
items = await self.get_profiles(dev_id, include_pending=True)
return {"device_id": dev_id, "items": items}
async def get_device_bundle(self, dev_id: str, include_pending: bool = True) -> dict[str, Any]:
"""Whole-device package for download: the device's shareable ``settings`` (from
the device doc) + its profiles, each with its reference cycles nested under
``cycles`` (hydrated + rating-summarised by get_cycles). One device GET + one
profiles query + one cycles query per profile. Never raises.
"""
device = await self.get_device(dev_id) or {}
settings = device.get("settings") if isinstance(device.get("settings"), dict) else {}
profiles = await self.get_profiles(dev_id, include_pending=include_pending)
# Bound the per-profile fan-out: each get_cycles issues one query plus a
# rating fan-out, so an unbounded gather over a device with many profiles
# could burst hundreds of concurrent requests and trip the store's rate
# limiter. A shared semaphore caps how many profiles hydrate at once.
sem = asyncio.Semaphore(_BUNDLE_HYDRATE_LIMIT)
async def _cycles_for(p: dict[str, Any]) -> list[dict[str, Any]]:
pid = p.get("id")
if not pid:
return []
async with sem:
return await self.get_cycles(pid, include_pending=include_pending)
# Fetch profiles' cycles concurrently (bounded) rather than one at a time.
cycle_lists = await asyncio.gather(*(_cycles_for(p) for p in profiles))
for p, cycles in zip(profiles, cycle_lists):
p["cycles"] = cycles
return {"device_id": dev_id, "settings": settings, "profiles": profiles}
async def get_cycles(
self, prof_id: str, include_pending: bool = True, page_size: int = 50
) -> list[dict[str, Any]]:
"""Reference cycles for a profile, most-recent-first.
``include_pending`` (default True) also returns still-awaiting-approval
recordings so they can be browsed/imported before the community votes them
in (they are publicly readable, shown with an "awaiting approval" tag).
Each cycle gets a ``rating`` = ``{"avg", "count"}`` summary attached.
"""
sq = {
"from": [{"collectionId": "cycles"}],
"where": self._where([
self._field_filter("profileId", "EQUAL", prof_id),
self._status_filter(include_pending),
]),
"orderBy": [{"field": {"fieldPath": "createdAt"}, "direction": "DESCENDING"}],
"limit": page_size,
}
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
# aggregation requests.
sem = asyncio.Semaphore(_RATING_FANOUT_LIMIT)
async def _rate(cyc: dict[str, Any]) -> dict[str, Any]:
cid = cyc.get("id")
if not cid:
return {"avg": None, "count": 0}
async with sem:
return await self.cycle_rating(cid)
summaries = await asyncio.gather(*(_rate(c) for c in cycles), return_exceptions=True)
for cyc, summary in zip(cycles, summaries):
cyc["rating"] = summary if isinstance(summary, dict) else {"avg": None, "count": 0}
return cycles
async def get_cycle(self, cycle_id: str) -> dict[str, Any] | None:
try:
async with self._sess().get(f"{self._base}/cycles/{cycle_id}", timeout=15) as resp:
if resp.status in (403, 404):
return None
if resp.status != 200:
_LOGGER.debug("Store get_cycle HTTP %s", resp.status)
return None
doc = await resp.json()
except Exception as exc: # noqa: BLE001
_LOGGER.debug("Store get_cycle error: %s", exc)
return None
return self._with_decoded_trace(_decode_doc(doc))
@staticmethod
def _with_decoded_trace(cycle: dict[str, Any]) -> dict[str, Any]:
"""Attach ``importable`` = trace points when the cycleSchemaVersion is supported."""
ver = cycle.get("cycleSchemaVersion", 1)
trace = cycle.get("trace")
if ver in SUPPORTED_CYCLE_SCHEMA_VERSIONS and isinstance(trace, dict) and isinstance(trace.get("points"), list):
pairs = unpack_points(trace["points"])
trace["points"] = pairs # hydrate to [[offset, watts]] for the panel sparkline
cycle["importable"] = pairs
else:
cycle["importable"] = None
return cycle
# ── write: upload a reference cycle (authed) ────────────────────────────────
async def _commit_create(self, id_token: str, path: str, fields: dict[str, Any], server_ts_field: str = "createdAt") -> bool:
"""Create-if-missing. Returns True on create OR if it already exists; False on
real failure. Thin wrapper over :meth:`_commit_create_ex` (drops the created flag).
"""
ok, _created = await self._commit_create_ex(id_token, path, fields, server_ts_field)
return ok
async def _commit_create_ex(
self, id_token: str, path: str, fields: dict[str, Any], server_ts_field: str = "createdAt"
) -> tuple[bool, bool]:
"""Create a document if it does not already exist, stamping ``server_ts_field``
with the server request time (so the store rules' ``createdAt == request.time``
holds). Returns ``(ok, created)``: ``created=False`` means the doc already
existed (a benign no-op that supports idempotent re-upload); ``ok=False`` is a
real failure.
"""
write: dict[str, Any] = {
"update": {
"name": f"projects/{self._pid}/databases/(default)/documents/{path}",
"fields": {k: _encode(v) for k, v in fields.items()},
},
"currentDocument": {"exists": False},
"updateTransforms": [
{"fieldPath": server_ts_field, "setToServerValue": "REQUEST_TIME"}
],
}
try:
async with self._sess().post(
f"{self._base}:commit",
json={"writes": [write]},
headers={"Authorization": f"Bearer {id_token}"},
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).
if resp.status == 409 or "ALREADY_EXISTS" in body or "FAILED_PRECONDITION" in body:
return (True, False)
_LOGGER.warning("Store create %s failed: HTTP %s %s", path, resp.status, body[:300])
coll = path.split("/", 1)[0]
if resp.status == 403 or "PERMISSION_DENIED" in body:
self._last_error = f"{coll} rejected by the store rules (HTTP 403) - the community catalog rules may be out of date"
else:
self._last_error = f"{coll} create failed (HTTP {resp.status})"
return (False, False)
except Exception as exc: # noqa: BLE001
_LOGGER.warning("Store create %s error: %s", path, exc)
self._last_error = f"{path.split('/', 1)[0]} create error: {exc}"
return (False, False)
async def upload_reference_cycle(
self, refresh_token: str, uid: str, uploader_name: str | None, meta: dict[str, Any],
points: list[list[float]], stats: dict[str, Any], qc: int, return_status: bool = False,
) -> str | None | dict[str, Any]:
"""Ensure brand/device/profile docs exist, then create the reference cycle.
The cycle's document id is a deterministic content hash of its trace (scoped
to the profile), so re-uploading an identical trace collides on the same id
and the create is refused server-side -- share is idempotent. Returns the
cycle id (on create OR already-exists), or None on real failure. With
``return_status=True`` returns ``{"id": str|None, "created": bool}`` where
``created=False`` means the trace was already in the store. All writes authed.
"""
def _out(cid: str | None, created: bool) -> str | None | dict[str, Any]:
return {"id": cid, "created": created} if return_status else cid
self._last_error = None
token = await self.ensure_id_token(refresh_token)
if not token:
return _out(None, False)
# Preserve the documented never-raise contract: malformed metadata/points must
# return a failure marker (with _last_error set), not propagate an exception to
# the no-raise StoreBridge caller.
if not isinstance(meta, dict):
self._last_error = "invalid upload metadata"
return _out(None, False)
# Reject (don't coerce) missing/blank required metadata: str(None) -> "None"
# would otherwise pollute the catalog with a literal "None" brand/model/etc.
required: dict[str, str] = {}
for _key in ("applianceType", "brand", "model", "program"):
_val = meta.get(_key)
if not isinstance(_val, str) or not _val.strip():
self._last_error = f"invalid or missing upload metadata: {_key}"
return _out(None, False)
required[_key] = _val.strip()
appliance = required["applianceType"]
brand = required["brand"]
model = required["model"]
program = required["program"]
try:
interval = float(meta.get("sampleIntervalSec") or 0)
except (TypeError, ValueError):
interval = 0.0
if appliance not in _APPLIANCE_TYPES:
_LOGGER.warning("Store upload: invalid applianceType %r", appliance)
self._last_error = f"unsupported appliance type {appliance!r} (only washer/dryer/dishwasher/washer_dryer)"
return _out(None, False)
b_id = brand_id(brand)
d_id = device_id(appliance, brand, model)
p_id = profile_id(d_id, program)
qc_code = qc if qc in (1, 2, 3) else 3
try:
pts = [[float(p[0]), float(p[1])] for p in (points or [])[:10000] if len(p) >= 2]
except (TypeError, ValueError):
self._last_error = "malformed trace points"
return _out(None, False)
if len(pts) < 2:
self._last_error = "empty or too-short trace"
return _out(None, False)
# 1-3: brand/device/profile (create-if-missing; rules deny updating existing).
ok = await self._commit_create(token, f"brands/{b_id}", {
"brand": brand, "brand_lc": b_id, "status": "pending", "createdByUid": uid,
})
device_fields: dict[str, Any] = {
"applianceType": appliance, "brand": brand, "brand_lc": b_id,
"model": model, "model_lc": model.lower(), "status": "pending",
"createdByUid": uid, "createdByName": None, "manualUrl": None,
"favoriteCount": 0, "confirmCount": 0,
}
# Stage 3: bundle the device's recognition/matching settings (allow-listed,
# numeric only) onto the device doc when supplied. Create rule allows extra
# fields, so no rules change; settings attach at create time (owner update is
# Stage 5).
settings = meta.get("settings")
if isinstance(settings, dict) and settings:
# Defense in depth at the store boundary: keep only allow-listed, numeric
# settings (never trust the caller to have filtered) so nothing arbitrary is
# ever written to the shared device doc.
filtered = {
str(k): v for k, v in settings.items()
if k in SHAREABLE_SETTING_KEYS
and isinstance(v, (int, float)) and not isinstance(v, bool)
}
if filtered:
device_fields["settings"] = filtered
ok = ok and await self._commit_create(token, f"devices/{d_id}", device_fields)
profile_fields: dict[str, Any] = {
"deviceId": d_id, "applianceType": appliance, "program": program,
"program_lc": program.lower(), "description": meta.get("description", ""),
"status": "pending", "createdByUid": uid,
}
# Stage 2: bundle the program's phase map onto the profile doc when the caller
# supplies it. The profile create rule allows extra fields, so this needs no
# rules change; phases attach at create time (updating an existing profile's
# phases is an owner action -> Stage 5).
phases = meta.get("phases")
if isinstance(phases, list) and phases:
def _valid_phase(p: Any) -> dict[str, Any] | None:
# Drop a phase with non-numeric start/end rather than coercing it to
# 0.0 (which would ship a bogus zero-length phase to the catalog).
try:
return {"name": str(p.get("name", "")), "start": float(p["start"]), "end": float(p["end"])}
except (KeyError, TypeError, ValueError):
return None
valid_phases = [
vp for vp in (_valid_phase(p) for p in phases if isinstance(p, dict)) if vp is not None
]
if valid_phases:
profile_fields["phases"] = valid_phases
profile_fields["phaseSourceCycleId"] = str(meta.get("phaseSourceCycleId") or "")
profile_fields["phasesSchemaVersion"] = 1
ok = ok and await self._commit_create(token, f"profiles/{p_id}", profile_fields)
if not ok:
return _out(None, False)
# 4: the reference cycle. Its id is a deterministic content hash of the trace
# (scoped to the profile), so an identical re-upload collides on the same id
# and the create precondition refuses it -> idempotent share (no duplicate).
cyc_id = trace_hash(p_id, pts)
cycle_fields = {
"profileId": p_id, "deviceId": d_id, "brand_lc": b_id,
"program_lc": program.lower(), "applianceType": appliance,
"uploaderUid": uid, "uploaderName": uploader_name,
"status": "pending", "rejectionReason": None,
"traceHash": cyc_id,
# Firestore rejects nested arrays -> store points as {o,w} maps.
"trace": {"points": pack_points(pts), "sampleIntervalSec": interval},
"stats": stats if isinstance(stats, dict) else {},
"cycleSchemaVersion": 1, "downloads": 0, "commentCount": 0, "confirmCount": 0, "qc": qc_code,
}
cyc_ok, created = await self._commit_create_ex(token, f"cycles/{cyc_id}", cycle_fields)
if not cyc_ok:
return _out(None, False)
# NB: cycle/profile counts are CALCULATED on the store (COUNT aggregation over
# approved+pending), not maintained as a running total here -- a best-effort
# increment that a rule denied is what left the browse counters stuck at 0.
return _out(cyc_id, created)
async def upload_device_bundle(
self, refresh_token: str, uid: str, uploader_name: str | None,
device_meta: dict[str, Any], items: list[dict[str, Any]],
) -> dict[str, Any]:
"""Upload a whole-device bundle: one item per selected reference cycle.
``device_meta`` = ``{applianceType, brand, model}``; each ``item`` =
``{program, points, stats, qc, sampleIntervalSec}``. Reuses
``upload_reference_cycle`` per item, which idempotently upserts the
brand/device/profile chain (existing ancestors are treated as success) and
creates the cycle. Returns ``{ok, cycle_ids, created, duplicates, errors}``:
``created`` counts newly-uploaded cycles, ``duplicates`` counts ones whose
identical trace was already in the store (both still land in ``cycle_ids``).
Never raises.
"""
cycle_ids: list[str] = []
errors: list[str] = []
created = 0
duplicates = 0
token = await self.ensure_id_token(refresh_token)
if not token:
return {"ok": False, "cycle_ids": [], "created": 0, "duplicates": 0,
"errors": [self._last_error or "not_connected"]}
for it in items or []:
meta = {
"applianceType": device_meta.get("applianceType"),
"brand": device_meta.get("brand"),
"model": device_meta.get("model"),
"program": it.get("program"),
"sampleIntervalSec": it.get("sampleIntervalSec"),
# Stage 2: optional phase map for the profile doc (create-time).
"phases": it.get("phases"),
"phaseSourceCycleId": it.get("phaseSourceCycleId"),
# Stage 3: optional device-level settings (attach to the device doc).
"settings": device_meta.get("settings"),
}
res = await self.upload_reference_cycle(
refresh_token, uid, uploader_name, meta,
it.get("points") or [], it.get("stats") or {}, int(it.get("qc") or 3),
return_status=True,
)
cid = res.get("id") if isinstance(res, dict) else res
if cid:
cycle_ids.append(cid)
if isinstance(res, dict) and res.get("created"):
created += 1
else:
duplicates += 1
else:
errors.append(self._last_error or f"failed to upload {it.get('program')!r}")
return {"ok": not errors, "cycle_ids": cycle_ids,
"created": created, "duplicates": duplicates, "errors": errors}
# ── community catalog: confirm + rate a device (authed) ──────────────────────
async def _commit(self, id_token: str, writes: list[dict[str, Any]]) -> tuple[bool, str]:
"""Post a batched :commit. Returns (ok, response_body_text)."""
try:
async with self._sess().post(
f"{self._base}:commit",
json={"writes": writes},
headers={"Authorization": f"Bearer {id_token}"},
timeout=15,
) as resp:
return (resp.status == 200, await resp.text())
except Exception as exc: # noqa: BLE001
_LOGGER.warning("Store commit error: %s", exc)
return (False, str(exc))
def _doc_path(self, rel: str) -> str:
return f"projects/{self._pid}/databases/(default)/documents/{rel}"
async def confirm_device(self, refresh_token: str, uid: str, device_id: str) -> dict[str, Any] | None:
"""Confirm a device (one per user). Bumps the honest confirmCount in the same
batch that creates confirmations/{uid}, then best-effort promotes to approved
once the threshold is reached (the rule is the real guard). Returns state."""
token = await self.ensure_id_token(refresh_token)
if not token:
return None
dev_path = self._doc_path(f"devices/{device_id}")
conf_path = self._doc_path(f"devices/{device_id}/confirmations/{uid}")
writes = [
{
"update": {"name": conf_path, "fields": {"uid": _encode(uid)}},
"currentDocument": {"exists": False},
"updateTransforms": [{"fieldPath": "createdAt", "setToServerValue": "REQUEST_TIME"}],
},
{
"transform": {
"document": dev_path,
"fieldTransforms": [{"fieldPath": "confirmCount", "increment": _encode(1)}],
},
},
]
ok, body = await self._commit(token, writes)
# A precondition failure means this user already confirmed - not an error.
if not ok and "ALREADY_EXISTS" not in body and "FAILED_PRECONDITION" not in body:
_LOGGER.warning("Store confirm_device failed: %s", body[:200])
return None
dev = await self.get_device(device_id) or {}
count = int(dev.get("confirmCount") or 0)
status = dev.get("status")
try:
threshold = int((await self.get_config()).get("confirmThreshold") or 5)
except (TypeError, ValueError):
threshold = 5
if status == "pending" and count >= threshold:
promote = [{
"update": {"name": dev_path, "fields": {"status": _encode("approved")}},
"updateMask": {"fieldPaths": ["status"]},
"currentDocument": {"exists": True},
}]
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:
"""Set this user's 5-star quality rating for a device (info only)."""
if rating not in (1, 2, 3, 4, 5):
return False
token = await self.ensure_id_token(refresh_token)
if not token:
return False
path = self._doc_path(f"devices/{device_id}/ratings/{uid}")
writes = [{
"update": {"name": path, "fields": {"uid": _encode(uid), "rating": _encode(rating)}},
"updateTransforms": [{"fieldPath": "updatedAt", "setToServerValue": "REQUEST_TIME"}],
}]
ok, body = await self._commit(token, writes)
if not ok:
_LOGGER.warning("Store rate_device failed: %s", body[:200])
return ok
async def bump_downloads(self, cycle_ids: list[str]) -> None:
"""Best-effort +1 to each cycle's public ``downloads`` counter. The store rule
allows an anonymous ``downloads++`` (unauthenticated), so this needs no token --
it mirrors the website's per-download bump. Chunked to stay under Firestore's
500-writes-per-commit limit. Never raises."""
# Deduplicate (order-preserving): Firestore :commit rejects a batch (HTTP 400)
# if it contains two writes to the same document, which a bundle referencing
# the same shared cycle across profiles can produce.
ids = list(dict.fromkeys(c for c in (cycle_ids or []) if c))
for start in range(0, len(ids), 400):
writes = [
{"transform": {
"document": self._doc_path(f"cycles/{cid}"),
"fieldTransforms": [{"fieldPath": "downloads", "increment": _encode(1)}],
}}
for cid in ids[start:start + 400]
]
try:
async with self._sess().post(
f"{self._base}:commit", json={"writes": writes}, timeout=15,
) as resp:
if resp.status != 200:
_LOGGER.debug("Store bump_downloads HTTP %s: %s", resp.status, (await resp.text())[:200])
except Exception as exc: # noqa: BLE001
_LOGGER.debug("Store bump_downloads error: %s", exc)
async def bump_analytics(self, field: str, n: int = 1) -> None:
"""Best-effort +n to an aggregate usage counter the store's admin dashboard reads
(``analytics/totals`` + ``analytics/daily_YYYYMMDD``). This is how integration
downloads become a real, community-wide usage metric -- the website has no
download action, so the integration is the source of truth for "someone adopted
this". Unauthenticated (the analytics rule allows anonymous counter writes) and
never raises. The daily doc id is UTC to line up with the website's own writes.
"""
if not field or n <= 0:
return
day = dt_util.utcnow().strftime("%Y%m%d") # UTC to match the website's daily docs
writes = [
{"transform": {
"document": self._doc_path(f"analytics/daily_{day}"),
"fieldTransforms": [{"fieldPath": field, "increment": _encode(n)}],
}},
{"transform": {
"document": self._doc_path("analytics/totals"),
"fieldTransforms": [{"fieldPath": field, "increment": _encode(n)}],
}},
]
try:
async with self._sess().post(
f"{self._base}:commit", json={"writes": writes}, timeout=15,
) as resp:
if resp.status != 200:
_LOGGER.debug("Store bump_analytics HTTP %s: %s", resp.status, (await resp.text())[:200])
except Exception as exc: # noqa: BLE001
_LOGGER.debug("Store bump_analytics error: %s", exc)