from __future__ import annotations import hashlib import logging import shutil from pathlib import Path from uuid import uuid4 from homeassistant.core import HomeAssistant # type: ignore[reportMissingImports] from homeassistant.helpers.storage import Store # type: ignore[reportMissingImports] from homeassistant.helpers.event import async_call_later # type: ignore[reportMissingImports] _LOGGER = logging.getLogger(__name__) # The JS file shipped with this integration under ./www/ JS_FILENAME = "thermostat-pro-timeline.js" # Where Home Assistant serves files from config/www/ as /local/ RESOURCE_URL = f"/local/{JS_FILENAME}" RESOURCE_TYPE = "module" # modern custom cards should be loaded as ES modules def _strip_query(url: str) -> str: try: return str(url).split("?", 1)[0] except Exception: return str(url) def _token_from_hash(sha_hex: str) -> str: """Make a stable, numeric-ish token (similar vibe to HACS hacstag).""" try: # 48-bit is plenty; keeps token reasonably short and numeric. return str(int(sha_hex[:12], 16)) except Exception: return sha_hex[:12] or "0" def _get_lovelace_resources(hass: HomeAssistant): """Return the Lovelace ResourceStorageCollection when in storage mode, else None.""" try: lovelace_data = hass.data.get("lovelace") if lovelace_data is None: return None resources = None if hasattr(lovelace_data, "resources"): resources = lovelace_data.resources elif isinstance(lovelace_data, dict): resources = lovelace_data.get("resources") if resources is None: return None # YAML mode: store is missing/None. if not hasattr(resources, "store") or resources.store is None: return None # Basic capability check. for attr in ("async_items", "async_create_item", "async_update_item"): if not hasattr(resources, attr): return None return resources except Exception: return None def _sha256_sync(path: Path) -> str: h = hashlib.sha256() with path.open("rb") as f: for chunk in iter(lambda: f.read(1024 * 256), b""): h.update(chunk) return h.hexdigest() async def _sha256(hass: HomeAssistant, path: Path) -> str: return await hass.async_add_executor_job(_sha256_sync, path) async def ensure_frontend(hass: HomeAssistant) -> None: """Best-effort: 1) Copy bundled JS from the integration package to /config/www/ 2) Register the JS as a Lovelace resource (storage mode) This is intentionally best-effort: failures must not block the integration (and must not break config flow loading). """ try: await _ensure_js_in_www(hass) except Exception: _LOGGER.debug("ensure_frontend: failed copying JS to /config/www", exc_info=True) try: await _ensure_lovelace_resource_retry(hass, attempts_left=6) except Exception: _LOGGER.debug("ensure_frontend: failed registering Lovelace resource", exc_info=True) async def _ensure_lovelace_resource_retry(hass: HomeAssistant, attempts_left: int) -> None: """Retry resource registration if Lovelace isn't fully ready yet.""" if attempts_left <= 0: return resources = _get_lovelace_resources(hass) if resources is None: async_call_later( hass, 5, lambda _now: hass.async_create_task( _ensure_lovelace_resource_retry(hass, attempts_left - 1) ), ) return await _ensure_lovelace_resource(hass) async def _ensure_js_in_www(hass: HomeAssistant) -> None: src = Path(__file__).parent / "www" / JS_FILENAME dst = Path(hass.config.path("www", JS_FILENAME)) if not src.exists(): # If the file isn't shipped, do nothing. _LOGGER.debug("ensure_frontend: bundled JS not found at %s", src) return dst.parent.mkdir(parents=True, exist_ok=True) # Copy only if missing or changed if dst.exists(): try: if await _sha256(hass, src) == await _sha256(hass, dst): return except Exception: # If hashing fails for any reason, overwrite. pass await hass.async_add_executor_job(shutil.copyfile, src, dst) async def _ensure_lovelace_resource(hass: HomeAssistant) -> None: """Add/update /local/... as a Lovelace resource (storage mode) with cache-busting.""" # Prefer HA's resource collection API (what the UI uses). resources = _get_lovelace_resources(hass) if resources is not None: if not getattr(resources, "loaded", True): await resources.async_load() deployed = Path(hass.config.path("www", JS_FILENAME)) token = "0" if deployed.exists(): try: token = _token_from_hash(await _sha256(hass, deployed)) except Exception: token = "0" url = f"{RESOURCE_URL}?v={token}" matches = [] for entry in resources.async_items(): try: if _strip_query(entry.get("url", "")) == RESOURCE_URL: matches.append(entry) except Exception: continue if matches: entry = matches[0] entry_url = str(entry.get("url", "")) if entry_url != url or entry.get("res_type") != RESOURCE_TYPE: await resources.async_update_item( entry["id"], {"res_type": RESOURCE_TYPE, "url": url}, ) _LOGGER.info("Updated Lovelace resource: %s", url) # Remove duplicates (keep first) for dup in matches[1:]: try: await resources.async_delete_item(dup["id"]) except Exception: pass return await resources.async_create_item({"res_type": RESOURCE_TYPE, "url": url}) _LOGGER.info("Added Lovelace resource: %s", url) return # Fallback: older setups / YAML mode. This may not show up in the UI, but keep it # as a best-effort compatibility path. try: from homeassistant.components.lovelace.resources import ( # type: ignore[reportMissingImports] STORAGE_KEY, STORAGE_VERSION, ) store = Store(hass, STORAGE_VERSION, STORAGE_KEY) except Exception: store = Store(hass, 1, "lovelace.resources") resources = await store.async_load() if resources is None: resources = [] # Some older / custom setups could theoretically store dicts; keep it safe. if isinstance(resources, dict): resources = resources.get("resources", []) deployed = Path(hass.config.path("www", JS_FILENAME)) token = "0" if deployed.exists(): try: token = _token_from_hash(await _sha256(hass, deployed)) except Exception: token = "0" url = f"{RESOURCE_URL}?v={token}" for r in resources: if isinstance(r, dict) and _strip_query(r.get("url", "")) == RESOURCE_URL: if r.get("url") != url or r.get("type") != RESOURCE_TYPE: r["url"] = url r["type"] = RESOURCE_TYPE await store.async_save(resources) _LOGGER.info("Updated Lovelace resource (fallback): %s", url) return resources.append( { "id": str(uuid4()), "type": RESOURCE_TYPE, "url": url, } ) await store.async_save(resources) _LOGGER.info("Added Lovelace resource (fallback): %s", url)