This commit is contained in:
Home Assistant Version Control
2026-08-22 14:33:12 +00:00
parent 66fc59e9a7
commit eddec8ffd7
67 changed files with 11380 additions and 2569 deletions
+49 -19
View File
@@ -30,7 +30,7 @@ from homeassistant.core import HomeAssistant
from . import store_account
from .const import QC_EDITED, QC_MANUAL, QC_RECORDING
from .store_client import StoreClient, device_id, profile_id, trace_hash
from .store_client import device_id, get_client, profile_id, trace_hash
_LOGGER = logging.getLogger(__name__)
@@ -158,7 +158,10 @@ class StoreBridge:
def __init__(self, hass: Any, profile_store: Any) -> None:
self._hass = hass
self._ps = profile_store
self._client = StoreClient(hass)
# One client for the whole install, not one per appliance: the catalog it reads is
# public and device-agnostic, so a per-bridge client made an N-appliance install
# issue N cold-cache copies of the same brand/device queries. See get_client.
self._client = get_client(hass)
def _fire_download_telemetry(self, cycle_ids: list[str]) -> None:
"""Fire best-effort download/adoption telemetry as a detached background task.
@@ -182,7 +185,12 @@ class StoreBridge:
async def connect(self, refresh_token: str, uid: str, name: str | None) -> dict[str, Any]:
# Validate the refresh token by exchanging it once before persisting.
if not await self._client.ensure_id_token(refresh_token):
# ensure_id_token writes the client-wide _last_error slot on failure, so it
# takes the same lock the upload paths use: otherwise a sign-in failing here
# could overwrite the reason a concurrent share is about to report.
async with self._client.write_lock:
token = await self._client.ensure_id_token(refresh_token)
if not token:
return {"error": "token_invalid"}
await store_account.async_set_account(self._hass, {"refresh_token": refresh_token, "uid": uid, "name": name})
return store_account.get_identity(self._hass)
@@ -204,6 +212,22 @@ class StoreBridge:
brand, appliance_type, model_query=model_query, include_pending=include_pending,
)
async def catalog_entry(self, brand: str, model: str, appliance_type: str) -> dict[str, Any]:
"""The catalog identity of one appliance -- its brand + device documents, resolved
by deterministic id, for the settings form's status badges.
The badges used to be a by-product of the pickers' full brand/device *lists*, which
meant simply opening the Settings tab downloaded the catalog. Two point reads answer
the same question, so the lists are now only fetched when the user actually opens a
picker. Maps the HA device type to the catalog type first.
"""
return await self._client.catalog_entry(brand, model, store_appliance_type(appliance_type))
def refresh_catalog(self) -> dict[str, Any]:
"""Drop the cached catalog so the next read is fresh (panel "refresh" action)."""
self._client.refresh_catalog()
return {"ok": True}
async def get_profiles(self, device_id: str) -> list[dict[str, Any]]:
return await self._client.get_profiles(device_id)
@@ -224,14 +248,16 @@ class StoreBridge:
acct = store_account.get_account(self._hass)
if not acct.get("refresh_token"):
return {"error": "not_connected"}
res = await self._client.confirm_device(acct["refresh_token"], acct.get("uid", ""), device_id)
async with self._client.write_lock: # writes _last_error; see connect()
res = await self._client.confirm_device(acct["refresh_token"], acct.get("uid", ""), device_id)
return res if res else {"error": "confirm_failed"}
async def rate_device(self, device_id: str, rating: int) -> dict[str, Any]:
acct = store_account.get_account(self._hass)
if not acct.get("refresh_token"):
return {"error": "not_connected"}
ok = await self._client.rate_device(acct["refresh_token"], acct.get("uid", ""), device_id, rating)
async with self._client.write_lock: # writes _last_error; see connect()
ok = await self._client.rate_device(acct["refresh_token"], acct.get("uid", ""), device_id, rating)
return {"ok": True} if ok else {"error": "rate_failed"}
# ── import / share (target this device's ProfileStore) ───────────────────────
@@ -293,12 +319,15 @@ class StoreBridge:
downsampled = await self._hass.async_add_executor_job(
_downsample, [[p[0], p[1]] for p in pts]
)
new_id = await self._client.upload_reference_cycle(
acct["refresh_token"], acct.get("uid", ""), acct.get("name"),
meta, downsampled, stats, derive_qc(cyc),
)
if not new_id:
return {"error": "upload_failed", "detail": self._client.last_error()}
# The client is shared install-wide, and last_error() is a single slot, so the
# write and the read that interprets it have to be one critical section.
async with self._client.write_lock:
new_id = await self._client.upload_reference_cycle(
acct["refresh_token"], acct.get("uid", ""), acct.get("name"),
meta, downsampled, stats, derive_qc(cyc),
)
if not new_id:
return {"error": "upload_failed", "detail": self._client.last_error()}
return {"store_cycle_id": new_id}
async def share_device(
@@ -366,14 +395,15 @@ class StoreBridge:
# to the allow-list by the WS layer, which owns entry.options).
if isinstance(settings, dict) and settings:
device_meta["settings"] = dict(settings)
res = await self._client.upload_device_bundle(
acct["refresh_token"], acct.get("uid", ""), acct.get("name"), device_meta, bundle_items,
)
# Return the raw bundle result ({ok, cycle_ids, errors}) so the caller can
# tell a partial upload (some cycle_ids present) from a total failure.
# Only a pre-flight gate short-circuits with an {"error": ...} marker above.
if not res.get("ok") and not res.get("cycle_ids"):
res = {**res, "detail": self._client.last_error()}
async with self._client.write_lock: # see share_cycle
res = await self._client.upload_device_bundle(
acct["refresh_token"], acct.get("uid", ""), acct.get("name"), device_meta, bundle_items,
)
# Return the raw bundle result ({ok, cycle_ids, errors}) so the caller can
# tell a partial upload (some cycle_ids present) from a total failure.
# Only a pre-flight gate short-circuits with an {"error": ...} marker above.
if not res.get("ok") and not res.get("cycle_ids"):
res = {**res, "detail": self._client.last_error()}
return res
async def download_device(self, device_id_: str, device_type: str = "") -> dict[str, Any]: