Files
HomeAssistantVS/custom_components/album_slideshow/__init__.py
T
2026-07-08 10:43:39 -04:00

432 lines
16 KiB
Python

from __future__ import annotations
import asyncio
import json
import logging
import os
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from .const import DOMAIN, SERVICE_NEXT_SLIDE, SERVICE_REFRESH_ALBUM, ATTR_ENTRY_ID
from .store import SlideshowStore
_LOGGER = logging.getLogger(__name__)
PLATFORMS: list[str] = ["camera", "sensor", "button", "number", "select", "text", "switch"]
CARD_STATIC_PATH = "/album_slideshow_static"
CARD_FILE = "album-slideshow-card.js"
async def _async_register_card(hass: HomeAssistant) -> None:
"""Serve the Lovelace card JS and register it as a frontend module.
Idempotent: only the first config entry to load triggers registration
for the HA session. The card lets dashboards cross-fade between slides
in the browser (GPU compositor) instead of forcing the camera entity
to render a JPEG burst on the event loop.
"""
if hass.data.get(DOMAIN, {}).get("card_registered"):
return
integration_dir = os.path.dirname(__file__)
www_dir = os.path.join(integration_dir, "www")
card_path = os.path.join(www_dir, CARD_FILE)
if not os.path.isfile(card_path):
# Some HACS upgrade paths (and broken zip extractors) drop the
# ``www/`` subdirectory. Try to recover by checking whether the
# integration root has the file under a literal-backslash name
# (a symptom of zips written with Windows path separators) or
# directly at the root, and salvage it into ``www/`` so the
# rest of the registration can proceed.
recovered = await hass.async_add_executor_job(
_recover_card_from_root, integration_dir, www_dir, card_path
)
if not recovered:
_LOGGER.warning(
"Album Slideshow card missing on disk (%s). Re-install"
" the integration via HACS (3-dot menu -> Redownload)"
" or copy %s/%s into the album_slideshow folder."
" The custom:album-slideshow-card type will not be"
" available until this is fixed.",
card_path,
"www",
CARD_FILE,
)
return
try:
from homeassistant.components.http import StaticPathConfig
await hass.http.async_register_static_paths(
[StaticPathConfig(CARD_STATIC_PATH, www_dir, False)]
)
except Exception: # noqa: BLE001 - many possible failure modes here
_LOGGER.exception(
"Failed to register static path for Album Slideshow card"
)
return
# Cache-bust the card URL with the integration version so dashboards
# always pick up the script that matches the running integration
# rather than a stale copy from a previous release.
version = await hass.async_add_executor_job(
_read_manifest_version, integration_dir
)
card_url = f"{CARD_STATIC_PATH}/{CARD_FILE}"
if version:
card_url = f"{card_url}?v={version}"
# Prefer registering the card as a Lovelace resource for storage-mode
# dashboards. Resources are loaded as part of the Lovelace bootstrap,
# before any dashboard renders custom cards, which removes the race
# where the dashboard can hit "Custom element doesn't exist" if the
# browser hasn't finished loading the module yet (a real risk after
# HA restarts and integration upgrades that bust the cache).
#
# The storage-mode resources collection is only consumed by
# storage-mode dashboards; YAML-mode dashboards (whether the user
# has ``lovelace.mode: yaml`` globally or per-dashboard
# ``mode: yaml`` entries) read only the resources declared in their
# own YAML and would otherwise never load the card. That's why we
# *always* also call ``add_extra_js_url``: the frontend injects a
# ``<script>`` tag on every Lovelace render regardless of mode, so
# it's the universal fallback. Module loads are deduplicated by URL
# and the card's ``customElements.define()`` calls have an
# idempotency guard, so a storage-mode dashboard picking up both
# paths is a harmless no-op.
resource_registered = await _try_register_lovelace_resource(hass, card_url)
if resource_registered:
hass.data.setdefault(DOMAIN, {})[
"lovelace_resource_registered"
] = True
try:
from homeassistant.components.frontend import add_extra_js_url
add_extra_js_url(hass, card_url)
except Exception: # noqa: BLE001
_LOGGER.exception(
"Failed to add Album Slideshow card via add_extra_js_url"
" (URL %s); the card may still load if a Lovelace resource"
" was registered above.",
card_url,
)
hass.data.setdefault(DOMAIN, {})["card_registered"] = True
if resource_registered:
_LOGGER.info(
"Album Slideshow card registered (Lovelace resource +"
" add_extra_js_url) at %s",
card_url,
)
else:
_LOGGER.info(
"Album Slideshow card registered via add_extra_js_url at"
" %s; Lovelace resources collection was not available so"
" no resource entry was created.",
card_url,
)
async def _try_register_lovelace_resource(
hass: HomeAssistant, card_url: str
) -> bool:
"""Register the card as a Lovelace resource for storage-mode dashboards.
Returns ``True`` if the resource was added (or already present at the
requested version); ``False`` if Lovelace isn't loaded, the dashboard
is in YAML mode, or anything else went wrong - in which case the
caller is expected to fall back to ``add_extra_js_url``.
The resource collection API is internal to HA and has changed shape
between versions, so we feel our way through it with ``getattr`` and
swallow any unexpected exception. The downside of getting this wrong
is one extra script tag in the dashboard, not a crash.
"""
try:
lovelace_data = hass.data.get("lovelace")
if lovelace_data is None:
_LOGGER.debug(
"Lovelace data not yet present; cannot register resource"
)
return False
# In recent HA the lovelace key is a LovelaceData object exposing
# ``resources``; in older versions it was a dict with the same key.
if isinstance(lovelace_data, dict):
resources = lovelace_data.get("resources")
else:
resources = getattr(lovelace_data, "resources", None)
if resources is None or not hasattr(resources, "async_create_item"):
# YAML-mode dashboards expose a ResourceYAMLCollection that is
# read-only; users edit ``configuration.yaml`` themselves.
_LOGGER.debug(
"Lovelace resources unavailable or read-only;"
" falling back to add_extra_js_url"
)
return False
# Make sure the storage collection has loaded its file. Some HA
# versions lazy-load on first ``async_items()`` access; calling
# ``async_load`` explicitly is safe either way.
if hasattr(resources, "async_load"):
try:
await resources.async_load()
except Exception: # noqa: BLE001
# Storage collection may be in an unloaded state with no
# file yet - treated the same as "no items".
pass
items = []
if hasattr(resources, "async_items"):
try:
items = list(resources.async_items())
except Exception: # noqa: BLE001
items = []
base = card_url.split("?", 1)[0]
same_version_present = False
stale_ids: list[str] = []
for item in items:
if isinstance(item, dict):
url = item.get("url", "") or ""
item_id = item.get("id")
else:
url = getattr(item, "url", "") or ""
item_id = getattr(item, "id", None)
if url.split("?", 1)[0] != base:
continue
if url == card_url:
same_version_present = True
elif item_id:
stale_ids.append(item_id)
# Strip stale registrations (older versions of the card pinned via
# an out-of-date ``?v=...`` query) so the dashboard doesn't pull
# both the new and the old script.
for item_id in stale_ids:
try:
await resources.async_delete_item(item_id)
except Exception: # noqa: BLE001
_LOGGER.debug(
"Could not delete stale Lovelace resource id=%s",
item_id,
exc_info=True,
)
if not same_version_present:
await resources.async_create_item(
{"url": card_url, "res_type": "module"}
)
return True
except Exception: # noqa: BLE001
_LOGGER.debug(
"Lovelace resource registration failed; will fall back to"
" add_extra_js_url",
exc_info=True,
)
return False
def _read_manifest_version(integration_dir: str) -> str | None:
try:
with open(
os.path.join(integration_dir, "manifest.json"),
"r",
encoding="utf-8",
) as fh:
return json.load(fh).get("version")
except Exception: # noqa: BLE001
return None
def _recover_card_from_root(
integration_dir: str, www_dir: str, card_path: str
) -> bool:
"""Salvage the card file from a broken extraction.
PowerShell's ``Compress-Archive`` writes zip entries with backslash
separators, which Linux unzip implementations may treat as literal
filenames. The resulting layout is::
custom_components/album_slideshow/www\\album-slideshow-card.js
instead of the expected ``www/album-slideshow-card.js``. Move it to
the right place so subsequent installs don't need a re-download.
"""
candidates = [
os.path.join(integration_dir, f"www\\{CARD_FILE}"),
os.path.join(integration_dir, CARD_FILE),
]
for src in candidates:
if os.path.isfile(src):
try:
os.makedirs(www_dir, exist_ok=True)
os.replace(src, card_path)
_LOGGER.info(
"Recovered Album Slideshow card from %s", src
)
return True
except OSError:
_LOGGER.exception(
"Found candidate card at %s but could not move it"
" to %s",
src,
card_path,
)
return False
return False
async def _async_cleanup_legacy_entities(hass: HomeAssistant, entry: ConfigEntry) -> None:
registry = er.async_get(hass)
# Server-side transitions were tried in earlier 0.7-rc builds and
# removed before the first public 0.7 pre-release because the
# resource cost on low-end hardware was too high. Drop any leftover
# transition entities so users don't see stale disabled rows under
# the device.
legacy_unique_ids = {
f"{entry.entry_id}_max_items",
f"{entry.entry_id}_transition",
f"{entry.entry_id}_transition_duration_ms",
f"{entry.entry_id}_transition_fps",
}
for entity in er.async_entries_for_config_entry(registry, entry.entry_id):
if entity.unique_id in legacy_unique_ids:
registry.async_remove(entity.entity_id)
async def async_setup(hass: HomeAssistant, config: dict) -> bool:
"""Register the Lovelace card during HA bootstrap.
Running this in ``async_setup`` (not only in ``async_setup_entry``)
registers the card's static path and frontend resource at
integration load time, well before any config entry finishes
setting up. The earlier the resource is registered, the smaller
the window in which a dashboard can render a card before the
script's ``customElements.define()`` has run, which is what
produces the intermittent "Custom element doesn't exist:
album-slideshow-card" errors on slow devices (tablets) that open
the dashboard during HA startup.
We also retry the Lovelace storage-resource registration once HA
finishes starting, because ``hass.data['lovelace']`` may not yet
be populated when ``async_setup`` runs. The storage-resource path
is the only mechanism that *gates* dashboard render on resource
load (``add_extra_js_url`` just injects a script tag with no
ordering guarantee), so we want it to succeed even if we beat
Lovelace to the punch on the first attempt.
"""
hass.data.setdefault(DOMAIN, {})
await _async_register_card(hass)
async def _retry_lovelace_resource(_event) -> None:
if hass.data.get(DOMAIN, {}).get("lovelace_resource_registered"):
return
integration_dir = os.path.dirname(__file__)
version = await hass.async_add_executor_job(
_read_manifest_version, integration_dir
)
card_url = f"{CARD_STATIC_PATH}/{CARD_FILE}"
if version:
card_url = f"{card_url}?v={version}"
if await _try_register_lovelace_resource(hass, card_url):
hass.data.setdefault(DOMAIN, {})[
"lovelace_resource_registered"
] = True
_LOGGER.info(
"Album Slideshow card registered as Lovelace resource"
" on HA started (late retry; Lovelace was not yet"
" ready during integration setup)"
)
hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_STARTED, _retry_lovelace_resource
)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
from .coordinator import AlbumCoordinator
hass.data.setdefault(DOMAIN, {})
# Domain-wide concurrency limit on compose work. Multiple album cameras
# share HA's small executor pool; without coordination they can all
# decode + render in parallel and saturate the loop. One ticket means
# at most one album does PIL work at a time, queueing the rest.
if "compose_semaphore" not in hass.data[DOMAIN]:
hass.data[DOMAIN]["compose_semaphore"] = asyncio.Semaphore(1)
# Register the Lovelace card once per HA session. The card runs the
# GPU-composited transitions in the browser so the camera entity
# itself never has to render a transition burst on the event loop.
await _async_register_card(hass)
await _async_cleanup_legacy_entities(hass, entry)
store = SlideshowStore()
coordinator = AlbumCoordinator(hass, entry, store)
await coordinator.async_config_entry_first_refresh()
hass.data[DOMAIN][entry.entry_id] = {
"coordinator": coordinator,
"store": store,
"camera": None,
}
async def _next_slide(call) -> None:
entry_id = call.data.get(ATTR_ENTRY_ID)
if not entry_id:
return
data = hass.data.get(DOMAIN, {}).get(entry_id)
if not data:
return
cam = data.get("camera")
if cam:
await cam.async_force_next()
async def _refresh_album(call) -> None:
entry_id = call.data.get(ATTR_ENTRY_ID)
if not entry_id:
return
data = hass.data.get(DOMAIN, {}).get(entry_id)
if not data:
return
cam = data.get("camera")
if cam:
await cam.async_force_refresh()
if not hass.services.has_service(DOMAIN, SERVICE_NEXT_SLIDE):
hass.services.async_register(DOMAIN, SERVICE_NEXT_SLIDE, _next_slide)
if not hass.services.has_service(DOMAIN, SERVICE_REFRESH_ALBUM):
hass.services.async_register(DOMAIN, SERVICE_REFRESH_ALBUM, _refresh_album)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
store.notify()
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
domain_data = hass.data.get(DOMAIN, {})
domain_data.pop(entry.entry_id, None)
# Drop the shared semaphore once the last album is gone so it's
# re-created if the integration is re-added later.
entry_keys = [
k for k in domain_data.keys() if k != "compose_semaphore"
]
if not entry_keys:
domain_data.pop("compose_semaphore", None)
return unload_ok