New apps Added

This commit is contained in:
2026-07-08 10:43:39 -04:00
parent 3b1f4bbd75
commit fefc2c8b5c
1114 changed files with 406637 additions and 154 deletions
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

@@ -0,0 +1,68 @@
from __future__ import annotations
from homeassistant.components.button import ButtonEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN, SERVICE_NEXT_SLIDE, SERVICE_REFRESH_ALBUM, ATTR_ENTRY_ID
from .coordinator import AlbumCoordinator
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None:
coordinator: AlbumCoordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"]
async_add_entities(
[
NextSlideButton(hass, entry, coordinator),
RefreshAlbumButton(hass, entry, coordinator),
]
)
class _BaseButton(ButtonEntity):
_attr_has_entity_name = True
def __init__(self, hass: HomeAssistant, entry: ConfigEntry, coordinator: AlbumCoordinator) -> None:
self.hass = hass
self.entry = entry
self.coordinator = coordinator
@property
def device_info(self):
return {
"identifiers": {(DOMAIN, self.entry.entry_id)},
"name": f"Album Slideshow {self.entry.title}",
"manufacturer": "Album Slideshow",
}
class NextSlideButton(_BaseButton):
def __init__(self, hass: HomeAssistant, entry: ConfigEntry, coordinator: AlbumCoordinator) -> None:
super().__init__(hass, entry, coordinator)
self._attr_unique_id = f"{entry.entry_id}_next_button"
self._attr_name = "Next slide"
self._attr_icon = "mdi:skip-next"
async def async_press(self) -> None:
await self.hass.services.async_call(
DOMAIN,
SERVICE_NEXT_SLIDE,
{ATTR_ENTRY_ID: self.entry.entry_id},
blocking=False,
)
class RefreshAlbumButton(_BaseButton):
def __init__(self, hass: HomeAssistant, entry: ConfigEntry, coordinator: AlbumCoordinator) -> None:
super().__init__(hass, entry, coordinator)
self._attr_unique_id = f"{entry.entry_id}_refresh_button"
self._attr_name = "Refresh album"
self._attr_icon = "mdi:refresh"
async def async_press(self) -> None:
await self.hass.services.async_call(
DOMAIN,
SERVICE_REFRESH_ALBUM,
{ATTR_ENTRY_ID: self.entry.entry_id},
blocking=False,
)
+922
View File
@@ -0,0 +1,922 @@
from __future__ import annotations
import asyncio
from collections import OrderedDict
import logging
import random
from pathlib import Path
import async_timeout
from PIL import Image
from homeassistant.components.camera import Camera
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import (
DOMAIN,
MAX_RESOLUTION_SHORT_EDGE,
ORIENTATION_MISMATCH_PAIR,
ORIENTATION_MISMATCH_AVOID,
ORDER_ALBUM,
ORDER_RANDOM,
PROVIDER_GOOGLE_SHARED,
)
from . import image_processing as ip
from . import playlist
from .coordinator import AlbumCoordinator, MediaItem
from .store import SlideshowStore
_LOGGER = logging.getLogger(__name__)
# Cap a single download at 64 MB. Larger images are rejected before decode
# to protect low-memory devices. This is well above any realistic camera
# JPEG; RAW/NEF/etc. aren't supported as camera frames anyway.
_MAX_DOWNLOAD_BYTES = 64 * 1024 * 1024
# Only these content types are accepted as image bodies. If a server returns
# HTML (captive portal, 404 page rendered as 200, etc.) we reject it early.
_ACCEPTED_IMAGE_PREFIX = ("image/",)
# Max candidates we'll scan when searching for a mismatched-orientation
# pairing partner. Metadata-only checks are nearly free; decode-only checks
# (no metadata available) are expensive.
_PAIR_SEARCH_LIMIT = 12
_SKIP_SEARCH_LIMIT = 30
def _ts_to_iso(ts_ms: int | None) -> str | None:
"""Convert epoch milliseconds to an ISO-8601 string in UTC, or None."""
if not isinstance(ts_ms, int):
return None
from datetime import datetime, timezone
try:
return datetime.fromtimestamp(ts_ms / 1000, tz=timezone.utc).isoformat()
except (OverflowError, OSError, ValueError):
return None
class _DownloadCache:
"""Byte-budget LRU cache for downloaded image data, O(1) per operation."""
def __init__(self, max_bytes: int) -> None:
self._cache: "OrderedDict[str, bytes]" = OrderedDict()
self._total_bytes: int = 0
self._max_bytes: int = max(max_bytes, 1)
@property
def total_bytes(self) -> int:
return self._total_bytes
def get(self, url: str) -> bytes | None:
data = self._cache.get(url)
if data is None:
return None
self._cache.move_to_end(url)
return data
def put(self, url: str, data: bytes) -> None:
if len(data) > self._max_bytes:
# Item exceeds the entire cache budget; skip caching but don't raise.
return
if url in self._cache:
self._total_bytes -= len(self._cache[url])
del self._cache[url]
self._cache[url] = data
self._total_bytes += len(data)
self._evict()
def resize(self, max_bytes: int) -> None:
self._max_bytes = max(max_bytes, 1)
self._evict()
def _evict(self) -> None:
while self._total_bytes > self._max_bytes and self._cache:
_, data = self._cache.popitem(last=False)
self._total_bytes -= len(data)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None:
coordinator: AlbumCoordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"]
store: SlideshowStore = hass.data[DOMAIN][entry.entry_id]["store"]
cam = AlbumSlideshowCamera(hass, entry, coordinator, store)
hass.data[DOMAIN][entry.entry_id]["camera"] = cam
async_add_entities([cam])
class AlbumSlideshowCamera(Camera):
_attr_should_poll = False
def __init__(self, hass: HomeAssistant, entry: ConfigEntry, coordinator: AlbumCoordinator, store: SlideshowStore) -> None:
super().__init__()
self.hass = hass
self.entry = entry
self.coordinator = coordinator
self.store = store
self._attr_name = f"Album Slideshow {entry.title}"
self._attr_unique_id = f"{entry.entry_id}_camera"
self._rng = random.Random()
self._index = 0
self._random_order: list[int] = []
self._random_pos = 0
self._download_cache = _DownloadCache(
max_bytes=store.image_cache_mb * 1024 * 1024
)
self._recent_urls: list[str] = []
self._last_is_portrait: bool | None = None
# When the current frame is a paired image, this is [taken_a, taken_b]
# ISO strings (top/left first); None for single frames.
self._last_captured_at_pair: list[str | None] | None = None
# Full per-half caption metadata for a paired frame: a list of two
# dicts (top/left first) each carrying captured_at / location /
# latitude / longitude. None for single frames. Lets the Lovelace
# card overlay an accurate caption on each half of a pair.
self._last_pair_frames: list[dict] | None = None
# ``horizontal`` (side-by-side, left/right) or ``vertical`` (stacked,
# top/bottom) for a paired frame; None for single frames.
self._last_pair_orientation: str | None = None
# Cached effective playlist (after date filter + ordering). Invalidated
# by any store change or coordinator update.
self._effective_cache: tuple[int, list[MediaItem]] | None = None
self._framebuffer: bytes | None = None
# MJPEG subscribers. Each open stream owns an asyncio.Queue of JPEG
# byte payloads. The render loop pushes the latest still as soon
# as it's encoded; if a subscriber falls behind we drop frames
# for that subscriber rather than block the whole loop.
self._mjpeg_subscribers: set[asyncio.Queue[bytes]] = set()
# Monotonic counter incremented every time a new still is committed.
# Exposed as the ``frame_id`` state attribute so the Lovelace card
# has an unambiguous "new frame ready" signal even when other
# attributes happen not to change between slides.
self._frame_id: int = 0
self._interrupt_event: asyncio.Event = asyncio.Event()
self._force_next: bool = False
self._consecutive_failures: int = 0
self._render_task: asyncio.Task | None = None
def _on_coordinator_update() -> None:
self._effective_cache = None
self._interrupt_event.set()
self.async_write_ha_state()
coordinator.async_add_listener(_on_coordinator_update)
def _on_store_change() -> None:
self._download_cache.resize(self.store.image_cache_mb * 1024 * 1024)
self._effective_cache = None
self._interrupt_event.set()
self.async_write_ha_state()
store.add_listener(_on_store_change)
async def async_added_to_hass(self) -> None:
await super().async_added_to_hass()
# Restore last framebuffer (if the store kept one) so the camera has
# something to show immediately after a restart, rather than a broken
# image placeholder while the first render completes.
restored = getattr(self.store, "last_frame", None)
if isinstance(restored, (bytes, bytearray)) and restored:
self._framebuffer = bytes(restored)
# Stagger the first render across multiple albums so they don't all
# decode + encode at the same instant on HA startup. Deterministic
# offset based on entry_id keeps the pattern stable across
# restarts. Up to ~3 s spread across albums.
startup_delay = (hash(self.entry.entry_id) % 3000) / 1000.0
self._render_task = self.hass.async_create_background_task(
self._render_loop(initial_delay=startup_delay),
name="album_slideshow_render_loop",
)
async def async_will_remove_from_hass(self) -> None:
if self._render_task is not None:
self._render_task.cancel()
try:
await self._render_task
except asyncio.CancelledError:
pass
@property
def device_info(self):
return {
"identifiers": {(DOMAIN, self.entry.entry_id)},
"name": f"Album Slideshow {self.entry.title}",
"manufacturer": "Album Slideshow",
}
@property
def icon(self) -> str:
if self.coordinator.provider == PROVIDER_GOOGLE_SHARED:
return "mdi:google-photos"
return "mdi:folder-multiple-image"
@property
def extra_state_attributes(self):
data = self.coordinator.data or {}
items: list[MediaItem] = self._effective_items()
cur = items[self._index] if items and 0 <= self._index < len(items) else None
captured_at = _ts_to_iso(getattr(cur, "captured_at", None))
captured_at_pair = self._last_captured_at_pair
return {
"album_title": data.get("title"),
"media_count": len(items),
"media_count_total": len(data.get("items", []) or []),
"current_index": self._index,
"current_filename": getattr(cur, "filename", None),
"current_url": getattr(cur, "url", None),
"current_is_portrait": self._last_is_portrait,
"captured_at": captured_at_pair if captured_at_pair else captured_at,
"captured_at_primary": captured_at,
"uploaded_at": _ts_to_iso(getattr(cur, "uploaded_at", None)),
"byte_size": getattr(cur, "byte_size", None),
# GPS + reverse-geocoded label come from EXIF for local-folder
# entries; Google albums leave these as ``None``.
"latitude": getattr(cur, "latitude", None),
"longitude": getattr(cur, "longitude", None),
"location": getattr(cur, "location", None),
# Structured per-image caption metadata. A single-element list for
# normal slides; two elements (top/left first) for paired slides,
# so the card can overlay an accurate date/location on each half.
# ``pair_orientation`` tells the card how the two halves are laid
# out: ``horizontal`` (left/right) or ``vertical`` (top/bottom).
"caption_frames": self._caption_frames(cur, captured_at),
"pair_orientation": self._last_pair_orientation,
"slide_interval": int(self.store.slide_interval),
"fill_mode": self.store.fill_mode,
"portrait_mode": self.store.portrait_mode,
"order_mode": self.store.order_mode,
"date_filter": self.store.date_filter,
"paused": bool(self.store.paused),
"refresh_hours": int(self.store.refresh_hours),
"aspect_ratio": self.store.aspect_ratio,
"pair_divider_px": int(self.store.pair_divider_px),
"pair_divider_color": self.store.pair_divider_color,
"frame_id": self._frame_id,
"pagination_debug": data.get("pagination_debug"),
}
def _caption_frames(self, cur, captured_at: str | None) -> list[dict]:
"""Per-image caption metadata for the current slide.
Returns a list with one dict for a normal slide, or two (top/left
first) for a paired slide. Each dict carries ``captured_at`` (ISO
string or ``None``), ``location`` (human label or ``None``), and
``latitude`` / ``longitude``. The card reads this to overlay an
accurate caption on each image, including each half of a pair.
"""
if self._last_pair_frames:
return self._last_pair_frames
return [
{
"captured_at": captured_at,
"location": getattr(cur, "location", None),
"latitude": getattr(cur, "latitude", None),
"longitude": getattr(cur, "longitude", None),
}
]
@property
def entity_picture(self) -> str | None:
"""Return the camera proxy URL with a per-frame cache-buster.
HA core's default ``entity_picture`` only changes when the access
token rotates (about every five minutes). Browsers happily serve
the cached image to the more-info dialog and other surfaces in
between rotations, so they end up showing the previous slide
while a fresh slide is already in the framebuffer. Appending the
``frame_id`` invalidates that cache as soon as a new slide is
committed, no matter where in HA the picture is rendered.
"""
base = super().entity_picture
if not base:
return base
sep = "&" if "?" in base else "?"
return f"{base}{sep}frame={self._frame_id}"
@property
def cache_usage_mb(self) -> float:
return round(self._download_cache.total_bytes / (1024 * 1024), 1)
def _effective_items(self) -> list[MediaItem]:
"""Return the playlist after applying the date filter and order mode.
Cached until the coordinator or store changes (see invalidations
wired up in __init__).
"""
data = self.coordinator.data or {}
raw: list[MediaItem] = data.get("items", []) or []
cache_key = (
id(raw),
self.store.date_filter,
self.store.order_mode,
)
if self._effective_cache is not None and self._effective_cache[0] == hash(cache_key):
return self._effective_cache[1]
filtered = playlist.filter_items(
raw,
mode=self.store.date_filter,
)
ordered = playlist.order_items(filtered, self.store.order_mode)
self._effective_cache = (hash(cache_key), ordered)
return ordered
async def async_force_next(self) -> None:
self._force_next = True
self._interrupt_event.set()
self.async_write_ha_state()
async def async_force_refresh(self) -> None:
await self.coordinator.async_request_refresh()
async def async_camera_image(self, width: int | None = None, height: int | None = None) -> bytes | None:
return self._framebuffer
async def handle_async_mjpeg_stream(self, request):
"""Stream the slideshow as multipart MJPEG.
Each open client gets a bounded asyncio.Queue that the render loop
pushes JPEG payloads into when a new still is committed. Visible
transitions are now handled by the Lovelace card on the client
side, so this stream just emits the latest still per slide change.
"""
# Imported lazily so the module still loads in test environments
# that stub out homeassistant without installing aiohttp.
from aiohttp import web
boundary = "frame"
response = web.StreamResponse(
status=200,
reason="OK",
headers={
"Content-Type": f"multipart/x-mixed-replace;boundary={boundary}",
"Cache-Control": "no-cache, private",
"Pragma": "no-cache",
},
)
await response.prepare(request)
# Bounded queue: a slow client should fall behind on slide commits
# rather than balloon memory.
queue: asyncio.Queue[bytes] = asyncio.Queue(maxsize=4)
self._mjpeg_subscribers.add(queue)
# Push the held still immediately so the client renders something
# before the next slide change.
if self._framebuffer is not None:
try:
queue.put_nowait(self._framebuffer)
except asyncio.QueueFull:
pass
try:
while True:
payload = await queue.get()
try:
await response.write(
b"--" + boundary.encode() + b"\r\n"
b"Content-Type: image/jpeg\r\n"
b"Content-Length: " + str(len(payload)).encode() + b"\r\n\r\n"
+ payload + b"\r\n"
)
except (ConnectionResetError, asyncio.CancelledError):
raise
except Exception as err:
_LOGGER.debug("Album Slideshow: mjpeg client write failed: %s", err)
break
except (ConnectionResetError, asyncio.CancelledError):
pass
finally:
self._mjpeg_subscribers.discard(queue)
try:
await response.write_eof()
except Exception:
pass
return response
# Older HA cores may dispatch via the alt name; alias for compatibility.
async def async_handle_async_mjpeg_stream(self, request):
return await self.handle_async_mjpeg_stream(request)
async def _wait_or_interrupt(self, timeout: float) -> bool:
"""Wait up to ``timeout`` seconds, returning True if interrupted.
Safe wrapper around clear() + wait_for() - callers don't have to
worry about the ordering of the two operations. The clear() runs
synchronously before the awaitable is created, so no interrupt can
be lost on the single-threaded event loop.
"""
self._interrupt_event.clear()
try:
await asyncio.wait_for(self._interrupt_event.wait(), timeout=timeout)
return True
except asyncio.TimeoutError:
return False
async def _render_loop(self, initial_delay: float = 0.0) -> None:
"""Background task: render slides into _framebuffer, advance on timer or interrupt."""
if initial_delay > 0:
try:
await asyncio.sleep(initial_delay)
except asyncio.CancelledError:
raise
should_advance = False # Don't advance on the very first render
while True:
try:
await self._render_cycle(advance=should_advance)
self._consecutive_failures = 0
except asyncio.CancelledError:
raise
except Exception as err:
self._consecutive_failures += 1
backoff = min(2 ** self._consecutive_failures, 60)
_LOGGER.warning(
"Album Slideshow: render cycle failed (attempt %d), retrying in %ds: %s",
self._consecutive_failures, backoff, err,
)
try:
await asyncio.sleep(backoff)
except asyncio.CancelledError:
raise
should_advance = True # Skip the broken image on retry
continue
interrupted = await self._wait_or_interrupt(float(int(self.store.slide_interval)))
if interrupted:
should_advance = self._force_next
self._force_next = False
else:
# Paused slideshows hold the current frame until the user
# un-pauses or hits "next slide" explicitly.
should_advance = not bool(self.store.paused)
async def _render_cycle(self, advance: bool) -> None:
"""Render one frame.
The slideshow is just "advance index, compose, encode, broadcast".
Visible transitions are handled by the Lovelace card client-side,
so this path stays minimal: at most one PIL decode + encode per
slide change.
Compose work is serialised across all albums via a domain-wide
semaphore so 4 cameras don't all decode + encode at once.
"""
items: list[MediaItem] = self._effective_items()
if not items:
return
count = len(items)
if advance:
self._do_advance(count, items)
async with self._compose_semaphore:
composed, meta = await self._compose_for_index(items)
if composed is None:
return
try:
await self._commit_composed(composed, meta)
finally:
ip.safe_close(composed)
async def _commit_composed(self, composed: Image.Image, meta: dict) -> None:
"""Encode the composed slide into the framebuffer and broadcast.
Encodes off the loop so the JPEG encode (30-80 ms at 1080p, more
at 4K) doesn't block HA.
"""
encoded = await self.hass.async_add_executor_job(
ip.encode_image, composed
)
self._framebuffer = encoded
self.store.last_frame = encoded
self._frame_id += 1
if meta:
self._last_is_portrait = meta.get("is_portrait")
else:
self._last_is_portrait = None
self._last_captured_at_pair = meta.get("captured_at_pair") if meta else None
self._last_pair_frames = meta.get("pair_frames") if meta else None
self._last_pair_orientation = meta.get("pair_orientation") if meta else None
self._broadcast_frame(encoded)
self.async_write_ha_state()
@property
def _compose_semaphore(self) -> asyncio.Semaphore:
"""Return the domain-wide compose semaphore, creating it on demand.
``__init__.py`` populates it during setup, but defensive
initialisation here means a partially-loaded integration can
still render without crashing.
"""
domain_data = self.hass.data.setdefault(DOMAIN, {})
sem = domain_data.get("compose_semaphore")
if sem is None:
sem = asyncio.Semaphore(1)
domain_data["compose_semaphore"] = sem
return sem
def _broadcast_frame(self, payload: bytes) -> None:
"""Push a frame to every active MJPEG subscriber.
Slow subscribers get their frame dropped rather than backing up the
queue; the next still emission will catch them up.
"""
for queue in list(self._mjpeg_subscribers):
try:
queue.put_nowait(payload)
except asyncio.QueueFull:
# Drain one and retry once so a wedged client still sees
# the latest frame eventually instead of forever stale.
try:
queue.get_nowait()
except asyncio.QueueEmpty:
pass
try:
queue.put_nowait(payload)
except asyncio.QueueFull:
pass
def _do_advance(self, count: int, items: list) -> None:
"""Advance _index to the next slide and commit random-order position."""
if count <= 0:
self._index = 0
return
self._index %= count
order_mode = self.store.order_mode
# Sequential modes (album order + sorted-by-time orderings) walk in
# order. The list is already pre-sorted by ``order_items``, so we
# only need to step forward.
if order_mode != ORDER_RANDOM:
self._index = (self._index + 1) % count
return
self._index = self._next_random_index(count)
cur_url = items[self._index].url
self._recent_urls.append(cur_url)
keep = min(20, max(1, count - 1))
if len(self._recent_urls) > keep:
self._recent_urls = self._recent_urls[-keep:]
def _peek_advance(self, count: int, items: list) -> None:
"""Advance _index without committing to random-order bookkeeping.
Used by the orientation-avoid search so that rejected candidates
don't burn through the random cycle and cause premature repeats.
"""
if count <= 0:
self._index = 0
return
self._index = (self._index + 1) % count
async def _compose_for_index(
self, items: list[MediaItem]
) -> tuple[Image.Image | None, dict | None]:
"""Compose the slide at ``self._index`` into a PIL image.
Returns ``(composed, meta)`` where ``meta`` carries the orientation
and paired-capture metadata that ``_commit_composed`` will publish
as state attributes. Returns ``(None, None)`` if compose failed.
Pure compose - does NOT mutate ``self._framebuffer`` or
``self._last_*`` state. The caller commits via ``_commit_composed``.
"""
fill_mode = self.store.fill_mode
portrait_mode = self.store.portrait_mode
divider = max(0, int(self.store.pair_divider_px))
divider_fill, transparent_divider = ip.parse_divider_color(self.store.pair_divider_color)
max_short_edge = MAX_RESOLUTION_SHORT_EDGE.get(self.store.max_resolution)
width, height = ip.resolve_output_size(None, None, self.store.aspect_ratio, max_short_edge)
cur = items[self._index]
is_portrait_canvas = height > width
# Metadata fast path: if we can resolve orientation without downloading,
# we may short-circuit the mismatch handling before any bytes are read.
meta_portrait = ip.is_portrait_item_by_metadata(cur)
if (
meta_portrait is not None
and meta_portrait != is_portrait_canvas
and portrait_mode == ORIENTATION_MISMATCH_AVOID
):
return await self._compose_skip_mismatch(items, width, height, fill_mode, is_portrait_canvas)
cur_bytes = await self._fetch_bytes(cur.url)
if not cur_bytes:
raise RuntimeError(f"Failed to fetch image: {cur.url}")
img = await self.hass.async_add_executor_job(
ip.open_image, cur_bytes, (width, height)
)
try:
cur_is_portrait = ip.is_portrait_item(cur, img)
orientation_mismatch = cur_is_portrait != is_portrait_canvas
if orientation_mismatch and portrait_mode == ORIENTATION_MISMATCH_AVOID:
ip.safe_close(img)
img = None
return await self._compose_skip_mismatch(items, width, height, fill_mode, is_portrait_canvas)
if orientation_mismatch and portrait_mode == ORIENTATION_MISMATCH_PAIR:
pair = await self._find_next_mismatch_image(
items, is_portrait_canvas, width, height, limit=_PAIR_SEARCH_LIMIT
)
other_img = pair[0] if pair else None
other_item = pair[1] if pair else None
pair_meta: list[str | None] | None = None
pair_frames: list[dict] | None = None
try:
if other_img is not None:
composed = await self.hass.async_add_executor_job(
ip.pair_images, img, other_img, width, height, fill_mode,
is_portrait_canvas, divider, divider_fill, transparent_divider,
)
pair_frames = [
{
"captured_at": _ts_to_iso(getattr(cur, "captured_at", None)),
"location": getattr(cur, "location", None),
"latitude": getattr(cur, "latitude", None),
"longitude": getattr(cur, "longitude", None),
},
{
"captured_at": _ts_to_iso(getattr(other_item, "captured_at", None)),
"location": getattr(other_item, "location", None),
"latitude": getattr(other_item, "latitude", None),
"longitude": getattr(other_item, "longitude", None),
},
]
pair_meta = [f["captured_at"] for f in pair_frames]
else:
composed = await self.hass.async_add_executor_job(
ip.render_image, img, fill_mode, width, height,
)
finally:
ip.safe_close(other_img)
meta = {
"is_portrait": cur_is_portrait,
"captured_at_pair": pair_meta,
"pair_frames": pair_frames,
# ``pair_images`` stacks images top/bottom on a portrait
# canvas and places them left/right on a landscape canvas.
"pair_orientation": (
("vertical" if is_portrait_canvas else "horizontal")
if pair_frames
else None
),
}
return composed, meta
composed = await self.hass.async_add_executor_job(
ip.render_image, img, fill_mode, width, height
)
return composed, {
"is_portrait": cur_is_portrait,
"captured_at_pair": None,
}
finally:
ip.safe_close(img)
async def _compose_skip_mismatch(
self,
items: list[MediaItem],
width: int,
height: int,
fill_mode: str,
is_portrait_canvas: bool,
) -> tuple[Image.Image | None, dict | None]:
"""Skip-mismatch variant of ``_compose_for_index``.
Walks forward (peek-advancing for non-matches) until it finds an
image whose orientation matches the canvas, then composes it.
"""
count = len(items)
if count <= 0:
return None, None
start = self._index
for _ in range(min(count, _SKIP_SEARCH_LIMIT)):
cur = items[self._index]
meta_portrait = ip.is_portrait_item_by_metadata(cur)
if meta_portrait is not None:
if meta_portrait != is_portrait_canvas:
self._peek_advance(count, items)
continue
if self._index != start:
self._do_advance(count, items)
return await self._compose_single(cur, width, height, fill_mode)
b = await self._fetch_bytes(cur.url)
if not b:
self._peek_advance(count, items)
continue
img = await self.hass.async_add_executor_job(ip.open_image, b, (width, height))
try:
if ip.is_portrait_item(cur, img) != is_portrait_canvas:
self._peek_advance(count, items)
continue
if self._index != start:
self._do_advance(count, items)
composed = await self.hass.async_add_executor_job(
ip.render_image, img, fill_mode, width, height
)
return composed, {
"is_portrait": is_portrait_canvas,
"captured_at_pair": None,
}
finally:
ip.safe_close(img)
self._index = start
return await self._compose_single(items[self._index], width, height, fill_mode)
async def _compose_single(
self,
item: MediaItem,
width: int,
height: int,
fill_mode: str,
) -> tuple[Image.Image | None, dict | None]:
b = await self._fetch_bytes(item.url)
if not b:
return None, None
img = await self.hass.async_add_executor_job(ip.open_image, b, (width, height))
try:
cur_is_portrait = ip.is_portrait_item(item, img)
composed = await self.hass.async_add_executor_job(
ip.render_image, img, fill_mode, width, height
)
return composed, {
"is_portrait": cur_is_portrait,
"captured_at_pair": None,
}
finally:
ip.safe_close(img)
async def _render_current(self, items: list[MediaItem]) -> bytes | None:
"""Compatibility wrapper: compose + encode the current slide.
Kept as a thin wrapper because external code paths (e.g., tests)
may still call it. ``_render_cycle`` no longer does.
"""
composed, _ = await self._compose_for_index(items)
if composed is None:
return None
try:
return await self.hass.async_add_executor_job(ip.encode_image, composed)
finally:
ip.safe_close(composed)
async def _find_next_mismatch_image(
self,
items: list[MediaItem],
is_portrait_canvas: bool,
width: int,
height: int,
limit: int = _PAIR_SEARCH_LIMIT,
) -> tuple[Image.Image, MediaItem] | None:
"""Find an image with the opposite orientation of the canvas.
Uses metadata wherever possible - only candidates without width/height
metadata are downloaded and decoded for their orientation. The returned
PIL image is the caller's to close. The matching ``MediaItem`` is
returned alongside so the caller can attribute timestamps etc.
"""
if not items:
return None
n = len(items)
tries = 0
offset = 1
while tries < limit and offset < n:
idx = (self._index + offset) % n
it = items[idx]
offset += 1
tries += 1
if it.url in self._recent_urls:
continue
meta_portrait = ip.is_portrait_item_by_metadata(it)
if meta_portrait is not None and meta_portrait == is_portrait_canvas:
# Metadata says this one is the wrong orientation for pairing; skip.
continue
b = await self._fetch_bytes(it.url)
if not b:
continue
try:
img = await self.hass.async_add_executor_job(ip.open_image, b, (width, height))
except Exception:
continue
if ip.is_portrait_item(it, img) != is_portrait_canvas:
return img, it
ip.safe_close(img)
return None
def _next_random_index(self, count: int) -> int:
if count <= 1:
self._random_order = [0]
self._random_pos = 0
return 0
needs_new_cycle = len(self._random_order) != count or self._random_pos >= len(self._random_order)
if needs_new_cycle:
self._random_order = list(range(count))
self._rng.shuffle(self._random_order)
self._random_pos = 0
if self._random_order and self._random_order[0] == self._index:
self._random_order.append(self._random_order.pop(0))
idx = self._random_order[self._random_pos]
self._random_pos += 1
return idx
async def _fetch_bytes(self, url: str) -> bytes | None:
cached = self._download_cache.get(url)
if cached is not None:
return cached
if url.startswith("file://"):
try:
p = Path(url[7:])
data = await self.hass.async_add_executor_job(p.read_bytes)
except Exception as err:
_LOGGER.warning("Album Slideshow: failed to read local image: %s", err)
return None
if len(data) > _MAX_DOWNLOAD_BYTES:
_LOGGER.warning(
"Album Slideshow: local image %s is %d bytes, exceeds %d byte limit; skipping",
url, len(data), _MAX_DOWNLOAD_BYTES,
)
return None
else:
data = await self._http_get(url)
if data is None:
return None
self._download_cache.put(url, data)
return data
async def _http_get(self, url: str) -> bytes | None:
session = async_get_clientsession(self.hass)
try:
async with async_timeout.timeout(30):
async with session.get(url) as resp:
resp.raise_for_status()
content_type = resp.headers.get("Content-Type", "")
primary = content_type.split(";", 1)[0].strip().lower()
if primary and not primary.startswith(_ACCEPTED_IMAGE_PREFIX):
_LOGGER.debug(
"Album Slideshow: rejecting %s, content-type %r is not an image",
url, primary,
)
return None
content_length = resp.headers.get("Content-Length")
if content_length is not None:
try:
declared = int(content_length)
except ValueError:
declared = -1
if declared > _MAX_DOWNLOAD_BYTES:
_LOGGER.warning(
"Album Slideshow: %s advertises %d bytes, exceeds %d byte limit; skipping",
url, declared, _MAX_DOWNLOAD_BYTES,
)
return None
chunks: list[bytes] = []
total = 0
async for chunk in resp.content.iter_chunked(64 * 1024):
total += len(chunk)
if total > _MAX_DOWNLOAD_BYTES:
_LOGGER.warning(
"Album Slideshow: %s exceeded %d byte limit mid-download; aborting",
url, _MAX_DOWNLOAD_BYTES,
)
return None
chunks.append(chunk)
return b"".join(chunks)
except Exception as err:
_LOGGER.warning("Album Slideshow: failed to fetch image: %s", err)
return None
@@ -0,0 +1,194 @@
from __future__ import annotations
import re
from typing import Any
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult
from .const import (
DOMAIN,
CONF_PROVIDER,
CONF_ALBUM_NAME,
CONF_ALBUM_URL,
CONF_LOCAL_PATH,
CONF_RECURSIVE,
CONF_REVERSE_GEOCODE,
DEFAULT_REVERSE_GEOCODE,
PROVIDER_GOOGLE_SHARED,
PROVIDER_LOCAL_FOLDER,
DEFAULT_RECURSIVE,
)
def _normalize_local_path(hass, path: str) -> str:
p = path.strip()
if p.startswith("/local/"):
p = "/config/www/" + p[len("/local/"):]
elif p == "/local":
p = "/config/www"
elif p.startswith("local/"):
p = "/config/www/" + p[len("local/"):]
elif p.startswith("/media/local/"):
p = "/media/" + p[len("/media/local/"):]
elif p.startswith("media/local/"):
p = "/media/" + p[len("media/local/"):]
elif p.startswith("media/"):
p = "/media/" + p[len("media/"):]
elif p == "media":
p = "/media"
if not p.startswith("/"):
p = hass.config.path(p)
return p
ALBUM_URL_RE = re.compile(r"^https?://photos\.app\.goo\.gl/[^/]+/?$")
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
VERSION = 1
def __init__(self) -> None:
self._provider: str | None = None
@staticmethod
@callback
def async_get_options_flow(
config_entry: config_entries.ConfigEntry,
) -> config_entries.OptionsFlow:
"""Return the options flow handler.
Only local-folder entries expose user-tunable options today (the
reverse-geocode toggle); Google entries get a no-op handler so
that the "Configure" button doesn't appear empty in the UI.
Note: do NOT pass ``config_entry`` to the OptionsFlow constructor.
Since Home Assistant 2024.12 the base class manages
``self.config_entry`` as a property and assigning to it in
``__init__`` raises (the symptom is a 500 when the user clicks
Configure).
"""
if config_entry.data.get(CONF_PROVIDER) == PROVIDER_LOCAL_FOLDER:
return LocalFolderOptionsFlow()
return _NoOptionsFlow()
async def async_step_user(self, user_input: dict[str, Any] | None = None) -> FlowResult:
if user_input is not None:
self._provider = user_input[CONF_PROVIDER]
if self._provider == PROVIDER_LOCAL_FOLDER:
return await self.async_step_local_folder()
return await self.async_step_google_shared()
schema = vol.Schema(
{
vol.Required(CONF_PROVIDER, default=PROVIDER_GOOGLE_SHARED): vol.In({
PROVIDER_GOOGLE_SHARED: "Google Photos",
PROVIDER_LOCAL_FOLDER: "Local Folder",
})
}
)
return self.async_show_form(step_id="user", data_schema=schema)
async def async_step_google_shared(self, user_input: dict[str, Any] | None = None) -> FlowResult:
errors: dict[str, str] = {}
if user_input is not None:
url = user_input[CONF_ALBUM_URL].strip()
name = user_input[CONF_ALBUM_NAME].strip()
if not ALBUM_URL_RE.match(url):
errors[CONF_ALBUM_URL] = "invalid_album_url"
else:
await self.async_set_unique_id(f"{DOMAIN}:{PROVIDER_GOOGLE_SHARED}:{url}")
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=name,
data={
CONF_PROVIDER: PROVIDER_GOOGLE_SHARED,
CONF_ALBUM_URL: url,
CONF_ALBUM_NAME: name,
},
)
schema = vol.Schema(
{
vol.Required(CONF_ALBUM_NAME): str,
vol.Required(CONF_ALBUM_URL): str,
}
)
return self.async_show_form(step_id="google_shared", data_schema=schema, errors=errors)
async def async_step_local_folder(self, user_input: dict[str, Any] | None = None) -> FlowResult:
errors: dict[str, str] = {}
if user_input is not None:
path = _normalize_local_path(self.hass, user_input[CONF_LOCAL_PATH])
name = user_input[CONF_ALBUM_NAME].strip()
recursive = bool(user_input.get(CONF_RECURSIVE, DEFAULT_RECURSIVE))
if not path:
errors[CONF_LOCAL_PATH] = "invalid_path"
else:
await self.async_set_unique_id(f"{DOMAIN}:{PROVIDER_LOCAL_FOLDER}:{path}")
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=name,
data={
CONF_PROVIDER: PROVIDER_LOCAL_FOLDER,
CONF_LOCAL_PATH: path,
CONF_RECURSIVE: recursive,
CONF_ALBUM_NAME: name,
},
)
schema = vol.Schema(
{
vol.Required(CONF_ALBUM_NAME): str,
vol.Required(CONF_LOCAL_PATH): str,
vol.Optional(CONF_RECURSIVE, default=DEFAULT_RECURSIVE): bool,
}
)
return self.async_show_form(step_id="local_folder", data_schema=schema, errors=errors)
class LocalFolderOptionsFlow(config_entries.OptionsFlow):
"""Options for local-folder entries.
Currently exposes a single toggle: ``reverse_geocode``. Users with
privacy concerns about handing EXIF GPS coordinates to an external
OSM endpoint can turn this off; the GPS coordinates remain available
as ``latitude``/``longitude`` attributes regardless.
``self.config_entry`` is provided by ``OptionsFlow`` as a managed
property (HA 2024.12+); we deliberately do NOT define ``__init__``
or assign to it, since doing so raises in newer cores.
"""
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
if user_input is not None:
return self.async_create_entry(title="", data=user_input)
current = self.config_entry.options.get(
CONF_REVERSE_GEOCODE, DEFAULT_REVERSE_GEOCODE
)
schema = vol.Schema(
{
vol.Required(
CONF_REVERSE_GEOCODE, default=bool(current)
): bool,
}
)
return self.async_show_form(step_id="init", data_schema=schema)
class _NoOptionsFlow(config_entries.OptionsFlow):
"""Fallback options flow for providers that expose nothing tunable."""
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
return self.async_create_entry(title="", data={})
@@ -0,0 +1,94 @@
DOMAIN = "album_slideshow"
CONF_PROVIDER = "provider"
CONF_ALBUM_URL = "album_url"
CONF_ALBUM_NAME = "album_name"
CONF_LOCAL_PATH = "local_path"
CONF_RECURSIVE = "recursive"
CONF_IMAGE_CACHE_MB = "image_cache_mb"
# Local-folder option: when True (default) the coordinator does best-effort
# reverse geocoding of EXIF GPS coordinates via the public Nominatim
# (OpenStreetMap) endpoint and exposes a human-readable ``location``
# attribute. Users with privacy concerns can disable this from the
# integration's options dialog.
CONF_REVERSE_GEOCODE = "reverse_geocode"
DEFAULT_REVERSE_GEOCODE = True
PROVIDER_GOOGLE_SHARED = "google_shared"
PROVIDER_LOCAL_FOLDER = "local_folder"
FILL_COVER = "cover"
FILL_CONTAIN = "contain"
FILL_BLUR = "blur"
ORIENTATION_MISMATCH_PAIR = "pair"
ORIENTATION_MISMATCH_SINGLE = "single"
ORIENTATION_MISMATCH_AVOID = "avoid"
ORDER_RANDOM = "random"
ORDER_ALBUM = "album_order"
ORDER_NEWEST_TAKEN = "newest_taken"
ORDER_OLDEST_TAKEN = "oldest_taken"
ORDER_NEWEST_ADDED = "newest_added"
ORDER_OLDEST_ADDED = "oldest_added"
ORDER_OPTIONS = [
ORDER_RANDOM,
ORDER_ALBUM,
ORDER_NEWEST_TAKEN,
ORDER_OLDEST_TAKEN,
ORDER_NEWEST_ADDED,
ORDER_OLDEST_ADDED,
]
DATE_FILTER_OFF = "off"
DATE_FILTER_LAST_7 = "last_7_days"
DATE_FILTER_LAST_30 = "last_30_days"
DATE_FILTER_LAST_365 = "last_365_days"
DATE_FILTER_THIS_MONTH = "this_month"
DATE_FILTER_THIS_YEAR = "this_year"
DATE_FILTER_ON_THIS_DAY = "on_this_day"
DATE_FILTER_OPTIONS = [
DATE_FILTER_OFF,
DATE_FILTER_LAST_7,
DATE_FILTER_LAST_30,
DATE_FILTER_LAST_365,
DATE_FILTER_THIS_MONTH,
DATE_FILTER_THIS_YEAR,
DATE_FILTER_ON_THIS_DAY,
]
DEFAULT_DATE_FILTER = DATE_FILTER_OFF
DEFAULT_SLIDE_INTERVAL = 60
DEFAULT_REFRESH_HOURS = 24
DEFAULT_FILL_MODE = FILL_BLUR
DEFAULT_ORIENTATION_MISMATCH_MODE = ORIENTATION_MISMATCH_PAIR
DEFAULT_ORDER_MODE = ORDER_RANDOM
DEFAULT_ASPECT_RATIO = "16:9"
DEFAULT_PAIR_DIVIDER_PX = 8
DEFAULT_PAIR_DIVIDER_COLOR = "#FFFFFF"
DEFAULT_RECURSIVE = True
# Per-album download cache. Multiple albums add up: 4 × 150 MB = 600 MB
# of just-in-case downloaded JPEGs. 75 MB caches roughly 10-20 photos
# at typical Google Photos resolutions, which is enough for the preload
# path. Users with one album and lots of RAM can bump this via the
# Image cache size number entity.
DEFAULT_IMAGE_CACHE_MB = 75
MAX_RESOLUTION_OPTIONS = ["480p", "720p", "1080p", "1440p", "4K (2160p)", "original"]
DEFAULT_MAX_RESOLUTION = "1080p"
MAX_RESOLUTION_SHORT_EDGE: dict[str, int | None] = {
"480p": 480,
"720p": 720,
"1080p": 1080,
"1440p": 1440,
"4K (2160p)": 2160,
"original": None,
}
PUBLICALBUM_ENDPOINT = "https://www.publicalbum.org/api/v2/webapp/embed-player/jsonrpc"
SERVICE_NEXT_SLIDE = "next_slide"
SERVICE_REFRESH_ALBUM = "refresh_album"
ATTR_ENTRY_ID = "entry_id"
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,472 @@
"""Google Photos shared album client that bypasses the ~300 item HTML cap.
Strategy
--------
1. Fetch the share URL HTML (with browser User-Agent so Google serves the
real page, not a JS-only shim).
2. Extract the album media key and auth key from the embedded
``AF_dataServiceRequests`` blob - the same parameters Google's JS uses
when it scrolls and needs more pages.
3. Page through the album by POSTing to the public ``batchexecute`` endpoint
with the ``snAcKc`` RPC (the same one googlephotos.com calls). Each page
carries up to 300 items plus a continuation token; we loop until the
token is empty.
This mirrors the approach used by community projects like
``xob0t/google-photos-toolkit``. It uses only undocumented public endpoints
and no auth.
Brittleness
-----------
Google occasionally reshuffles the per-item array layout. We keep parsing
positional but verify each field at access time and skip malformed entries
rather than failing the whole batch.
"""
from __future__ import annotations
import json
import logging
import re
from typing import Any
from urllib.parse import quote
from .coordinator import MediaItem
_LOGGER = logging.getLogger(__name__)
_BROWSER_UA = (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/124.0.0.0 Safari/537.36"
)
# AF_dataServiceRequests block in the HTML carries the snAcKc request payload:
# "snAcKc",ext: ... ,request:["<albumKey>",null,null,"<authKey>"]
_REQUEST_RE = re.compile(
r"snAcKc[^}]*?request:\s*\[\s*\"([A-Za-z0-9_-]+)\"\s*,\s*null\s*,\s*null\s*,\s*\"([A-Za-z0-9_-]+)\"",
re.DOTALL,
)
# XSSI prefix Google prepends to batchexecute responses. We strip it before parsing.
_XSSI = ")]}'"
# Hard ceiling - matches the upstream Google album item limit.
_MAX_ITEMS = 20_000
# Per-page item count Google returns. Used only for sanity logging.
_PAGE_SIZE = 300
# Strip any existing ``=w...-h...`` suffix from a Google CDN URL so we can
# attach our own size hint based on the photo's known dimensions.
_SIZE_SUFFIX_RE = re.compile(r"=[wh]\d+(?:-[a-z0-9]+)*$", re.IGNORECASE)
_VIDEO_DURATION_KEY = 76647426 # presence indicates a video; we skip those
_LIVEPHOTO_KEY = 146008172
class _AlbumKeys:
__slots__ = ("album_key", "auth_key")
def __init__(self, album_key: str, auth_key: str) -> None:
self.album_key = album_key
self.auth_key = auth_key
async def fetch_album(session, share_url: str, *, timeout: float = 30.0) -> tuple[str | None, list[MediaItem]]:
"""Fetch a shared album in full. Returns (title, items).
The HTML page is fetched once - just to recover the album/auth keys and
title. All actual photo enumeration goes through Google's ``batchexecute``
endpoint, which is the only way to reach photos beyond the first ~300.
"""
keys, title = await _fetch_album_keys(session, share_url, timeout=timeout)
if keys is None:
return None, []
items: list[MediaItem] = []
seen_urls: set[str] = set()
page_id: str | None = None
page_no = 0
while True:
page_no += 1
try:
page_items, page_id = await _fetch_album_page(
session, keys, page_id, timeout=timeout
)
except Exception as err:
_LOGGER.warning(
"Album scrape: page %d batchexecute failed (%s); returning %d items so far",
page_no, err, len(items),
)
break
added = 0
for it in page_items:
if it.url in seen_urls:
continue
seen_urls.add(it.url)
items.append(it)
added += 1
if len(items) >= _MAX_ITEMS:
break
_LOGGER.debug(
"Album scrape: page %d returned %d items (%d new), running total %d",
page_no, len(page_items), added, len(items),
)
if not page_id or len(items) >= _MAX_ITEMS or added == 0:
break
_LOGGER.info(
"Album scraper: batchexecute fetched %d photos in %d page(s)",
len(items), page_no,
)
return title, items
# -- Internals ---------------------------------------------------------------
async def _fetch_album_keys(
session, share_url: str, *, timeout: float
) -> tuple[_AlbumKeys | None, str | None]:
"""Fetch the share URL HTML and extract the album/auth keys + title."""
headers = {
"User-Agent": _BROWSER_UA,
"Accept": "text/html,application/xhtml+xml",
"Accept-Language": "en-US,en;q=0.9",
}
try:
async with session.get(
share_url, headers=headers, timeout=timeout, allow_redirects=True
) as resp:
resp.raise_for_status()
ct = resp.headers.get("Content-Type", "").lower()
if "html" not in ct and "text" not in ct:
_LOGGER.debug("Album scrape: unexpected content-type %r", ct)
return None, None
html = await resp.text()
except Exception as err:
_LOGGER.debug("Album scrape: failed to fetch %s: %s", share_url, err)
return None, None
keys = _extract_keys(html)
if keys is None:
_LOGGER.debug("Album scrape: could not locate album keys in HTML")
return None, None
return keys, _extract_title(html)
def _extract_keys(html: str) -> _AlbumKeys | None:
m = _REQUEST_RE.search(html)
if not m:
return None
return _AlbumKeys(album_key=m.group(1), auth_key=m.group(2))
def _extract_title(html: str) -> str | None:
m = re.search(r"<title[^>]*>(.*?)</title>", html, re.IGNORECASE | re.DOTALL)
if not m:
return None
title = m.group(1).strip()
suffix = " - Google Photos"
if title.endswith(suffix):
title = title[: -len(suffix)].strip()
return title or None
def _extract_first_page_items(html: str) -> list[MediaItem]:
"""Pull the first 300 items out of the AF_initDataCallback blocks.
Returns an empty list if the embedded data can't be parsed - the caller
will still get the rest via batchexecute pagination.
"""
candidates: list[list[Any]] = []
for blob in _iter_af_data_blobs(html):
try:
tree = json.loads(blob)
except json.JSONDecodeError:
continue
candidates.extend(_collect_album_item_lists(tree))
if not candidates:
return []
best = max(candidates, key=len)
items: list[MediaItem] = []
seen: set[str] = set()
for raw in best:
item = _parse_album_item(raw)
if item is None or item.url in seen:
continue
seen.add(item.url)
items.append(item)
return items
def _next_page_token_for_first_page(
first_items: list[MediaItem], first_page_size: int
) -> str | None:
"""The first page's nextPageId isn't easy to find in the HTML AF blob.
Strategy: if the first page is exactly the standard page size we use a
sentinel empty token so the caller fetches page 2 with ``pageId=None``.
The batchexecute endpoint, given ``pageId=None``, returns page 1 again
along with the real continuation token, which we then use for the rest.
Slightly wasteful (we re-fetch page 1) but robust to layout changes.
"""
if first_page_size >= _PAGE_SIZE:
return "" # sentinel: drives the first batchexecute call
return None
async def _fetch_album_page(
session,
keys: _AlbumKeys,
page_id: str | None,
*,
timeout: float,
) -> tuple[list[MediaItem], str | None]:
"""Call snAcKc once. ``page_id=""`` is treated as ``None`` (initial fetch)."""
pid = page_id or None
inner = json.dumps([keys.album_key, pid, None, keys.auth_key])
envelope = json.dumps([[["snAcKc", inner, None, "generic"]]])
form = f"f.req={quote(envelope)}"
url = (
"https://photos.google.com/u/0/_/PhotosUi/data/batchexecute"
f"?rpcids=snAcKc&source-path=/share/{quote(keys.album_key)}"
)
headers = {
"User-Agent": _BROWSER_UA,
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
"Accept": "*/*",
"Origin": "https://photos.google.com",
"Referer": f"https://photos.google.com/share/{keys.album_key}?key={keys.auth_key}",
}
async with session.post(url, data=form, headers=headers, timeout=timeout) as resp:
resp.raise_for_status()
body = await resp.text()
return _parse_batchexecute_album_page(body)
def _parse_batchexecute_album_page(body: str) -> tuple[list[MediaItem], str | None]:
"""Parse a batchexecute response for one snAcKc call.
Format (per line, after the XSSI prefix):
[["wrb.fr", "snAcKc", "<json-encoded inner>", null, null, "generic"], ...]
The inner data shape (per gptk-toolkit):
data[1] = list of album items
data[2] = nextPageId (str) or null
"""
text = body.lstrip()
if text.startswith(_XSSI):
text = text[len(_XSSI):]
text = text.lstrip()
line = text.split("\n", 1)[0].strip()
if not line:
return [], None
try:
outer = json.loads(line)
except json.JSONDecodeError:
return [], None
inner_json: str | None = None
for entry in outer:
if isinstance(entry, list) and len(entry) >= 3 and entry[0] == "wrb.fr":
inner_json = entry[2]
break
if not isinstance(inner_json, str):
return [], None
try:
inner = json.loads(inner_json)
except json.JSONDecodeError:
return [], None
raw_items = inner[1] if len(inner) > 1 and isinstance(inner[1], list) else []
next_page = inner[2] if len(inner) > 2 and isinstance(inner[2], str) else None
if next_page == "":
next_page = None
items: list[MediaItem] = []
for raw in raw_items:
item = _parse_album_item(raw)
if item is not None:
items.append(item)
return items, next_page
def _parse_album_item(raw: Any) -> MediaItem | None:
"""Parse a single album item array.
Layout used in both AF blocks and snAcKc responses:
[mediaKey, [url, w, h, ..., [byte_size]], captured_ms, dedupKey,
timezoneOffsetMin, uploaded_ms, ..., {<numeric_keys>: ...}]
"""
if not isinstance(raw, list) or len(raw) < 2:
return None
visual = raw[1]
if not isinstance(visual, list) or len(visual) < 3:
return None
url = visual[0]
if not isinstance(url, str) or not url.startswith("http"):
return None
width = visual[1] if isinstance(visual[1], int) else None
height = visual[2] if isinstance(visual[2], int) else None
# Skip videos: their last element is a dict with key 76647426 (duration).
if raw and isinstance(raw[-1], dict):
if _VIDEO_DURATION_KEY in raw[-1] or "76647426" in raw[-1]:
# Note: live photos also carry a duration but are still images;
# we treat the presence of duration as "video". If a user reports
# missing live photos we can revisit by checking 146008172 too.
return None
captured_at = raw[2] if len(raw) > 2 and _looks_like_timestamp_ms(raw[2]) else None
uploaded_at = raw[5] if len(raw) > 5 and _looks_like_timestamp_ms(raw[5]) else None
# File size, when present, lives at visual[-1][0] as a single int.
byte_size: int | None = None
if visual and isinstance(visual[-1], list) and visual[-1]:
candidate = visual[-1][0]
if isinstance(candidate, int) and candidate > 0:
byte_size = candidate
return MediaItem(
url=_normalise_size(url, width, height),
width=width,
height=height,
mime_type=None,
filename=None,
captured_at=captured_at,
uploaded_at=uploaded_at,
byte_size=byte_size,
)
# Plausible epoch-ms range: 2000-01-01 to 2100-01-01.
_MIN_TS_MS = 946_684_800_000
_MAX_TS_MS = 4_102_444_800_000
def _looks_like_timestamp_ms(value: Any) -> bool:
return isinstance(value, int) and _MIN_TS_MS <= value <= _MAX_TS_MS
# -- AF block parsing (for the initial 300 items embedded in the HTML) ------
_AF_BLOCK_RE = re.compile(r"AF_initDataCallback\s*\(\s*\{", re.DOTALL)
_DATA_KEY_RE = re.compile(r"[\s,{]data\s*:\s*\[", re.DOTALL)
def _iter_af_data_blobs(html: str):
"""Yield the JSON text of every AF_initDataCallback ``data:`` value."""
for m in _AF_BLOCK_RE.finditer(html):
block_end = _balanced_close(html, m.end() - 1, "{", "}")
if block_end is None:
continue
block = html[m.end() - 1: block_end + 1]
data_match = _DATA_KEY_RE.search(block)
if data_match is None:
continue
open_pos = data_match.end() - 1
close_pos = _balanced_close(block, open_pos, "[", "]")
if close_pos is None:
continue
yield block[open_pos: close_pos + 1]
def _balanced_close(s: str, open_idx: int, open_char: str, close_char: str) -> int | None:
if open_idx >= len(s) or s[open_idx] != open_char:
return None
depth = 0
in_string: str | None = None
i = open_idx
n = len(s)
while i < n:
c = s[i]
if in_string is not None:
if c == "\\":
i += 2
continue
if c == in_string:
in_string = None
i += 1
continue
if c in ("'", '"'):
in_string = c
i += 1
continue
if c == open_char:
depth += 1
elif c == close_char:
depth -= 1
if depth == 0:
return i
i += 1
return None
def _collect_album_item_lists(node: Any, _out: list[list[Any]] | None = None) -> list[list[Any]]:
"""Walk the AF data tree, collecting lists of album-item-shaped entries."""
out = _out if _out is not None else []
if isinstance(node, list):
if _list_looks_like_album_items(node):
out.append(node)
for child in node:
_collect_album_item_lists(child, out)
elif isinstance(node, dict):
for child in node.values():
_collect_album_item_lists(child, out)
return out
def _list_looks_like_album_items(lst: list[Any]) -> bool:
if not lst:
return False
sample = lst[:20]
for item in sample:
if _parse_album_item(item) is None:
return False
return True
# -- URL normalisation -------------------------------------------------------
def _normalise_size(url: str, width: int | None, height: int | None) -> str:
"""Strip any existing size suffix and request a 4K-capped version."""
base = _SIZE_SUFFIX_RE.sub("", url)
if width and height:
try:
w = int(width)
h = int(height)
except (TypeError, ValueError):
return f"{base}=w1920-h1080"
longest = max(w, h)
if longest > 3840:
scale = 3840 / longest
w = max(1, int(round(w * scale)))
h = max(1, int(round(h * scale)))
return f"{base}=w{w}-h{h}"
return f"{base}=w1920-h1080"
# Backwards-compatible names so the existing tests still find the helpers.
def parse_album_html(html: str) -> list[MediaItem]:
"""Parse only the first-page items embedded in the HTML.
Retained for backwards compatibility with the 0.5.0-rc1 surface; new
callers should use ``fetch_album`` for the full paginated result.
"""
return _extract_first_page_items(html)
_PHOTO_HOST_RE = re.compile(
r"^https?://(?:[a-z0-9-]+\.)?(?:googleusercontent\.com|google\.com)/",
re.IGNORECASE,
)
def _is_dimension(v: Any) -> bool:
return isinstance(v, int) and 16 <= v <= 20_000
@@ -0,0 +1,277 @@
from __future__ import annotations
import io
import logging
from PIL import Image, ImageColor, ImageFilter, ImageOps
from .coordinator import MediaItem
_LOGGER = logging.getLogger(__name__)
# Re-export fill mode constants so callers can import from here.
FILL_COVER = "cover"
FILL_CONTAIN = "contain"
FILL_BLUR = "blur"
# Absolute pixel ceiling. A 20000x20000 JPEG decodes to ~1.2 GB of RGB; Pillow
# raises DecompressionBombError above MAX_IMAGE_PIXELS. We set this high enough
# that 4K+ sources still decode, but reject anything absurd to protect
# low-memory devices like the Home Assistant Green.
_MAX_IMAGE_PIXELS = 80_000_000 # ~8K x 10K
Image.MAX_IMAGE_PIXELS = _MAX_IMAGE_PIXELS
def open_image(
data: bytes,
target_size: tuple[int, int] | None = None,
) -> Image.Image:
"""Open image bytes, apply EXIF orientation, normalise to RGB/RGBA.
If ``target_size`` is given, uses PIL's ``draft`` mode so libjpeg decodes
at a reduced scale. Big speed/memory win on low-power devices when the
source is much larger than the output canvas.
"""
img = Image.open(io.BytesIO(data))
if target_size is not None and img.format == "JPEG":
try:
img.draft("RGB", target_size)
except Exception:
pass
img = ImageOps.exif_transpose(img)
# Force pixel data into memory; BytesIO must stay reachable until here.
img.load()
if img.mode not in ("RGB", "RGBA"):
img = img.convert("RGB")
return img
def safe_close(img: Image.Image | None) -> None:
"""Close a PIL image without raising. No-op on None."""
if img is None:
return
try:
img.close()
except Exception:
pass
def is_portrait_img(img: Image.Image) -> bool:
try:
w, h = img.size
return h >= w
except Exception:
return False
def is_portrait_item(item: MediaItem, img: Image.Image | None = None) -> bool:
by_meta = _is_portrait_dims(item.width, item.height)
if by_meta is not None:
return by_meta
if img is not None:
return is_portrait_img(img)
return False
def is_portrait_item_by_metadata(item: MediaItem) -> bool | None:
"""Return portrait/landscape from item metadata only, or None if unknown."""
return _is_portrait_dims(item.width, item.height)
def resolve_output_size(
req_w: int | None,
req_h: int | None,
ratio: str,
max_short_edge: int | None = None,
) -> tuple[int, int]:
ratio_w, ratio_h = _parse_aspect_ratio(ratio)
target = ratio_w / ratio_h
if req_w is None and req_h is None:
if ratio_w >= ratio_h:
width = 3840
height = max(1, int(round(width / target)))
else:
height = 3840
width = max(1, int(round(height * target)))
elif req_w is None:
height = max(1, int(req_h or 2160))
width = max(1, int(round(height * target)))
elif req_h is None:
width = max(1, int(req_w or 3840))
height = max(1, int(round(width / target)))
else:
req_w = max(1, int(req_w))
req_h = max(1, int(req_h))
if (req_w / req_h) >= target:
height = req_h
width = max(1, int(round(height * target)))
else:
width = req_w
height = max(1, int(round(width / target)))
if max_short_edge is not None:
short = min(width, height)
if short > max_short_edge:
scale = max_short_edge / short
width = max(1, int(round(width * scale)))
height = max(1, int(round(height * scale)))
return (width, height)
def render_image(img: Image.Image, fill_mode: str, width: int, height: int) -> Image.Image:
"""Render img into a (width x height) canvas using the given fill mode."""
if fill_mode == FILL_CONTAIN:
return _resize_contain(img, width, height)
if fill_mode == FILL_BLUR:
return _blur_fill(img, width, height)
return _resize_cover(img, width, height)
def pair_images(
img1: Image.Image,
img2: Image.Image,
target_w: int,
target_h: int,
fill_mode: str,
portrait_canvas: bool,
divider: int,
divider_fill: tuple[int, int, int] | tuple[int, int, int, int],
transparent_divider: bool,
) -> Image.Image:
canvas_mode = "RGBA" if transparent_divider else "RGB"
canvas = Image.new(canvas_mode, (target_w, target_h), divider_fill)
if portrait_canvas:
top_h = max(1, (target_h - divider) // 2)
bottom_h = max(1, target_h - divider - top_h)
top_img = render_image(img1, fill_mode, target_w, top_h)
bottom_img = render_image(img2, fill_mode, target_w, bottom_h)
canvas.paste(top_img.convert(canvas_mode), (0, 0))
canvas.paste(bottom_img.convert(canvas_mode), (0, top_h + divider))
safe_close(top_img)
safe_close(bottom_img)
return canvas
left_w = max(1, (target_w - divider) // 2)
right_w = max(1, target_w - divider - left_w)
left_img = render_image(img1, fill_mode, left_w, target_h)
right_img = render_image(img2, fill_mode, right_w, target_h)
canvas.paste(left_img.convert(canvas_mode), (0, 0))
canvas.paste(right_img.convert(canvas_mode), (left_w + divider, 0))
safe_close(left_img)
safe_close(right_img)
return canvas
def encode_image(img: Image.Image) -> bytes:
"""Encode a PIL image to a client-compatible JPEG or PNG.
JPEGs are written as baseline (non-progressive) with 4:2:0 subsampling and
without EXIF, which maximises compatibility with Android WebView and older
clients. RGBA images are encoded as PNG to preserve alpha.
"""
out = io.BytesIO()
if "A" in img.getbands():
img.save(out, format="PNG", optimize=True)
return out.getvalue()
rgb = img if img.mode == "RGB" else img.convert("RGB")
rgb.save(
out,
format="JPEG",
quality=88,
optimize=True,
progressive=False,
subsampling=2,
)
if rgb is not img:
safe_close(rgb)
return out.getvalue()
def parse_divider_color(color: str) -> tuple[tuple[int, int, int] | tuple[int, int, int, int], bool]:
raw = (color or "").strip().lower()
compact = raw.replace(" ", "")
if compact in ("transparent", "transperant", "none", "clear", "rgba(0,0,0,0)"):
return (0, 0, 0, 0), True
try:
return ImageColor.getrgb(color), False
except Exception:
return (255, 255, 255), False
# -- Private helpers ---------------------------------------------------------
def _is_portrait_dims(width: int | None, height: int | None) -> bool | None:
if not width or not height:
return None
try:
w, h = int(width), int(height)
if w <= 0 or h <= 0:
return None
return h >= w
except Exception:
return None
def _parse_aspect_ratio(ratio: str) -> tuple[int, int]:
try:
left, right = ratio.split(":", maxsplit=1)
w, h = int(left), int(right)
if w > 0 and h > 0:
return (w, h)
except Exception:
pass
return (16, 9)
def _resize_cover(img: Image.Image, target_w: int, target_h: int) -> Image.Image:
src_w, src_h = img.size
if src_w <= 0 or src_h <= 0:
return img.resize((target_w, target_h))
scale = max(target_w / src_w, target_h / src_h)
new_w = max(1, int(round(src_w * scale)))
new_h = max(1, int(round(src_h * scale)))
resized = img.resize((new_w, new_h), Image.Resampling.LANCZOS)
left = max(0, int(round((new_w - target_w) / 2)))
top = max(0, int(round((new_h - target_h) / 2)))
cropped = resized.crop((left, top, left + target_w, top + target_h))
if cropped is not resized:
safe_close(resized)
return cropped
def _resize_contain(img: Image.Image, target_w: int, target_h: int, bg=(0, 0, 0)) -> Image.Image:
src_w, src_h = img.size
if src_w <= 0 or src_h <= 0:
return img.resize((target_w, target_h))
scale = min(target_w / src_w, target_h / src_h)
new_w = max(1, int(src_w * scale))
new_h = max(1, int(src_h * scale))
resized = img.resize((new_w, new_h), Image.Resampling.LANCZOS)
canvas = Image.new("RGB", (target_w, target_h), bg)
rgb_resized = resized if resized.mode == "RGB" else resized.convert("RGB")
canvas.paste(rgb_resized, ((target_w - new_w) // 2, (target_h - new_h) // 2))
if rgb_resized is not resized:
safe_close(rgb_resized)
safe_close(resized)
return canvas
def _blur_fill(img: Image.Image, target_w: int, target_h: int) -> Image.Image:
bg = _resize_cover(img, target_w, target_h).filter(ImageFilter.GaussianBlur(radius=24))
src_w, src_h = img.size
if src_w <= 0 or src_h <= 0:
return bg
scale = min(target_w / src_w, target_h / src_h)
new_w = max(1, int(src_w * scale))
new_h = max(1, int(src_h * scale))
fg = img.resize((new_w, new_h), Image.Resampling.LANCZOS)
rgb_fg = fg if fg.mode == "RGB" else fg.convert("RGB")
bg.paste(rgb_fg, ((target_w - new_w) // 2, (target_h - new_h) // 2))
if rgb_fg is not fg:
safe_close(rgb_fg)
safe_close(fg)
return bg
@@ -0,0 +1,12 @@
{
"domain": "album_slideshow",
"name": "Album Slideshow Camera",
"codeowners": ["@eyalgal"],
"config_flow": true,
"dependencies": ["http", "frontend"],
"documentation": "https://github.com/eyalgal/album_slideshow",
"iot_class": "cloud_polling",
"issue_tracker": "https://github.com/eyalgal/album_slideshow/issues",
"requirements": ["Pillow"],
"version": "1.0.0"
}
+174
View File
@@ -0,0 +1,174 @@
from __future__ import annotations
from homeassistant.components.number import NumberEntity, NumberMode
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from .const import DOMAIN
from .coordinator import AlbumCoordinator
from .store import SlideshowStore
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None:
store: SlideshowStore = hass.data[DOMAIN][entry.entry_id]["store"]
coordinator: AlbumCoordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"]
async_add_entities(
[
SlideIntervalNumber(entry, store),
RefreshHoursNumber(entry, store, coordinator),
PairDividerWidthNumber(entry, store),
ImageCacheMbNumber(entry, store),
]
)
class _BaseNumber(NumberEntity, RestoreEntity):
_attr_has_entity_name = True
_attr_should_poll = False
_attr_mode = NumberMode.BOX
def __init__(self, entry: ConfigEntry, store: SlideshowStore) -> None:
self.entry = entry
self.store = store
async def async_added_to_hass(self) -> None:
await super().async_added_to_hass()
self.store.add_listener(self.async_write_ha_state)
@property
def device_info(self):
return {
"identifiers": {(DOMAIN, self.entry.entry_id)},
"name": f"Album Slideshow {self.entry.title}",
"manufacturer": "Album Slideshow",
}
class SlideIntervalNumber(_BaseNumber):
_attr_icon = "mdi:timer-outline"
_attr_native_min_value = 3
_attr_native_max_value = 3600
_attr_native_step = 1
def __init__(self, entry: ConfigEntry, store: SlideshowStore) -> None:
super().__init__(entry, store)
self._attr_unique_id = f"{entry.entry_id}_interval"
self._attr_name = "Slide interval (seconds)"
@property
def native_value(self):
return int(self.store.slide_interval)
async def async_set_native_value(self, value: float) -> None:
self.store.slide_interval = int(value)
self.store.notify()
async def async_added_to_hass(self) -> None:
await super().async_added_to_hass()
old = await self.async_get_last_state()
if old and old.state not in (None, "unknown", "unavailable"):
try:
self.store.slide_interval = int(float(old.state))
self.store.notify()
except Exception:
return
class RefreshHoursNumber(_BaseNumber):
_attr_icon = "mdi:refresh"
_attr_native_min_value = 1
_attr_native_max_value = 168
_attr_native_step = 1
def __init__(self, entry: ConfigEntry, store: SlideshowStore, coordinator: AlbumCoordinator) -> None:
super().__init__(entry, store)
self.coordinator = coordinator
self._attr_unique_id = f"{entry.entry_id}_refresh_minutes"
self._attr_name = "Album refresh (hours)"
@property
def native_value(self):
return int(self.store.refresh_hours)
async def async_set_native_value(self, value: float) -> None:
self.store.refresh_hours = int(value)
self.store.notify()
await self.coordinator.async_request_refresh()
async def async_added_to_hass(self) -> None:
await super().async_added_to_hass()
old = await self.async_get_last_state()
if old and old.state not in (None, "unknown", "unavailable"):
try:
val = int(float(old.state))
# Migration: old values were in minutes; convert if above max hours
if val > self._attr_native_max_value:
val = max(int(val / 60), int(self._attr_native_min_value))
self.store.refresh_hours = val
self.store.notify()
except Exception:
return
class PairDividerWidthNumber(_BaseNumber):
_attr_icon = "mdi:border-vertical"
_attr_native_min_value = 0
_attr_native_max_value = 64
_attr_native_step = 1
def __init__(self, entry: ConfigEntry, store: SlideshowStore) -> None:
super().__init__(entry, store)
self._attr_unique_id = f"{entry.entry_id}_pair_divider_px"
self._attr_name = "Pair divider size (px)"
@property
def native_value(self):
return int(self.store.pair_divider_px)
async def async_set_native_value(self, value: float) -> None:
self.store.pair_divider_px = max(0, int(value))
self.store.notify()
async def async_added_to_hass(self) -> None:
await super().async_added_to_hass()
old = await self.async_get_last_state()
if old and old.state not in (None, "unknown", "unavailable"):
try:
self.store.pair_divider_px = max(0, int(float(old.state)))
self.store.notify()
except Exception:
return
class ImageCacheMbNumber(_BaseNumber):
_attr_icon = "mdi:database-outline"
_attr_native_min_value = 50
_attr_native_max_value = 1000
_attr_native_step = 50
_attr_native_unit_of_measurement = "MB"
def __init__(self, entry: ConfigEntry, store: SlideshowStore) -> None:
super().__init__(entry, store)
self._attr_unique_id = f"{entry.entry_id}_image_cache_mb"
self._attr_name = "Image cache size (MB)"
@property
def native_value(self):
return int(self.store.image_cache_mb)
async def async_set_native_value(self, value: float) -> None:
self.store.image_cache_mb = max(50, int(value))
self.store.notify()
async def async_added_to_hass(self) -> None:
await super().async_added_to_hass()
old = await self.async_get_last_state()
if old and old.state not in (None, "unknown", "unavailable"):
try:
self.store.image_cache_mb = max(50, int(float(old.state)))
self.store.notify()
except Exception:
return
@@ -0,0 +1,138 @@
"""Playlist construction: ordering and date filtering.
Pure, dependency-free helpers so the camera and tests can share a single
implementation. Operates on ``MediaItem``-like objects that expose
``captured_at`` / ``uploaded_at`` (epoch ms or ``None``).
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Iterable, TypeVar
from .const import (
DATE_FILTER_LAST_7,
DATE_FILTER_LAST_30,
DATE_FILTER_LAST_365,
DATE_FILTER_OFF,
DATE_FILTER_ON_THIS_DAY,
DATE_FILTER_THIS_MONTH,
DATE_FILTER_THIS_YEAR,
ORDER_ALBUM,
ORDER_NEWEST_ADDED,
ORDER_NEWEST_TAKEN,
ORDER_OLDEST_ADDED,
ORDER_OLDEST_TAKEN,
ORDER_RANDOM,
)
T = TypeVar("T")
def order_items(items: list[T], order_mode: str) -> list[T]:
"""Return a new list ordered per ``order_mode``.
``random`` and ``album_order`` are no-ops here - random shuffling lives
in the camera so it can dedupe recent slides; ``album_order`` keeps the
source order untouched. The taken/added orderings are stable; items
without the required timestamp keep their relative position at the end.
"""
if order_mode == ORDER_RANDOM or order_mode == ORDER_ALBUM:
return list(items)
key_attr, reverse = _order_key(order_mode)
if key_attr is None:
return list(items)
with_ts: list[tuple[int, int, T]] = []
without_ts: list[tuple[int, T]] = []
for idx, it in enumerate(items):
ts = getattr(it, key_attr, None)
if isinstance(ts, int):
with_ts.append((ts, idx, it))
else:
without_ts.append((idx, it))
with_ts.sort(key=lambda t: (t[0], t[1]), reverse=reverse)
return [it for _, _, it in with_ts] + [it for _, it in without_ts]
def _order_key(order_mode: str) -> tuple[str | None, bool]:
if order_mode == ORDER_NEWEST_TAKEN:
return "captured_at", True
if order_mode == ORDER_OLDEST_TAKEN:
return "captured_at", False
if order_mode == ORDER_NEWEST_ADDED:
return "uploaded_at", True
if order_mode == ORDER_OLDEST_ADDED:
return "uploaded_at", False
return None, False
def filter_items(
items: Iterable[T],
*,
mode: str,
now: datetime | None = None,
) -> list[T]:
"""Filter items by ``captured_at`` according to ``mode``.
Items with no ``captured_at`` are kept by default unless the mode is
``on_this_day`` (treated as a strict filter).
``now`` is overridable for deterministic tests.
"""
if not mode or mode == DATE_FILTER_OFF:
return list(items)
today_utc = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
pred, strict = _build_predicate(mode, today_utc)
if pred is None:
return list(items)
out: list[T] = []
for it in items:
ts = getattr(it, "captured_at", None)
if not isinstance(ts, int):
if not strict:
out.append(it)
continue
if pred(ts):
out.append(it)
return out
def _build_predicate(
mode: str,
today_utc: datetime,
):
"""Return (predicate, strict). ``strict`` drops items without timestamps."""
if mode == DATE_FILTER_LAST_7:
cutoff = int((today_utc - timedelta(days=7)).timestamp() * 1000)
return (lambda ts: ts >= cutoff), False
if mode == DATE_FILTER_LAST_30:
cutoff = int((today_utc - timedelta(days=30)).timestamp() * 1000)
return (lambda ts: ts >= cutoff), False
if mode == DATE_FILTER_LAST_365:
cutoff = int((today_utc - timedelta(days=365)).timestamp() * 1000)
return (lambda ts: ts >= cutoff), False
if mode == DATE_FILTER_THIS_MONTH:
start = today_utc.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
cutoff = int(start.timestamp() * 1000)
return (lambda ts: ts >= cutoff), False
if mode == DATE_FILTER_THIS_YEAR:
start = today_utc.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0)
cutoff = int(start.timestamp() * 1000)
return (lambda ts: ts >= cutoff), False
if mode == DATE_FILTER_ON_THIS_DAY:
# Match items whose UTC month+day equals today's. Useful for daily
# "memories"-style rotation across all years.
today_md = (today_utc.month, today_utc.day)
def _on_this_day(ts: int) -> bool:
d = datetime.fromtimestamp(ts / 1000, tz=timezone.utc)
return (d.month, d.day) == today_md
return _on_this_day, True
return None, False
+241
View File
@@ -0,0 +1,241 @@
from __future__ import annotations
from homeassistant.components.select import SelectEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from .const import (
DOMAIN,
FILL_COVER,
FILL_CONTAIN,
FILL_BLUR,
MAX_RESOLUTION_OPTIONS,
ORIENTATION_MISMATCH_PAIR,
ORIENTATION_MISMATCH_SINGLE,
ORIENTATION_MISMATCH_AVOID,
ORDER_OPTIONS,
ORDER_RANDOM,
DATE_FILTER_OPTIONS,
DATE_FILTER_OFF,
)
from .store import SlideshowStore
ASPECT_RATIO_OPTIONS = ["16:9", "16:10", "4:3", "1:1", "3:4", "10:16", "9:16"]
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
store: SlideshowStore = hass.data[DOMAIN][entry.entry_id]["store"]
async_add_entities(
[
FillModeSelect(entry, store),
PortraitModeSelect(entry, store),
OrderModeSelect(entry, store),
AspectRatioSelect(entry, store),
MaxResolutionSelect(entry, store),
DateFilterSelect(entry, store),
]
)
class _BaseSelect(SelectEntity, RestoreEntity):
_attr_has_entity_name = True
_attr_should_poll = False
_attr_entity_registry_enabled_default = True
def __init__(self, entry: ConfigEntry, store: SlideshowStore) -> None:
self.entry = entry
self.store = store
async def async_added_to_hass(self) -> None:
await super().async_added_to_hass()
self.store.add_listener(self.async_write_ha_state)
@property
def device_info(self):
return {
"identifiers": {(DOMAIN, self.entry.entry_id)},
"name": f"Album Slideshow {self.entry.title}",
"manufacturer": "Album Slideshow",
}
class FillModeSelect(_BaseSelect):
_attr_icon = "mdi:aspect-ratio"
def __init__(self, entry: ConfigEntry, store: SlideshowStore) -> None:
super().__init__(entry, store)
self._attr_unique_id = f"{entry.entry_id}_fill_mode"
self._attr_name = "Fill mode"
self._attr_options = [FILL_BLUR, FILL_COVER, FILL_CONTAIN]
@property
def current_option(self):
value = self.store.fill_mode
return value if value in self.options else self.options[0]
async def async_select_option(self, option: str) -> None:
if option not in self.options:
return
self.store.fill_mode = option
self.store.notify()
async def async_added_to_hass(self) -> None:
await super().async_added_to_hass()
old = await self.async_get_last_state()
if old and old.state in self.options:
self.store.fill_mode = old.state
self.store.notify()
class PortraitModeSelect(_BaseSelect):
_attr_icon = "mdi:account-box-outline"
def __init__(self, entry: ConfigEntry, store: SlideshowStore) -> None:
super().__init__(entry, store)
self._attr_unique_id = f"{entry.entry_id}_portrait_mode"
self._attr_name = "Orientation mismatch mode"
self._attr_options = [
ORIENTATION_MISMATCH_PAIR,
ORIENTATION_MISMATCH_SINGLE,
ORIENTATION_MISMATCH_AVOID,
]
@property
def current_option(self):
value = self.store.portrait_mode
return value if value in self.options else self.options[0]
async def async_select_option(self, option: str) -> None:
if option not in self.options:
return
self.store.portrait_mode = option
self.store.notify()
async def async_added_to_hass(self) -> None:
await super().async_added_to_hass()
old = await self.async_get_last_state()
if old:
restored = old.state
if restored in ("blur", "crop"):
restored = ORIENTATION_MISMATCH_SINGLE
if restored in self.options:
self.store.portrait_mode = restored
self.store.notify()
class OrderModeSelect(_BaseSelect):
_attr_icon = "mdi:shuffle-variant"
def __init__(self, entry: ConfigEntry, store: SlideshowStore) -> None:
super().__init__(entry, store)
self._attr_unique_id = f"{entry.entry_id}_order_mode"
self._attr_name = "Order mode"
self._attr_options = list(ORDER_OPTIONS)
@property
def current_option(self):
value = self.store.order_mode
return value if value in self.options else ORDER_RANDOM
async def async_select_option(self, option: str) -> None:
if option not in self.options:
return
self.store.order_mode = option
self.store.notify()
async def async_added_to_hass(self) -> None:
await super().async_added_to_hass()
old = await self.async_get_last_state()
if old and old.state in self.options:
self.store.order_mode = old.state
self.store.notify()
class AspectRatioSelect(_BaseSelect):
_attr_icon = "mdi:crop"
def __init__(self, entry: ConfigEntry, store: SlideshowStore) -> None:
super().__init__(entry, store)
self._attr_unique_id = f"{entry.entry_id}_aspect_ratio"
self._attr_name = "Aspect ratio"
self._attr_options = ASPECT_RATIO_OPTIONS
@property
def current_option(self):
value = self.store.aspect_ratio
return value if value in self.options else self.options[0]
async def async_select_option(self, option: str) -> None:
if option not in self.options:
return
self.store.aspect_ratio = option
self.store.notify()
async def async_added_to_hass(self) -> None:
await super().async_added_to_hass()
old = await self.async_get_last_state()
if old and old.state in self.options:
self.store.aspect_ratio = old.state
self.store.notify()
class MaxResolutionSelect(_BaseSelect):
_attr_icon = "mdi:image-size-select-large"
def __init__(self, entry: ConfigEntry, store: SlideshowStore) -> None:
super().__init__(entry, store)
self._attr_unique_id = f"{entry.entry_id}_max_resolution"
self._attr_name = "Max resolution"
self._attr_options = MAX_RESOLUTION_OPTIONS
@property
def current_option(self):
value = self.store.max_resolution
return value if value in self.options else self.options[0]
async def async_select_option(self, option: str) -> None:
if option not in self.options:
return
self.store.max_resolution = option
self.store.notify()
async def async_added_to_hass(self) -> None:
await super().async_added_to_hass()
old = await self.async_get_last_state()
if old and old.state in self.options:
self.store.max_resolution = old.state
self.store.notify()
class DateFilterSelect(_BaseSelect):
_attr_icon = "mdi:calendar-filter"
def __init__(self, entry: ConfigEntry, store: SlideshowStore) -> None:
super().__init__(entry, store)
self._attr_unique_id = f"{entry.entry_id}_date_filter"
self._attr_name = "Date filter"
self._attr_options = list(DATE_FILTER_OPTIONS)
@property
def current_option(self):
value = self.store.date_filter
return value if value in self.options else DATE_FILTER_OFF
async def async_select_option(self, option: str) -> None:
if option not in self.options:
return
self.store.date_filter = option
self.store.notify()
async def async_added_to_hass(self) -> None:
await super().async_added_to_hass()
old = await self.async_get_last_state()
if old and old.state in self.options:
self.store.date_filter = old.state
self.store.notify()
+150
View File
@@ -0,0 +1,150 @@
from __future__ import annotations
from homeassistant.components.sensor import SensorEntity, SensorStateClass
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN, PROVIDER_GOOGLE_SHARED, PROVIDER_LOCAL_FOLDER
from .coordinator import AlbumCoordinator
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None:
coordinator: AlbumCoordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"]
entities: list[SensorEntity] = [
AlbumCountSensor(entry, coordinator),
AlbumTitleSensor(entry, coordinator),
CacheUsageSensor(entry, coordinator),
]
if coordinator.provider == PROVIDER_LOCAL_FOLDER:
# Diagnostic surface for the local-folder background enrichment
# (EXIF reads + reverse-geocode). For Google albums there's no
# enrichment work, so this sensor is omitted to keep the device
# screen tidy.
entities.append(EnrichmentProgressSensor(entry, coordinator))
async_add_entities(entities)
class _BaseAlbumSensor(SensorEntity):
_attr_should_poll = False
_attr_has_entity_name = True
def __init__(self, entry: ConfigEntry, coordinator: AlbumCoordinator) -> None:
self.entry = entry
self.coordinator = coordinator
coordinator.async_add_listener(self.async_write_ha_state)
@property
def device_info(self):
return {
"identifiers": {(DOMAIN, self.entry.entry_id)},
"name": f"Album Slideshow {self.entry.title}",
"manufacturer": "Album Slideshow",
}
def _provider_icon(self) -> str:
if self.coordinator.provider == PROVIDER_GOOGLE_SHARED:
return "mdi:google-photos"
return "mdi:folder-multiple-image"
class AlbumCountSensor(_BaseAlbumSensor):
def __init__(self, entry: ConfigEntry, coordinator: AlbumCoordinator) -> None:
super().__init__(entry, coordinator)
self._attr_unique_id = f"{entry.entry_id}_count"
self._attr_name = "Photo count"
@property
def icon(self) -> str:
return self._provider_icon()
@property
def native_value(self):
data = self.coordinator.data or {}
return len(data.get("items", []))
class AlbumTitleSensor(_BaseAlbumSensor):
def __init__(self, entry: ConfigEntry, coordinator: AlbumCoordinator) -> None:
super().__init__(entry, coordinator)
self._attr_unique_id = f"{entry.entry_id}_title"
self._attr_name = "Album title"
@property
def icon(self) -> str:
return self._provider_icon()
@property
def native_value(self):
data = self.coordinator.data or {}
return data.get("title")
class CacheUsageSensor(_BaseAlbumSensor):
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_native_unit_of_measurement = "MB"
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_icon = "mdi:database"
_attr_should_poll = True
def __init__(self, entry: ConfigEntry, coordinator: AlbumCoordinator) -> None:
super().__init__(entry, coordinator)
self._attr_unique_id = f"{entry.entry_id}_cache_usage"
self._attr_name = "Image cache usage"
@property
def native_value(self):
cam = self.hass.data.get(DOMAIN, {}).get(self.entry.entry_id, {}).get("camera")
if cam is None:
return None
return cam.cache_usage_mb
class EnrichmentProgressSensor(_BaseAlbumSensor):
"""Percent-complete sensor for the local-folder enrichment worker.
Reports the slower-changing of the two phases:
- ``exif``: reading capture date + GPS from EXIF tags.
- ``geocoding``: reverse-geocoding GPS to a city/country label.
Holds at ``100`` when both phases finish and stays there until the
next coordinator refresh discovers new files. The ``phase`` and
raw counts are surfaced as state attributes so dashboards can show
progress text alongside the bar.
"""
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_native_unit_of_measurement = "%"
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_icon = "mdi:map-marker-radius"
def __init__(self, entry: ConfigEntry, coordinator: AlbumCoordinator) -> None:
super().__init__(entry, coordinator)
self._attr_unique_id = f"{entry.entry_id}_enrichment_progress"
self._attr_name = "Enrichment progress"
@property
def native_value(self):
prog = getattr(self.coordinator, "_enrich_progress", None) or {}
phase = prog.get("phase")
if phase == "geocoding":
total = prog.get("geocode_total") or 0
done = prog.get("geocode_done") or 0
else:
total = prog.get("exif_total") or 0
done = prog.get("exif_done") or 0
if total <= 0:
return None
return max(0, min(100, round(100 * done / total)))
@property
def extra_state_attributes(self):
prog = getattr(self.coordinator, "_enrich_progress", None) or {}
return {
"phase": prog.get("phase"),
"exif_total": prog.get("exif_total", 0),
"exif_done": prog.get("exif_done", 0),
"geocode_total": prog.get("geocode_total", 0),
"geocode_done": prog.get("geocode_done", 0),
}
@@ -0,0 +1,21 @@
next_slide:
name: Next slide
description: Advance the slideshow to the next image for a specific config entry.
fields:
entry_id:
name: Entry ID
description: The config entry id for the album slideshow instance.
required: true
selector:
text:
refresh_album:
name: Refresh album
description: Refresh the album list for a specific config entry.
fields:
entry_id:
name: Entry ID
description: The config entry id for the album slideshow instance.
required: true
selector:
text:
@@ -0,0 +1,55 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Callable
from .const import (
DEFAULT_SLIDE_INTERVAL,
DEFAULT_REFRESH_HOURS,
DEFAULT_FILL_MODE,
DEFAULT_ORIENTATION_MISMATCH_MODE,
DEFAULT_ORDER_MODE,
DEFAULT_ASPECT_RATIO,
DEFAULT_PAIR_DIVIDER_PX,
DEFAULT_PAIR_DIVIDER_COLOR,
DEFAULT_IMAGE_CACHE_MB,
DEFAULT_MAX_RESOLUTION,
DEFAULT_DATE_FILTER,
)
Listener = Callable[[], None]
@dataclass
class SlideshowStore:
slide_interval: int = DEFAULT_SLIDE_INTERVAL
refresh_hours: int = DEFAULT_REFRESH_HOURS
fill_mode: str = DEFAULT_FILL_MODE
portrait_mode: str = DEFAULT_ORIENTATION_MISMATCH_MODE
order_mode: str = DEFAULT_ORDER_MODE
aspect_ratio: str = DEFAULT_ASPECT_RATIO
pair_divider_px: int = DEFAULT_PAIR_DIVIDER_PX
pair_divider_color: str = DEFAULT_PAIR_DIVIDER_COLOR
image_cache_mb: int = DEFAULT_IMAGE_CACHE_MB
max_resolution: str = DEFAULT_MAX_RESOLUTION
# Date filter mode (preset windows like this_year / on_this_day).
date_filter: str = DEFAULT_DATE_FILTER
# Pause toggle - when True, the slideshow holds on the current frame.
paused: bool = False
# In-memory last rendered frame. Not user-configurable; used to re-serve
# the previous slide instantly across a camera reload.
last_frame: bytes | None = None
_listeners: list[Listener] = field(default_factory=list)
def add_listener(self, cb: Listener) -> None:
if cb not in self._listeners:
self._listeners.append(cb)
def notify(self) -> None:
for cb in list(self._listeners):
cb()
@@ -0,0 +1,45 @@
{
"config": {
"step": {
"user": {
"title": "Add album slideshow",
"description": "Choose a source for the slideshow.",
"data": {
"provider": "Source"
}
},
"google_shared": {
"title": "Google Photos",
"description": "Paste a Google Photos shared album share link.",
"data": {
"album_name": "Album name",
"album_url": "Shared album link"
}
},
"local_folder": {
"title": "Local Folder",
"description": "Pick a folder path on your Home Assistant filesystem. Use /local/... (maps to /config/www/...) or /media/local/... for NAS-mounted media folders. See the README for details.",
"data": {
"album_name": "Album name",
"local_path": "Folder path",
"recursive": "Include subfolders"
}
}
},
"error": {
"invalid_album_url": "That does not look like a Google Photos shared album link.",
"invalid_path": "Path is empty or invalid."
}
},
"options": {
"step": {
"init": {
"title": "Local Folder options",
"description": "Reverse geocoding sends your photos' EXIF GPS coordinates to the public OpenStreetMap Nominatim service to look up a human-readable place name. Coordinates are rounded to ~100 m before lookup and cached on disk. Turn this off to keep coordinates entirely local; the latitude and longitude attributes still work either way.",
"data": {
"reverse_geocode": "Reverse-geocode EXIF GPS coordinates via OpenStreetMap"
}
}
}
}
}
@@ -0,0 +1,55 @@
from __future__ import annotations
from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from .const import DOMAIN
from .store import SlideshowStore
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None:
store: SlideshowStore = hass.data[DOMAIN][entry.entry_id]["store"]
async_add_entities([PauseSwitch(entry, store)])
class PauseSwitch(SwitchEntity, RestoreEntity):
_attr_has_entity_name = True
_attr_should_poll = False
_attr_icon = "mdi:pause"
def __init__(self, entry: ConfigEntry, store: SlideshowStore) -> None:
self.entry = entry
self.store = store
self._attr_unique_id = f"{entry.entry_id}_paused"
self._attr_name = "Pause slideshow"
@property
def is_on(self) -> bool:
return bool(self.store.paused)
@property
def device_info(self):
return {
"identifiers": {(DOMAIN, self.entry.entry_id)},
"name": f"Album Slideshow {self.entry.title}",
"manufacturer": "Album Slideshow",
}
async def async_turn_on(self, **kwargs) -> None:
self.store.paused = True
self.store.notify()
async def async_turn_off(self, **kwargs) -> None:
self.store.paused = False
self.store.notify()
async def async_added_to_hass(self) -> None:
await super().async_added_to_hass()
self.store.add_listener(self.async_write_ha_state)
old = await self.async_get_last_state()
if old is not None and old.state in ("on", "off"):
self.store.paused = old.state == "on"
self.store.notify()
+60
View File
@@ -0,0 +1,60 @@
from __future__ import annotations
from homeassistant.components.text import TextEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from .const import DOMAIN, DEFAULT_PAIR_DIVIDER_COLOR
from .store import SlideshowStore
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None:
store: SlideshowStore = hass.data[DOMAIN][entry.entry_id]["store"]
async_add_entities([PairDividerColorText(entry, store)])
class PairDividerColorText(TextEntity, RestoreEntity):
_attr_has_entity_name = True
_attr_should_poll = False
_attr_icon = "mdi:palette"
_attr_native_min = 1
_attr_native_max = 32
def __init__(self, entry: ConfigEntry, store: SlideshowStore) -> None:
self.entry = entry
self.store = store
self._attr_unique_id = f"{entry.entry_id}_pair_divider_color"
self._attr_name = "Pair divider color"
def _on_store_change() -> None:
self.async_write_ha_state()
store.add_listener(_on_store_change)
@property
def native_value(self) -> str:
return self.store.pair_divider_color
@property
def device_info(self):
return {
"identifiers": {(DOMAIN, self.entry.entry_id)},
"name": f"Album Slideshow {self.entry.title}",
"manufacturer": "Album Slideshow",
}
async def async_set_value(self, value: str) -> None:
val = (value or "").strip()
self.store.pair_divider_color = val or DEFAULT_PAIR_DIVIDER_COLOR
self.store.notify()
async def async_added_to_hass(self) -> None:
await super().async_added_to_hass()
self.store.add_listener(self.async_write_ha_state)
old = await self.async_get_last_state()
if old and old.state not in (None, "unknown", "unavailable"):
self.store.pair_divider_color = old.state.strip() or DEFAULT_PAIR_DIVIDER_COLOR
self.store.notify()
@@ -0,0 +1,45 @@
{
"config": {
"step": {
"user": {
"title": "Add album slideshow",
"description": "Choose a source for the slideshow.",
"data": {
"provider": "Source"
}
},
"google_shared": {
"title": "Google Photos",
"description": "Paste a Google Photos shared album share link.",
"data": {
"album_name": "Album name",
"album_url": "Shared album link"
}
},
"local_folder": {
"title": "Local Folder",
"description": "Pick a folder path on your Home Assistant filesystem. Use /local/... (maps to /config/www/...) or /media/local/... for NAS-mounted media folders. See the README for details.",
"data": {
"album_name": "Album name",
"local_path": "Folder path",
"recursive": "Include subfolders"
}
}
},
"error": {
"invalid_album_url": "That does not look like a Google Photos shared album link.",
"invalid_path": "Path is empty or invalid."
}
},
"options": {
"step": {
"init": {
"title": "Local Folder options",
"description": "Reverse geocoding sends your photos' EXIF GPS coordinates to the public OpenStreetMap Nominatim service to look up a human-readable place name. Coordinates are rounded to ~100 m before lookup and cached on disk. Turn this off to keep coordinates entirely local; the latitude and longitude attributes still work either way.",
"data": {
"reverse_geocode": "Reverse-geocode EXIF GPS coordinates via OpenStreetMap"
}
}
}
}
}
File diff suppressed because it is too large Load Diff