466 lines
20 KiB
Python
466 lines
20 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/>.
|
|
"""Frontend card and panel registration for WashData."""
|
|
|
|
import asyncio
|
|
import logging
|
|
import os
|
|
from pathlib import Path
|
|
from typing import Any, Literal, TypedDict, cast
|
|
from homeassistant.core import HomeAssistant, Event
|
|
from homeassistant.const import EVENT_COMPONENT_LOADED
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
LOCAL_SUBDIR = "ha_washdata"
|
|
CARD_NAME = "ha-washdata-card.js"
|
|
INTEGRATION_URL = f"/{LOCAL_SUBDIR}/{CARD_NAME}"
|
|
CARD_REGISTERED = "registered"
|
|
|
|
# Full-screen panel constants
|
|
PANEL_JS_NAME = "ha-washdata-panel.js"
|
|
PANEL_JS_URL = f"/{LOCAL_SUBDIR}/{PANEL_JS_NAME}"
|
|
PANEL_ELEMENT = "ha-washdata-panel"
|
|
PANEL_URL_PATH = "ha-washdata"
|
|
PANEL_REGISTERED_KEY = "ha_washdata_panel_registered"
|
|
# Per-language panel translations are served straight from the integration's
|
|
# translations/panel/ directory (one {lang}.json per language). The panel fetches
|
|
# only the user's language + en fallback, instead of one monolithic bundle.
|
|
PANEL_TRANSLATIONS_DIRNAME = "panel"
|
|
PANEL_TRANSLATIONS_URL = f"/{LOCAL_SUBDIR}/panel-translations"
|
|
BRAND_ICON_URL = f"/{LOCAL_SUBDIR}/icon.png"
|
|
# Set in hass.data after _do_register_panel confirms the icon file exists and was
|
|
# served; ws_get_constants reads it so it never advertises an unreachable URL.
|
|
BRAND_ICON_REGISTERED_KEY = "ha_washdata_brand_icon_registered"
|
|
# Single task in hass.data that covers the entire panel-registration lifecycle
|
|
# (static paths + sidebar). Concurrent setup_entry calls share it; teardown
|
|
# cancels it before clearing state.
|
|
PANEL_TASK_KEY = "ha_washdata_panel_task"
|
|
CARD_DEFERRED = "deferred"
|
|
CARD_FAILED = "failed"
|
|
CardRegisterResult = Literal["registered", "deferred", "failed"]
|
|
|
|
|
|
class LovelaceResourceItem(TypedDict, total=False):
|
|
"""Known lovelace resource item shape used by this integration."""
|
|
|
|
id: str
|
|
url: str
|
|
res_type: str
|
|
|
|
|
|
def get_cache_buster(filename: str = CARD_NAME) -> str:
|
|
"""Generate a stable cache buster based on a www asset's mtime.
|
|
|
|
Also considers the translations/panel/ directory mtime so that
|
|
translation-only releases (e.g. GitLocalize merges) still bust the
|
|
browser cache for both the panel JS and the per-language JSON files.
|
|
"""
|
|
try:
|
|
base = Path(__file__).parent
|
|
src_mtime = os.path.getmtime(base / "www" / filename)
|
|
try:
|
|
panel_dir = base / "translations" / "panel"
|
|
trans_mtime = max(
|
|
(os.path.getmtime(f) for f in panel_dir.iterdir() if f.is_file()),
|
|
default=0.0,
|
|
)
|
|
except OSError:
|
|
trans_mtime = 0.0
|
|
return str(int(max(src_mtime, trans_mtime)))
|
|
except OSError:
|
|
# Deterministic fallback when file is unavailable.
|
|
return "1"
|
|
|
|
|
|
def _register_static_path(hass: HomeAssistant, url_path: str, path: str) -> None:
|
|
"""Register a static path with the HA HTTP component, compatible with multiple HA versions."""
|
|
try:
|
|
# pylint: disable=import-outside-toplevel
|
|
from homeassistant.components.http import StaticPathConfig
|
|
|
|
if hasattr(hass.http, "async_register_static_paths"):
|
|
|
|
async def _safe_register():
|
|
try:
|
|
await hass.http.async_register_static_paths(
|
|
[StaticPathConfig(url_path, path, True)]
|
|
)
|
|
except Exception as exc: # pylint: disable=broad-exception-caught
|
|
_LOGGER.debug(
|
|
"Failed to async register static path %s -> %s: %s",
|
|
url_path,
|
|
path,
|
|
exc,
|
|
)
|
|
|
|
hass.async_create_task(_safe_register())
|
|
return
|
|
except Exception as exc: # pylint: disable=broad-exception-caught
|
|
_LOGGER.debug(
|
|
"Async static path registration not available; falling back to "
|
|
"sync registration for %s -> %s (%s)",
|
|
url_path,
|
|
path,
|
|
exc,
|
|
)
|
|
|
|
# Fallback for older HA
|
|
try:
|
|
http_obj = cast(Any, hass.http)
|
|
register_static_path = getattr(http_obj, "register_static_path", None)
|
|
if callable(register_static_path):
|
|
register_static_path(url_path, path, cache_headers=True)
|
|
except Exception: # pylint: disable=broad-exception-caught
|
|
_LOGGER.debug("Failed to register static path %s -> %s", url_path, path)
|
|
|
|
|
|
async def _init_resource(hass: HomeAssistant, url: str, ver: str) -> bool:
|
|
"""Safely add or update a Lovelace resource for the given URL."""
|
|
try:
|
|
# pylint: disable=import-outside-toplevel
|
|
from homeassistant.components.frontend import add_extra_js_url
|
|
from homeassistant.components.lovelace.resources import (
|
|
ResourceStorageCollection,
|
|
)
|
|
except Exception: # pylint: disable=broad-exception-caught
|
|
_LOGGER.debug(
|
|
"Lovelace resource helpers unavailable; skipping auto resource init"
|
|
)
|
|
return False
|
|
|
|
lovelace = hass.data.get("lovelace")
|
|
if not lovelace:
|
|
_LOGGER.debug("Lovelace storage not available; skipping auto resource init")
|
|
return False
|
|
|
|
resources = (
|
|
lovelace.resources if hasattr(lovelace, "resources") else lovelace["resources"]
|
|
)
|
|
|
|
url2 = f"{url}?v={ver}"
|
|
|
|
if not isinstance(resources, ResourceStorageCollection):
|
|
_LOGGER.debug("Add extra JS module (non-storage): %s", url2)
|
|
add_extra_js_url(hass, url2)
|
|
return True
|
|
|
|
resources_obj = resources
|
|
await resources_obj.async_get_info()
|
|
|
|
for raw_item in resources_obj.async_items():
|
|
if not isinstance(raw_item, dict):
|
|
continue
|
|
|
|
item = cast(LovelaceResourceItem, raw_item)
|
|
item_url = item.get("url")
|
|
if not isinstance(item_url, str) or not item_url.startswith(url):
|
|
continue
|
|
|
|
if item_url == url2 and item.get("res_type") == "module":
|
|
return True
|
|
|
|
item_id = item.get("id")
|
|
if not isinstance(item_id, str):
|
|
continue
|
|
|
|
_LOGGER.debug("Update lovelace resource to: %s", url2)
|
|
await resources_obj.async_update_item(
|
|
item_id, {"res_type": "module", "url": url2}
|
|
)
|
|
|
|
return True
|
|
|
|
_LOGGER.debug("Add new lovelace resource: %s", url2)
|
|
await resources_obj.async_create_item({"res_type": "module", "url": url2})
|
|
|
|
return True
|
|
|
|
|
|
class WashDataCardRegistration:
|
|
"""Serve ha-washdata-card.js from the integration package."""
|
|
|
|
def __init__(self, hass: HomeAssistant) -> None:
|
|
self.hass = hass
|
|
|
|
def _src_path(self) -> Path:
|
|
return Path(__file__).parent / "www" / CARD_NAME
|
|
|
|
async def async_register(self) -> CardRegisterResult:
|
|
"""Register card assets/resources and report registration outcome."""
|
|
src = self._src_path()
|
|
if not await self.hass.async_add_executor_job(src.exists):
|
|
_LOGGER.warning("Card file not found: %s", src)
|
|
return CARD_FAILED
|
|
|
|
_register_static_path(self.hass, INTEGRATION_URL, str(src))
|
|
|
|
version = await self.hass.async_add_executor_job(get_cache_buster)
|
|
|
|
# Try auto-registration of the lovelace resource
|
|
# If lovelace is not yet loaded, wait for it
|
|
if not self.hass.data.get("lovelace"):
|
|
_LOGGER.debug("Lovelace not loaded yet; waiting for component loaded event")
|
|
|
|
unsubscribe_on_lovelace_loaded: Any = None
|
|
|
|
async def _on_lovelace_loaded(event: Event) -> None:
|
|
if event.data.get("component") == "lovelace":
|
|
_LOGGER.debug(
|
|
"Lovelace component loaded; retrying resource registration"
|
|
)
|
|
if unsubscribe_on_lovelace_loaded:
|
|
unsubscribe_on_lovelace_loaded()
|
|
try:
|
|
if await _init_resource(self.hass, INTEGRATION_URL, version):
|
|
self.hass.data["ha_washdata_card_registered"] = True
|
|
self.hass.data["ha_washdata_card_deferred"] = False
|
|
else:
|
|
self.hass.data["ha_washdata_card_deferred"] = False
|
|
except Exception: # pylint: disable=broad-exception-caught
|
|
self.hass.data["ha_washdata_card_deferred"] = False
|
|
_LOGGER.debug(
|
|
"Delayed auto-registration of lovelace resource failed for %s",
|
|
INTEGRATION_URL,
|
|
)
|
|
|
|
unsubscribe_on_lovelace_loaded = self.hass.bus.async_listen(EVENT_COMPONENT_LOADED, _on_lovelace_loaded)
|
|
|
|
# Re-check in case lovelace loaded between the initial check and listener registration.
|
|
if self.hass.data.get("lovelace"):
|
|
unsubscribe_on_lovelace_loaded()
|
|
_LOGGER.debug("Lovelace already loaded after deferred listener; registering now")
|
|
try:
|
|
if await _init_resource(self.hass, INTEGRATION_URL, version):
|
|
self.hass.data["ha_washdata_card_registered"] = True
|
|
self.hass.data["ha_washdata_card_deferred"] = False
|
|
return CARD_REGISTERED
|
|
self.hass.data["ha_washdata_card_deferred"] = False
|
|
return CARD_FAILED
|
|
except Exception: # pylint: disable=broad-exception-caught
|
|
self.hass.data["ha_washdata_card_deferred"] = False
|
|
return CARD_FAILED
|
|
|
|
return CARD_DEFERRED
|
|
|
|
# Lovelace is already loaded
|
|
try:
|
|
registered = await _init_resource(self.hass, INTEGRATION_URL, version)
|
|
except Exception as err: # pylint: disable=broad-exception-caught
|
|
_LOGGER.debug(
|
|
"Auto-registration of lovelace resource failed for %s: %s",
|
|
INTEGRATION_URL,
|
|
err,
|
|
)
|
|
return CARD_FAILED
|
|
|
|
if registered:
|
|
_LOGGER.debug("Auto-registered lovelace resource for %s", INTEGRATION_URL)
|
|
return CARD_REGISTERED
|
|
return CARD_FAILED
|
|
|
|
|
|
async def _async_register_path(hass: HomeAssistant, url_path: str, path: str) -> None:
|
|
"""Register one static path.
|
|
|
|
Uses the modern ``async_register_static_paths`` API when available. Falls
|
|
back to the legacy sync helper only when that API is absent — not on a
|
|
genuine registration failure. An already-registered path is treated as
|
|
success (benign on integration reload); any other exception propagates so
|
|
the caller can decide whether to report failure.
|
|
"""
|
|
try:
|
|
from homeassistant.components.http import StaticPathConfig # pylint: disable=import-outside-toplevel
|
|
except ImportError:
|
|
_register_static_path(hass, url_path, path)
|
|
return
|
|
|
|
if not hasattr(hass.http, "async_register_static_paths"):
|
|
_register_static_path(hass, url_path, path)
|
|
return
|
|
|
|
try:
|
|
await hass.http.async_register_static_paths(
|
|
[StaticPathConfig(url_path, path, cache_headers=True)]
|
|
)
|
|
except Exception as exc: # pylint: disable=broad-exception-caught
|
|
if "already" in str(exc).lower():
|
|
_LOGGER.debug("Static path already registered (ok): %s", url_path)
|
|
return
|
|
raise
|
|
|
|
|
|
async def _do_register_panel(hass: HomeAssistant, src: Path) -> bool:
|
|
"""Register all static paths AND the sidebar panel as one atomic operation.
|
|
|
|
Covers the full panel-registration lifecycle so concurrent callers awaiting
|
|
the same shared task are all serialized through both phases.
|
|
"""
|
|
# ── Phase 1: static paths ────────────────────────────────────────────────
|
|
try:
|
|
# Panel JS (primary asset — must be available before the sidebar fires).
|
|
await _async_register_path(hass, PANEL_JS_URL, str(src))
|
|
|
|
# Per-language translation files.
|
|
trans_src = Path(__file__).parent / "translations" / PANEL_TRANSLATIONS_DIRNAME
|
|
if await hass.async_add_executor_job(trans_src.is_dir):
|
|
await _async_register_path(hass, PANEL_TRANSLATIONS_URL, str(trans_src))
|
|
|
|
# Brand icon (panel header). Track registration so ws_get_constants
|
|
# never advertises an unreachable URL.
|
|
icon_src = Path(__file__).parent / "brand" / "icon.png"
|
|
if await hass.async_add_executor_job(icon_src.is_file):
|
|
await _async_register_path(hass, BRAND_ICON_URL, str(icon_src))
|
|
hass.data[BRAND_ICON_REGISTERED_KEY] = True
|
|
else:
|
|
hass.data[BRAND_ICON_REGISTERED_KEY] = False
|
|
|
|
except Exception as exc: # pylint: disable=broad-exception-caught
|
|
_LOGGER.warning("WashData panel static path registration failed: %s", exc)
|
|
return False
|
|
|
|
# ── Phase 2: sidebar panel ───────────────────────────────────────────────
|
|
try:
|
|
from homeassistant.components import frontend # pylint: disable=import-outside-toplevel
|
|
|
|
# Cache-buster query so browsers refetch the module after each update
|
|
# while still honoring immutable cache headers between releases.
|
|
# get_cache_buster() calls os.path.getmtime(), a synchronous FS stat, so
|
|
# offload it to the executor rather than blocking the event loop.
|
|
panel_version = await hass.async_add_executor_job(
|
|
get_cache_buster, PANEL_JS_NAME
|
|
)
|
|
|
|
# HA's ha-panel-custom.ts reads panel.config._panel_custom for the
|
|
# loading parameters (name, module_url, etc.). Flat config keys at the
|
|
# top level are NOT read by the frontend — only _panel_custom is.
|
|
# This matches what panel_custom.async_register_panel() produces.
|
|
frontend.async_register_built_in_panel(
|
|
hass,
|
|
component_name="custom",
|
|
sidebar_title="WashData",
|
|
sidebar_icon="mdi:washing-machine",
|
|
frontend_url_path=PANEL_URL_PATH,
|
|
config={
|
|
"_panel_custom": {
|
|
"name": PANEL_ELEMENT,
|
|
"module_url": f"{PANEL_JS_URL}?v={panel_version}",
|
|
"embed_iframe": False,
|
|
"trust_external": False,
|
|
}
|
|
},
|
|
require_admin=False,
|
|
)
|
|
hass.data[PANEL_REGISTERED_KEY] = True
|
|
_LOGGER.debug("WashData sidebar panel registered at /%s", PANEL_URL_PATH)
|
|
return True
|
|
except Exception as exc: # pylint: disable=broad-exception-caught
|
|
_LOGGER.warning("Failed to register WashData panel: %s", exc)
|
|
return False
|
|
|
|
|
|
async def async_register_panel(hass: HomeAssistant) -> bool:
|
|
"""Serve ha-washdata-panel.js and register a sidebar panel with Home Assistant.
|
|
|
|
Safe to call on every integration setup; subsequent calls are no-ops once
|
|
hass.data[PANEL_REGISTERED_KEY] is set. Returns True on success.
|
|
"""
|
|
if hass.data.get(PANEL_REGISTERED_KEY):
|
|
return True
|
|
|
|
src = Path(__file__).parent / "www" / PANEL_JS_NAME
|
|
# Path.exists() hits the filesystem; offload it so the event loop is not
|
|
# blocked on I/O during setup.
|
|
if not await hass.async_add_executor_job(src.exists):
|
|
_LOGGER.warning("Panel JS not found at %s — sidebar panel not registered", src)
|
|
return False
|
|
|
|
# Coalesce concurrent setup_entry calls (multiple WashData devices) onto a
|
|
# single shared Task covering the full registration lifecycle (static paths
|
|
# + sidebar). The first caller creates and stores the task; all callers —
|
|
# including the first — await it. A completed task re-awaited returns its
|
|
# result immediately, so later callers (e.g. a second device added after
|
|
# boot) are fast no-ops.
|
|
if PANEL_TASK_KEY not in hass.data:
|
|
hass.data[PANEL_TASK_KEY] = hass.async_create_task(
|
|
_do_register_panel(hass, src)
|
|
)
|
|
|
|
task = hass.data[PANEL_TASK_KEY]
|
|
try:
|
|
result = bool(await asyncio.shield(task))
|
|
if not result and hass.data.get(PANEL_TASK_KEY) is task:
|
|
# Task completed but registration failed; clear so a later
|
|
# setup_entry can create a fresh task and retry.
|
|
hass.data.pop(PANEL_TASK_KEY, None)
|
|
return result
|
|
except asyncio.CancelledError:
|
|
# Re-raise when this caller was cancelled so HA setup propagates correctly;
|
|
# otherwise a CancelledError came from the shielded inner task (not us),
|
|
# and we return False so other callers remain unaffected.
|
|
if asyncio.current_task().cancelling():
|
|
raise
|
|
return False
|
|
except Exception as exc: # pylint: disable=broad-exception-caught
|
|
_LOGGER.warning("WashData panel registration failed: %s", exc)
|
|
if hass.data.get(PANEL_TASK_KEY) is task:
|
|
hass.data.pop(PANEL_TASK_KEY, None)
|
|
return False
|
|
|
|
|
|
async def async_unregister_panel(hass: HomeAssistant) -> None:
|
|
"""Tear down the WashData sidebar panel and its static routes.
|
|
|
|
Integration teardown counterpart to :func:`async_register_panel`. Intended to
|
|
be called from ``async_unload_entry`` when the *final* WashData config entry
|
|
is removed, so no stale panel registration, sidebar entry, or static route is
|
|
left behind. Cancels any in-flight registration task before clearing state
|
|
to prevent registration completing after teardown. After clearing the guards
|
|
a later setup revalidates the assets and registers the panel + routes again.
|
|
"""
|
|
if not hass.data.get(PANEL_REGISTERED_KEY) and PANEL_TASK_KEY not in hass.data:
|
|
return
|
|
|
|
# Cancel and drain any in-flight registration task so it cannot complete
|
|
# and set PANEL_REGISTERED_KEY after we clear it below.
|
|
task: asyncio.Task[bool] | None = hass.data.pop(PANEL_TASK_KEY, None)
|
|
if task is not None and not task.done():
|
|
task.cancel()
|
|
try:
|
|
await task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
except Exception as exc: # pylint: disable=broad-exception-caught
|
|
_LOGGER.debug("WashData panel task ended with error during teardown: %s", exc)
|
|
|
|
# Remove the sidebar panel using Home Assistant's supported API.
|
|
if hass.data.get(PANEL_REGISTERED_KEY):
|
|
try:
|
|
from homeassistant.components import frontend # pylint: disable=import-outside-toplevel
|
|
|
|
frontend.async_remove_panel(hass, PANEL_URL_PATH)
|
|
_LOGGER.debug("WashData sidebar panel removed from /%s", PANEL_URL_PATH)
|
|
except Exception as exc: # pylint: disable=broad-exception-caught
|
|
_LOGGER.debug("Failed to remove WashData panel: %s", exc)
|
|
|
|
# Home Assistant exposes no public API to unregister a previously registered
|
|
# static path; clearing the guards lets a later setup revalidate the assets
|
|
# and re-register the routes (a benign "already registered" on the next
|
|
# setup is handled by _async_register_path) rather than leaving stale flags.
|
|
hass.data.pop(PANEL_REGISTERED_KEY, None)
|
|
hass.data.pop(BRAND_ICON_REGISTERED_KEY, None)
|