New apps Added
This commit is contained in:
@@ -0,0 +1,431 @@
|
||||
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
|
||||
Reference in New Issue
Block a user