diff --git a/custom_components/album_slideshow/__init__.py b/custom_components/album_slideshow/__init__.py
index 5647de81..db146623 100644
--- a/custom_components/album_slideshow/__init__.py
+++ b/custom_components/album_slideshow/__init__.py
@@ -10,7 +10,7 @@ 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 .const import DOMAIN, SERVICE_NEXT_SLIDE, SERVICE_PREVIOUS_SLIDE, SERVICE_REFRESH_ALBUM, ATTR_ENTRY_ID
from .store import SlideshowStore
_LOGGER = logging.getLogger(__name__)
@@ -392,6 +392,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if cam:
await cam.async_force_next()
+ async def _previous_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_prev()
+
async def _refresh_album(call) -> None:
entry_id = call.data.get(ATTR_ENTRY_ID)
if not entry_id:
@@ -406,6 +417,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
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_PREVIOUS_SLIDE):
+ hass.services.async_register(DOMAIN, SERVICE_PREVIOUS_SLIDE, _previous_slide)
+
if not hass.services.has_service(DOMAIN, SERVICE_REFRESH_ALBUM):
hass.services.async_register(DOMAIN, SERVICE_REFRESH_ALBUM, _refresh_album)
diff --git a/custom_components/album_slideshow/button.py b/custom_components/album_slideshow/button.py
index 532cbfd3..ce4a81cd 100644
--- a/custom_components/album_slideshow/button.py
+++ b/custom_components/album_slideshow/button.py
@@ -5,7 +5,7 @@ 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 .const import DOMAIN, SERVICE_REFRESH_ALBUM, ATTR_ENTRY_ID
from .coordinator import AlbumCoordinator
@@ -13,6 +13,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e
coordinator: AlbumCoordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"]
async_add_entities(
[
+ PreviousSlideButton(hass, entry, coordinator),
NextSlideButton(hass, entry, coordinator),
RefreshAlbumButton(hass, entry, coordinator),
]
@@ -36,6 +37,19 @@ class _BaseButton(ButtonEntity):
}
+class PreviousSlideButton(_BaseButton):
+ def __init__(self, hass: HomeAssistant, entry: ConfigEntry, coordinator: AlbumCoordinator) -> None:
+ super().__init__(hass, entry, coordinator)
+ self._attr_unique_id = f"{entry.entry_id}_previous_button"
+ self._attr_name = "Previous slide"
+ self._attr_icon = "mdi:skip-previous"
+
+ async def async_press(self) -> None:
+ camera = self.hass.data[DOMAIN][self.entry.entry_id].get("camera")
+ if camera is not None:
+ await camera.async_force_prev()
+
+
class NextSlideButton(_BaseButton):
def __init__(self, hass: HomeAssistant, entry: ConfigEntry, coordinator: AlbumCoordinator) -> None:
super().__init__(hass, entry, coordinator)
@@ -44,12 +58,9 @@ class NextSlideButton(_BaseButton):
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,
- )
+ camera = self.hass.data[DOMAIN][self.entry.entry_id].get("camera")
+ if camera is not None:
+ await camera.async_force_next()
class RefreshAlbumButton(_BaseButton):
diff --git a/custom_components/album_slideshow/camera.py b/custom_components/album_slideshow/camera.py
index e6b5bec6..fbf0d0a2 100644
--- a/custom_components/album_slideshow/camera.py
+++ b/custom_components/album_slideshow/camera.py
@@ -1,10 +1,13 @@
from __future__ import annotations
import asyncio
-from collections import OrderedDict
+from collections import OrderedDict, deque
+from dataclasses import dataclass
+from datetime import datetime, timezone
import logging
import random
from pathlib import Path
+from typing import Any
import async_timeout
from PIL import Image
@@ -46,18 +49,44 @@ _ACCEPTED_IMAGE_PREFIX = ("image/",)
_PAIR_SEARCH_LIMIT = 12
_SKIP_SEARCH_LIMIT = 30
+_MAX_RENDER_ATTEMPTS = 10
+
+
+@dataclass(frozen=True, slots=True)
+class _NavigationCursor:
+ """All mutable ordering state needed to render the following slide."""
+
+ index: int
+ random_order: tuple[int, ...]
+ random_pos: int
+ recent_urls: tuple[str, ...]
+ rng_state: Any
+
+
+@dataclass(frozen=True, slots=True)
+class _RenderedFrame:
+ """A complete slide that can be displayed without any further work."""
+
+ data: bytes
+ cursor: _NavigationCursor
+ meta: dict
+
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
+def _utc_now_iso() -> str:
+ """Return the current time as an ISO-8601 UTC string."""
+ return datetime.now(timezone.utc).isoformat()
+
+
class _DownloadCache:
"""Byte-budget LRU cache for downloaded image data, O(1) per operation."""
@@ -155,22 +184,43 @@ class AlbumSlideshowCamera(Camera):
self._frame_id: int = 0
self._interrupt_event: asyncio.Event = asyncio.Event()
- self._force_next: bool = False
+ # Navigation runs directly in the button/service coroutine. The lock
+ # serialises rapid presses while buffered swaps remain independent of
+ # the background timer loop.
+ self._navigation_lock: asyncio.Lock = asyncio.Lock()
+ self._navigation_pending: int = 0
+ # Rendered timeline around the current frame. Previous frames and future
+ # frames are final encoded JPEGs, so both navigation directions are an
+ # O(1) deque swap. The future deque is replenished in the background.
+ self._current_frame: _RenderedFrame | None = None
+ self._previous_frames: deque[_RenderedFrame] = deque()
+ self._next_frames: deque[_RenderedFrame] = deque()
+ self._timeline_generation: int = 0
+ self._timeline_dirty: bool = False
+ self._next_ready_event: asyncio.Event = asyncio.Event()
+ self._preload_task: asyncio.Task | None = None
+ # Visible navigation diagnostics. These are state attributes rather
+ # than debug-only log messages so they remain observable when Home
+ # Assistant's UI filters debug logs.
+ self._last_nav_direction: str | None = None
+ self._last_nav_requested_at: str | None = None
+ self._last_nav_started_at: str | None = None
+ self._last_nav_committed_at: str | None = None
+ self._last_nav_outcome: str | None = None
+ self._last_nav_error: str | None = None
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()
+ self._invalidate_timeline()
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()
+ self._invalidate_timeline()
store.add_listener(_on_store_change)
@@ -193,12 +243,19 @@ class AlbumSlideshowCamera(Camera):
)
async def async_will_remove_from_hass(self) -> None:
+ preload_task = self._preload_task
+ self._cancel_preload()
if self._render_task is not None:
self._render_task.cancel()
try:
await self._render_task
except asyncio.CancelledError:
pass
+ if preload_task is not None:
+ try:
+ await preload_task
+ except asyncio.CancelledError:
+ pass
@property
def device_info(self):
@@ -258,6 +315,19 @@ class AlbumSlideshowCamera(Camera):
"pair_divider_px": int(self.store.pair_divider_px),
"pair_divider_color": self.store.pair_divider_color,
"frame_id": self._frame_id,
+ "navigation_buffer_size": self._buffer_depth,
+ "previous_frames_cached": len(self._previous_frames),
+ "next_frames_preloaded": len(self._next_frames),
+ "navigation_preloading": bool(
+ self._preload_task is not None and not self._preload_task.done()
+ ),
+ "navigation_queue_size": self._navigation_pending,
+ "last_navigation_direction": self._last_nav_direction,
+ "last_navigation_requested_at": self._last_nav_requested_at,
+ "last_navigation_started_at": self._last_nav_started_at,
+ "last_navigation_committed_at": self._last_nav_committed_at,
+ "last_navigation_outcome": self._last_nav_outcome,
+ "last_navigation_error": self._last_nav_error,
"pagination_debug": data.get("pagination_debug"),
}
@@ -331,10 +401,53 @@ class AlbumSlideshowCamera(Camera):
return ordered
async def async_force_next(self) -> None:
- self._force_next = True
+ """Display the next buffered slide immediately."""
+ await self._async_navigate(1)
+
+ async def async_force_prev(self) -> None:
+ """Display the previous retained slide immediately."""
+ await self._async_navigate(-1)
+
+ async def _async_navigate(self, direction: int) -> None:
+ """Serialise a manual navigation request and execute it directly."""
+ direction_name = "next" if direction > 0 else "previous"
+ self._navigation_pending += 1
+ self._last_nav_direction = direction_name
+ self._last_nav_requested_at = _utc_now_iso()
+ self._last_nav_outcome = "pending"
+ self._last_nav_error = None
+ # Wake the timer loop so the manual frame starts a fresh interval.
self._interrupt_event.set()
self.async_write_ha_state()
+ try:
+ async with self._navigation_lock:
+ self._last_nav_started_at = _utc_now_iso()
+ if self._timeline_dirty:
+ await self._rebuild_current_frame()
+ changed = (
+ await self._show_next_frame()
+ if direction > 0
+ else await self._show_previous_frame()
+ )
+ if changed:
+ self._last_nav_committed_at = _utc_now_iso()
+ self._last_nav_outcome = "displayed"
+ else:
+ self._last_nav_outcome = "not_available"
+ except Exception as err:
+ self._last_nav_outcome = "error"
+ self._last_nav_error = str(err)
+ _LOGGER.warning(
+ "Album Slideshow %s: manual %s navigation failed: %s",
+ self.entry.title,
+ direction_name,
+ err,
+ )
+ finally:
+ self._navigation_pending -= 1
+ self.async_write_ha_state()
+
async def async_force_refresh(self) -> None:
await self.coordinator.async_request_refresh()
@@ -373,123 +486,387 @@ class AlbumSlideshowCamera(Camera):
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.
+ @property
+ def _buffer_depth(self) -> int:
+ """Configured number of rendered frames retained in each direction."""
+ return min(10, max(0, int(self.store.navigation_buffer_size)))
- Does NOT clear ``_interrupt_event`` - the render loop clears it once
- per cycle, before rendering, so a signal that arrives while we're
- rendering (a "next slide" press, a coordinator/store change) survives
- until we get here instead of being wiped.
+ def _capture_cursor(self, source=None) -> _NavigationCursor:
+ """Snapshot ordering state from this camera or a private renderer."""
+ source = source or self
+ return _NavigationCursor(
+ index=int(source._index),
+ random_order=tuple(source._random_order),
+ random_pos=int(source._random_pos),
+ recent_urls=tuple(source._recent_urls),
+ rng_state=source._rng.getstate(),
+ )
- A force-next that's already pending is honored immediately without
- sleeping, so a press that landed during the last render (or right at
- the loop boundary) isn't held until the full slide interval elapses.
+ def _make_renderer(self, cursor: _NavigationCursor):
+ """Create an isolated render context sharing only immutable services/cache.
+
+ Composition helpers historically operate on ``self._index`` and random
+ ordering fields. A private camera-shaped context lets background
+ preloading reuse those mature helpers without ever mutating the live
+ entity's current frame or navigation cursor.
"""
- if self._force_next:
+ renderer = AlbumSlideshowCamera.__new__(AlbumSlideshowCamera)
+ renderer.hass = self.hass
+ renderer.entry = self.entry
+ renderer.coordinator = self.coordinator
+ renderer.store = self.store
+ renderer._download_cache = self._download_cache
+ renderer._index = cursor.index
+ renderer._random_order = list(cursor.random_order)
+ renderer._random_pos = cursor.random_pos
+ renderer._recent_urls = list(cursor.recent_urls)
+ renderer._rng = random.Random()
+ renderer._rng.setstate(cursor.rng_state)
+ return renderer
+
+ async def _render_available_frame(
+ self,
+ cursor: _NavigationCursor,
+ items: list[MediaItem],
+ *,
+ advance: bool,
+ ) -> _RenderedFrame:
+ """Render the current or next usable slide from ``cursor``.
+
+ Broken candidates are skipped, matching the old loop's retry behavior.
+ All state mutations occur on a private renderer. The returned JPEG and
+ cursor are therefore safe to place in the future buffer.
+ """
+ if not items:
+ raise RuntimeError("No media available")
+
+ attempts = min(len(items), _MAX_RENDER_ATTEMPTS)
+ last_error: Exception | None = None
+ should_advance = advance
+
+ for _ in range(attempts):
+ renderer = self._make_renderer(cursor)
+ renderer._index %= len(items)
+ if should_advance:
+ renderer._do_advance(len(items), items)
+
+ composed: Image.Image | None = None
+ try:
+ async with self._compose_semaphore:
+ composed, meta = await renderer._compose_for_index(items)
+ cursor = self._capture_cursor(renderer)
+ if composed is None:
+ raise RuntimeError("Image composition returned no frame")
+ encoded = await self.hass.async_add_executor_job(
+ ip.encode_image, composed
+ )
+ return _RenderedFrame(encoded, cursor, meta or {})
+ except asyncio.CancelledError:
+ raise
+ except Exception as err:
+ last_error = err
+ cursor = self._capture_cursor(renderer)
+ should_advance = True
+ _LOGGER.debug(
+ "Album Slideshow %s: skipping unrenderable buffered slide: %s",
+ self.entry.title,
+ err,
+ )
+ finally:
+ ip.safe_close(composed)
+
+ raise RuntimeError(
+ f"Could not render a usable slide after {attempts} attempts: {last_error}"
+ )
+
+ def _apply_frame(self, frame: _RenderedFrame) -> None:
+ """Make a rendered frame current and publish it to Home Assistant."""
+ self._current_frame = frame
+ self._framebuffer = frame.data
+ self.store.last_frame = frame.data
+ self._index = frame.cursor.index
+ self._random_order = list(frame.cursor.random_order)
+ self._random_pos = frame.cursor.random_pos
+ self._recent_urls = list(frame.cursor.recent_urls)
+ self._rng.setstate(frame.cursor.rng_state)
+
+ meta = frame.meta
+ self._last_is_portrait = meta.get("is_portrait")
+ self._last_captured_at_pair = meta.get("captured_at_pair")
+ self._last_pair_frames = meta.get("pair_frames")
+ self._last_pair_orientation = meta.get("pair_orientation")
+ self._frame_id += 1
+
+ _LOGGER.debug(
+ "Album Slideshow %s: displayed buffered frame_id=%d index=%d "
+ "previous=%d next=%d",
+ self.entry.title,
+ self._frame_id,
+ self._index,
+ len(self._previous_frames),
+ len(self._next_frames),
+ )
+ self.async_write_ha_state()
+
+ def _trim_timeline(self) -> None:
+ """Enforce the configured frame count on both sides of current."""
+ depth = self._buffer_depth
+ while len(self._previous_frames) > depth:
+ self._previous_frames.popleft()
+ while len(self._next_frames) > depth:
+ self._next_frames.pop()
+
+ def _cancel_preload(self) -> None:
+ task = self._preload_task
+ self._preload_task = None
+ if task is not None and not task.done():
+ task.cancel()
+ self._next_ready_event.set()
+
+ def _invalidate_timeline(self) -> None:
+ """Drop frames rendered from stale media/settings and wake the loop."""
+ self._timeline_generation += 1
+ self._timeline_dirty = True
+ self._previous_frames.clear()
+ self._next_frames.clear()
+ self._cancel_preload()
+ self._interrupt_event.set()
+ self.async_write_ha_state()
+
+ def _schedule_preload(self) -> None:
+ """Start the single per-camera worker that fills future frames."""
+ self._trim_timeline()
+ if (
+ self._buffer_depth <= 0
+ or self._current_frame is None
+ or len(self._next_frames) >= self._buffer_depth
+ ):
+ return
+ if self._preload_task is not None and not self._preload_task.done():
+ return
+
+ generation = self._timeline_generation
+ self._preload_task = self.hass.async_create_background_task(
+ self._preload_loop(generation),
+ name="album_slideshow_preload",
+ )
+
+ async def _preload_loop(self, generation: int) -> None:
+ """Render future slides sequentially until the configured buffer is full."""
+ this_task = asyncio.current_task()
+ try:
+ while generation == self._timeline_generation:
+ self._trim_timeline()
+ if len(self._next_frames) >= self._buffer_depth:
+ return
+
+ base = self._next_frames[-1] if self._next_frames else self._current_frame
+ if base is None:
+ return
+ items = self._effective_items()
+ if not items:
+ return
+
+ frame = await self._render_available_frame(
+ base.cursor,
+ items,
+ advance=True,
+ )
+ if generation != self._timeline_generation:
+ return
+
+ # Navigation may have moved the base from future to current or
+ # vice versa while rendering. It is still valid if it remains
+ # the last frame in the known timeline.
+ current_tail = (
+ self._next_frames[-1]
+ if self._next_frames
+ else self._current_frame
+ )
+ if current_tail is not base:
+ continue
+ if len(self._next_frames) < self._buffer_depth:
+ self._next_frames.append(frame)
+ self._next_ready_event.set()
+ self.async_write_ha_state()
+ await asyncio.sleep(0)
+ except asyncio.CancelledError:
+ raise
+ except Exception as err:
+ _LOGGER.warning(
+ "Album Slideshow %s: could not fill navigation buffer: %s",
+ self.entry.title,
+ err,
+ )
+ finally:
+ if self._preload_task is this_task:
+ self._preload_task = None
+ self._next_ready_event.set()
+ self.async_write_ha_state()
+
+ async def _await_preloaded_frame(self) -> bool:
+ """Wait for the in-flight worker's first frame, without waiting for all X."""
+ if self._next_frames:
+ return True
+ if self._buffer_depth <= 0:
+ return False
+
+ self._schedule_preload()
+ while not self._next_frames:
+ task = self._preload_task
+ if task is None or task.done():
+ return False
+ self._next_ready_event.clear()
+ if self._next_frames:
+ return True
+ waiter = asyncio.create_task(self._next_ready_event.wait())
+ try:
+ await asyncio.wait(
+ {task, waiter},
+ return_when=asyncio.FIRST_COMPLETED,
+ )
+ finally:
+ waiter.cancel()
+ try:
+ await waiter
+ except asyncio.CancelledError:
+ pass
+ return True
+
+ async def _show_next_frame(self) -> bool:
+ """Display the next buffered frame, rendering on demand only if empty."""
+ if self._current_frame is None:
+ return await self._rebuild_current_frame()
+
+ await self._await_preloaded_frame()
+ if self._next_frames:
+ frame = self._next_frames.popleft()
+ else:
+ items = self._effective_items()
+ frame = await self._render_available_frame(
+ self._current_frame.cursor,
+ items,
+ advance=True,
+ )
+
+ if self._buffer_depth > 0:
+ self._previous_frames.append(self._current_frame)
+ self._trim_timeline()
+ self._apply_frame(frame)
+ self._schedule_preload()
+ return True
+
+ async def _show_previous_frame(self) -> bool:
+ """Restore the most recent retained frame without I/O or composition."""
+ if not self._previous_frames:
+ return False
+
+ frame = self._previous_frames.pop()
+ if self._current_frame is not None and self._buffer_depth > 0:
+ self._next_frames.appendleft(self._current_frame)
+ self._trim_timeline()
+ self._apply_frame(frame)
+ self._schedule_preload()
+ return True
+
+ async def _rebuild_current_frame(self) -> bool:
+ """Render the current cursor after startup or playlist/settings changes."""
+ generation = self._timeline_generation
+ self._timeline_dirty = False
+ items = self._effective_items()
+ if not items:
+ return False
+
+ cursor = (
+ self._current_frame.cursor
+ if self._current_frame is not None
+ else self._capture_cursor()
+ )
+ frame = await self._render_available_frame(cursor, items, advance=False)
+ if generation != self._timeline_generation:
+ return False
+ self._previous_frames.clear()
+ self._next_frames.clear()
+ self._apply_frame(frame)
+ self._schedule_preload()
+ return True
+
+ async def _wait_or_interrupt(self, timeout: float) -> bool:
+ """Wait for configuration/navigation or for the slide timer to expire."""
+ if self._timeline_dirty:
+ return True
+ self._interrupt_event.clear()
+ # Close the clear/wait race: callbacks cannot run between these two
+ # synchronous statements without setting the event again.
+ if self._timeline_dirty:
return True
try:
- await asyncio.wait_for(self._interrupt_event.wait(), timeout=timeout)
+ if self.store.paused:
+ await self._interrupt_event.wait()
+ else:
+ 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."""
+ """Display buffered frames on command/timer and refill them in the background."""
if initial_delay > 0:
+ await asyncio.sleep(initial_delay)
+
+ while self._current_frame is None:
try:
- await asyncio.sleep(initial_delay)
- except asyncio.CancelledError:
- raise
- should_advance = False # Don't advance on the very first render
- while True:
- # Clear the wake signal before rendering. Anything that happens
- # from here on (a "next slide" press, a coordinator/store change)
- # re-sets it and is picked up after this frame commits, so no
- # wake is lost while we're mid-render.
- self._interrupt_event.clear()
- try:
- await self._render_cycle(advance=should_advance)
- self._consecutive_failures = 0
+ async with self._navigation_lock:
+ if self._current_frame is None:
+ await self._rebuild_current_frame()
+ if self._current_frame is not None:
+ self._consecutive_failures = 0
+ break
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,
+ "Album Slideshow: initial render 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
+ await asyncio.sleep(backoff)
+ if not self._effective_items():
+ self._interrupt_event.clear()
+ if not self._effective_items():
+ await self._interrupt_event.wait()
- interrupted = await self._wait_or_interrupt(float(int(self.store.slide_interval)))
- if self._force_next:
- # Explicit "next slide" request: always advance.
- should_advance = True
- self._force_next = False
- elif interrupted:
- # A coordinator/store change woke us: re-render the current
- # frame (new data or settings) without skipping ahead.
- should_advance = 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
+ while True:
try:
- await self._commit_composed(composed, meta)
- finally:
- ip.safe_close(composed)
+ if self._timeline_dirty:
+ async with self._navigation_lock:
+ if self._timeline_dirty:
+ await self._rebuild_current_frame()
+ continue
- 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.async_write_ha_state()
+ interrupted = await self._wait_or_interrupt(
+ float(int(self.store.slide_interval))
+ )
+ if not interrupted and not self.store.paused:
+ async with self._navigation_lock:
+ if not self._timeline_dirty:
+ await self._show_next_frame()
+ self._consecutive_failures = 0
+ except asyncio.CancelledError:
+ raise
+ except Exception as err:
+ self._consecutive_failures += 1
+ _LOGGER.warning(
+ "Album Slideshow: buffered navigation/render failed (attempt %d): %s",
+ self._consecutive_failures,
+ err,
+ )
@property
def _compose_semaphore(self) -> asyncio.Semaphore:
@@ -849,6 +1226,7 @@ class AlbumSlideshowCamera(Camera):
return None
async def _http_get(self, url: str) -> bytes | None:
+ """Fetch one remote image with validation and a hard timeout."""
session = async_get_clientsession(self.hass)
try:
async with async_timeout.timeout(30):
diff --git a/custom_components/album_slideshow/config_flow.py b/custom_components/album_slideshow/config_flow.py
index 56a95624..5ec5d54e 100644
--- a/custom_components/album_slideshow/config_flow.py
+++ b/custom_components/album_slideshow/config_flow.py
@@ -1,6 +1,7 @@
from __future__ import annotations
import json
+import logging
import re
from typing import Any
@@ -116,6 +117,15 @@ def _normalize_local_path(hass, path: str) -> str:
ALBUM_URL_RE = re.compile(r"^https?://photos\.app\.goo\.gl/[^/]+/?$")
+_LOGGER = logging.getLogger(__name__)
+
+
+def _describe_error(err: BaseException) -> str:
+ """Render an exception for the log, including an HTTP status when known."""
+ status = getattr(err, "status", None)
+ detail = str(err) or type(err).__name__
+ return f"HTTP {status}: {detail}" if status else f"{type(err).__name__}: {detail}"
+
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
VERSION = 1
@@ -318,13 +328,43 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
from . import immich as immich_api
client = immich_api.ImmichClient(self.hass, url, key)
+ albums: list[dict[str, Any]] = []
+ people: list[dict[str, Any]] = []
try:
await client.async_validate()
- albums = await client.async_list_albums()
- people = await client.async_list_people()
- except Exception: # noqa: BLE001 - any failure means bad URL/key
- errors["base"] = "immich_cannot_connect"
+ except Exception as err: # noqa: BLE001 - any failure means bad URL/key
+ _LOGGER.warning(
+ "Immich validation failed for %s: %s",
+ client.base_url,
+ _describe_error(err),
+ )
+ errors["base"] = (
+ "immich_invalid_auth"
+ if getattr(err, "status", None) in (401, 403)
+ else "immich_cannot_connect"
+ )
else:
+ # A key with limited permissions can read the server but not
+ # albums or people; keep going with whatever it can see.
+ for label, call in (
+ ("albums", client.async_list_albums),
+ ("people", client.async_list_people),
+ ):
+ try:
+ result = await call()
+ except Exception as err: # noqa: BLE001
+ _LOGGER.warning(
+ "Immich %s listing failed for %s: %s",
+ label,
+ client.base_url,
+ _describe_error(err),
+ )
+ else:
+ if label == "albums":
+ albums = result
+ else:
+ people = result
+
self._immich_url = client.base_url
self._immich_key = key
# id -> name maps for the two multi-select pickers.
diff --git a/custom_components/album_slideshow/const.py b/custom_components/album_slideshow/const.py
index d6021124..82b91ec7 100644
--- a/custom_components/album_slideshow/const.py
+++ b/custom_components/album_slideshow/const.py
@@ -254,6 +254,10 @@ DEFAULT_RECURSIVE = True
# path. Users with one album and lots of RAM can bump this via the
# Image cache size number entity.
DEFAULT_IMAGE_CACHE_MB = 75
+# Number of fully rendered slides retained on each side of the current frame.
+# Previous/Next can swap these JPEGs immediately without downloading,
+# composing, or encoding during the button press.
+DEFAULT_NAVIGATION_BUFFER_SIZE = 2
MAX_RESOLUTION_OPTIONS = ["480p", "720p", "1080p", "1440p", "4K (2160p)", "original"]
DEFAULT_MAX_RESOLUTION = "1080p"
@@ -269,5 +273,6 @@ MAX_RESOLUTION_SHORT_EDGE: dict[str, int | None] = {
PUBLICALBUM_ENDPOINT = "https://www.publicalbum.org/api/v2/webapp/embed-player/jsonrpc"
SERVICE_NEXT_SLIDE = "next_slide"
+SERVICE_PREVIOUS_SLIDE = "previous_slide"
SERVICE_REFRESH_ALBUM = "refresh_album"
ATTR_ENTRY_ID = "entry_id"
diff --git a/custom_components/album_slideshow/google_scraper.py b/custom_components/album_slideshow/google_scraper.py
index 58f44623..37a077b7 100644
--- a/custom_components/album_slideshow/google_scraper.py
+++ b/custom_components/album_slideshow/google_scraper.py
@@ -194,6 +194,8 @@ def _extract_first_page_items(html: str) -> list[MediaItem]:
items: list[MediaItem] = []
seen: set[str] = set()
for raw in best:
+ if _is_video_item(raw):
+ continue
item = _parse_album_item(raw)
if item is None or item.url in seen:
continue
@@ -290,12 +292,39 @@ def _parse_batchexecute_album_page(body: str) -> tuple[list[MediaItem], str | No
items: list[MediaItem] = []
for raw in raw_items:
+ if _is_video_item(raw):
+ continue
item = _parse_album_item(raw)
if item is not None:
items.append(item)
return items, next_page
+def _is_video_item(raw: Any) -> bool:
+ """Return ``True`` when an album item carries a video duration.
+
+ Google tags videos with key ``76647426`` (duration in ms) inside a metadata
+ dict. That dict is usually the item's last element, but its position moves
+ between responses, so scan every nested container instead of just ``[-1]``.
+ Live photos carry a duration too and are skipped along with videos, which
+ is what the photo-only slideshow wants (see #26).
+ """
+ return _contains_key(raw, 0)
+
+
+def _contains_key(node: Any, depth: int) -> bool:
+ if depth > 6:
+ return False
+ if isinstance(node, dict):
+ for key in node:
+ if key == _VIDEO_DURATION_KEY or key == str(_VIDEO_DURATION_KEY):
+ return True
+ return any(_contains_key(v, depth + 1) for v in node.values())
+ if isinstance(node, list):
+ return any(_contains_key(v, depth + 1) for v in node)
+ return False
+
+
def _parse_album_item(raw: Any) -> MediaItem | None:
"""Parse a single album item array.
@@ -315,14 +344,6 @@ def _parse_album_item(raw: Any) -> MediaItem | 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
diff --git a/custom_components/album_slideshow/manifest.json b/custom_components/album_slideshow/manifest.json
index 25296c31..ac13cf93 100644
--- a/custom_components/album_slideshow/manifest.json
+++ b/custom_components/album_slideshow/manifest.json
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"issue_tracker": "https://github.com/eyalgal/album_slideshow/issues",
"requirements": ["Pillow"],
- "version": "1.7.1"
+ "version": "1.8.1"
}
diff --git a/custom_components/album_slideshow/number.py b/custom_components/album_slideshow/number.py
index 9fca2db8..e35ed7c7 100644
--- a/custom_components/album_slideshow/number.py
+++ b/custom_components/album_slideshow/number.py
@@ -20,6 +20,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e
SlideIntervalNumber(entry, store),
RefreshHoursNumber(entry, store, coordinator),
PairDividerWidthNumber(entry, store),
+ NavigationBufferSizeNumber(entry, store),
ImageCacheMbNumber(entry, store),
]
)
@@ -143,6 +144,38 @@ class PairDividerWidthNumber(_BaseNumber):
return
+class NavigationBufferSizeNumber(_BaseNumber):
+ _attr_icon = "mdi:page-previous-outline"
+ _attr_native_min_value = 0
+ _attr_native_max_value = 10
+ _attr_native_step = 1
+
+ def __init__(self, entry: ConfigEntry, store: SlideshowStore) -> None:
+ super().__init__(entry, store)
+ self._attr_unique_id = f"{entry.entry_id}_navigation_buffer_size"
+ self._attr_name = "Navigation buffer (slides)"
+
+ @property
+ def native_value(self):
+ return int(self.store.navigation_buffer_size)
+
+ async def async_set_native_value(self, value: float) -> None:
+ self.store.navigation_buffer_size = min(10, 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.navigation_buffer_size = min(
+ 10, 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
diff --git a/custom_components/album_slideshow/services.yaml b/custom_components/album_slideshow/services.yaml
index 8a304a93..7d2bbca9 100644
--- a/custom_components/album_slideshow/services.yaml
+++ b/custom_components/album_slideshow/services.yaml
@@ -9,6 +9,17 @@ next_slide:
selector:
text:
+previous_slide:
+ name: Previous slide
+ description: Go back to the previously shown 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.
diff --git a/custom_components/album_slideshow/store.py b/custom_components/album_slideshow/store.py
index fa8b5c0c..84e9d075 100644
--- a/custom_components/album_slideshow/store.py
+++ b/custom_components/album_slideshow/store.py
@@ -13,6 +13,7 @@ from .const import (
DEFAULT_PAIR_DIVIDER_PX,
DEFAULT_PAIR_DIVIDER_COLOR,
DEFAULT_IMAGE_CACHE_MB,
+ DEFAULT_NAVIGATION_BUFFER_SIZE,
DEFAULT_MAX_RESOLUTION,
DEFAULT_DATE_FILTER,
DEFAULT_MISSING_DATE_MODE,
@@ -33,6 +34,7 @@ class SlideshowStore:
pair_divider_px: int = DEFAULT_PAIR_DIVIDER_PX
pair_divider_color: str = DEFAULT_PAIR_DIVIDER_COLOR
image_cache_mb: int = DEFAULT_IMAGE_CACHE_MB
+ navigation_buffer_size: int = DEFAULT_NAVIGATION_BUFFER_SIZE
max_resolution: str = DEFAULT_MAX_RESOLUTION
# Date filter mode (preset windows like this_year / on_this_day).
diff --git a/custom_components/album_slideshow/strings.json b/custom_components/album_slideshow/strings.json
index a9daf994..836a6681 100644
--- a/custom_components/album_slideshow/strings.json
+++ b/custom_components/album_slideshow/strings.json
@@ -129,6 +129,7 @@
"invalid_path": "Path is empty or invalid.",
"invalid_media_source": "That does not look like a Media Source id (should start with media-source://).",
"immich_cannot_connect": "Could not connect to Immich. Check the URL and API key.",
+ "immich_invalid_auth": "Immich rejected the API key. Check that it is valid and that its permissions include server.about, album.read and person.read.",
"immich_no_content": "No albums or named people were found on this Immich server.",
"immich_filter_required": "Custom search needs a JSON filter.",
"immich_filter_invalid": "The filter must be a valid JSON object. See the README for examples.",
diff --git a/custom_components/album_slideshow/translations/en.json b/custom_components/album_slideshow/translations/en.json
index a9daf994..836a6681 100644
--- a/custom_components/album_slideshow/translations/en.json
+++ b/custom_components/album_slideshow/translations/en.json
@@ -129,6 +129,7 @@
"invalid_path": "Path is empty or invalid.",
"invalid_media_source": "That does not look like a Media Source id (should start with media-source://).",
"immich_cannot_connect": "Could not connect to Immich. Check the URL and API key.",
+ "immich_invalid_auth": "Immich rejected the API key. Check that it is valid and that its permissions include server.about, album.read and person.read.",
"immich_no_content": "No albums or named people were found on this Immich server.",
"immich_filter_required": "Custom search needs a JSON filter.",
"immich_filter_invalid": "The filter must be a valid JSON object. See the README for examples.",
diff --git a/custom_components/album_slideshow/www/album-slideshow-card.js b/custom_components/album_slideshow/www/album-slideshow-card.js
index bb1a3ef2..c80ef8c1 100644
--- a/custom_components/album_slideshow/www/album-slideshow-card.js
+++ b/custom_components/album_slideshow/www/album-slideshow-card.js
@@ -26,7 +26,7 @@
* tap_action: none # none | more-info
*/
-const VERSION = "1.7.1";
+const VERSION = "1.8.1";
const ANIMATED_TRANSITIONS = [
"fade",
@@ -974,6 +974,7 @@ const LIVE_SUFFIX = {
slide_interval: "_interval",
pair_divider_px: "_pair_divider_px",
pair_divider_color: "_pair_divider_color",
+ previous_button: "_previous_button",
next_button: "_next_button",
refresh_button: "_refresh_button",
};
@@ -1092,7 +1093,9 @@ function createAlbumSlideshowCardEditorClass(Base) {
_hasActions() {
return !!(
this._siblings &&
- (this._siblings.next_button || this._siblings.refresh_button)
+ (this._siblings.previous_button ||
+ this._siblings.next_button ||
+ this._siblings.refresh_button)
);
}
@@ -1543,13 +1546,19 @@ function createAlbumSlideshowCardEditorClass(Base) {
wrap.innerHTML = `
Actions
+ ${s.previous_button ? `Previous slide ` : ""}
${s.next_button ? `Next slide ` : ""}
${s.refresh_button ? `Refresh album ` : ""}
`;
+ const actionEntities = {
+ previous: s.previous_button,
+ next: s.next_button,
+ refresh: s.refresh_button,
+ };
wrap.querySelectorAll("button.act").forEach((b) => {
b.addEventListener("click", () => {
- const id = b.dataset.act === "next" ? s.next_button : s.refresh_button;
+ const id = actionEntities[b.dataset.act];
if (id && this._hass) {
this._hass.callService("button", "press", { entity_id: id });
}
diff --git a/www/community/lovelace-multiple-entity-row/multiple-entity-row.js b/www/community/lovelace-multiple-entity-row/multiple-entity-row.js
index cd345a3d..5db8d09c 100644
--- a/www/community/lovelace-multiple-entity-row/multiple-entity-row.js
+++ b/www/community/lovelace-multiple-entity-row/multiple-entity-row.js
@@ -1,2 +1,2 @@
/*! For license information please see multiple-entity-row.js.LICENSE.txt */
-(()=>{"use strict";const t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,n=Symbol(),i=new WeakMap;class r{constructor(t,e,i){if(this._$cssResult$=!0,i!==n)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=t,this.t=e}get styleSheet(){let t=this.o;const n=this.t;if(e&&void 0===t){const e=void 0!==n&&1===n.length;e&&(t=i.get(n)),void 0===t&&((this.o=t=new CSSStyleSheet).replaceSync(this.cssText),e&&i.set(n,t))}return t}toString(){return this.cssText}}const o=(t,...e)=>{const i=1===t.length?t[0]:e.reduce((e,n,i)=>e+(t=>{if(!0===t._$cssResult$)return t.cssText;if("number"==typeof t)return t;throw Error("Value passed to 'css' function must be a 'css' function result: "+t+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(n)+t[i+1],t[0]);return new r(i,t,n)},a=(n,i)=>{if(e)n.adoptedStyleSheets=i.map(t=>t instanceof CSSStyleSheet?t:t.styleSheet);else for(const e of i){const i=document.createElement("style"),r=t.litNonce;void 0!==r&&i.setAttribute("nonce",r),i.textContent=e.cssText,n.appendChild(i)}},s=e?t=>t:t=>t instanceof CSSStyleSheet?(t=>{let e="";for(const n of t.cssRules)e+=n.cssText;return(t=>new r("string"==typeof t?t:t+"",void 0,n))(e)})(t):t,{is:c,defineProperty:l,getOwnPropertyDescriptor:u,getOwnPropertyNames:f,getOwnPropertySymbols:d,getPrototypeOf:h}=Object,p=globalThis,m=p.trustedTypes,y=m?m.emptyScript:"",v=p.reactiveElementPolyfillSupport,b=(t,e)=>t,g={toAttribute(t,e){switch(e){case Boolean:t=t?y:null;break;case Object:case Array:t=null==t?t:JSON.stringify(t)}return t},fromAttribute(t,e){let n=t;switch(e){case Boolean:n=null!==t;break;case Number:n=null===t?null:Number(t);break;case Object:case Array:try{n=JSON.parse(t)}catch(t){n=null}}return n}},_=(t,e)=>!c(t,e),w={attribute:!0,type:String,converter:g,reflect:!1,useDefault:!1,hasChanged:_};Symbol.metadata??=Symbol("metadata"),p.litPropertyMetadata??=new WeakMap;class A extends HTMLElement{static addInitializer(t){this._$Ei(),(this.l??=[]).push(t)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(t,e=w){if(e.state&&(e.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(t)&&((e=Object.create(e)).wrapped=!0),this.elementProperties.set(t,e),!e.noAccessor){const n=Symbol(),i=this.getPropertyDescriptor(t,n,e);void 0!==i&&l(this.prototype,t,i)}}static getPropertyDescriptor(t,e,n){const{get:i,set:r}=u(this.prototype,t)??{get(){return this[e]},set(t){this[e]=t}};return{get:i,set(e){const o=i?.call(this);r?.call(this,e),this.requestUpdate(t,o,n)},configurable:!0,enumerable:!0}}static getPropertyOptions(t){return this.elementProperties.get(t)??w}static _$Ei(){if(this.hasOwnProperty(b("elementProperties")))return;const t=h(this);t.finalize(),void 0!==t.l&&(this.l=[...t.l]),this.elementProperties=new Map(t.elementProperties)}static finalize(){if(this.hasOwnProperty(b("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(b("properties"))){const t=this.properties,e=[...f(t),...d(t)];for(const n of e)this.createProperty(n,t[n])}const t=this[Symbol.metadata];if(null!==t){const e=litPropertyMetadata.get(t);if(void 0!==e)for(const[t,n]of e)this.elementProperties.set(t,n)}this._$Eh=new Map;for(const[t,e]of this.elementProperties){const n=this._$Eu(t,e);void 0!==n&&this._$Eh.set(n,t)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(t){const e=[];if(Array.isArray(t)){const n=new Set(t.flat(1/0).reverse());for(const t of n)e.unshift(s(t))}else void 0!==t&&e.push(s(t));return e}static _$Eu(t,e){const n=e.attribute;return!1===n?void 0:"string"==typeof n?n:"string"==typeof t?t.toLowerCase():void 0}constructor(){super(),this._$Ep=void 0,this.isUpdatePending=!1,this.hasUpdated=!1,this._$Em=null,this._$Ev()}_$Ev(){this._$ES=new Promise(t=>this.enableUpdating=t),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach(t=>t(this))}addController(t){(this._$EO??=new Set).add(t),void 0!==this.renderRoot&&this.isConnected&&t.hostConnected?.()}removeController(t){this._$EO?.delete(t)}_$E_(){const t=new Map,e=this.constructor.elementProperties;for(const n of e.keys())this.hasOwnProperty(n)&&(t.set(n,this[n]),delete this[n]);t.size>0&&(this._$Ep=t)}createRenderRoot(){const t=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return a(t,this.constructor.elementStyles),t}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach(t=>t.hostConnected?.())}enableUpdating(t){}disconnectedCallback(){this._$EO?.forEach(t=>t.hostDisconnected?.())}attributeChangedCallback(t,e,n){this._$AK(t,n)}_$ET(t,e){const n=this.constructor.elementProperties.get(t),i=this.constructor._$Eu(t,n);if(void 0!==i&&!0===n.reflect){const r=(void 0!==n.converter?.toAttribute?n.converter:g).toAttribute(e,n.type);this._$Em=t,null==r?this.removeAttribute(i):this.setAttribute(i,r),this._$Em=null}}_$AK(t,e){const n=this.constructor,i=n._$Eh.get(t);if(void 0!==i&&this._$Em!==i){const t=n.getPropertyOptions(i),r="function"==typeof t.converter?{fromAttribute:t.converter}:void 0!==t.converter?.fromAttribute?t.converter:g;this._$Em=i;const o=r.fromAttribute(e,t.type);this[i]=o??this._$Ej?.get(i)??o,this._$Em=null}}requestUpdate(t,e,n,i=!1,r){if(void 0!==t){const o=this.constructor;if(!1===i&&(r=this[t]),n??=o.getPropertyOptions(t),!((n.hasChanged??_)(r,e)||n.useDefault&&n.reflect&&r===this._$Ej?.get(t)&&!this.hasAttribute(o._$Eu(t,n))))return;this.C(t,e,n)}!1===this.isUpdatePending&&(this._$ES=this._$EP())}C(t,e,{useDefault:n,reflect:i,wrapped:r},o){n&&!(this._$Ej??=new Map).has(t)&&(this._$Ej.set(t,o??e??this[t]),!0!==r||void 0!==o)||(this._$AL.has(t)||(this.hasUpdated||n||(e=void 0),this._$AL.set(t,e)),!0===i&&this._$Em!==t&&(this._$Eq??=new Set).add(t))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(t){Promise.reject(t)}const t=this.scheduleUpdate();return null!=t&&await t,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(const[t,e]of this._$Ep)this[t]=e;this._$Ep=void 0}const t=this.constructor.elementProperties;if(t.size>0)for(const[e,n]of t){const{wrapped:t}=n,i=this[e];!0!==t||this._$AL.has(e)||void 0===i||this.C(e,void 0,n,i)}}let t=!1;const e=this._$AL;try{t=this.shouldUpdate(e),t?(this.willUpdate(e),this._$EO?.forEach(t=>t.hostUpdate?.()),this.update(e)):this._$EM()}catch(e){throw t=!1,this._$EM(),e}t&&this._$AE(e)}willUpdate(t){}_$AE(t){this._$EO?.forEach(t=>t.hostUpdated?.()),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(t)),this.updated(t)}_$EM(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(t){return!0}update(t){this._$Eq&&=this._$Eq.forEach(t=>this._$ET(t,this[t])),this._$EM()}updated(t){}firstUpdated(t){}}A.elementStyles=[],A.shadowRootOptions={mode:"open"},A[b("elementProperties")]=new Map,A[b("finalized")]=new Map,v?.({ReactiveElement:A}),(p.reactiveElementVersions??=[]).push("2.1.2");const O=globalThis,S=t=>t,$=O.trustedTypes,j=$?$.createPolicy("lit-html",{createHTML:t=>t}):void 0,x="$lit$",k=`lit$${Math.random().toFixed(9).slice(2)}$`,E="?"+k,C=`<${E}>`,P=document,T=()=>P.createComment(""),M=t=>null===t||"object"!=typeof t&&"function"!=typeof t,I=Array.isArray,H=t=>I(t)||"function"==typeof t?.[Symbol.iterator],D="[ \t\n\f\r]",R=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,F=/-->/g,N=/>/g,U=RegExp(`>|${D}(?:([^\\s"'>=/]+)(${D}*=${D}*(?:[^ \t\n\f\r"'\`<>=]|("|')|))|$)`,"g"),L=/'/g,V=/"/g,z=/^(?:script|style|textarea|title)$/i,B=t=>(e,...n)=>({_$litType$:t,strings:e,values:n}),W=B(1),q=(B(2),B(3),Symbol.for("lit-noChange")),Z=Symbol.for("lit-nothing"),J=new WeakMap,G=P.createTreeWalker(P,129);function K(t,e){if(!I(t)||!t.hasOwnProperty("raw"))throw Error("invalid template strings array");return void 0!==j?j.createHTML(e):e}const Y=(t,e)=>{const n=t.length-1,i=[];let r,o=2===e?"":3===e?"":"",a=R;for(let e=0;e"===c[0]?(a=r??R,l=-1):void 0===c[1]?l=-2:(l=a.lastIndex-c[2].length,s=c[1],a=void 0===c[3]?U:'"'===c[3]?V:L):a===V||a===L?a=U:a===F||a===N?a=R:(a=U,r=void 0);const f=a===U&&t[e+1].startsWith("/>")?" ":"";o+=a===R?n+C:l>=0?(i.push(s),n.slice(0,l)+x+n.slice(l)+k+f):n+k+(-2===l?e:f)}return[K(t,o+(t[n]||">")+(2===e?" ":3===e?"":"")),i]};class Q{constructor({strings:t,_$litType$:e},n){let i;this.parts=[];let r=0,o=0;const a=t.length-1,s=this.parts,[c,l]=Y(t,e);if(this.el=Q.createElement(c,n),G.currentNode=this.el.content,2===e||3===e){const t=this.el.content.firstChild;t.replaceWith(...t.childNodes)}for(;null!==(i=G.nextNode())&&s.length0){i.textContent=$?$.emptyScript:"";for(let n=0;n2||""!==n[0]||""!==n[1]?(this._$AH=Array(n.length-1).fill(new String),this.strings=n):this._$AH=Z}_$AI(t,e=this,n,i){const r=this.strings;let o=!1;if(void 0===r)t=X(this,t,e,0),o=!M(t)||t!==this._$AH&&t!==q,o&&(this._$AH=t);else{const i=t;let a,s;for(t=r[0],a=0;a{const i=n?.renderBefore??e;let r=i._$litPart$;if(void 0===r){const t=n?.renderBefore??null;i._$litPart$=r=new et(e.insertBefore(T(),t),t,void 0,n??{})}return r._$AI(t),r})(e,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return q}}ut._$litElement$=!0,ut.finalized=!0,lt.litElementHydrateSupport?.({LitElement:ut});const ft=lt.litElementPolyfillSupport;ft?.({LitElement:ut}),(lt.litElementVersions??=[]).push("4.2.2");var dt=["unavailable","unknown"],ht="last-changed",pt="last-updated",mt=["relative","total","date","time","datetime"],yt=["entity-id","last-changed","last-updated","last-triggered","position","tilt-position","brightness"];function vt(t,e){return function(t){if(Array.isArray(t))return t}(t)||function(t,e){var n=null==t?null:"undefined"!=typeof Symbol&&t[Symbol.iterator]||t["@@iterator"];if(null!=n){var i,r,o,a,s=[],c=!0,l=!1;try{if(o=(n=n.call(t)).next,0===e){if(Object(n)!==n)return;c=!1}else for(;!(c=(i=o.call(n)).done)&&(s.push(i.value),s.length!==e);c=!0);}catch(t){l=!0,r=t}finally{try{if(!c&&null!=n.return&&(a=n.return(),Object(a)!==a))return}finally{if(l)throw r}}return s}}(t,e)||bt(t,e)||function(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function bt(t,e){if(t){if("string"==typeof t)return gt(t,e);var n={}.toString.call(t).slice(8,-1);return"Object"===n&&t.constructor&&(n=t.constructor.name),"Map"===n||"Set"===n?Array.from(t):"Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?gt(t,e):void 0}}function gt(t,e){(null==e||e>t.length)&&(e=t.length);for(var n=0,i=Array(e);n=t.length?{done:!0}:{done:!1,value:t[n++]}},e:function(t){throw t},f:i}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var r,o=!0,a=!1;return{s:function(){e=e.call(t)},n:function(){var t=e.next();return o=t.done,t},e:function(t){a=!0,r=t},f:function(){try{o||null==e.return||e.return()}finally{if(a)throw r}}}}(wt);try{for(i.s();!(e=i.n()).done;){var r=vt(e.value,2),o=r[0],a=r[1];if(customElements.get(o))wt.delete(o);else try{customElements.define(o,a),n.push(o),wt.delete(o)}catch(t){console.warn("multiple-entity-row: re-defining ".concat(o," after registry swap failed"),t)}}}catch(t){i.e(t)}finally{i.f()}n.length&&console.info("multiple-entity-row: re-defined ".concat(n.join(", ")," after customElements registry swap, caught by ").concat(t," (frontend#52960)"))}catch(t){console.warn("multiple-entity-row: customElements registry check failed",t)}},$t=function(){wt.size&&(At>=30?wt.clear():(At+=1,setTimeout(function(){St("fallback poll"),$t()},1e3)))},jt=function(t,e){try{if(_t.get(t))return void console.warn("multiple-entity-row: ".concat(t," is already defined - a duplicate resource entry or stale cached copy loaded first"));_t.define(t,e)}catch(e){return void console.warn("multiple-entity-row: defining ".concat(t," failed"),e)}wt.set(t,e),Ot||(Ot=!0,_t.whenDefined("home-assistant").then(function(){return St("ha-boot signal")}).catch(function(){}),$t())},xt=new Set(["primary","accent","red","pink","purple","deep-purple","indigo","blue","light-blue","cyan","teal","green","light-green","lime","yellow","amber","orange","deep-orange","brown","light-grey","grey","dark-grey","blue-grey","black","white","primary-text","secondary-text","disabled"]),kt=function(t){return xt.has(t)?"var(--".concat(t,"-color)"):t},Et=function(t){var e,n=null!==(e=t.color)&&void 0!==e?e:void 0===t.state_color?void 0:t.state_color?"state":"none";return void 0===n?t.icon_color?{stateColor:!1}:{stateColor:!0}:"state"===n?{stateColor:!0}:"none"===n?{stateColor:!1}:{cssColor:kt(n)}},Ct=function(t){return t<10?"0".concat(t):t};function Pt(t){var e=Math.floor(t/3600),n=Math.floor(t%3600/60),i=Math.floor(t%3600%60);return e>0?"".concat(e,":").concat(Ct(n),":").concat(Ct(i)):n>0?"".concat(n,":").concat(Ct(i)):i>0?""+i:null}function Tt(t){return Tt="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t},Tt(t)}function Mt(t,e){var n=Object.keys(t);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(t);e&&(i=i.filter(function(e){return Object.getOwnPropertyDescriptor(t,e).enumerable})),n.push.apply(n,i)}return n}function It(t,e,n){return(e=function(t){var e=function(t){if("object"!=Tt(t)||!t)return t;var e=t[Symbol.toPrimitive];if(void 0!==e){var n=e.call(t,"string");if("object"!=Tt(n))return n;throw new TypeError("@@toPrimitive must return a primitive value.")}return String(t)}(t);return"symbol"==Tt(e)?e:e+""}(e))in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t}var Ht=function(t,e,n){var i=e?function(t){switch(t.number_format){case"comma_decimal":return["en-US","en"];case"decimal_comma":return["de","es","it"];case"space_comma":return["fr","sv","cs"];case"system":return;default:return t.language}}(e):void 0;if(Number.isNaN=Number.isNaN||function t(e){return"number"==typeof e&&t(e)},"none"!==(null==e?void 0:e.number_format)&&!Number.isNaN(Number(t))&&Intl)try{return new Intl.NumberFormat(i,Dt(t,n)).format(Number(t))}catch(e){return console.error(e),new Intl.NumberFormat(void 0,Dt(t,n)).format(Number(t))}return"string"==typeof t?t:"".concat(function(t){var e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:2;return Math.round(t*Math.pow(10,e))/Math.pow(10,e)}(t,null==n?void 0:n.maximumFractionDigits).toString()).concat("currency"===(null==n?void 0:n.style)?" ".concat(n.currency):"")},Dt=function(t,e){var n=function(t){for(var e=1;e-1?t.split(".")[1].length:0;n.minimumFractionDigits=i,n.maximumFractionDigits=i}return n};function Rt(t){return Rt="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t},Rt(t)}var Ft=function(t){return"object"===Rt(t)&&!Array.isArray(t)&&!!t},Nt=function(t,e,n){var i=new Event(e,{bubbles:!0,composed:!0});i.detail=n,t.dispatchEvent(i)},Ut=function(t){return!t||dt.includes(t.state)},Lt=function(t,e,n){if(function(t,e){return e.hide_unavailable&&(Ut(t)||e.attribute&&![ht,pt].includes(e.attribute)&&void 0===t.attributes[e.attribute])}(t,e))return!0;if(void 0===e.hide_if)return!1;if("boolean"==typeof e.hide_if)return e.hide_if;var i;if(Ft(e.hide_if)&&(e.hide_if.entity||e.hide_if.attribute)){var r=e.hide_if.entity?null==n?void 0:n.states[e.hide_if.entity]:t;i=e.hide_if.attribute?null==r?void 0:r.attributes[e.hide_if.attribute]:null==r?void 0:r.state}else i=e.attribute?t.attributes[e.attribute]:t.state;var o=[];if(Ft(e.hide_if)){if(e.hide_if.below&&ie.hide_if.above)return!0;e.hide_if.value&&(o=o.concat(e.hide_if.value))}else o=o.concat(e.hide_if);return o.some(function(t){return"number"==typeof t?t===+i:t===i})},Vt=["formatEntityName","formatEntityState","formatEntityAttributeValue"];function zt(t){return zt="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t},zt(t)}function Bt(t,e){var n=Object.keys(t);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(t);e&&(i=i.filter(function(e){return Object.getOwnPropertyDescriptor(t,e).enumerable})),n.push.apply(n,i)}return n}function Wt(t){for(var e=1;et.length)&&(e=t.length);for(var n=0,i=Array(e);n1&&a.every(function(t){return te.test(t)})){var s=r??0;return!isNaN(parseFloat(s))&&isFinite(s)?function(t,e,n,i){var r,o,a=parseFloat(e),s=Kt(e),c=!1,l=function(t){c||(s=void 0,r=t)},u=function(t){var e="undefined"!=typeof Symbol&&t[Symbol.iterator]||t["@@iterator"];if(!e){if(Array.isArray(t)||(e=Zt(t))){e&&(t=e);var n=0,i=function(){};return{s:i,n:function(){return n>=t.length?{done:!0}:{done:!1,value:t[n++]}},e:function(t){throw t},f:i}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var r,o=!0,a=!1;return{s:function(){e=e.call(t)},n:function(){var t=e.next();return o=t.done,t},e:function(t){a=!0,r=t},f:function(){try{o||null==e.return||e.return()}finally{if(a)throw r}}}}(t);try{for(u.s();!(o=u.n()).done;){var f=o.value,d=Qt.exec(f);if(d&&"precision"===d[1])s=parseInt(d[2],10),r=void 0,c=!0;else if(d)a/={kilo:1e3,mega:1e6,milli:.001}[d[1]],void 0!==d[2]?(s=parseInt(d[2],10),r=void 0,c=!0):l(2);else switch(f){case"brightness":a=Math.round(a/255*100),n="%",l(0);break;case"percent":a*=100,n="%",l(2);break;case"invert":a=-a;break;case"position":a=100-a;break;case"celsius_to_fahrenheit":a=1.8*a+32,l(0);break;case"fahrenheit_to_celsius":a=5*(a-32)/9,l(1)}}}catch(t){u.e(t)}finally{u.f()}return"".concat(Ht(a,i,void 0!==s?{minimumFractionDigits:s,maximumFractionDigits:s}:void 0!==r?{maximumFractionDigits:r}:void 0)).concat(n?" ".concat(n):"")}(a,s,o,t.locale):"".concat(s).concat(o?" ".concat(o):"")}if(n.format&&!function(t){return Yt.some(function(e){return null==t?void 0:t.startsWith(e)})&&!Xt(t)}(n.format)){if(null==r&&(r=ee.includes(n.format)?"":0),ee.includes(n.format))return"".concat(function(t,e){switch(t){case"upper":return e.toUpperCase();case"lower":return e.toLowerCase();case"capitalize":return e.charAt(0).toUpperCase()+e.slice(1);case"title":return e.split(/(\s+)/).map(function(t){return t.charAt(0).toUpperCase()+t.slice(1)}).join("")}}(n.format,String(r))).concat(o?" ".concat(o):"");if(isNaN(parseFloat(r))||!isFinite(r));else if("brightness"===n.format)r=Math.round(r/255*100),o="%";else if("percent"===n.format)r=Ht(100*r,t.locale,{maximumFractionDigits:2}),o="%";else if("duration"===n.format){var c;r=null!==(c=Pt(r))&&void 0!==c?c:"0",o=void 0}else if("duration-m"===n.format){var l;r=null!==(l=Pt(r/1e3))&&void 0!==l?l:"0",o=void 0}else if("duration-h"===n.format){var u;r=null!==(u=Pt(3600*r))&&void 0!==u?u:"0",o=void 0}else if(Xt(n.format)){var f=function(t,e){return function(t){if(Array.isArray(t))return t}(t)||function(t,e){var n=null==t?null:"undefined"!=typeof Symbol&&t[Symbol.iterator]||t["@@iterator"];if(null!=n){var i,r,o,a,s=[],c=!0,l=!1;try{if(o=(n=n.call(t)).next,0===e){if(Object(n)!==n)return;c=!1}else for(;!(c=(i=o.call(n)).done)&&(s.push(i.value),s.length!==e);c=!0);}catch(t){l=!0,r=t}finally{try{if(!c&&null!=n.return&&(a=n.return(),Object(a)!==a))return}finally{if(l)throw r}}return s}}(t,e)||Zt(t,e)||function(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}(Xt(n.format),3),d=f[1],h=f[2];if("precision"===d){var p=parseInt(h,10);r=Ht(parseFloat(r),t.locale,{minimumFractionDigits:p,maximumFractionDigits:p})}else r=function(t,e,n,i){if(void 0===n)return Ht(t/e,i,{maximumFractionDigits:2});var r=parseInt(n,10);return Ht(t/e,i,{minimumFractionDigits:r,maximumFractionDigits:r})}(r,{kilo:1e3,mega:1e6,milli:.001}[d],h,t.locale)}else if("invert"===n.format){var m=Kt(r);r=Ht(r-2*r,t.locale,void 0!==m?{minimumFractionDigits:m,maximumFractionDigits:m}:void 0)}else if("position"===n.format){var y=Kt(r);r=Ht(100-r,t.locale,void 0!==y?{minimumFractionDigits:y,maximumFractionDigits:y}:void 0)}else"celsius_to_fahrenheit"===n.format?r=Ht(1.8*r+32,t.locale,{maximumFractionDigits:0}):"fahrenheit_to_celsius"===n.format&&(r=Ht(5*(r-32)/9,t.locale,{maximumFractionDigits:1}));return"".concat(r).concat(o?" ".concat(o):"")}var v=Wt(Wt({},e),{},{attributes:Wt(Wt({},e.attributes),{},{unit_of_measurement:o})});if(void 0!==n.unit&&!n.attribute){if(!isNaN(parseFloat(r))&&isFinite(r)){var b,g=null===(b=t.entities)||void 0===b||null===(b=b[e.entity_id])||void 0===b?void 0:b.display_precision,_=Ht(r,t.locale,null!=g?{minimumFractionDigits:g,maximumFractionDigits:g}:void 0);return"".concat(_).concat(o?" ".concat(o):"")}return"".concat(t.formatEntityState(e)).concat(o?" ".concat(o):"")}if(n.attribute){if(void 0!==n.unit){var w=null==r?"":isNaN(r)?r:Ht(r,t.locale);return"".concat(w).concat(o?" ".concat(o):"")}return oe(t.formatEntityAttributeValue(v,n.attribute))}return oe(t.formatEntityState(v))},se=function(t){return Ft(null==t?void 0:t.styles)?Object.keys(t.styles).map(function(e){return"".concat(e,": ").concat(t.styles[e],";")}).join(""):""};function ce(t,e){var n="undefined"!=typeof Symbol&&t[Symbol.iterator]||t["@@iterator"];if(!n){if(Array.isArray(t)||(n=me(t))||e&&t&&"number"==typeof t.length){n&&(t=n);var i=0,r=function(){};return{s:r,n:function(){return i>=t.length?{done:!0}:{done:!1,value:t[i++]}},e:function(t){throw t},f:r}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var o,a=!0,s=!1;return{s:function(){n=n.call(t)},n:function(){var t=n.next();return a=t.done,t},e:function(t){s=!0,o=t},f:function(){try{a||null==n.return||n.return()}finally{if(s)throw o}}}}function le(t,e){for(var n=0;nt.length)&&(e=t.length);for(var n=0,i=Array(e);n2&&void 0!==arguments[2]?arguments[2]:"";ge(t)&&n.push({template:i+t,entity:e})},r=function(t,e){var n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:"";i(t.name,e,n),i(t.icon,e,n),i(t.icon_color,e,n),i(t.color,e,n),i(t.template,e,n),i(we(t),e,n),xe.forEach(function(r){return ke(t[r],function(t){return i(t,e,n)})})},o=t.entity;if(r(t,o,je(Ee(t))),"string"==typeof t.secondary_info)i(t.secondary_info,o,je(Ee(t)));else if(Ft(t.secondary_info)){var a,s=t.secondary_info;r(s,null!==(a=s.entity)&&void 0!==a?a:o,je(Ee(t,s)))}return null===(e=t.entities)||void 0===e||e.forEach(function(e){if(Ft(e)){var n,i=e;r(i,null!==(n=i.entity)&&void 0!==n?n:o,je(Ee(t,i)))}}),n}(t).map(function(t){return[Se(t.template,t.entity),t]})),this.sync()}},{key:"setHass",value:function(t){var e,n=!(null===(e=this.hass)||void 0===e||!e.connection);this.hass=t,!n&&null!=t&&t.connection&&this.sync()}},{key:"connect",value:function(){this.connected=!0,this.sync()}},{key:"disconnect",value:function(){this.connected=!1,this.sync()}},{key:"sync",value:function(){var t,e,n=ce(this.unsubs);try{for(n.s();!(e=n.n()).done;){var i=pe(e.value,2),r=i[0],o=i[1];this.connected&&this.requests.has(r)||(this.unsubs.delete(r),this.requests.has(r)||(this.results.delete(r),this.errors.delete(r)),o.then(function(t){return null==t?void 0:t()}).catch(function(){}))}}catch(t){n.e(t)}finally{n.f()}if(this.connected&&null!==(t=this.hass)&&void 0!==t&&t.connection){var a,s=ce(this.requests);try{for(s.s();!(a=s.n()).done;){var c=pe(a.value,2),l=c[0],u=c[1];this.unsubs.has(l)||this.unsubs.set(l,this.subscribe(l,u))}}catch(t){s.e(t)}finally{s.f()}}}},{key:"subscribe",value:function(t,e){var n=this;return this.hass.connection.subscribeMessage(function(e){return n.onMessage(t,e)},{type:"render_template",template:e.template,variables:{entity:e.entity},report_errors:!0}).catch(function(t){return console.warn("multiple-entity-row: template subscription failed:",e.template,t),null})}},{key:"onMessage",value:function(t,e){this.unsubs.has(t)&&(void 0!==e.error?(this.errors.get(t)!==e.error&&(this.errors.set(t,e.error),console.warn("multiple-entity-row: template error:",e.error)),this.results.set(t,"")):(this.errors.delete(t),this.results.set(t,e.result)),this.notify(new Map(this.results)))}}])}();class Te{constructor(t){}get _$AU(){return this._$AM._$AU}_$AT(t,e,n){this._$Ct=t,this._$AM=e,this._$Ci=n}_$AS(t,e){return this.update(t,e)}update(t,e){return this.render(...e)}}const{I:Me}=st,Ie={},He=(t=>(...e)=>({_$litDirective$:t,values:e}))(class extends Te{constructor(){super(...arguments),this.key=Z}render(t,e){return this.key=t,e}update(t,[e,n]){return e!==this.key&&(((t,e=Ie)=>{t._$AH=e})(t),this.key=e),n}});var De,Re,Fe,Ne,Ue,Le,Ve,ze,Be,We,qe,Ze,Je,Ge,Ke,Ye,Qe,Xe="__custom__",tn=[{value:"",label:"No format"},{value:"brightness",label:"Brightness (0-255 → %)"},{value:"percent",label:"Percent (value × 100 → x %)"},{value:"duration",label:"Duration (seconds → h:mm:ss)"},{value:"duration-m",label:"Duration (milliseconds)"},{value:"duration-h",label:"Duration (hours)"},{value:"precision0",label:"Precision 0 decimals"},{value:"precision1",label:"Precision 1 decimal"},{value:"precision2",label:"Precision 2 decimals"},{value:"precision3",label:"Precision 3 decimals"},{value:"kilo",label:"Kilo (value / 1,000)"},{value:"mega",label:"Mega (value / 1,000,000)"},{value:"milli",label:"Milli (value × 1,000)"},{value:"invert",label:"Invert (value × -1)"},{value:"position",label:"Position (100 - value)"},{value:"celsius_to_fahrenheit",label:"°C → °F"},{value:"fahrenheit_to_celsius",label:"°F → °C"},{value:"upper",label:"Text: UPPERCASE"},{value:"lower",label:"Text: lowercase"},{value:"capitalize",label:"Text: Capitalize first letter"},{value:"title",label:"Text: Title Case"},{value:"relative",label:"Timestamp: relative"},{value:"total",label:"Timestamp: total"},{value:"date",label:"Timestamp: date"},{value:"time",label:"Timestamp: time"},{value:"datetime",label:"Timestamp: date + time"},{value:Xe,label:"Custom…"}],en=new Set(tn.map(function(t){return t.value}).filter(function(t){return t&&t!==Xe})),nn=[{name:"entity",required:!0,selector:{entity:{}}},{type:"grid",schema:[{name:"name",selector:{text:{}}},{name:"attribute",selector:{text:{}}}]},{type:"grid",schema:[{name:"unit",selector:{text:{}}},{name:"icon",selector:{icon:{}}}]},{name:"icon_color",selector:{text:{}}},{type:"grid",schema:[{name:"show_state",default:!0,selector:{boolean:{}}},{name:"color",selector:{ui_color:{include_state:!0,include_none:!0}}}]},{type:"grid",schema:[{name:"toggle",selector:{boolean:{}}},{name:"column",selector:{boolean:{}}}]},{type:"grid",schema:[{name:"show_state_first",selector:{boolean:{}}},{name:"hide_unavailable",selector:{boolean:{}}},{name:"wrap",selector:{boolean:{}}}]},{name:"state_header",selector:{text:{}}},{name:"image",selector:{text:{}}},{name:"format",selector:{select:{mode:"dropdown",options:tn}}}],rn=[{name:"tap_action",selector:{ui_action:{default_action:"more-info"}}},{name:"hold_action",selector:{ui_action:{default_action:"none"}}},{name:"double_tap_action",selector:{ui_action:{default_action:"none"}}}],on=[{name:"entity",selector:{entity:{}}},{type:"grid",schema:[{name:"name",selector:{text:{}}},{name:"attribute",selector:{text:{}}}]},{type:"grid",schema:[{name:"unit",selector:{text:{}}},{name:"icon",selector:{icon:{}}}]},{name:"icon_color",selector:{text:{}}},{type:"grid",schema:[{name:"color",selector:{ui_color:{include_state:!0,include_none:!0}}},{name:"toggle",selector:{boolean:{}}}]},{name:"hide_unavailable",selector:{boolean:{}}},{name:"format",selector:{select:{mode:"dropdown",options:tn}}}],an={entity:"Entity",attribute:"Attribute",name:"Name override (or false to hide)",unit:"Unit (or false to hide)",icon:"Icon",icon_color:"Icon color (CSS value, e.g. red, #ff0000, var(--my-color))",image:"Image URL",format:"Format",show_state:"Show main entity state",show_state_first:"State before entities",state_header:"State header label",color:"Icon color",state_color:"State color",column:"Column layout",wrap:"Wrap instead of overflowing",toggle:"Show as toggle",hide_unavailable:"Hide if unavailable",tap_action:"Tap action",hold_action:"Hold action",double_tap_action:"Double-tap action"};function sn(t,e){return e||(e=t.slice(0)),Object.freeze(Object.defineProperties(t,{raw:{value:Object.freeze(e)}}))}function cn(t){return function(t){if(Array.isArray(t))return _n(t)}(t)||function(t){if("undefined"!=typeof Symbol&&null!=t[Symbol.iterator]||null!=t["@@iterator"])return Array.from(t)}(t)||gn(t)||function(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function ln(t,e){for(var n=0;n=t.length?{done:!0}:{done:!1,value:t[i++]}},e:function(t){throw t},f:r}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var o,a=!0,s=!1;return{s:function(){n=n.call(t)},n:function(){var t=n.next();return a=t.done,t},e:function(t){s=!0,o=t},f:function(){try{a||null==n.return||n.return()}finally{if(s)throw o}}}}function bn(t,e){return function(t){if(Array.isArray(t))return t}(t)||function(t,e){var n=null==t?null:"undefined"!=typeof Symbol&&t[Symbol.iterator]||t["@@iterator"];if(null!=n){var i,r,o,a,s=[],c=!0,l=!1;try{if(o=(n=n.call(t)).next,0===e){if(Object(n)!==n)return;c=!1}else for(;!(c=(i=o.call(n)).done)&&(s.push(i.value),s.length!==e);c=!0);}catch(t){l=!0,r=t}finally{try{if(!c&&null!=n.return&&(a=n.return(),Object(a)!==a))return}finally{if(l)throw r}}return s}}(t,e)||gn(t,e)||function(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function gn(t,e){if(t){if("string"==typeof t)return _n(t,e);var n={}.toString.call(t).slice(8,-1);return"Object"===n&&t.constructor&&(n=t.constructor.name),"Map"===n||"Set"===n?Array.from(t):"Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?_n(t,e):void 0}}function _n(t,e){(null==e||e>t.length)&&(e=t.length);for(var n=0,i=Array(e);ni.length&&(t._selectedTab=i.length),t._keys.clear(),t._updateConfig({entities:i.length?i:void 0})}}),mn(t,"_moveAdditional",function(e,n){var i;if(t._config){var r=cn(null!==(i=t._config.entities)&&void 0!==i?i:[]),o=e+n;if(!(o<0||o>=r.length)){var a=[r[o],r[e]];r[e]=a[0],r[o]=a[1],t._selectedTab=o+1,t._keys.clear(),t._updateConfig({entities:r})}}}),mn(t,"_copyAdditional",function(e){var n,i=null===(n=t._config)||void 0===n||null===(n=n.entities)||void 0===n?void 0:n[e];null!=i&&t._writeClipboard("string"==typeof i?{entity:i}:pn({},i))}),mn(t,"_cutAdditional",function(e){t._copyAdditional(e),t._deleteAdditional(e)}),mn(t,"_copyMainAsTemplate",function(){if(t._config){var e,n={},i=vn(Hn);try{for(i.s();!(e=i.n()).done;){var r=e.value,o=t._config[r];void 0!==o&&(n[r]=o)}}catch(t){i.e(t)}finally{i.f()}t._writeClipboard(n)}}),mn(t,"_pasteEntity",function(){var e;if(t._config){var n=t._readClipboard();if(n){var i=[].concat(cn(null!==(e=t._config.entities)&&void 0!==e?e:[]),[n]);t._selectedTab=i.length,t._keys.clear(),t._updateConfig({entities:i})}}}),mn(t,"_secondaryModeChanged",function(e){if(t._config){var n=e.detail.value.mode;if(n!==t._secondaryInfoMode()){var i;switch(n){case"none":i=void 0;break;case"text":i="";break;case"generic":i="last-changed";break;case"entity":i={entity:t._config.entity}}t._updateConfig({secondary_info:i})}}}),mn(t,"_secondaryTextChanged",function(e){t._updateConfig({secondary_info:e.detail.value.text})}),mn(t,"_secondaryTokenChanged",function(e){t._updateConfig({secondary_info:e.detail.value.token})}),mn(t,"_secondaryEntityChanged",function(e){var n,i=Ft(null===(n=t._config)||void 0===n?void 0:n.secondary_info)?t._config.secondary_info.format:void 0;t._updateConfig({secondary_info:t._formatFromForm("secondary",Mn(e.detail.value),i)})}),t._selectedTab=0,t._entitiesExpanded=!0,t._stateIconRowsMain=[],t._stateIconRowsAdditional=new Map,t._customFormatScopes=new Set,t}return function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Super expression must either be null or a function");t.prototype=Object.create(e&&e.prototype,{constructor:{value:t,writable:!0,configurable:!0}}),Object.defineProperty(t,"prototype",{writable:!1}),e&&dn(t,e)}(e,t),function(t,e,n){return e&&ln(t.prototype,e),n&&ln(t,n),Object.defineProperty(t,"prototype",{writable:!1}),t}(e,[{key:"setConfig",value:function(t){var e,n,i,r=this;if(_e(t))throw new Error("This row uses templates - edit it in the code (YAML) editor.");this._config=t;var o,a=null!==(e=null===(n=t.entities)||void 0===n?void 0:n.length)&&void 0!==e?e:0;this._selectedTab>a&&(this._selectedTab=a),this._clipboardEntity=this._readClipboard(),this._stateIconRowsMatch(this._stateIconRowsMain,t.state_icon)||(this._stateIconRowsMain=Object.entries(null!==(o=t.state_icon)&&void 0!==o?o:{}));var s=new Map;null===(i=t.entities)||void 0===i||i.forEach(function(t,e){var n=Ft(t)?t.state_icon:void 0,i=r._stateIconRowsAdditional.get(e);i&&r._stateIconRowsMatch(i,n)?s.set(e,i):n&&s.set(e,Object.entries(n))}),this._stateIconRowsAdditional=s}},{key:"_stateIconRowsMatch",value:function(t,e){var n,i={},r=vn(t);try{for(r.s();!(n=r.n()).done;){var o=bn(n.value,2),a=o[0],s=o[1];a.trim()&&s.trim()&&(i[a.trim()]=s.trim())}}catch(t){r.e(t)}finally{r.f()}var c=null!=e?e:{},l=Object.keys(i);return l.length===Object.keys(c).length&&l.every(function(t){return i[t]===c[t]})}},{key:"_readClipboard",value:function(){try{var t=sessionStorage.getItem(In);if(!t)return;var e=JSON.parse(t);if(e&&"object"===wn(e))return e}catch(t){}}},{key:"_writeClipboard",value:function(t){try{sessionStorage.setItem(In,JSON.stringify(t)),this._clipboardEntity=t}catch(t){}}},{key:"_hasNativeTabs",get:function(){return!!customElements.get("ha-tab-group")&&!!customElements.get("ha-tab-group-tab")}},{key:"render",value:function(){return this.hass&&this._config?W(De||(De=sn(["\n \n ",'\n \n\n \n "])),"Entities",this._entitiesExpanded,this._onEntitiesExpandedChanged,this._renderEntitiesPanel(),"4.10.2","2026-08-16T17:39:39.571Z"):Z}},{key:"_isCustomFormat",value:function(t,e){return this._customFormatScopes.has(t)||!!e&&!en.has(e)}},{key:"_setCustomFormatScope",value:function(t,e){if(e!==this._customFormatScopes.has(t)){var n=new Set(this._customFormatScopes);e?n.add(t):n.delete(t),this._customFormatScopes=n}}},{key:"_formatToForm",value:function(t,e){return this._isCustomFormat(t,e.format)?pn(pn({},e),{},{format:Xe}):e}},{key:"_formatFromForm",value:function(t,e,n){if(e.format===Xe){this._setCustomFormatScope(t,!0);var i=pn({},e);return void 0!==n?i.format=n:delete i.format,i}return this._setCustomFormatScope(t,!1),e}},{key:"_renderCustomFormatField",value:function(t,e,n){return this._isCustomFormat(t,e)?W(Re||(Re=sn(["\n \n "])),this.hass,{custom_format:null!=e?e:""},[{name:"custom_format",selector:{text:{}}}],function(){return"Custom format (e.g. invert, precision3)"},function(t){var e,i=(null!==(e=t.detail.value.custom_format)&&void 0!==e?e:"").trim();n(i||void 0)}):Z}},{key:"_keyFor",value:function(t){return this._keys.has(t)||this._keys.set(t,"".concat(t,"-").concat(Math.random().toString(36).slice(2,10))),this._keys.get(t)}},{key:"_renderEntitiesPanel",value:function(){var t,e,n=this,i=null!==(t=null===(e=this._config)||void 0===e?void 0:e.entities)&&void 0!==t?t:[],r=Math.min(this._selectedTab,i.length);return W(Fe||(Fe=sn(['\n \n
\n ','\n \n
\n\n
\n ',"\n
\n
\n "])),this._hasNativeTabs?W(Ne||(Ne=sn(['\n \n \n \n Main\n \n ","\n \n "])),"tab "+(0===r?"tab--active":""),0===r?"true":"false",function(){return n._selectedTab=0},i.map(function(t,e){return W(Ve||(Ve=sn(['\n \n ","\n \n "])),"tab "+(r===e+1?"tab--active":""),r===e+1?"true":"false",function(){return n._selectedTab=e+1},e+1)})),"Add entity","M19,13H13V19H11V13H5V11H11V5H13V11H19V13Z",this._addEntity,He(this._keyFor(r),0===r?this._renderMainTab():this._renderAdditionalTab(r-1)))}},{key:"_renderMainTab",value:function(){var t,e,n=this;return W(ze||(ze=sn(['\n \n \n
\n \n Interactions
\n \n Secondary info
\n ','\n State-based icons
\n ','\n Custom CSS
\n ',"\n \n "])),"Copy main as template",On,this._copyMainAsTemplate,"Paste as new entity",Sn,!this._clipboardEntity,this._pasteEntity,this.hass,this._mainFormData(),nn,this._computeLabel,this._mainValueChanged,this._renderCustomFormatField("main",null===(t=this._config)||void 0===t?void 0:t.format,function(t){return n._updateConfig({format:t})}),this.hass,this._mainFormData(),rn,this._computeLabel,this._mainValueChanged,this._renderSecondaryInfoBlock(),this._renderStateIconRows("main"),this._renderStylesBlock(null===(e=this._config)||void 0===e?void 0:e.styles,function(t){return n._mainStylesChanged(t)}))}},{key:"_renderStylesBlock",value:function(t,e){return W(Be||(Be=sn(['\n \n ','\n \n + Add state\n \n \n "])),n.map(function(i,r){return W(qe||(qe=sn(['\n \n \n \n
\n \n Interactions
\n \n State-based icons
\n ','\n Custom CSS
\n ',"\n \n "])),"Move before","M15.41,16.58L10.83,12L15.41,7.41L14,6L8,12L14,18L15.41,16.58Z",0===t,function(){return i._moveAdditional(t,-1)},"Move after","M8.59,16.58L13.17,12L8.59,7.41L10,6L16,12L10,18L8.59,16.58Z",t===a,function(){return i._moveAdditional(t,1)},"Copy entity",On,function(){return i._copyAdditional(t)},"Cut entity","M19,3L13,9L15,11L22,4V3M12,12.5A0.5,0.5 0 0,1 11.5,12A0.5,0.5 0 0,1 12,11.5A0.5,0.5 0 0,1 12.5,12A0.5,0.5 0 0,1 12,12.5M6,20A2,2 0 0,1 4,18C4,16.89 4.9,16 6,16A2,2 0 0,1 8,18C8,19.11 7.1,20 6,20M6,8A2,2 0 0,1 4,6C4,4.89 4.9,4 6,4A2,2 0 0,1 8,6C8,7.11 7.1,8 6,8M9.64,7.64C9.87,7.14 10,6.59 10,6A4,4 0 0,0 6,2A4,4 0 0,0 2,6A4,4 0 0,0 6,10C6.59,10 7.14,9.87 7.64,9.64L10,12L7.64,14.36C7.14,14.13 6.59,14 6,14A4,4 0 0,0 2,18A4,4 0 0,0 6,22A4,4 0 0,0 10,18C10,17.41 9.87,16.86 9.64,16.36L12,14L19,21H22V20L9.64,7.64Z",function(){return i._cutAdditional(t)},"Paste from clipboard",Sn,!this._clipboardEntity,this._pasteEntity,"Delete entity",An,function(){return i._deleteAdditional(t)},this.hass,this._formatToForm("sub-".concat(t),Tn(o)),on,this._computeLabel,function(e){return i._additionalValueChanged(e,t)},this._renderCustomFormatField("sub-".concat(t),o.format,function(e){return i._setAdditionalFormat(t,e)}),this.hass,this._formatToForm("sub-".concat(t),Tn(o)),rn,this._computeLabel,function(e){return i._additionalValueChanged(e,t)},this._renderStateIconRows(t),this._renderStylesBlock(o.styles,function(e){return i._additionalStylesChanged(e,t)}))}},{key:"_mainFormData",value:function(){return this._formatToForm("main",Tn(pn({show_state:!0},this._config)))}},{key:"_setAdditionalFormat",value:function(t,e){var n;if(this._config){var i=cn(null!==(n=this._config.entities)&&void 0!==n?n:[]),r=i[t],o="string"==typeof r?{entity:r}:pn({},r);e?o.format=e:delete o.format,i[t]=o,this._updateConfig({entities:i})}}},{key:"_secondaryInfoMode",value:function(){var t,e=null===(t=this._config)||void 0===t?void 0:t.secondary_info;return null==e?"none":"object"===wn(e)?"entity":yt.includes(e)?"generic":"text"}},{key:"_renderSecondaryInfoBlock",value:function(){var t,e=this,n=this._secondaryInfoMode(),i=null===(t=this._config)||void 0===t?void 0:t.secondary_info;return W(Je||(Je=sn(["\n \n \n ","\n ","\n ","\n
\n "])),this.hass,{mode:n},$n,function(){return"Mode"},this._secondaryModeChanged,"text"===n?W(Ge||(Ge=sn(['\n "])),"tab "+(0===r?"tab--active":""),0===r?"true":"false",function(){return n._selectedTab=0},i.map(function(t,e){return W(qe||(qe=fn(['\n \n ","\n \n "])),"tab "+(r===e+1?"tab--active":""),r===e+1?"true":"false",function(){return n._selectedTab=e+1},e+1)})),"Add entity","M19,13H13V19H11V13H5V11H11V5H13V11H19V13Z",this._addEntity,Ne(this._keyFor(r),0===r?this._renderMainTab():this._renderAdditionalTab(r-1)))}},{key:"_renderMainTab",value:function(){var t,e,n=this;return W(Ze||(Ze=fn(['\n \n \n
\n \n Interactions
\n \n Secondary info
\n ','\n State-based icons
\n ','\n Custom CSS
\n ',"\n \n "])),"Copy main as template",xn,this._copyMainAsTemplate,"Paste as new entity",kn,!this._clipboardEntity,this._pasteEntity,this.hass,this._mainFormData(),sn,this._computeLabel,this._mainValueChanged,this._renderCustomFormatField("main",null===(t=this._config)||void 0===t?void 0:t.format,function(t){return n._updateConfig({format:t})}),this.hass,this._mainFormData(),cn,this._computeLabel,this._mainValueChanged,this._renderSecondaryInfoBlock(),this._renderStateIconRows("main"),this._renderStylesBlock(null===(e=this._config)||void 0===e?void 0:e.styles,function(t){return n._mainStylesChanged(t)}))}},{key:"_renderStylesBlock",value:function(t,e){return W(Ge||(Ge=fn(['\n \n ','\n \n + Add state\n \n \n "])),n.map(function(i,r){return W(Ke||(Ke=fn(['\n \n \n \n
\n \n Interactions
\n \n State-based icons
\n ','\n Custom CSS
\n ',"\n \n "])),"Move before","M15.41,16.58L10.83,12L15.41,7.41L14,6L8,12L14,18L15.41,16.58Z",0===t,function(){return i._moveAdditional(t,-1)},"Move after","M8.59,16.58L13.17,12L8.59,7.41L10,6L16,12L10,18L8.59,16.58Z",t===a,function(){return i._moveAdditional(t,1)},"Copy entity",xn,function(){return i._copyAdditional(t)},"Cut entity","M19,3L13,9L15,11L22,4V3M12,12.5A0.5,0.5 0 0,1 11.5,12A0.5,0.5 0 0,1 12,11.5A0.5,0.5 0 0,1 12.5,12A0.5,0.5 0 0,1 12,12.5M6,20A2,2 0 0,1 4,18C4,16.89 4.9,16 6,16A2,2 0 0,1 8,18C8,19.11 7.1,20 6,20M6,8A2,2 0 0,1 4,6C4,4.89 4.9,4 6,4A2,2 0 0,1 8,6C8,7.11 7.1,8 6,8M9.64,7.64C9.87,7.14 10,6.59 10,6A4,4 0 0,0 6,2A4,4 0 0,0 2,6A4,4 0 0,0 6,10C6.59,10 7.14,9.87 7.64,9.64L10,12L7.64,14.36C7.14,14.13 6.59,14 6,14A4,4 0 0,0 2,18A4,4 0 0,0 6,22A4,4 0 0,0 10,18C10,17.41 9.87,16.86 9.64,16.36L12,14L19,21H22V20L9.64,7.64Z",function(){return i._cutAdditional(t)},"Paste from clipboard",kn,!this._clipboardEntity,this._pasteEntity,"Delete entity",$n,function(){return i._deleteAdditional(t)},this.hass,this._formatToForm("sub-".concat(t),Hn(o)),ln,this._computeLabel,function(e){return i._additionalValueChanged(e,t)},this._renderCustomFormatField("sub-".concat(t),o.format,function(e){return i._setAdditionalFormat(t,e)}),this.hass,this._formatToForm("sub-".concat(t),Hn(o)),cn,this._computeLabel,function(e){return i._additionalValueChanged(e,t)},this._renderStateIconRows(t),this._renderStylesBlock(o.styles,function(e){return i._additionalStylesChanged(e,t)}))}},{key:"_mainFormData",value:function(){return this._formatToForm("main",Hn(bn({show_state:!0},this._config)))}},{key:"_setAdditionalFormat",value:function(t,e){var n;if(this._config){var i=dn(null!==(n=this._config.entities)&&void 0!==n?n:[]),r=i[t],o="string"==typeof r?{entity:r}:bn({},r);e?o.format=e:delete o.format,i[t]=o,this._updateConfig({entities:i})}}},{key:"_secondaryInfoMode",value:function(){var t,e=null===(t=this._config)||void 0===t?void 0:t.secondary_info;return null==e?"none":"object"===jn(e)?"entity":yt.includes(e)?"generic":"text"}},{key:"_renderSecondaryInfoBlock",value:function(){var t,e=this,n=this._secondaryInfoMode(),i=null===(t=this._config)||void 0===t?void 0:t.secondary_info;return W(Qe||(Qe=fn(["\n \n \n ","\n ","\n ","\n
\n "])),this.hass,{mode:n},En,function(){return"Mode"},this._secondaryModeChanged,"text"===n?W(Xe||(Xe=fn(['