Files
Home-Assistant/custom_components/ha_washdata/store_client.py
T
2026-07-17 10:12:15 -04:00

803 lines
38 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 typing import Any
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
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"
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"
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
# ── 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]]:
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 []
rows = await resp.json()
except Exception as exc: # noqa: BLE001
_LOGGER.warning("Store query error: %s", exc)
return []
return [_decode_doc(r["document"]) for r in rows if isinstance(r, dict) and "document" in r]
@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 = 60,
) -> 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)
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 = {
"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)]
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."""
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())
except Exception as exc: # noqa: BLE001
_LOGGER.debug("Store get_config error: %s", exc)
return {}
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": {}},
{"alias": "avg", "average": {"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)
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)]
# 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:
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"
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