# 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 . """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