# 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 .
"""Integration-wide (device-agnostic) community-store state.
One GitHub connection and one online-features on/off for the whole HA install,
held in a single domain-scoped Store rather than in any per-device config entry.
The refresh token is a credential: never logged, never put in events, and redacted
in diagnostics (see ``diagnostics._SENSITIVE_KEYS``).
"""
from __future__ import annotations
import asyncio
import logging
from typing import Any
from homeassistant.core import HomeAssistant
from homeassistant.helpers.storage import Store
from .const import DEFAULT_ENABLE_ONLINE_FEATURES, DOMAIN
_LOGGER = logging.getLogger(__name__)
_STORE_VERSION = 1
_STORE_FILE = f"{DOMAIN}_online"
_DATA_KEY = f"{DOMAIN}_online_cfg"
_LOAD_LOCK_KEY = f"{DOMAIN}_online_load_lock"
# Integration-wide community-store display/behaviour preferences. To add a new
# online setting, add one entry here (key -> default) and one declarative row in the
# panel's _STORE_PREFS list; the generic get_prefs / async_set_prefs / store_set_prefs
# plumbing carries it end-to-end with no further wiring.
_DEFAULT_PREFS: dict[str, Any] = {
"show_contributor": True, # show "by " attribution in the pickers
}
def _default() -> dict[str, Any]:
return {
"online_enabled": DEFAULT_ENABLE_ONLINE_FEATURES,
"account": {},
"migrated": False,
"prefs": dict(_DEFAULT_PREFS),
}
async def async_load(hass: HomeAssistant) -> None:
"""Load (once) the global online config + account into hass.data.
Several device config entries set up concurrently at HA startup and each
calls this, so the load is serialized under a lock with a second check
inside it -- otherwise two callers could both pass the guard across the
``await`` and the later one would clobber the first's bucket.
"""
if _DATA_KEY in hass.data:
return
lock = hass.data.setdefault(_LOAD_LOCK_KEY, asyncio.Lock())
async with lock:
if _DATA_KEY in hass.data:
return
store = Store(hass, _STORE_VERSION, _STORE_FILE)
data = _default()
try:
loaded = await store.async_load()
if isinstance(loaded, dict):
data["online_enabled"] = bool(loaded.get("online_enabled", DEFAULT_ENABLE_ONLINE_FEATURES))
data["migrated"] = bool(loaded.get("migrated", False))
if isinstance(loaded.get("account"), dict):
data["account"] = dict(loaded["account"])
# Merge persisted prefs over the defaults, keeping only known keys so
# a stale/removed pref can never linger.
if isinstance(loaded.get("prefs"), dict):
data["prefs"] = {
k: loaded["prefs"].get(k, _DEFAULT_PREFS[k]) for k in _DEFAULT_PREFS
}
except Exception as exc: # noqa: BLE001 - never fail setup over this
_LOGGER.warning("Failed to load online config, using defaults: %s", exc)
hass.data[_DATA_KEY] = {"store": store, "data": data}
def _data(hass: HomeAssistant) -> dict[str, Any]:
bucket = hass.data.get(_DATA_KEY)
return bucket["data"] if bucket else _default()
async def _save(hass: HomeAssistant) -> None:
bucket = hass.data.get(_DATA_KEY)
if bucket and bucket.get("store"):
await bucket["store"].async_save(bucket["data"])
def online_enabled(hass: HomeAssistant) -> bool:
"""True when online features are enabled integration-wide (default off)."""
return bool(_data(hass).get("online_enabled", DEFAULT_ENABLE_ONLINE_FEATURES))
async def async_set_online(hass: HomeAssistant, on: bool) -> None:
await async_load(hass)
_data(hass)["online_enabled"] = bool(on)
# Turning online features off is a full opt-out: drop the stored refresh token
# so a disabled install never leaves a live credential on disk (a later re-enable
# simply reconnects). Explicit disconnect clears it the same way.
if not on:
_data(hass)["account"] = {}
await _save(hass)
def get_prefs(hass: HomeAssistant) -> dict[str, Any]:
"""Integration-wide community-store preferences, defaults filled in."""
stored = _data(hass).get("prefs")
stored = stored if isinstance(stored, dict) else {}
return {k: stored.get(k, _DEFAULT_PREFS[k]) for k in _DEFAULT_PREFS}
def get_pref(hass: HomeAssistant, key: str) -> Any:
"""A single store preference (default if unknown/unset)."""
return get_prefs(hass).get(key, _DEFAULT_PREFS.get(key))
async def async_set_prefs(hass: HomeAssistant, patch: dict[str, Any]) -> dict[str, Any]:
"""Merge a subset of store preferences (only known keys) and persist."""
await async_load(hass)
data = _data(hass)
prefs = data.get("prefs")
prefs = dict(prefs) if isinstance(prefs, dict) else dict(_DEFAULT_PREFS)
for k, v in (patch or {}).items():
if k in _DEFAULT_PREFS:
prefs[k] = bool(v) if isinstance(_DEFAULT_PREFS[k], bool) else v
data["prefs"] = prefs
await _save(hass)
return get_prefs(hass)
def migration_done(hass: HomeAssistant) -> bool:
"""True once the one-time per-device -> global online migration has run."""
return bool(_data(hass).get("migrated", False))
async def async_mark_migrated(hass: HomeAssistant) -> None:
await async_load(hass)
_data(hass)["migrated"] = True
await _save(hass)
def get_account(hass: HomeAssistant) -> dict[str, Any]:
"""Full account incl. the refresh token (credential; internal use only)."""
acct = _data(hass).get("account")
return dict(acct) if isinstance(acct, dict) else {}
def get_identity(hass: HomeAssistant) -> dict[str, Any]:
"""Safe account view for status/UI - never includes the refresh token."""
acct = get_account(hass)
return {"connected": bool(acct.get("refresh_token")), "uid": acct.get("uid"), "name": acct.get("name")}
async def async_set_account(hass: HomeAssistant, account: dict[str, Any]) -> None:
"""Persist the account, replacing any previously stored one.
Both callers (connect / migration hoist) pass a complete account dict, so a
fresh login fully supersedes the old one -- no field from a previous account
(e.g. a stale ``uid``) can survive an account switch. ``None`` values are
dropped so an unset optional (``name``) doesn't overwrite with null.
"""
await async_load(hass)
_data(hass)["account"] = {k: v for k, v in account.items() if v is not None}
await _save(hass)
async def async_clear_account(hass: HomeAssistant) -> None:
await async_load(hass)
_data(hass)["account"] = {}
await _save(hass)