251 files

This commit is contained in:
Home Assistant Version Control
2026-10-16 12:38:25 +00:00
parent 3acf0cdf8d
commit 64eb0c647c
251 changed files with 6380 additions and 14560 deletions
+1 -1
View File
@@ -1 +1 @@
{"pid": 73, "version": 1, "ha_version": "2026.10.1", "start_ts": 1792001005.2570755}
{"pid": 71, "version": 1, "ha_version": "2026.10.1", "start_ts": 1792153632.14175}
+40 -35
View File
@@ -81,117 +81,117 @@
},
{
"id": "88b90f2b1d6b45f0b44bee786ee1a525",
"url": "/taskmate/taskmate-attr-resolver.js?v=6.3.0",
"url": "/taskmate/taskmate-attr-resolver.js?v=6.4.0",
"type": "module"
},
{
"id": "0796c92931dd458e81dc9c2677248274",
"url": "/taskmate/taskmate-localize.js?v=6.3.0",
"url": "/taskmate/taskmate-localize.js?v=6.4.0",
"type": "module"
},
{
"id": "d2e281920ac7409583eafff1f1db69bd",
"url": "/taskmate/taskmate-design.js?v=6.3.0",
"url": "/taskmate/taskmate-design.js?v=6.4.0",
"type": "module"
},
{
"id": "9eae9632f0734480be5edd784b030673",
"url": "/taskmate/taskmate-badges-card.js?v=6.3.0",
"url": "/taskmate/taskmate-badges-card.js?v=6.4.0",
"type": "module"
},
{
"id": "5df49cd8a4eb420497b5df22cb31362c",
"url": "/taskmate/taskmate-child-card.js?v=6.3.0",
"url": "/taskmate/taskmate-child-card.js?v=6.4.0",
"type": "module"
},
{
"id": "b44c87d0d74f468cb96c5eabc340f9ee",
"url": "/taskmate/taskmate-rewards-card.js?v=6.3.0",
"url": "/taskmate/taskmate-rewards-card.js?v=6.4.0",
"type": "module"
},
{
"id": "787a96f9c30548708165a48457c99feb",
"url": "/taskmate/taskmate-approvals-card.js?v=6.3.0",
"url": "/taskmate/taskmate-approvals-card.js?v=6.4.0",
"type": "module"
},
{
"id": "84031597de424ad68e7c2bc66d06b72e",
"url": "/taskmate/taskmate-points-card.js?v=6.3.0",
"url": "/taskmate/taskmate-points-card.js?v=6.4.0",
"type": "module"
},
{
"id": "02613225844746b699bfbfac9642eca2",
"url": "/taskmate/taskmate-reorder-card.js?v=6.3.0",
"url": "/taskmate/taskmate-reorder-card.js?v=6.4.0",
"type": "module"
},
{
"id": "7a06a07e17ba4b31b712bc91b77360b4",
"url": "/taskmate/taskmate-overview-card.js?v=6.3.0",
"url": "/taskmate/taskmate-overview-card.js?v=6.4.0",
"type": "module"
},
{
"id": "08b42b53b33b4fa88db932455cf8d5aa",
"url": "/taskmate/taskmate-activity-card.js?v=6.3.0",
"url": "/taskmate/taskmate-activity-card.js?v=6.4.0",
"type": "module"
},
{
"id": "15907eff51784acdba88ff694b574509",
"url": "/taskmate/taskmate-streak-card.js?v=6.3.0",
"url": "/taskmate/taskmate-streak-card.js?v=6.4.0",
"type": "module"
},
{
"id": "1bd6bb5c0a6d4f0d916e3d82cb7fac39",
"url": "/taskmate/taskmate-weekly-card.js?v=6.3.0",
"url": "/taskmate/taskmate-weekly-card.js?v=6.4.0",
"type": "module"
},
{
"id": "289ce5943f5a4972a593697db4c96ece",
"url": "/taskmate/taskmate-graph-card.js?v=6.3.0",
"url": "/taskmate/taskmate-graph-card.js?v=6.4.0",
"type": "module"
},
{
"id": "d2904acee4c74a398dcc7b996c23e7e7",
"url": "/taskmate/taskmate-reward-progress-card.js?v=6.3.0",
"url": "/taskmate/taskmate-reward-progress-card.js?v=6.4.0",
"type": "module"
},
{
"id": "46d378736fd54a26be253f2dfb27202e",
"url": "/taskmate/taskmate-leaderboard-card.js?v=6.3.0",
"url": "/taskmate/taskmate-leaderboard-card.js?v=6.4.0",
"type": "module"
},
{
"id": "db11ae36457842fa826c7991e1d9c6f4",
"url": "/taskmate/taskmate-parent-dashboard-card.js?v=6.3.0",
"url": "/taskmate/taskmate-parent-dashboard-card.js?v=6.4.0",
"type": "module"
},
{
"id": "ce85f301b59a412e9abfe5ffd51cb9a2",
"url": "/taskmate/taskmate-penalties-card.js?v=6.3.0",
"url": "/taskmate/taskmate-penalties-card.js?v=6.4.0",
"type": "module"
},
{
"id": "1959e1629de2422daa8adaf87069c288",
"url": "/taskmate/taskmate-bonuses-card.js?v=6.3.0",
"url": "/taskmate/taskmate-bonuses-card.js?v=6.4.0",
"type": "module"
},
{
"id": "2548524b269c4959bc7e0e967df943af",
"url": "/taskmate/taskmate-points-display-card.js?v=6.3.0",
"url": "/taskmate/taskmate-points-display-card.js?v=6.4.0",
"type": "module"
},
{
"id": "36ada49368db4187a283fbbbef45454f",
"url": "/taskmate/taskmate-calendar-card.js?v=6.3.0",
"url": "/taskmate/taskmate-calendar-card.js?v=6.4.0",
"type": "module"
},
{
"id": "67c32687acd2497b90030ef141f4b6db",
"url": "/taskmate/taskmate-photo-gallery-card.js?v=6.3.0",
"url": "/taskmate/taskmate-photo-gallery-card.js?v=6.4.0",
"type": "module"
},
{
"id": "6f0c8348a91f4b74a9b846ab3f62621f",
"url": "/taskmate/taskmate-family-goal-card.js?v=6.3.0",
"url": "/taskmate/taskmate-family-goal-card.js?v=6.4.0",
"type": "module"
},
{
@@ -276,7 +276,7 @@
},
{
"id": "73dbd2de03de4530b451a55bfe6c46cf",
"url": "/taskmate/taskmate-routine-card.js?v=6.3.0",
"url": "/taskmate/taskmate-routine-card.js?v=6.4.0",
"type": "module"
},
{
@@ -291,7 +291,7 @@
},
{
"id": "1a4939ce1d2b47f89502176d3c5ac6e7",
"url": "/taskmate/taskmate-sounds.js?v=6.3.0",
"url": "/taskmate/taskmate-sounds.js?v=6.4.0",
"type": "module"
},
{
@@ -306,32 +306,32 @@
},
{
"id": "2cc757922cd142b69ce8844afc0e8410",
"url": "/taskmate/taskmate-wishlist-card.js?v=6.3.0",
"url": "/taskmate/taskmate-wishlist-card.js?v=6.4.0",
"type": "module"
},
{
"id": "107319877a72413fbbea40b7f8adb06c",
"url": "/taskmate/taskmate-kiosk-card.js?v=6.3.0",
"url": "/taskmate/taskmate-kiosk-card.js?v=6.4.0",
"type": "module"
},
{
"id": "5a2899c29e9046c78f060580ba113d9f",
"url": "/taskmate/taskmate-bounty-card.js?v=6.3.0",
"url": "/taskmate/taskmate-bounty-card.js?v=6.4.0",
"type": "module"
},
{
"id": "a1aae7e334ba4c2d8dd3849178f780f6",
"url": "/taskmate/taskmate-auction-card.js?v=6.3.0",
"url": "/taskmate/taskmate-auction-card.js?v=6.4.0",
"type": "module"
},
{
"id": "464b73198b7d4fe480c09bbf386a4e5d",
"url": "/taskmate/taskmate-recap-card.js?v=6.3.0",
"url": "/taskmate/taskmate-recap-card.js?v=6.4.0",
"type": "module"
},
{
"id": "35f1f324116d4b589f3087065519360b",
"url": "/taskmate/taskmate-chore-board-card.js?v=6.3.0",
"url": "/taskmate/taskmate-chore-board-card.js?v=6.4.0",
"type": "module"
},
{
@@ -350,13 +350,18 @@
"type": "module"
},
{
"id": "a1a494805ac84a78a6dba82b528ba900",
"url": "/album_slideshow_static/album-slideshow-card.js?v=1.15.0",
"id": "9eef7a5619294eefa48bda26787ad279",
"url": "/climate_scheduler/static/climate-scheduler-card.js?v=1.15.1",
"type": "module"
},
{
"id": "ec621063df614ae38f26b7c0c97d16fd",
"url": "/climate_scheduler/static/climate-scheduler-card.js?v=1.15.1",
"id": "35e5c04420bc4aa0bf56704195e4fcb7",
"url": "/album_slideshow_static/album-slideshow-card.js?v=1.15.1",
"type": "module"
},
{
"id": "f65bb1cdcb3f4fefb7c8ca3bfd28aa45",
"url": "/taskmate/taskmate-sticker-chart-card.js?v=6.4.0",
"type": "module"
}
]
@@ -22,6 +22,7 @@ from .const import (
SERVICE_LIST_HIDDEN_PHOTOS,
)
from .store import SlideshowStore
from .viewers import async_register_websocket as async_register_viewer_websocket
_LOGGER = logging.getLogger(__name__)
@@ -336,6 +337,7 @@ async def async_setup(hass: HomeAssistant, config: dict) -> bool:
"""
hass.data.setdefault(DOMAIN, {})
await _async_register_card(hass)
async_register_viewer_websocket(hass)
async def _retry_lovelace_resource(_event) -> None:
if hass.data.get(DOMAIN, {}).get("lovelace_resource_registered"):
@@ -456,6 +458,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# itself never has to render a transition burst on the event loop.
await _async_register_card(hass)
# Streams video slides to the card (provider credentials stay server-side).
try:
from .video import async_register_video_view
async_register_video_view(hass)
except Exception: # noqa: BLE001 - stills keep working without it
_LOGGER.exception("Failed to register Album Slideshow video view")
await _async_cleanup_legacy_entities(hass, entry)
store = SlideshowStore()
+251 -17
View File
@@ -36,8 +36,10 @@ from .const import (
)
from . import image_processing as ip
from . import playlist
from . import video
from .coordinator import AlbumCoordinator, MediaItem
from .store import SlideshowStore
from .viewers import async_get_tracker
_LOGGER = logging.getLogger(__name__)
@@ -59,6 +61,13 @@ _SKIP_SEARCH_LIMIT = 30
_MAX_RENDER_ATTEMPTS = 10
# How long a render waits for an item's metadata (faces for the crop,
# caption fields) to be fetched on demand before rendering without it.
# Upcoming slides render ahead of time, so this rarely delays a slide.
_ENRICH_WAIT_SECONDS = 8.0
_MB = 1024 * 1024
# Fixed floor (in items) for the pairing cooldown exclusion zone, applied on
# each side of the current index in addition to the percentage-based radius.
# Keeps tiny albums from pairing consecutive/near-consecutive shots even when
@@ -161,9 +170,34 @@ def _caption_frame(item: MediaItem | None) -> dict[str, Any]:
"longitude": getattr(item, "longitude", None),
"description": getattr(item, "description", None),
**_camera_metadata_attributes(item),
**_file_attributes(item),
}
def _file_attributes(item: MediaItem | None) -> dict[str, Any]:
return {
"filename": getattr(item, "filename", None),
"path": getattr(item, "source_path", None),
}
# Describe the stream a video slide is served as (see
# ``AlbumCoordinator.async_video_info``); ``None`` for photos.
VIDEO_INFO_FIELDS = (
"video_version", "video_bitrate_mbps", "video_size_bytes", "video_content_type",
)
def _is_video(item: Any) -> bool:
"""A video slide (played alone). Live Photos are photos with motion."""
return bool(getattr(item, "video_url", None)) and not getattr(item, "live_photo", False)
def _has_motion(item: Any) -> bool:
"""A video, or a Live Photo whose motion clip can play over its still."""
return bool(getattr(item, "video_url", None))
def _item_faces(item: MediaItem | None) -> tuple[ip.FaceBox, ...] | None:
"""Return the validated face boxes stored on a media item.
@@ -213,6 +247,10 @@ class _DownloadCache:
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.
_LOGGER.debug(
"Image cache: not caching %.1f MB image, larger than the %.1f MB budget",
len(data) / _MB, self._max_bytes / _MB,
)
return
if url in self._cache:
self._total_bytes -= len(self._cache[url])
@@ -222,13 +260,28 @@ class _DownloadCache:
self._evict()
def resize(self, max_bytes: int) -> None:
self._max_bytes = max(max_bytes, 1)
max_bytes = max(max_bytes, 1)
if max_bytes != self._max_bytes:
_LOGGER.debug(
"Image cache: budget %.1f MB -> %.1f MB (%.1f MB in use)",
self._max_bytes / _MB, max_bytes / _MB, self._total_bytes / _MB,
)
self._max_bytes = max_bytes
self._evict()
def _evict(self) -> None:
count = freed = 0
while self._total_bytes > self._max_bytes and self._cache:
_, data = self._cache.popitem(last=False)
self._total_bytes -= len(data)
count += 1
freed += len(data)
if count:
_LOGGER.debug(
"Image cache: evicted %d image(s) (%.1f MB), now %.1f/%.1f MB, %d cached",
count, freed / _MB, self._total_bytes / _MB, self._max_bytes / _MB,
len(self._cache),
)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None:
@@ -243,6 +296,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e
class AlbumSlideshowCamera(Camera):
_attr_should_poll = False
# The signed stream URL and the server's file paths stay out of history.
_unrecorded_attributes = frozenset({"video_url", "current_path"})
def __init__(self, hass: HomeAssistant, entry: ConfigEntry, coordinator: AlbumCoordinator, store: SlideshowStore) -> None:
super().__init__()
@@ -272,6 +327,10 @@ class AlbumSlideshowCamera(Camera):
# ``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
# ``{"photo_id", "duration_ms"}`` when the current slide is a video
# (always unpaired); ``_video_url`` is its signed proxy URL.
self._last_video: dict | None = None
self._video_url: 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
@@ -315,6 +374,8 @@ class AlbumSlideshowCamera(Camera):
self._consecutive_failures: int = 0
self._render_task: asyncio.Task | None = None
self._render_settings = self._current_render_settings()
self._viewers = async_get_tracker(hass)
self._unsub_viewers = None
def _on_coordinator_update() -> None:
previous_items = self._effective_cache[1] if self._effective_cache else None
@@ -326,7 +387,14 @@ class AlbumSlideshowCamera(Camera):
coordinator.async_add_listener(_on_coordinator_update)
self._pause_when_unwatched = bool(store.pause_when_unwatched)
def _on_store_change() -> None:
enabled = bool(self.store.pause_when_unwatched)
if enabled and not self._pause_when_unwatched and self._unsub_viewers is not None:
# Cards stay quiet while the setting is off; let them check in.
self._viewers.async_restart_grace(self.entry.entry_id)
self._pause_when_unwatched = enabled
self._download_cache.resize(self.store.image_cache_mb * 1024 * 1024)
self._slide_deadline = None
settings = self._current_render_settings()
@@ -355,12 +423,18 @@ class AlbumSlideshowCamera(Camera):
# 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._unsub_viewers = self._viewers.async_track(
self.entry.entry_id, self._on_viewers_change
)
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._unsub_viewers is not None:
self._unsub_viewers()
self._unsub_viewers = None
preload_task = self._preload_task
self._cancel_preload()
if self._render_task is not None:
@@ -396,6 +470,12 @@ class AlbumSlideshowCamera(Camera):
cur = items[self._index] if items and 0 <= self._index < len(items) else None
captured_at = _ts_to_iso(getattr(cur, "captured_at", None))
caption_frames = self._caption_frames(cur)
# Paths go out separately as ``current_path``, which isn't recorded.
paths = [frame.get("path") for frame in caption_frames]
caption_frames = [
{key: value for key, value in frame.items() if key != "path"}
for frame in caption_frames
]
captured_at_pair = (
[frame["captured_at"] for frame in caption_frames]
if self._last_pair_frames else None
@@ -425,6 +505,7 @@ class AlbumSlideshowCamera(Camera):
"media_count_total": len(data.get("items", []) or []),
"current_index": self._index,
"current_filename": getattr(cur, "filename", None),
"current_path": paths if self._last_pair_frames else getattr(cur, "source_path", 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,
@@ -445,6 +526,17 @@ class AlbumSlideshowCamera(Camera):
# ``pair_orientation`` tells the card how the two halves are laid
# out: ``horizontal`` (left/right) or ``vertical`` (top/bottom).
"caption_frames": caption_frames,
# Video slides: the camera image is the poster frame; the card
# plays ``video_url`` over it for ``video_hold_seconds``.
"media_kind": (
("live_photo" if self._last_video.get("live") else "video")
if self._last_video else "image"
),
"video_url": self._video_url if self._last_video else None,
"video_hold_seconds": (
self._slide_hold_seconds() if self._last_video else None
),
**self._video_info_attributes(),
"pair_orientation": self._last_pair_orientation,
"slide_interval": int(self.store.slide_interval),
"fill_mode": self.store.fill_mode,
@@ -455,6 +547,8 @@ class AlbumSlideshowCamera(Camera):
"shuffle_age_bias": int(self.store.shuffle_age_bias),
"missing_date_mode": self.store.missing_date_mode,
"paused": bool(self.store.paused),
"pause_when_unwatched": bool(self.store.pause_when_unwatched),
"paused_unwatched": self._paused_unwatched,
"refresh_hours": int(self.store.refresh_hours),
"aspect_ratio": self.store.aspect_ratio,
"pair_divider_px": int(self.store.pair_divider_px),
@@ -489,7 +583,7 @@ class AlbumSlideshowCamera(Camera):
the slide was rendered still shows.
"""
if not self._last_pair_frames:
return [_caption_frame(cur)]
return [{**_caption_frame(cur), **self._video_info_attributes()}]
photo_ids = self._current_frame.meta.get("photo_ids", []) if self._current_frame else []
if len(photo_ids) != len(self._last_pair_frames):
return self._last_pair_frames
@@ -762,6 +856,8 @@ class AlbumSlideshowCamera(Camera):
self._last_is_portrait = None
self._last_pair_frames = None
self._last_pair_orientation = None
self._last_video = None
self._video_url = None
def blank_frame():
with Image.new("RGB", (16, 9), "black") as image:
@@ -795,12 +891,17 @@ class AlbumSlideshowCamera(Camera):
# that stub out homeassistant.
from homeassistant.components.camera import async_get_still_stream
return await async_get_still_stream(
request,
self.async_camera_image,
self.content_type,
self.frame_interval,
)
# Someone is watching the stream, so "Pause when not viewed" holds off.
close = self._viewers.async_add_stream(self.entry.entry_id)
try:
return await async_get_still_stream(
request,
self.async_camera_image,
self.content_type,
self.frame_interval,
)
finally:
close()
# Older HA cores may dispatch via the alt name; alias for compatibility.
async def async_handle_async_mjpeg_stream(self, request):
@@ -874,9 +975,20 @@ class AlbumSlideshowCamera(Camera):
try:
composed, meta = await renderer._compose_for_index(items)
meta = dict(meta or {})
meta.setdefault("photo_ids", [getattr(items[renderer._index], "photo_id", None)])
shown = items[renderer._index]
meta.setdefault("photo_ids", [getattr(shown, "photo_id", None)])
# Faces used for the crop, so later face data can be detected.
meta.setdefault("faces", (_item_faces(items[renderer._index]),))
meta.setdefault("faces", (_item_faces(shown),))
# A paired Live Photo shows only its still.
if _has_motion(shown) and len(meta["photo_ids"]) == 1 and shown.photo_id:
if _is_video(shown):
meta["video"] = {
"photo_id": shown.photo_id,
"duration_ms": shown.duration_ms,
**await self._video_info(shown),
}
else:
meta["video"] = {"photo_id": shown.photo_id, "live": True}
cursor = self._capture_cursor(renderer)
if composed is None:
raise RuntimeError("Image composition returned no frame")
@@ -902,6 +1014,7 @@ class AlbumSlideshowCamera(Camera):
def _apply_frame(self, frame: _RenderedFrame) -> None:
"""Make a rendered frame current and publish it to Home Assistant."""
previous_frame = getattr(self, "_current_frame", None)
self._current_frame = frame
self._framebuffer = frame.data
self.store.last_frame = frame.data
@@ -915,19 +1028,41 @@ class AlbumSlideshowCamera(Camera):
self._last_is_portrait = meta.get("is_portrait")
self._last_pair_frames = meta.get("pair_frames")
self._last_pair_orientation = meta.get("pair_orientation")
previous_video = getattr(self, "_last_video", None)
self._last_video = meta.get("video")
if not self._last_video:
self._video_url = None
elif not (
previous_video
and previous_video.get("photo_id") == self._last_video["photo_id"]
and getattr(self, "_video_url", None)
):
# Re-rendering the same clip (metadata refreshes do this) keeps its
# URL, so the card lets it play on instead of restarting it.
self._video_url = video.sign_video_path(
self.hass, video.video_path(self.entry.entry_id, self._last_video["photo_id"])
)
self._frame_id += 1
self._frame_serials[self._frame_id] = frame.serial
while len(self._frame_serials) > _FRAME_ID_MEMORY:
del self._frame_serials[next(iter(self._frame_serials))]
if previous_frame is None or previous_frame.meta.get("photo_ids") != meta.get("photo_ids"):
# A different slide is up: restart the clock with its own hold
# (a video's length), whichever path put it here. Re-renders and
# exclusion rebuilds can swap the slide without going through the
# timer; a re-render of the same slide keeps the running clock.
self._slide_deadline = None
self._interrupt_event.set()
_LOGGER.debug(
"Album Slideshow %s: displayed buffered frame_id=%d index=%d "
"previous=%d next=%d",
"previous=%d next=%d hold=%.1fs",
self.entry.title,
self._frame_id,
self._index,
len(self._previous_frames),
len(self._next_frames),
self._slide_hold_seconds(),
)
self.async_write_ha_state()
@@ -1203,7 +1338,7 @@ class AlbumSlideshowCamera(Camera):
if self._timeline_dirty:
return True
try:
if self.store.paused:
if self._held:
await self._interrupt_event.wait()
else:
await asyncio.wait_for(
@@ -1214,6 +1349,33 @@ class AlbumSlideshowCamera(Camera):
except asyncio.TimeoutError:
return False
@property
def _paused_unwatched(self) -> bool:
"""Held by "Pause when not viewed": no card or stream is showing us."""
return bool(self.store.pause_when_unwatched) and self._viewers.is_unwatched(
self.entry.entry_id
)
@property
def _held(self) -> bool:
"""Whether the slide timer is stopped (manual navigation still works)."""
return bool(self.store.paused) or self._paused_unwatched
def _on_viewers_change(self) -> None:
if not self.store.pause_when_unwatched:
return
_LOGGER.debug(
"Album Slideshow %s: %s",
self.entry.title,
"paused, no one is viewing it" if self._paused_unwatched
else "resumed, a viewer is back",
)
# Wake the render loop; a returning viewer gets a full interval on
# the slide they find.
self._slide_deadline = None
self._interrupt_event.set()
self.async_write_ha_state()
async def _render_loop(self, initial_delay: float = 0.0) -> None:
"""Display buffered frames on command/timer and refill them in the background."""
loop = asyncio.get_running_loop()
@@ -1248,7 +1410,7 @@ class AlbumSlideshowCamera(Camera):
while True:
try:
if self._slide_deadline is None:
self._slide_deadline = loop.time() + float(int(self.store.slide_interval))
self._slide_deadline = loop.time() + self._slide_hold_seconds()
if self._timeline_dirty:
async with self._navigation_lock:
if self._timeline_dirty:
@@ -1258,7 +1420,7 @@ class AlbumSlideshowCamera(Camera):
interrupted = await self._wait_or_interrupt(
max(0.0, self._slide_deadline - loop.time())
)
if not interrupted and not self.store.paused:
if not interrupted and not self._held:
async with self._navigation_lock:
if (
not self._timeline_dirty
@@ -1266,7 +1428,7 @@ class AlbumSlideshowCamera(Camera):
and loop.time() >= self._slide_deadline
):
await self._show_next_frame()
self._slide_deadline = loop.time() + float(int(self.store.slide_interval))
self._slide_deadline = loop.time() + self._slide_hold_seconds()
self._consecutive_failures = 0
except asyncio.CancelledError:
raise
@@ -1279,6 +1441,68 @@ class AlbumSlideshowCamera(Camera):
err,
)
async def _ensure_enriched(self, item: MediaItem) -> None:
"""Fetch an item's metadata before rendering it, if still missing.
Faces steer the crop and caption fields are baked into paired slides,
so a slide rendered before the background pass reaches its photo
would otherwise be missing both. Bounded so a slow server only
delays the slide briefly; the fetch carries on in the background.
"""
enrich = getattr(getattr(self, "coordinator", None), "async_enrich_item", None)
if enrich is None:
return
loop = asyncio.get_running_loop()
started = loop.time()
try:
fetched = await asyncio.wait_for(asyncio.shield(enrich(item)), _ENRICH_WAIT_SECONDS)
if fetched:
_LOGGER.debug(
"Album Slideshow: fetched metadata for %s in %d ms before display",
getattr(item, "filename", None), (loop.time() - started) * 1000,
)
except asyncio.CancelledError:
raise
except Exception as err: # noqa: BLE001 - render without metadata
_LOGGER.debug(
"Album Slideshow: metadata for %s not ready: %r",
getattr(item, "filename", None), err,
)
async def _video_info(self, item: MediaItem) -> dict[str, Any]:
"""Which stream a video is served as; empty when it can't be told."""
fetch = getattr(self.coordinator, "async_video_info", None)
if fetch is None:
return {}
try:
info = await fetch(item)
except asyncio.CancelledError:
raise
except Exception as err: # noqa: BLE001 - a caption must not cost the slide
_LOGGER.debug("Album Slideshow: could not describe video %s: %s", item.filename, err)
return {}
return {field: info.get(field) for field in VIDEO_INFO_FIELDS if field in info}
def _video_info_attributes(self) -> dict[str, Any]:
video_meta = getattr(self, "_last_video", None) or {}
duration_ms = video_meta.get("duration_ms")
return {
"video_duration": duration_ms / 1000 if duration_ms else None,
**{field: video_meta.get(field) for field in VIDEO_INFO_FIELDS},
}
def _slide_hold_seconds(self) -> float:
"""Seconds the current slide stays up: the interval, or the clip's length."""
interval = int(self.store.slide_interval)
if not self._last_video or self._last_video.get("live"):
# Photos, including Live Photos, keep the slide interval.
return float(interval)
return video.video_hold_seconds(
self._last_video.get("duration_ms"),
int(getattr(self.store, "video_max_seconds", interval)),
interval,
)
@property
def _compose_semaphore(self) -> asyncio.Semaphore:
"""Return the domain-wide compose semaphore, creating it on demand.
@@ -1372,6 +1596,7 @@ class AlbumSlideshowCamera(Camera):
):
return await self._compose_skip_mismatch(items, width, height, fill_mode, is_portrait_canvas)
await self._ensure_enriched(cur)
cur_bytes = await self._fetch_bytes(cur.url)
if not cur_bytes:
raise RuntimeError(f"Failed to fetch image: {cur.url}")
@@ -1388,7 +1613,12 @@ class AlbumSlideshowCamera(Camera):
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:
# A video plays alone, so it is never split into a pair.
if (
orientation_mismatch
and portrait_mode == ORIENTATION_MISMATCH_PAIR
and not _is_video(cur)
):
pair = await self._find_next_mismatch_image(
items, is_portrait_canvas, width, height, limit=_PAIR_SEARCH_LIMIT
)
@@ -1485,6 +1715,7 @@ class AlbumSlideshowCamera(Camera):
continue
if self._index != start:
self._do_advance(count, items)
await self._ensure_enriched(cur)
composed = await self._async_image_job(
ip.render_image, img, fill_mode, width, height, self._crop_hints(cur)
)
@@ -1505,6 +1736,7 @@ class AlbumSlideshowCamera(Camera):
height: int,
fill_mode: str,
) -> tuple[Image.Image | None, dict | None]:
await self._ensure_enriched(item)
b = await self._fetch_bytes(item.url)
if not b:
return None, None
@@ -1592,7 +1824,7 @@ class AlbumSlideshowCamera(Camera):
it = items[idx]
tries += 1
if it.url in self._recent_urls:
if it.url in self._recent_urls or _is_video(it):
continue
meta_portrait = ip.is_portrait_item_by_metadata(it)
@@ -1610,6 +1842,8 @@ class AlbumSlideshowCamera(Camera):
continue
if ip.is_portrait_item(it, img) != is_portrait_canvas:
# Only the chosen partner needs faces and caption fields.
await self._ensure_enriched(it)
return img, it
ip.safe_close(img)
@@ -36,8 +36,12 @@ from .const import (
CONF_IMMICH_SELECTION_TYPE,
CONF_IMMICH_SELECTION_ID,
CONF_IMMICH_IMAGE_SIZE,
CONF_IMMICH_INCLUDE_VIDEOS,
CONF_IMMICH_LIVE_PHOTOS,
CONF_IMMICH_FILTER,
DEFAULT_IMMICH_IMAGE_SIZE,
DEFAULT_IMMICH_INCLUDE_VIDEOS,
DEFAULT_IMMICH_LIVE_PHOTOS,
IMMICH_IMAGE_SIZE_OPTIONS,
IMMICH_SELECTION_COMPOSITE,
CONF_PHOTOPRISM_URL,
@@ -279,6 +283,18 @@ def _immich_select_schema(
default=_default(CONF_IMMICH_IMAGE_SIZE, DEFAULT_IMMICH_IMAGE_SIZE),
)
] = vol.In(IMMICH_IMAGE_SIZE_OPTIONS)
fields[
vol.Optional(
CONF_IMMICH_INCLUDE_VIDEOS,
default=_default(CONF_IMMICH_INCLUDE_VIDEOS, DEFAULT_IMMICH_INCLUDE_VIDEOS),
)
] = selector.BooleanSelector()
fields[
vol.Optional(
CONF_IMMICH_LIVE_PHOTOS,
default=_default(CONF_IMMICH_LIVE_PHOTOS, DEFAULT_IMMICH_LIVE_PHOTOS),
)
] = selector.BooleanSelector()
return vol.Schema(fields)
@@ -323,6 +339,8 @@ def _parse_immich_select(
CONF_IMMICH_SELECTION_TYPE: IMMICH_SELECTION_COMPOSITE,
CONF_IMMICH_SELECTION_ID: json.dumps(selection, sort_keys=True),
CONF_IMMICH_IMAGE_SIZE: size,
CONF_IMMICH_INCLUDE_VIDEOS: bool(user_input.get(CONF_IMMICH_INCLUDE_VIDEOS)),
CONF_IMMICH_LIVE_PHOTOS: bool(user_input.get(CONF_IMMICH_LIVE_PHOTOS)),
CONF_ALBUM_NAME: name,
CONF_IMMICH_FILTER: raw_filter,
}
@@ -1711,6 +1729,12 @@ class ImmichOptionsFlow(config_entries.OptionsFlow):
CONF_IMMICH_IMAGE_SIZE: entry.data.get(
CONF_IMMICH_IMAGE_SIZE, DEFAULT_IMMICH_IMAGE_SIZE
),
CONF_IMMICH_INCLUDE_VIDEOS: bool(entry.data.get(
CONF_IMMICH_INCLUDE_VIDEOS, DEFAULT_IMMICH_INCLUDE_VIDEOS
)),
CONF_IMMICH_LIVE_PHOTOS: bool(entry.data.get(
CONF_IMMICH_LIVE_PHOTOS, DEFAULT_IMMICH_LIVE_PHOTOS
)),
}
if entry.data.get(CONF_IMMICH_FILTER):
defaults[CONF_IMMICH_FILTER] = entry.data[CONF_IMMICH_FILTER]
@@ -1739,7 +1763,10 @@ def _immich_source_changed(old: dict[str, Any], new: dict[str, Any]) -> bool:
CONF_IMMICH_FILTER,
CONF_IMMICH_IMAGE_SIZE,
)
return any(old.get(key) != new.get(key) for key in keys)
return any(old.get(key) != new.get(key) for key in keys) or any(
bool(old.get(key)) != bool(new.get(key))
for key in (CONF_IMMICH_INCLUDE_VIDEOS, CONF_IMMICH_LIVE_PHOTOS)
)
async def _async_clear_item_cache(hass: Any, entry_id: str) -> None:
@@ -58,6 +58,13 @@ IMMICH_IMAGE_SIZE_OPTIONS = [
IMMICH_IMAGE_ORIGINAL,
]
DEFAULT_IMMICH_IMAGE_SIZE = IMMICH_IMAGE_PREVIEW
# Immich option: include video assets. The camera still renders a poster
# frame for each video; the dashboard card plays the clip on top of it.
CONF_IMMICH_INCLUDE_VIDEOS = "immich_include_videos"
DEFAULT_IMMICH_INCLUDE_VIDEOS = False
# Immich option: play a Live Photo's motion clip over its still.
CONF_IMMICH_LIVE_PHOTOS = "immich_live_photos"
DEFAULT_IMMICH_LIVE_PHOTOS = False
# 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``
@@ -351,6 +358,19 @@ DEFAULT_IMAGE_CACHE_MB = 75
# Previous/Next can swap these JPEGs immediately without downloading,
# composing, or encoding during the button press.
DEFAULT_NAVIGATION_BUFFER_SIZE = 2
# A video slide stays up for the clip's length, capped by this setting.
# Very short clips are held (and looped by the card) for the minimum so a
# one-second clip doesn't flash past.
DEFAULT_VIDEO_MAX_SECONDS = 60
MAX_VIDEO_MAX_SECONDS = 3600
MIN_VIDEO_HOLD_SECONDS = 5
# "Pause when not viewed": each visible card checks in every
# VIEWER_HEARTBEAT_SECONDS (the card hard-codes the same value); the
# slideshow holds once nothing has checked in for VIEWER_GRACE_SECONDS, so
# one or two missed check-ins or a page reload never pause it.
VIEWER_HEARTBEAT_SECONDS = 20
VIEWER_GRACE_SECONDS = 60
WS_VIEWER_HEARTBEAT = "album_slideshow/viewer_heartbeat"
MAX_RESOLUTION_OPTIONS = ["480p", "720p", "1080p", "1440p", "4K (2160p)", "original"]
DEFAULT_MAX_RESOLUTION = "1080p"
+190 -96
View File
@@ -10,6 +10,7 @@ import math
import os
from pathlib import Path
import re
import time
from typing import Any
import aiohttp
@@ -41,8 +42,13 @@ from .const import (
CONF_IMMICH_SELECTION_TYPE,
CONF_IMMICH_SELECTION_ID,
CONF_IMMICH_IMAGE_SIZE,
CONF_IMMICH_INCLUDE_VIDEOS,
CONF_IMMICH_LIVE_PHOTOS,
CONF_IMMICH_FILTER,
DEFAULT_IMMICH_IMAGE_SIZE,
DEFAULT_IMMICH_INCLUDE_VIDEOS,
DEFAULT_IMMICH_LIVE_PHOTOS,
IMMICH_IMAGE_PREVIEW,
CONF_PHOTOPRISM_URL,
CONF_PHOTOPRISM_AUTH_METHOD,
CONF_PHOTOPRISM_TOKEN,
@@ -162,11 +168,29 @@ class MediaItem:
# True when ``location`` came from our OpenStreetMap lookup rather than
# the provider, so it can be re-formatted from the place cache.
location_geocoded: bool = False
# Upstream playback URL for a video item (``url`` is then its poster
# still), or for a Live Photo's motion clip (``live_photo``; ``url`` is
# the photo). Fetched server-side by the video proxy view only, so
# provider credentials never reach the browser. ``duration_ms`` is the
# clip length (videos only).
video_url: str | None = None
duration_ms: int | None = None
live_photo: bool = False
# Provider file path (Immich ``originalPath``), shown by the card's
# ``path`` caption.
source_path: str | None = None
# Runtime-only description of the stream a video is served as (see
# ``async_video_info``); not persisted.
video_info: dict[str, Any] | None = None
def __post_init__(self) -> None:
if self.photo_id is None:
self.photo_id = _photo_identifier(self.source_id)
@property
def is_video(self) -> bool:
return bool(self.video_url) and not self.live_photo
def _photo_identifier(source_id: str | None) -> str | None:
if not source_id:
@@ -505,9 +529,12 @@ _NAME_PREFIXES = (
"village of ", "town of ", "city of ",
)
# How many EXIF reads to perform between cache flushes. Bigger batches
# mean fewer Store writes but more rework if HA is killed mid-scan.
# How many EXIF reads to perform between listener updates, and the minimum
# time between items-cache writes during a pass (the cache holds every item,
# so writing it per batch is costly on large libraries; the pass still saves
# when it finishes or is cancelled).
_EXIF_BATCH_SAVE = 25
_ENRICHMENT_SAVE_INTERVAL = 60.0
_GEOCODE_BATCH_SAVE = 10
# Cap a single Nextcloud enrichment download. EXIF/IPTC/XMP live in the first
@@ -1145,6 +1172,11 @@ class AlbumCoordinator(DataUpdateCoordinator):
CONF_IMMICH_URL, CONF_IMMICH_API_KEY, CONF_IMMICH_SELECTION_TYPE,
CONF_IMMICH_SELECTION_ID, CONF_IMMICH_FILTER, CONF_IMMICH_IMAGE_SIZE,
)]
# Appended only when enabled so existing caches stay valid.
if entry.data.get(CONF_IMMICH_INCLUDE_VIDEOS):
source.append("videos")
if entry.data.get(CONF_IMMICH_LIVE_PHOTOS):
source.append("live_photos")
self._immich_cache_source = sha256(json.dumps(source).encode()).hexdigest()
self.album_url: str | None = entry.data.get(CONF_ALBUM_URL)
self.local_path: str | None = entry.data.get(CONF_LOCAL_PATH)
@@ -1208,6 +1240,10 @@ class AlbumCoordinator(DataUpdateCoordinator):
"geocode_total": 0,
"geocode_done": 0,
}
# Per-item enrichment in flight, shared by the background pass and
# on-demand requests from the camera so an item is fetched once.
self._enrich_inflight: dict[int, asyncio.Task] = {}
self._last_items_save: float | None = None
super().__init__(
hass,
@@ -1526,6 +1562,10 @@ class AlbumCoordinator(DataUpdateCoordinator):
} if isinstance(raw.get("camera_metadata"), dict) else None,
google_location_scanned=bool(raw.get("google_location_scanned", False)),
location_geocoded=bool(raw.get("location_geocoded", False)),
video_url=raw.get("video_url") if isinstance(raw.get("video_url"), str) else None,
duration_ms=raw.get("duration_ms") if isinstance(raw.get("duration_ms"), int) else None,
source_path=raw.get("source_path") if isinstance(raw.get("source_path"), str) else None,
live_photo=bool(raw.get("live_photo", False)),
))
except Exception:
continue
@@ -1570,6 +1610,10 @@ class AlbumCoordinator(DataUpdateCoordinator):
"camera_metadata": it.camera_metadata,
"google_location_scanned": it.google_location_scanned,
"location_geocoded": it.location_geocoded,
"video_url": it.video_url,
"duration_ms": it.duration_ms,
"source_path": it.source_path,
"live_photo": it.live_photo,
}
for it in items
],
@@ -1811,6 +1855,12 @@ class AlbumCoordinator(DataUpdateCoordinator):
sel_type = self.entry.data.get(CONF_IMMICH_SELECTION_TYPE)
sel_id = self.entry.data.get(CONF_IMMICH_SELECTION_ID)
size = self.entry.data.get(CONF_IMMICH_IMAGE_SIZE, DEFAULT_IMMICH_IMAGE_SIZE)
include_videos = bool(
self.entry.data.get(CONF_IMMICH_INCLUDE_VIDEOS, DEFAULT_IMMICH_INCLUDE_VIDEOS)
)
live_photos = bool(
self.entry.data.get(CONF_IMMICH_LIVE_PHOTOS, DEFAULT_IMMICH_LIVE_PHOTOS)
)
if not url or not api_key or not sel_type:
raise UpdateFailed("Immich provider is missing URL, API key, or selection")
@@ -1837,7 +1887,9 @@ class AlbumCoordinator(DataUpdateCoordinator):
self.image_request_headers = dict(client.image_headers)
try:
assets = await client.async_collect_assets(sel_type, sel_id, filter_body)
assets = await client.async_collect_assets(
sel_type, sel_id, filter_body, include_videos
)
except Exception as err:
raise UpdateFailed(f"Error querying Immich: {err}") from err
@@ -1854,18 +1906,46 @@ class AlbumCoordinator(DataUpdateCoordinator):
) or immich_api._to_epoch_ms(a.get("fileCreatedAt"))
w = a.get("width")
h = a.get("height")
is_video = immich_api.is_video_item(a)
motion_id = immich_api.live_photo_video_id(a) if live_photos else None
if is_video:
video_url = immich_api.build_video_url(client.base_url, aid)
elif motion_id:
video_url = immich_api.build_video_url(client.base_url, motion_id)
else:
video_url = None
items.append(
MediaItem(
url=immich_api.build_image_url(client.base_url, aid, size),
# A video's poster still is its preview thumbnail: the
# ``original`` endpoint would return the video file and
# ``fullsize`` is only generated for images.
url=immich_api.build_image_url(
client.base_url, aid, IMMICH_IMAGE_PREVIEW if is_video else size
),
width=w if isinstance(w, int) else None,
height=h if isinstance(h, int) else None,
mime_type=None,
filename=a.get("originalFileName"),
captured_at=captured,
source_id=aid,
video_url=video_url,
live_photo=bool(motion_id),
duration_ms=(
immich_api.parse_duration_ms(a.get("duration")) if is_video else None
),
source_path=(
a.get("originalPath") if isinstance(a.get("originalPath"), str) else None
),
)
)
_LOGGER.debug(
"Immich %s: %d assets (%d videos, %d Live Photos)",
self.entry.title,
len(items),
sum(1 for item in items if item.is_video),
sum(1 for item in items if item.live_photo),
)
return {
"title": self.entry.title,
"items": items,
@@ -2592,6 +2672,33 @@ class AlbumCoordinator(DataUpdateCoordinator):
return candidate, {}
return None, {}
async def async_video_info(self, item: MediaItem) -> dict[str, Any]:
"""Describe the stream a video slide is served as; cached per item.
Used for the card's ``video_version`` caption: whether Immich serves
its transcoded copy or the original, and the stream's bitrate.
"""
if item.video_info is not None:
return item.video_info
if self.provider != PROVIDER_IMMICH or not item.source_id or not item.is_video:
return {}
from . import immich as immich_api
client = immich_api.ImmichClient(
self.hass,
self.entry.data.get(CONF_IMMICH_URL),
self.entry.data.get(CONF_IMMICH_API_KEY),
)
content_type, served = await client.async_probe_video(item.source_id)
if item.byte_size is None:
# Enrichment may not have reached this item yet.
info = immich_api.parse_asset_exif(await client.async_get_asset(item.source_id))
item.byte_size = info.get("byte_size")
item.video_info = immich_api.describe_video(
content_type, served, item.byte_size, item.duration_ms
)
return item.video_info
async def _enrich_immich_item(self, item: MediaItem) -> None:
"""Fetch Immich metadata and the face boxes used for cropping."""
from . import immich as immich_api
@@ -2642,6 +2749,8 @@ class AlbumCoordinator(DataUpdateCoordinator):
item.location_geocoded = False
if "description" in info:
item.description = info["description"]
if "byte_size" in info:
item.byte_size = info["byte_size"]
item.exif_scanned = True
else:
if asset is None:
@@ -2798,6 +2907,73 @@ class AlbumCoordinator(DataUpdateCoordinator):
item.location_geocoded = False
item.exif_scanned = True
async def async_enrich_item(self, item: MediaItem) -> bool:
"""Enrich one item now, sharing the work with the background pass.
The camera calls this just before rendering a slide so faces (for
cropping) and caption metadata are in place without waiting for the
pass to reach the item. Concurrent callers await the same fetch.
Returns whether metadata was fetched (``False``: nothing to do).
"""
if self.provider == PROVIDER_GOOGLE_SHARED or not self._needs_enrichment(item):
return False
inflight = getattr(self, "_enrich_inflight", None)
if inflight is None:
inflight = self._enrich_inflight = {}
key = id(item)
task = inflight.get(key)
if task is None:
task = self.hass.async_create_background_task(
self._enrich_one(item),
name=f"album_slideshow_enrich_item_{self.entry.entry_id}",
)
inflight[key] = task
task.add_done_callback(lambda _task: inflight.pop(key, None))
# Shielded: a cancelled caller (e.g. a dropped preload) must not
# abandon a fetch another caller is waiting on.
return await asyncio.shield(task)
async def _enrich_one(self, item: MediaItem) -> bool:
"""Fetch one item's metadata from its provider; never raises."""
try:
if self.provider == PROVIDER_IMMICH:
await self._enrich_immich_item(item)
elif self.provider == PROVIDER_NEXTCLOUD:
await self._enrich_nextcloud_item(item)
elif self.provider == PROVIDER_UGREEN:
await self._enrich_ugreen_item(item)
elif item.url.startswith("file://"):
path = Path(item.url[len("file://"):])
info = await self.hass.async_add_executor_job(_read_local_exif, path)
if "captured_at" in info:
item.captured_at = info["captured_at"]
if "description" in info:
item.description = info["description"]
if "latitude" in info and "longitude" in info:
item.latitude = info["latitude"]
item.longitude = info["longitude"]
item.exif_scanned = True
else:
# Nothing to read for remote files without a provider API.
item.exif_scanned = True
return False
except asyncio.CancelledError:
raise
except Exception as err: # noqa: BLE001
_LOGGER.debug("%s enrich error: %s", self.provider, err)
item.exif_scanned = True
self._enrich_progress["exif_done"] = self._enrich_progress.get("exif_done", 0) + 1
return True
async def _flush_enrichment(self, data: dict[str, Any]) -> None:
"""Persist progress (at most once a minute) and notify listeners."""
now = time.monotonic()
last = getattr(self, "_last_items_save", None)
if last is None or now - last >= _ENRICHMENT_SAVE_INTERVAL:
self._last_items_save = now
await self._save_cached_items(data)
self.async_set_updated_data(data)
async def _enrich_items_background(self, data: dict[str, Any]) -> None:
"""Read EXIF for unscanned local files, then reverse-geocode.
@@ -2813,110 +2989,28 @@ class AlbumCoordinator(DataUpdateCoordinator):
if not items:
return
scanned_since_save = 0
pending = 0
try:
for idx, item in enumerate(items):
if not self._needs_enrichment(item):
# Fast path: yield occasionally so we don't starve
# the event loop when (re-)visiting a long list of
# already-processed items.
# already-processed items (including ones the camera
# enriched on demand).
if idx % _ENRICHMENT_FAST_PATH_YIELD_EVERY == 0:
await asyncio.sleep(0)
continue
if self.provider == PROVIDER_IMMICH:
try:
await self._enrich_immich_item(item)
except asyncio.CancelledError:
raise
except Exception as err: # noqa: BLE001
_LOGGER.debug("Immich enrich error: %s", err)
item.exif_scanned = True
scanned_since_save += 1
self._enrich_progress["exif_done"] = (
self._enrich_progress.get("exif_done", 0) + 1
)
if scanned_since_save >= _EXIF_BATCH_SAVE:
scanned_since_save = 0
await self._save_cached_items(data)
self.async_set_updated_data(data)
if not await self.async_enrich_item(item):
continue
if self.provider == PROVIDER_NEXTCLOUD:
try:
await self._enrich_nextcloud_item(item)
except asyncio.CancelledError:
raise
except Exception as err: # noqa: BLE001
_LOGGER.debug("Nextcloud enrich error: %s", err)
item.exif_scanned = True
scanned_since_save += 1
self._enrich_progress["exif_done"] = (
self._enrich_progress.get("exif_done", 0) + 1
)
if scanned_since_save >= _EXIF_BATCH_SAVE:
scanned_since_save = 0
await self._save_cached_items(data)
self.async_set_updated_data(data)
continue
if self.provider == PROVIDER_UGREEN:
try:
await self._enrich_ugreen_item(item)
except asyncio.CancelledError:
raise
except Exception as err: # noqa: BLE001
_LOGGER.debug("UGREEN enrich error: %s", err)
item.exif_scanned = True
scanned_since_save += 1
self._enrich_progress["exif_done"] = (
self._enrich_progress.get("exif_done", 0) + 1
)
if scanned_since_save >= _EXIF_BATCH_SAVE:
scanned_since_save = 0
await self._save_cached_items(data)
self.async_set_updated_data(data)
continue
url = item.url
if not url.startswith("file://"):
item.exif_scanned = True
continue
path = Path(url[len("file://"):])
try:
info = await self.hass.async_add_executor_job(
_read_local_exif, path
)
except asyncio.CancelledError:
raise
except Exception as err: # noqa: BLE001
_LOGGER.debug("EXIF: executor error for %s: %s", path, err)
info = {}
if "captured_at" in info:
item.captured_at = info["captured_at"]
if "description" in info:
item.description = info["description"]
if "latitude" in info and "longitude" in info:
item.latitude = info["latitude"]
item.longitude = info["longitude"]
item.exif_scanned = True
scanned_since_save += 1
self._enrich_progress["exif_done"] = (
self._enrich_progress.get("exif_done", 0) + 1
)
if scanned_since_save >= _EXIF_BATCH_SAVE:
scanned_since_save = 0
await self._save_cached_items(data)
self.async_set_updated_data(data)
pending += 1
if pending >= _EXIF_BATCH_SAVE:
pending = 0
await self._flush_enrichment(data)
# Flush any tail items before the geocode phase.
if scanned_since_save:
await self._save_cached_items(data)
self.async_set_updated_data(data)
if pending:
await self._flush_enrichment(data)
await self._geocode_items_background(data)
except asyncio.CancelledError:
+195 -42
View File
@@ -18,6 +18,11 @@ API shape (Immich v1.13x / v3, ``/api`` prefix, ``x-api-key`` header):
keep faces whole in cover-mode crops.
- Image bytes: ``/api/assets/{id}/thumbnail?size=preview|fullsize`` or
``/api/assets/{id}/original`` (all require the ``x-api-key`` header).
- Video bytes: ``/api/assets/{id}/video/playback`` - the browser-friendly
(transcoded when Immich's policy requires it) stream; honours ``Range``.
Video list items carry ``duration``: integer milliseconds on current
servers, an ``"H:MM:SS.ffffff"`` string on older ones. A Live Photo is an
image whose ``livePhotoVideoId`` names its (hidden) motion-clip asset.
"""
from __future__ import annotations
@@ -57,6 +62,97 @@ def build_image_url(base_url: str, asset_id: str, size: str) -> str:
return f"{base}/api/assets/{asset_id}/thumbnail?size={thumb_size}"
def build_video_url(base_url: str, asset_id: str) -> str:
"""Build the playback URL for a video asset (key sent as a header)."""
return f"{normalize_base_url(base_url)}/api/assets/{asset_id}/video/playback"
def is_video_item(item: Any) -> bool:
return isinstance(item, dict) and str(item.get("type", "")).upper() == "VIDEO"
def live_photo_video_id(item: Any) -> str | None:
"""The motion-clip asset id of a Live Photo, or ``None``."""
if not isinstance(item, dict) or is_video_item(item):
return None
value = item.get("livePhotoVideoId")
return value if isinstance(value, str) and value else None
def stream_total_bytes(status: int, headers: Any) -> int | None:
"""Total size of a stream from a ranged probe's response headers.
A ``206`` carries it after the slash in ``Content-Range``
(``bytes 0-0/12345``); a server that ignores ``Range`` answers ``200``
with the full ``Content-Length``.
"""
raw = None
if status == 206:
content_range = headers.get("Content-Range") or ""
raw = content_range.rpartition("/")[2]
elif status == 200:
raw = headers.get("Content-Length")
try:
total = int(raw)
except (TypeError, ValueError):
return None
return total if total > 0 else None
def describe_video(
content_type: str | None,
served_bytes: int | None,
original_bytes: int | None,
duration_ms: int | None,
) -> dict[str, Any]:
"""Summarise the stream Immich serves for a video.
Immich's playback endpoint serves its transcoded copy when one exists and
the original file otherwise, so a stream the same size as the original is
the original. The bitrate is the stream's average over the clip.
"""
version = None
if served_bytes and original_bytes:
version = "original" if served_bytes == original_bytes else "transcoded"
bitrate = None
if served_bytes and duration_ms:
bitrate = round(served_bytes * 8 / (duration_ms / 1000) / 1_000_000, 1)
return {
"video_version": version,
"video_bitrate_mbps": bitrate,
"video_size_bytes": served_bytes,
"video_content_type": (content_type or "").split(";", 1)[0].strip() or None,
}
def parse_duration_ms(value: Any) -> int | None:
"""Parse an Immich video duration to milliseconds.
Current servers send integer milliseconds; older ones send an
``"H:MM:SS.ffffff"`` string. Returns ``None`` for missing, malformed or
zero durations (images report ``null``, ``0`` or ``"0:00:00.00000"``).
"""
if isinstance(value, bool):
return None
if isinstance(value, (int, float)):
if not math.isfinite(value) or value <= 0:
return None
return int(round(value))
if not isinstance(value, str) or not value:
return None
parts = value.strip().split(":")
if len(parts) != 3:
return None
try:
hours, minutes, seconds = int(parts[0]), int(parts[1]), float(parts[2])
except ValueError:
return None
if hours < 0 or minutes < 0 or not math.isfinite(seconds) or seconds < 0:
return None
total = int(round((hours * 3600 + minutes * 60 + seconds) * 1000))
return total if total > 0 else None
def _to_epoch_ms(value: Any) -> int | None:
"""Parse an ISO-8601 timestamp to epoch milliseconds, or ``None``."""
if not isinstance(value, str) or not value:
@@ -93,17 +189,20 @@ def location_label(city: Any, state: Any, country: Any) -> str | None:
return ", ".join(parts) if parts else None
def parse_search_page(payload: Any) -> tuple[list[dict[str, Any]], int | None]:
"""Return ``(image_items, next_page)`` from a search/metadata response.
def parse_search_page(
payload: Any, include_videos: bool = False
) -> tuple[list[dict[str, Any]], int | None]:
"""Return ``(media_items, next_page)`` from a search/metadata response.
Filters out non-image assets and anything trashed/archived. ``next_page``
is the page number to request next, or ``None`` when done.
Filters out non-image assets (videos too unless ``include_videos``) and
anything trashed/archived. ``next_page`` is the page number to request
next, or ``None`` when done.
"""
assets = (payload or {}).get("assets") if isinstance(payload, dict) else None
if not isinstance(assets, dict):
return [], None
items = assets.get("items")
out = _filter_image_items(items)
out = _filter_image_items(items, include_videos)
next_page = assets.get("nextPage")
if isinstance(next_page, str) and next_page.isdigit():
next_page = int(next_page)
@@ -112,51 +211,74 @@ def parse_search_page(payload: Any) -> tuple[list[dict[str, Any]], int | None]:
return out, next_page
def parse_random(payload: Any) -> list[dict[str, Any]]:
def parse_random(payload: Any, include_videos: bool = False) -> list[dict[str, Any]]:
"""Return image items from a ``/api/search/random`` response.
``search/random`` returns a plain list of assets (no pagination wrapper).
"""
if isinstance(payload, list):
return _filter_image_items(payload)
return _filter_image_items(payload, include_videos)
# Some cores wrap it like search/metadata; handle that too.
if isinstance(payload, dict):
assets = payload.get("assets")
if isinstance(assets, dict):
return _filter_image_items(assets.get("items"))
return _filter_image_items(assets.get("items"), include_videos)
return []
def _filter_image_items(items: Any) -> list[dict[str, Any]]:
"""Keep only non-trashed, non-archived image assets with an id."""
def _filter_image_items(items: Any, include_videos: bool = False) -> list[dict[str, Any]]:
"""Keep only visible, non-trashed, non-archived image (or video) assets.
Hidden assets are Live Photo motion clips; they play with their still
(see ``live_photo_video_id``), never as videos of their own.
"""
allowed = {"IMAGE", "VIDEO"} if include_videos else {"IMAGE"}
out: list[dict[str, Any]] = []
if isinstance(items, list):
for it in items:
if not isinstance(it, dict):
continue
if str(it.get("type", "")).upper() != "IMAGE":
if str(it.get("type", "")).upper() not in allowed:
continue
if it.get("isTrashed") or it.get("isArchived"):
continue
if it.get("visibility") == "hidden" or it.get("isVisible") is False:
continue
if not it.get("id"):
continue
out.append(it)
return out
def constrain_type(body: dict[str, Any], include_videos: bool = False) -> dict[str, Any]:
"""Limit a search body to the asset types the slideshow can show.
Without videos the body is forced to ``IMAGE``. With videos, an explicit
``IMAGE``/``VIDEO`` type (from a custom filter) is honoured; anything
else is dropped so Immich returns both kinds.
"""
if not include_videos:
body["type"] = "IMAGE"
elif str(body.get("type", "")).upper() not in ("IMAGE", "VIDEO"):
body.pop("type", None)
return body
def build_search_body(
selection_type: str, selection_id: str | None, filter_body: dict | None
selection_type: str,
selection_id: str | None,
filter_body: dict | None,
include_videos: bool = False,
) -> dict[str, Any]:
"""Build the ``search/metadata`` request body for a selection.
Always constrains to images. For ``search`` the user-supplied filter is
used as a base (with ``type`` forced to IMAGE). ``album``/``person`` add
the id filter; ``favorites`` sets ``isFavorite``; ``all`` adds nothing.
Constrains to images (and videos when enabled). For ``search`` the
user-supplied filter is used as a base. ``album``/``person`` add the id
filter; ``favorites`` sets ``isFavorite``; ``all`` adds nothing.
"""
body: dict[str, Any] = {"type": "IMAGE"}
body: dict[str, Any] = {}
if selection_type == "search" and isinstance(filter_body, dict):
body = dict(filter_body)
body["type"] = "IMAGE"
elif selection_type == "album" and selection_id:
body["albumIds"] = [selection_id]
elif selection_type == "person" and selection_id:
@@ -164,7 +286,7 @@ def build_search_body(
elif selection_type == "favorites":
body["isFavorite"] = True
# ``all`` -> no extra filter (whole library).
return body
return constrain_type(body, include_videos)
def parse_composite_selection(selection_id: str | None) -> dict[str, Any]:
@@ -189,7 +311,9 @@ def parse_composite_selection(selection_id: str | None) -> dict[str, Any]:
def build_composite_bodies(
selection_id: str | None, filter_body: dict | None = None
selection_id: str | None,
filter_body: dict | None = None,
include_videos: bool = False,
) -> list[dict[str, Any]]:
"""Build one ``search/metadata`` body per composite union member.
@@ -201,18 +325,16 @@ def build_composite_bodies(
sel = parse_composite_selection(selection_id)
bodies: list[dict[str, Any]] = []
for aid in sel["albums"]:
bodies.append({"type": "IMAGE", "albumIds": [aid]})
bodies.append({"albumIds": [aid]})
for pid in sel["people"]:
bodies.append({"type": "IMAGE", "personIds": [pid]})
bodies.append({"personIds": [pid]})
if sel["favorites"]:
bodies.append({"type": "IMAGE", "isFavorite": True})
bodies.append({"isFavorite": True})
if isinstance(filter_body, dict) and filter_body:
member = dict(filter_body)
member["type"] = "IMAGE"
bodies.append(member)
bodies.append(dict(filter_body))
if not bodies:
bodies.append({"type": "IMAGE"})
return bodies
bodies.append({})
return [constrain_type(body, include_videos) for body in bodies]
def parse_asset_exif(asset: Any) -> dict[str, Any]:
@@ -241,6 +363,9 @@ def parse_asset_exif(asset: Any) -> dict[str, Any]:
desc = exif.get("description")
if isinstance(desc, str) and desc.strip():
out["description"] = desc.strip()
size = exif.get("fileSizeInByte")
if isinstance(size, int) and not isinstance(size, bool) and size > 0:
out["byte_size"] = size
return out
@@ -489,48 +614,58 @@ class ImmichClient:
selection_type: str,
selection_id: str | None = None,
filter_body: dict | None = None,
include_videos: bool = False,
) -> list[dict[str, Any]]:
"""Collect image assets for a selection.
"""Collect image (and optionally video) assets for a selection.
``random`` uses ``/api/search/random`` (a single, unpaginated batch).
Everything else pages through ``/api/search/metadata`` with a body
built from the selection.
"""
if selection_type == "random":
body = {"size": min(_PAGE_SIZE, 250), "type": "IMAGE"}
body: dict[str, Any] = {"size": min(_PAGE_SIZE, 250)}
if isinstance(filter_body, dict):
merged = dict(filter_body)
merged.update(body)
body = merged
constrain_type(body, include_videos)
payload = await self._post("/api/search/random", body)
return parse_random(payload)
return parse_random(payload, include_videos)
if selection_type == "people":
# Immich treats multiple personIds in one query as AND (only photos
# where everyone appears together). To get OR (any of the people),
# query each person separately and union by asset id. See #19.
ids = [p for p in (selection_id or "").split(",") if p]
bodies = [{"type": "IMAGE", "personIds": [p]} for p in ids]
return await self._collect_union(bodies)
bodies = [
constrain_type({"personIds": [p]}, include_videos) for p in ids
]
return await self._collect_union(bodies, include_videos)
if selection_type == "albums":
# Same OR behavior for a set of albums: query each album on its own
# and union the results, deduped by asset id.
ids = [a for a in (selection_id or "").split(",") if a]
bodies = [{"type": "IMAGE", "albumIds": [a]} for a in ids]
return await self._collect_union(bodies)
bodies = [
constrain_type({"albumIds": [a]}, include_videos) for a in ids
]
return await self._collect_union(bodies, include_videos)
if selection_type == "composite":
# A mix of albums, people, favorites and/or a custom filter. Each
# is queried on its own and unioned; an empty composite means the
# whole library. See #19.
bodies = build_composite_bodies(selection_id, filter_body)
return await self._collect_union(bodies)
bodies = build_composite_bodies(selection_id, filter_body, include_videos)
return await self._collect_union(bodies, include_videos)
base = build_search_body(selection_type, selection_id, filter_body)
return await self._collect_metadata(base)
base = build_search_body(
selection_type, selection_id, filter_body, include_videos
)
return await self._collect_metadata(base, include_videos)
async def _collect_metadata(self, base: dict[str, Any]) -> list[dict[str, Any]]:
async def _collect_metadata(
self, base: dict[str, Any], include_videos: bool = False
) -> list[dict[str, Any]]:
"""Page through ``search/metadata`` for a prebuilt body."""
collected: list[dict[str, Any]] = []
page: int | None = 1
@@ -539,13 +674,13 @@ class ImmichClient:
body["size"] = _PAGE_SIZE
body["page"] = page
payload = await self._post("/api/search/metadata", body)
items, next_page = parse_search_page(payload)
items, next_page = parse_search_page(payload, include_videos)
collected.extend(items)
page = next_page
return collected
async def _collect_union(
self, bodies: list[dict[str, Any]]
self, bodies: list[dict[str, Any]], include_videos: bool = False
) -> list[dict[str, Any]]:
"""Union several ``search/metadata`` queries (OR), deduped by asset id.
@@ -558,7 +693,7 @@ class ImmichClient:
for body in bodies:
if len(out) >= _MAX_ASSETS:
break
items = await self._collect_metadata(body)
items = await self._collect_metadata(body, include_videos)
for it in items:
aid = it.get("id")
if aid and aid not in seen:
@@ -569,6 +704,24 @@ class ImmichClient:
async def async_get_asset(self, asset_id: str) -> dict[str, Any]:
return await self._get(f"/api/assets/{asset_id}")
async def async_probe_video(self, asset_id: str) -> tuple[str | None, int | None]:
"""Return the playback stream's ``(content_type, total_bytes)``.
Requests a single byte; the body is never read, so this stays cheap
even against a server that ignores ``Range``.
"""
session = async_get_clientsession(self.hass)
async with async_timeout.timeout(_TIMEOUT):
async with session.get(
build_video_url(self.base_url, asset_id),
headers={**self.image_headers, "Range": "bytes=0-0"},
) as resp:
resp.raise_for_status()
return (
resp.headers.get("Content-Type"),
stream_total_bytes(resp.status, resp.headers),
)
async def async_get_faces(self, asset_id: str) -> list[dict[str, Any]]:
data = await self._get(f"/api/faces?id={asset_id}")
if not isinstance(data, list):
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"issue_tracker": "https://github.com/eyalgal/album_slideshow/issues",
"requirements": ["PyNaCl>=1.5.0"],
"version": "1.15.0"
"version": "1.15.1"
}
+45 -13
View File
@@ -6,7 +6,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from .const import DOMAIN, MAX_CUSTOM_LOOKBACK_DAYS
from .const import DOMAIN, MAX_CUSTOM_LOOKBACK_DAYS, MAX_VIDEO_MAX_SECONDS, PROVIDER_IMMICH
from .coordinator import AlbumCoordinator
from .store import SlideshowStore
@@ -15,18 +15,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e
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),
PairMinGapPercentNumber(entry, store),
NavigationBufferSizeNumber(entry, store),
ImageCacheMbNumber(entry, store),
CustomLookbackDaysNumber(entry, store),
ShuffleAgeBiasNumber(entry, store),
]
)
entities = [
SlideIntervalNumber(entry, store),
RefreshHoursNumber(entry, store, coordinator),
PairDividerWidthNumber(entry, store),
PairMinGapPercentNumber(entry, store),
NavigationBufferSizeNumber(entry, store),
ImageCacheMbNumber(entry, store),
CustomLookbackDaysNumber(entry, store),
ShuffleAgeBiasNumber(entry, store),
]
# Only providers that can surface videos get the video-length setting.
if getattr(coordinator, "provider", None) == PROVIDER_IMMICH:
entities.append(VideoMaxSecondsNumber(entry, store))
async_add_entities(entities)
class _BaseNumber(NumberEntity, RestoreEntity):
@@ -111,6 +113,36 @@ class ShuffleAgeBiasNumber(_BaseNumber):
return
class VideoMaxSecondsNumber(_BaseNumber):
_attr_icon = "mdi:video-outline"
_attr_native_min_value = 5
_attr_native_max_value = MAX_VIDEO_MAX_SECONDS
_attr_native_step = 1
_attr_native_unit_of_measurement = "s"
def __init__(self, entry: ConfigEntry, store: SlideshowStore) -> None:
super().__init__(entry, store)
self._attr_unique_id = f"{entry.entry_id}_video_max_seconds"
self._attr_name = "Max video length"
@property
def native_value(self):
return int(self.store.video_max_seconds)
async def async_set_native_value(self, value: float) -> None:
self.store.video_max_seconds = max(5, min(MAX_VIDEO_MAX_SECONDS, 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:
await self.async_set_native_value(float(old.state))
except (TypeError, ValueError, OverflowError):
return
class SlideIntervalNumber(_BaseNumber):
_attr_icon = "mdi:timer-outline"
_attr_native_min_value = 3
@@ -24,6 +24,7 @@ from .const import (
DEFAULT_DATE_FILTER,
DEFAULT_CUSTOM_LOOKBACK_DAYS,
DEFAULT_MISSING_DATE_MODE,
DEFAULT_VIDEO_MAX_SECONDS,
)
@@ -45,6 +46,7 @@ class SlideshowStore:
image_cache_mb: int = DEFAULT_IMAGE_CACHE_MB
navigation_buffer_size: int = DEFAULT_NAVIGATION_BUFFER_SIZE
max_resolution: str = DEFAULT_MAX_RESOLUTION
video_max_seconds: int = DEFAULT_VIDEO_MAX_SECONDS
# Date filter mode (preset windows like this_year / on_this_day).
date_filter: str = DEFAULT_DATE_FILTER
@@ -56,6 +58,9 @@ class SlideshowStore:
# Pause toggle - when True, the slideshow holds on the current frame.
paused: bool = False
# Hold the slideshow while no dashboard card or live stream is showing it.
pause_when_unwatched: bool = False
# Draw face boxes, a centre crosshair and a crop summary on each slide.
face_debug: bool = False
@@ -50,7 +50,9 @@
"people": "People",
"favorites": "Include favorites",
"immich_filter": "Extra search filter (JSON, optional)",
"immich_image_size": "Image quality"
"immich_image_size": "Image quality",
"immich_include_videos": "Include videos (played by the Album Slideshow card)",
"immich_live_photos": "Play Live Photo motion (played by the Album Slideshow card)"
}
},
"photoprism": {
@@ -225,6 +227,8 @@
"favorites": "Include favorites",
"immich_filter": "Extra search filter (JSON, optional)",
"immich_image_size": "Image quality",
"immich_include_videos": "Include videos (played by the Album Slideshow card)",
"immich_live_photos": "Play Live Photo motion (played by the Album Slideshow card)",
"reverse_geocode": "Look up EXIF GPS locations via OpenStreetMap",
"hide_home_country": "Hide the country for places in your home country"
}
+48 -1
View File
@@ -13,7 +13,11 @@ 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), CropDebugSwitch(entry, store)])
async_add_entities([
PauseSwitch(entry, store),
PauseWhenUnwatchedSwitch(entry, store),
CropDebugSwitch(entry, store),
])
class PauseSwitch(SwitchEntity, RestoreEntity):
@@ -56,6 +60,49 @@ class PauseSwitch(SwitchEntity, RestoreEntity):
self.store.notify()
class PauseWhenUnwatchedSwitch(SwitchEntity, RestoreEntity):
"""Hold the slideshow while no card or live stream is showing it."""
_attr_has_entity_name = True
_attr_should_poll = False
_attr_icon = "mdi:eye-off-outline"
_attr_entity_category = EntityCategory.CONFIG
def __init__(self, entry: ConfigEntry, store: SlideshowStore) -> None:
self.entry = entry
self.store = store
self._attr_unique_id = f"{entry.entry_id}_pause_when_unwatched"
self._attr_name = "Pause when not viewed"
@property
def is_on(self) -> bool:
return bool(self.store.pause_when_unwatched)
@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.pause_when_unwatched = True
self.store.notify()
async def async_turn_off(self, **kwargs) -> None:
self.store.pause_when_unwatched = 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.pause_when_unwatched = old.state == "on"
self.store.notify()
class CropDebugSwitch(SwitchEntity, RestoreEntity):
"""Overlay face boxes, a centre crosshair and a crop summary on slides."""
@@ -50,7 +50,9 @@
"people": "People",
"favorites": "Include favorites",
"immich_filter": "Extra search filter (JSON, optional)",
"immich_image_size": "Image quality"
"immich_image_size": "Image quality",
"immich_include_videos": "Include videos (played by the Album Slideshow card)",
"immich_live_photos": "Play Live Photo motion (played by the Album Slideshow card)"
}
},
"photoprism": {
@@ -225,6 +227,8 @@
"favorites": "Include favorites",
"immich_filter": "Extra search filter (JSON, optional)",
"immich_image_size": "Image quality",
"immich_include_videos": "Include videos (played by the Album Slideshow card)",
"immich_live_photos": "Play Live Photo motion (played by the Album Slideshow card)",
"reverse_geocode": "Look up EXIF GPS locations via OpenStreetMap",
"hide_home_country": "Hide the country for places in your home country"
}
@@ -24,9 +24,16 @@
* background: '' # CSS color shown behind contained images.
* # Empty inherits theme card background.
* tap_action: none # none | more-info
* videos: true # play video slides (muted, looping) and
* # Live Photo motion over the camera's still;
* # false shows only the still.
* video_audio: off # off | muted | on. ``muted`` starts videos
* # muted with a speaker button to unmute;
* # ``on`` plays with sound when the browser
* # allows it, falling back to that button.
*/
const VERSION = "1.15.0";
const VERSION = "1.15.1";
const ANIMATED_TRANSITIONS = [
"fade",
@@ -46,12 +53,31 @@ const TRANSITIONS = new Set(["random", "none", ...ANIMATED_TRANSITIONS]);
const FIT_MODES = new Set(["auto", "cover", "contain"]);
// While on screen, the card tells the integration it is being viewed so
// "Pause when not viewed" can hold the slideshow when nobody is looking.
// Matches VIEWER_HEARTBEAT_SECONDS in const.py.
const VIEWER_HEARTBEAT_MS = 20000;
const PHOTO_CONTROL_OPTIONS = [
{ value: "off", label: "Off" },
{ value: "on_demand", label: "On demand" },
{ value: "always", label: "Always" },
];
const VIDEO_AUDIO_OPTIONS = [
{ value: "off", label: "Off" },
{ value: "muted", label: "Muted, tap to unmute" },
{ value: "on", label: "On" },
];
/** ``true``/``false`` from earlier versions mean ``on``/``off``. */
function normalizeVideoAudio(value) {
if (value === true) return "on";
if (value == null || value === false) return "off";
if (VIDEO_AUDIO_OPTIONS.some((option) => option.value === value)) return value;
throw new Error(`album-slideshow-card: unknown video_audio mode '${value}'`);
}
function normalizePhotoControls(value) {
if (value == null || value === false) return "off";
if (value === true) return "always";
@@ -66,8 +92,27 @@ const CAMERA_CAPTION_FIELDS = [
"camera_make", "camera_model", "focal_length_mm", "aperture_f_number",
"iso", "exposure_time_seconds",
];
const CAPTION_FIELDS = ["date", "location", "description", "camera", ...CAMERA_CAPTION_FIELDS, "current_date", "current_time", "weather"];
const CAPTION_FIELDS = [
"date", "location", "description", "camera", ...CAMERA_CAPTION_FIELDS,
"filename", "path", "video_duration", "video_version", "current_date", "current_time", "weather",
];
const LIVE_CAPTION_FIELDS = new Set(["current_date", "current_time", "weather"]);
/** ``1:50`` / ``1:02:03`` for a clip length in seconds; empty otherwise. */
function formatClipLength(seconds) {
if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds <= 0) return "";
const total = Math.round(seconds);
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const secs = String(total % 60).padStart(2, "0");
return hours ? `${hours}:${String(minutes).padStart(2, "0")}:${secs}` : `${minutes}:${secs}`;
}
/** First corner that no caption or toolbar uses, for the video sound button. */
function soundButtonCorner(captionPositions, toolbarCorner) {
const taken = new Set([...captionPositions, toolbarCorner]);
return ["top-left", "top-right", "bottom-left", "bottom-right"].find((corner) => !taken.has(corner)) ?? "top-left";
}
const CAPTION_POSITIONS = new Set([
"top-left",
"top-center",
@@ -669,6 +714,8 @@ function createAlbumSlideshowCardClass(Base) {
// Empty/missing background means inherit theme.
background: typeof config.background === "string" ? config.background : "",
tap_action: config.tap_action === "more-info" ? "more-info" : "none",
videos: config.videos !== false,
video_audio: normalizeVideoAudio(config.video_audio),
photo_controls: normalizePhotoControls(config.photo_controls),
// Number of seconds the card freezes its visible slide after a
// tap, so the more-info dialog can settle without the slideshow
@@ -751,11 +798,16 @@ function createAlbumSlideshowCardClass(Base) {
}
this._captionResizeObserver ??= new ResizeObserver(() => this._fitCaptions());
this._captionResizeObserver.observe(this);
this._onVisibilityChange ??= () => this._syncViewerHeartbeat();
globalThis.document?.addEventListener?.("visibilitychange", this._onVisibilityChange);
this._maybeSwap();
this._syncViewerHeartbeat();
}
disconnectedCallback() {
this._captionResizeObserver?.disconnect();
globalThis.document?.removeEventListener?.("visibilitychange", this._onVisibilityChange);
this._syncViewerHeartbeat();
clearTimeout(this._captionClockTimer);
this._captionClockTimer = null;
this._cancelControlNavigation();
@@ -766,6 +818,7 @@ function createAlbumSlideshowCardClass(Base) {
this._loadGeneration += 1;
this._lastFrameId = null;
this._lastEntityPicture = null;
this._stopVideo();
}
set hass(hass) {
@@ -773,6 +826,7 @@ function createAlbumSlideshowCardClass(Base) {
this._hass = hass;
if (!this._rendered) return;
this._maybeSwap();
this._syncViewerHeartbeat();
if (this._captionData && this._captionConfigs().some((cap) => cap.show.includes("weather") && cap.weather_entity &&
(previous?.states?.[cap.weather_entity] !== hass.states?.[cap.weather_entity] || previous?.locale !== hass.locale ||
(cap.weather_display === "icon_temperature" &&
@@ -781,6 +835,26 @@ function createAlbumSlideshowCardClass(Base) {
}
}
/** Check in while this card is on a visible page and Pause when not viewed is on. */
_syncViewerHeartbeat() {
const attrs = this._hass?.states?.[this._config?.entity]?.attributes;
const entryId = attrs?.pause_when_unwatched ? attrs.entry_id : null;
if (!this.isConnected || globalThis.document?.visibilityState === "hidden" || !entryId) {
clearInterval(this._viewerTimer);
this._viewerTimer = null;
this._viewerEntryId = null;
return;
}
if (this._viewerTimer && this._viewerEntryId === entryId) return;
clearInterval(this._viewerTimer);
this._viewerEntryId = entryId;
// Errors (reconnecting, or an older integration) are retried next beat.
const beat = () => this._hass?.callWS?.({ type: "album_slideshow/viewer_heartbeat", entry_id: entryId })
?.catch?.(() => {});
beat();
this._viewerTimer = setInterval(beat, VIEWER_HEARTBEAT_MS);
}
_resolvedFit(attrs) {
// ``auto`` inherits from the camera's fill_mode attribute. The camera
// exposes cover / contain / blur. ``blur`` is rendered as ``contain``
@@ -815,6 +889,11 @@ function createAlbumSlideshowCardClass(Base) {
const stageBg = c.background
? c.background
: "var(--ha-card-background, var(--card-background-color, transparent))";
const toolbarEdge = c.caption?.position.startsWith("top") ? "bottom" : "top";
const [soundY, soundX] = soundButtonCorner(
c.captions.map((cap) => cap.position),
c.photo_controls === "off" ? null : `${toolbarEdge}-right`,
).split("-");
this.shadowRoot.innerHTML = `
<style>
:host { display: block; }
@@ -870,6 +949,35 @@ function createAlbumSlideshowCardClass(Base) {
.layer.fit-cover { object-fit: cover; }
.layer.fit-contain { object-fit: contain; }
.layer.show { opacity: 1; }
.video {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0;
transition: opacity ${c.duration}ms ${c.easing};
pointer-events: none;
}
.video.fit-contain { object-fit: contain; }
.video.show { opacity: 1; }
.sound {
position: absolute;
${soundY}: 8px;
${soundX}: 8px;
z-index: 3;
display: grid;
place-items: center;
width: 40px;
height: 40px;
padding: 0;
border: none;
border-radius: 50%;
background: rgba(0, 0, 0, 0.45);
color: #fff;
cursor: pointer;
}
.sound[hidden] { display: none; }
.placeholder {
position: absolute;
inset: 0;
@@ -911,7 +1019,7 @@ function createAlbumSlideshowCardClass(Base) {
max-width: 100%; max-height: 100%; min-width: 0; overflow: hidden;
}
.cap-line { font-weight: inherit; flex-shrink: 0; font-variant-numeric: tabular-nums; }
#photo-controls { position: absolute; right: 8px; max-width: calc(100% - 16px); ${c.caption?.position.startsWith("top") ? "bottom" : "top"}: 8px; z-index: 3; }
#photo-controls { position: absolute; right: 8px; max-width: calc(100% - 16px); ${toolbarEdge}: 8px; z-index: 3; }
.cap-box.cap-shadow {
text-shadow:
0 1px 2px rgba(0, 0, 0, 0.9),
@@ -939,10 +1047,12 @@ function createAlbumSlideshowCardClass(Base) {
<img class="blur-bg" id="blur-b" alt="" />
<img class="layer" id="a" alt="" />
<img class="layer" id="b" alt="" />
<video class="video" id="video" muted playsinline loop preload="auto" disablepictureinpicture aria-hidden="true"></video>
<div class="captions" id="captions" aria-hidden="true"></div>
<div class="placeholder" id="placeholder">Waiting for first frame...</div>
</div>
<div id="photo-controls" ${c.photo_controls === "always" ? "" : "hidden"}></div>
<button class="sound" id="sound" type="button" hidden aria-label="Unmute video"><ha-icon icon="mdi:volume-off"></ha-icon></button>
</ha-card>
`;
this._photoControls = new PhotoControls(
@@ -950,6 +1060,11 @@ function createAlbumSlideshowCardClass(Base) {
() => this._controlsReveal?.activity(),
(phase, entryId) => this._controlNavigation(phase, entryId),
);
this.shadowRoot.getElementById("sound").addEventListener("click", (event) => {
// Never let the speaker button open more-info or reveal controls.
event.stopPropagation();
this._toggleVideoSound();
});
const card = this.shadowRoot.querySelector("ha-card");
if (this._config.tap_action === "more-info") {
card.addEventListener("click", () => this._fireMoreInfo());
@@ -1077,6 +1192,7 @@ function createAlbumSlideshowCardClass(Base) {
}
this._hiddenRevision = attrs.hidden_revision;
this._refreshPhotoControls(attrs);
this._syncVideoPaused(attrs);
if (attrs.empty_reason || (Array.isArray(attrs.displayed_photo_ids) && !attrs.displayed_photo_ids.length)) {
this._clearDisplayedPhotos();
this._setPlaceholder(attrs.empty_reason === "all_hidden" ? "All photos hidden" : attrs.empty_reason ? "No matching photos" : "Preparing next photo...");
@@ -1129,6 +1245,7 @@ function createAlbumSlideshowCardClass(Base) {
const captionData = this._config.caption
? {
caption_frames: attrs.caption_frames,
current_path: attrs.current_path,
pair_orientation: attrs.pair_orientation,
captured_at_primary: attrs.captured_at_primary,
captured_at: attrs.captured_at,
@@ -1146,6 +1263,12 @@ function createAlbumSlideshowCardClass(Base) {
frameId,
hiddenRevision: attrs.hidden_revision,
entityId: this._config.entity,
videoUrl:
this._config.videos !== false && ["video", "live_photo"].includes(attrs.media_kind) && attrs.video_url
? attrs.video_url
: null,
// A Live Photo's motion plays once, then fades back to its still.
videoLoop: attrs.media_kind !== "live_photo",
};
this._loadAndSwap(url, fit, blurBackdrop, captionData, photoData);
}
@@ -1167,6 +1290,7 @@ function createAlbumSlideshowCardClass(Base) {
image.removeAttribute("src");
image.classList.remove("show", "exit", "enter");
}
this._stopVideo();
this.shadowRoot.getElementById("captions").replaceChildren();
this._refreshPhotoControls(this._hass.states[this._config.entity]?.attributes || {});
}
@@ -1203,11 +1327,17 @@ function createAlbumSlideshowCardClass(Base) {
this._maybeSwap();
return;
}
// A re-render of the same photo (new metadata, e.g. a face-aware crop)
// should not look like a new slide.
const samePhoto = photoData.photoIds.length > 0 &&
photoData.photoIds.length === this._displayedPhotoIds.length &&
photoData.photoIds.every((id, index) => id === this._displayedPhotoIds[index]);
this._displayedPhotoIds = [...photoData.photoIds];
this._displayedFrameId = photoData.frameId;
this._photoEntryId = photoData.entryId;
this._photoOrientation = photoData.orientation;
this._performSwap(url, fit, blurBackdrop, captionData);
this._performSwap(url, fit, blurBackdrop, captionData, samePhoto);
this._startVideo(photoData.videoUrl, fit, generation, photoData.videoLoop !== false);
if (this._navigationRequest && !this._navigationRequest.pending && photoData.frameId !== this._navigationRequest.frameId) {
this._cancelControlNavigation();
}
@@ -1219,7 +1349,105 @@ function createAlbumSlideshowCardClass(Base) {
next.src = url;
}
_performSwap(url, fit, blurBackdrop, captionData) {
/** Play a video slide over its poster frame (already swapped in). The
* clip fades in once it is actually playing; if it can't load or the
* browser blocks autoplay, the poster simply stays up. */
_startVideo(videoUrl, fit, generation, loop = true) {
const video = this.shadowRoot?.getElementById("video");
if (!video) return;
// A re-render of the same clip (metadata refreshes do this) keeps the
// same URL; let it play on rather than restarting it.
const continuing = Boolean(videoUrl) && video.getAttribute("src") === videoUrl;
if (!continuing) this._stopVideo();
if (!videoUrl) return;
const attrs = this._hass?.states[this._config.entity]?.attributes || {};
const reveal = () => {
if (generation === this._loadGeneration) video.classList.add("show");
};
video.classList.toggle("fit-contain", fit === "contain");
video.onerror = () => video.classList.remove("show");
// Fade in once playing, or, while paused, once the first frame is ready.
video.onplaying = reveal;
video.onloadeddata = attrs.paused ? reveal : null;
video.onended = loop ? null : () => {
if (generation === this._loadGeneration) video.classList.remove("show");
};
video.onvolumechange = () => this._updateSoundButton();
if (continuing) return;
// Browsers only autoplay muted unless the page has been interacted
// with; ``_playVideo`` falls back to muted. Set the properties as well
// as the attributes, which some browsers read only at parse time.
video.muted = !this._wantsVideoSound();
video.loop = loop;
video.playsInline = true;
video.src = videoUrl;
this._videoPaused = Boolean(attrs.paused);
this._updateSoundButton();
if (!attrs.paused) this._playVideo(video);
}
/** Sound for the next video: the speaker button's last choice this
* session, else the configured mode. */
_wantsVideoSound() {
if (this._config.video_audio === "off") return false;
return this._videoSoundChoice ?? this._config.video_audio === "on";
}
/** Play, falling back to muted when the browser blocks sound. */
_playVideo(video) {
video.play()?.catch((err) => {
if (video.muted || err?.name !== "NotAllowedError") return;
video.muted = true;
this._updateSoundButton();
video.play()?.catch(() => {});
});
}
_toggleVideoSound() {
const video = this.shadowRoot?.getElementById("video");
if (!video || !video.getAttribute("src")) return;
// The tap itself lets the browser allow sound from now on.
video.muted = !video.muted;
this._videoSoundChoice = !video.muted;
if (!video.muted && video.paused && !this._videoPaused) this._playVideo(video);
this._updateSoundButton();
}
_updateSoundButton() {
const button = this.shadowRoot?.getElementById("sound");
if (!button) return;
const video = this.shadowRoot.getElementById("video");
const active = this._config.video_audio !== "off" && Boolean(video?.getAttribute("src"));
button.hidden = !active;
if (!active) return;
button.setAttribute("aria-label", video.muted ? "Unmute video" : "Mute video");
button.querySelector("ha-icon")?.setAttribute("icon", video.muted ? "mdi:volume-off" : "mdi:volume-high");
}
/** Stop any playing clip and drop its source so it stops downloading. */
_stopVideo() {
const video = this.shadowRoot?.getElementById("video");
if (!video || !video.getAttribute("src")) return;
video.classList.remove("show");
video.onplaying = video.onerror = video.onloadeddata = video.onended = video.onvolumechange = null;
video.pause();
video.removeAttribute("src");
video.load();
this._updateSoundButton();
}
/** Follow the slideshow's pause switch. Acts only when it changes, since
* ``hass`` updates arrive for every entity in Home Assistant. */
_syncVideoPaused(attrs) {
const video = this.shadowRoot?.getElementById("video");
const paused = Boolean(attrs.paused);
if (!video || !video.getAttribute("src") || paused === this._videoPaused) return;
this._videoPaused = paused;
if (paused) video.pause();
else this._playVideo(video);
}
_performSwap(url, fit, blurBackdrop, captionData, samePhoto = false) {
const root = this.shadowRoot;
const placeholder = root.getElementById("placeholder");
if (placeholder) placeholder.remove();
@@ -1239,7 +1467,7 @@ function createAlbumSlideshowCardClass(Base) {
el.classList.add(fit === "contain" ? "fit-contain" : "fit-cover");
}
const transition = this._pickTransition();
const transition = samePhoto ? "fade" : this._pickTransition();
const transitionClass = `t-${transition}`;
// First frame: no animation, just place the image and reveal.
@@ -1422,7 +1650,12 @@ function createAlbumSlideshowCardClass(Base) {
* the flat attributes for older integration versions. */
_buildCaptionFrames(data) {
const cf = data.caption_frames;
if (Array.isArray(cf) && cf.length) return cf;
if (Array.isArray(cf) && cf.length) {
// File paths come separately (kept out of the recorder): a string, or
// one per image for a pair.
const paths = Array.isArray(data.current_path) ? data.current_path : [data.current_path];
return cf.map((frame, index) => (paths[index] == null ? frame : { ...frame, path: paths[index] }));
}
let captured = data.captured_at_primary;
if (captured == null) {
captured = Array.isArray(data.captured_at)
@@ -1458,6 +1691,14 @@ function createAlbumSlideshowCardClass(Base) {
if (frame.location) lines.push(String(frame.location));
} else if (field === "description") {
if (frame.description) lines.push(String(frame.description));
} else if (field === "filename" || field === "path") {
if (typeof frame[field] === "string" && frame[field].trim()) lines.push(frame[field].trim());
} else if (field === "video_duration") {
const length = formatClipLength(frame.video_duration);
if (length) lines.push(length);
} else if (field === "video_version") {
const version = this._videoVersionCaption(frame);
if (version) lines.push(version);
} else if (field === "camera") {
const make = typeof frame.camera_make === "string" ? frame.camera_make.trim() : "";
const model = typeof frame.camera_model === "string" ? frame.camera_model.trim() : "";
@@ -1481,6 +1722,17 @@ function createAlbumSlideshowCardClass(Base) {
return lines;
}
/** ``Transcoded video · 3.1 Mbps`` for a video slide; empty for photos. */
_videoVersionCaption(frame) {
const version = { original: "Original video", transcoded: "Transcoded video" }[frame.video_version];
const bitrate = frame.video_bitrate_mbps;
const rate = typeof bitrate === "number" && Number.isFinite(bitrate) && bitrate > 0
? `${new Intl.NumberFormat(this._locale(), { maximumFractionDigits: 1 }).format(bitrate)} Mbps`
: "";
if (!version && !rate) return "";
return [version || "Video", rate].filter(Boolean).join(" · ");
}
_weatherCaption(cap) {
if (typeof cap.weather_entity !== "string" || !/^(weather|sensor)\.[a-z0-9_]+$/.test(cap.weather_entity)) return "";
const entity = this._hass?.states?.[cap.weather_entity];
@@ -1805,6 +2057,10 @@ const CAPTION_SHOW_OPTIONS = [
{ value: "aperture_f_number", label: "Aperture" },
{ value: "iso", label: "ISO" },
{ value: "exposure_time_seconds", label: "Exposure time" },
{ value: "filename", label: "File name" },
{ value: "path", label: "File path" },
{ value: "video_duration", label: "Video length" },
{ value: "video_version", label: "Video version (original or transcoded)" },
{ value: "current_date", label: "Today's date" },
{ value: "current_time", label: "Current time" },
{ value: "weather", label: "Weather" },
@@ -1890,6 +2146,7 @@ const CAPTION_DEFAULTS = {
// service call. Buttons are handled separately (see LIVE_ACTIONS).
const LIVE_FIELDS = [
"paused",
"pause_when_unwatched",
"date_filter",
"custom_lookback_days",
"shuffle_age_bias",
@@ -1904,6 +2161,7 @@ const LIVE_FIELDS = [
const LIVE_SUFFIX = {
paused: "_paused",
pause_when_unwatched: "_pause_when_unwatched",
date_filter: "_date_filter",
custom_lookback_days: "_custom_lookback_days",
shuffle_age_bias: "_shuffle_age_bias",
@@ -1921,6 +2179,7 @@ const LIVE_SUFFIX = {
const LIVE_LABELS = {
live_paused: "Pause slideshow",
live_pause_when_unwatched: "Pause when not viewed",
live_date_filter: "Date filter",
live_custom_lookback_days: "Custom lookback (days)",
live_shuffle_age_bias: "Shuffle age bias (older - / newer +)",
@@ -2080,8 +2339,8 @@ function createAlbumSlideshowCardEditorClass(Base) {
_liveSchema() {
const s = this._siblings || {};
const items = [];
if (s.paused) {
items.push({ name: "live_paused", selector: { boolean: {} } });
for (const field of ["paused", "pause_when_unwatched"]) {
if (s[field]) items.push({ name: `live_${field}`, selector: { boolean: {} } });
}
for (const [field, id] of [
["date_filter", s.date_filter],
@@ -2221,6 +2480,11 @@ function createAlbumSlideshowCardEditorClass(Base) {
selector: { select: { mode: "dropdown", options: FIT_OPTIONS } },
},
{ name: "background", selector: { text: {} } },
{ name: "videos", selector: { boolean: {} } },
{
name: "video_audio",
selector: { select: { mode: "dropdown", options: VIDEO_AUDIO_OPTIONS } },
},
],
},
{
@@ -2277,6 +2541,8 @@ function createAlbumSlideshowCardEditorClass(Base) {
fit: c.fit || DEFAULTS.fit,
background: c.background || "",
tap_action: c.tap_action || DEFAULTS.tap_action,
videos: c.videos !== false,
video_audio: normalizeVideoAudio(c.video_audio),
tap_pause_seconds:
c.tap_pause_seconds != null
? Number(c.tap_pause_seconds)
@@ -2634,9 +2900,11 @@ function createAlbumSlideshowCardEditorClass(Base) {
const out = {};
if (!s || !this._hass) return out;
const st = (id) => (id ? this._hass.states[id] : null);
if (s.paused) {
const e = st(s.paused);
out.live_paused = !!e && e.state === "on";
for (const f of ["paused", "pause_when_unwatched"]) {
if (s[f]) {
const e = st(s[f]);
out[`live_${f}`] = !!e && e.state === "on";
}
}
for (const f of ["date_filter", "missing_date_mode", "portrait_mode", "order_mode"]) {
if (s[f]) {
@@ -2668,6 +2936,8 @@ function createAlbumSlideshowCardEditorClass(Base) {
background: "Background (optional)",
tap_action: "Tap action",
tap_pause_seconds: "Tap pause (seconds)",
videos: "Play videos",
video_audio: "Video sound",
photo_controls: "Photo controls",
caption_enabled: "Show caption overlay",
caption_show: "Show",
@@ -2693,6 +2963,8 @@ function createAlbumSlideshowCardEditorClass(Base) {
const helpers = {
background: "Leave blank to inherit the dashboard theme.",
transition: "Random picks a different effect each slide.",
videos: "Videos and Live Photo motion. Off shows a still frame instead. Must also be enabled in the integration.",
video_audio: "Muted starts videos silent with a speaker button. On plays sound when the browser allows it, otherwise shows the button.",
tap_pause_seconds:
"How long the card freezes its slide after a tap. 0 disables it.",
caption_date_format:
@@ -2707,6 +2979,8 @@ function createAlbumSlideshowCardEditorClass(Base) {
caption_font_size: "CSS size, e.g. 14px, 1.1em.",
live_paused:
"These control the Album Slideshow integration directly and apply everywhere this album is shown, not only this card.",
live_pause_when_unwatched:
"Hold the slideshow when no dashboard card or camera view has shown it for a minute; it resumes as soon as one opens.",
live_missing_date_mode:
"What a date filter does with photos that have no capture date: use the upload date, keep them, or drop them.",
};
@@ -2899,7 +3173,7 @@ function createAlbumSlideshowCardEditorClass(Base) {
if (!s || !hass) return;
const id = s[field];
if (!id) return;
if (field === "paused") {
if (field === "paused" || field === "pause_when_unwatched") {
hass.callService("switch", value ? "turn_on" : "turn_off", {
entity_id: id,
});
@@ -2982,6 +3256,10 @@ function createAlbumSlideshowCardEditorClass(Base) {
const ta = data.tap_action || DEFAULTS.tap_action;
if (ta !== DEFAULTS.tap_action) n.tap_action = ta;
if (data.videos === false) n.videos = false;
const audio = normalizeVideoAudio(data.video_audio);
if (audio !== "off") n.video_audio = audio;
const tps = Number(data.tap_pause_seconds);
if (!isNaN(tps) && tps !== DEFAULTS.tap_pause_seconds) {
n.tap_pause_seconds = tps;
@@ -33,6 +33,7 @@ from .const import (
)
from .coordinator import MaintenanceCoordinator
from .entity.entity_base import MaintenanceEntity
from .entity.id_change import entity_id_changed
from .helpers.aggregate import object_slug as aggregate_object_slug
from .helpers.coordinator_data import has_coordinator_data
from .helpers.status import compute_status_from_task_dict
@@ -127,6 +128,11 @@ class MaintenanceBinarySensor(MaintenanceEntity, BinarySensorEntity):
"parent_object": self._object_data.get("name"),
}
@callback
def async_entity_id_changed(self, old_entity_id: str) -> None:
"""Renamed in place (HA 2026.11, #214): nothing here is keyed on its own entity_id."""
entity_id_changed(super(), old_entity_id)
async def async_added_to_hass(self) -> None:
"""Register for task reset signals for immediate updates."""
await super().async_added_to_hass()
@@ -8,7 +8,7 @@ from datetime import date, datetime, time, timedelta
from typing import TYPE_CHECKING, Any
from homeassistant.components.calendar import CalendarEntity, CalendarEvent
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import dt as dt_util
@@ -20,6 +20,7 @@ from .const import (
MaintenanceStatus,
ScheduleType,
)
from .entity.id_change import entity_id_changed
from .helpers.aggregate import merged_tasks
from .helpers.dates import interval_span_days, parse_hhmm
from .helpers.global_options import is_schedule_time_enabled
@@ -603,6 +604,23 @@ class MaintenanceCalendar(CalendarEntity):
"""Added to Home Assistant and not removed since."""
return self.hass is not None and not self._removed
async def async_added_to_hass(self) -> None:
"""Live again whenever it is added, including added back.
Before Home Assistant 2026.11, renaming the calendar's entity_id
removes it and adds the same object again. ``_removed`` stayed set and
the global entry's registration was gone, so the coordinators stopped
repainting the calendar until the next reload (#214).
"""
await super().async_added_to_hass()
self._removed = False
self._hass.data.setdefault(DOMAIN, {})["_calendar_entity"] = self
@callback
def async_entity_id_changed(self, old_entity_id: str) -> None:
"""Renamed in place (HA 2026.11, #214): nothing here is keyed on its own entity_id."""
entity_id_changed(super(), old_entity_id)
async def async_will_remove_from_hass(self) -> None:
"""Stop the coordinators repainting a removed calendar.
@@ -182,9 +182,11 @@ class AdaptiveMixin:
default=current_adaptive.get(CONF_SENSOR_PREDICTION_ENABLED, True),
): selector.BooleanSelector(),
env_key: selector.EntitySelector(
# Any sensor with statistics feeds the correlation (fine
# dust for an air filter, soil moisture), not only
# temperature / humidity / pressure (audit after #215).
selector.EntitySelectorConfig(
domain=["sensor"],
device_class=["temperature", "humidity", "pressure"],
multiple=False,
)
),
@@ -78,6 +78,10 @@ TRIGGER_ENTITY_DOMAINS = [
"number",
"input_number",
"input_boolean",
# #215: a counter helper counts prints, cycles or uses; its state is the
# number. Missing from this list since it was written, and from the
# task dialog since its entity picker replaced the free text (2.56.0).
"counter",
"switch",
"climate",
"vacuum",
@@ -94,6 +98,18 @@ TRIGGER_ENTITY_DOMAINS = [
"lock",
# #204: Plant Monitor (HACS) sums a plant up as ok / problem.
"plant",
# Typed in freely before the task dialog's picker (2.56.0) and handled by
# the triggers all along (audit after #215): a selection for a state
# change (a wash programme), a date for the due-date trigger (an
# input_datetime / date / datetime state is an ISO date), a timer's
# active time, an update that is available.
"input_select",
"select",
"input_datetime",
"date",
"datetime",
"timer",
"update",
]
@@ -969,7 +985,7 @@ class TriggerConfigMixin:
schema_dict: dict[Any, Any] = {
vol.Required(CONF_TRIGGER_ENTITY): selector.EntitySelector(
selector.EntitySelectorConfig(
domain=["sensor", "binary_sensor", "number", "input_number", "input_boolean", "switch"],
domain=TRIGGER_ENTITY_DOMAINS,
multiple=True,
)
),
@@ -64,6 +64,7 @@ from .helpers.notification_gates import task_may_notify
from .helpers.notify_hooks import KIND_STATUS
from .helpers.pause import is_task_inert
from .helpers.phases import task_label
from .helpers.prediction_cache import PredictionCache
from .helpers.schedule import KIND_INTERVAL, KIND_MANUAL, Schedule, read_legacy_fields
from .models.maintenance_object import MaintenanceObject
from .models.maintenance_task import MaintenanceTask
@@ -200,6 +201,14 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# The post-completion cooldown is re-derived from the history once
# per instance (_seed_cooldown_from_history).
self._cooldown_seeded = False
# The sensor predictions' recorder half, kept per statistics hour: a
# skip or completion on an object with sensor-based tasks waited
# ~0.5 s for 180-day recorder queries on every refresh (2026-10-15).
# A refresh for a click (async_refresh_now) may use the previous
# hour's values; the next periodic refresh fetches the new hour.
self._prediction_cache = PredictionCache()
self._refresh_for_user = False
self._prediction_stale_ok = False
def _log_analysis_failure(self, task_id: str, stage: str, message: str) -> None:
"""Log a swallowed analysis error — loudly once, then quietly.
@@ -317,6 +326,8 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]):
async def _async_update_data(self) -> dict[str, Any]:
"""Fetch and compute the current state of all tasks."""
self._prediction_stale_ok = self._refresh_for_user
self._refresh_for_user = False
# Seasonal pause (N3): auto-resume on the first refresh on/after
# paused_until, then continue this refresh un-paused.
await self._async_maybe_auto_resume()
@@ -584,7 +595,7 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]):
try:
from .helpers.sensor_predictor import SensorPredictor
predictor = SensorPredictor(self.hass)
predictor = SensorPredictor(self.hass, cache=self._prediction_cache, stale_ok=self._prediction_stale_ok)
prediction = await predictor.async_analyze(task_result, adaptive_cfg)
if prediction:
# Degradation data
@@ -1094,7 +1105,12 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# The cycle modifiers: the drift-free planned anchor and the
# per-occurrence postpone (set by async_postpone_task, cleared on
# complete) — None removes the key.
self._store.update_task_state(task_id, last_planned_due=td.get("last_planned_due"), due_override=td.get("due_override"))
self._store.update_task_state(
task_id,
last_planned_due=td.get("last_planned_due"),
planned_day=td.get("planned_day"),
due_override=td.get("due_override"),
)
# Phase cursor (#139): to_dict emits it only for phase-carrying tasks.
pc = td.get("phase_cursor")
if pc is not None:
@@ -2039,6 +2055,7 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]):
cheap; the debounced path stays exactly as it was for triggers.
"""
self._calendar_refresh_forced = True
self._refresh_for_user = True
await self.async_refresh()
async def _persist_and_signal_task_change(
@@ -55,12 +55,19 @@ class CompoundSubEntity:
self._per_entity_states: dict[str, bool] = {eid: False for eid in _eids} if len(_eids) > 1 else {}
self._per_entity_values: dict[str, float | None] = {}
self._entity_logic = condition_config.get("entity_logic", "any")
# Mirror attributes the real entity exposes for trigger access
self.entity_id: str = parent.entity.entity_id
# Mirror attributes the real entity exposes for trigger access (the
# entity_id is read live, see the property).
self._task_id: str = parent.entity._task_id
# Replaced by the condition's _CompoundCoordinatorProxy right after.
self.coordinator: TriggerCoordinator = parent.entity.coordinator
@property
def entity_id(self) -> str:
"""The task sensor's entity_id, read live. From Home Assistant 2026.11 a
rename changes the sensor in place and its triggers keep running, so a
copy taken here would name the old entity until the next reload (#214)."""
return self._parent.entity.entity_id
@callback
def async_update_trigger_state(
self,
@@ -91,6 +91,7 @@ def _build_export_object(
# nth_weekday / day_of_month) that the flat fields above can't.
"schedule": Schedule.parse(tdata).to_dict(),
"last_planned_due": tdata.get("last_planned_due"),
"planned_day": tdata.get("planned_day"),
# A pending per-occurrence postpone is user intent — round-trip it
# like last_planned_due so a backup/restore keeps the deferral.
"due_override": tdata.get("due_override"),
@@ -38,6 +38,10 @@ interface BatteryRow {
* healthy. Never escalates to low/task — the row just shows the
* discrepancy (usual cause: an unrecorded swap). */
forecast_overdue?: boolean;
/** The forecast in days, NOT clamped: negative once the date has passed.
* `days_until` holds at 0 for overdue rows (B1, sorting), so the ~date
* is drawn from this one. */
forecast_days?: number | null;
/** D#162: a Battery Notes note whose device has NO battery level at
* all — read from its type sensor. No level bar, never offline; the
* forecast is the whole signal and (option) a passed one is `low`. */
@@ -235,7 +239,7 @@ export class MaintenanceBatteryFleetSection extends LitElement {
* replacements)" (D#162 follow-up). */
private _predictedTitle(b: BatteryRow, L: string): string {
if (b.forecast_overdue) return t("battery_fleet_forecast_overdue", L);
const date = this._predictedDate(b.days_until ?? 0);
const date = this._predictedDate(this._forecastDays(b) ?? 0);
if (b.predicted_source === "trend") {
return t("battery_fleet_predicted_trend", L)
.replace("{date}", date)
@@ -344,6 +348,15 @@ export class MaintenanceBatteryFleetSection extends LitElement {
return formatDate(addDaysIso(haToday(), daysUntil), this._lang);
}
/** The days the ~date is drawn from: the unclamped forecast, so a passed
* one keeps its own date instead of reading "today" every day (prod
* report 2026-10-13). `days_until` only covers a server from before
* `forecast_days` (an update not yet restarted); it is the same number
* wherever the forecast has not passed. */
private _forecastDays(b: BatteryRow): number | null {
return b.forecast_days !== undefined ? b.forecast_days : b.days_until;
}
/** The calendar date of an epoch (a detected swap), in the HA profile
* date format and time zone (#163 — a direct Intl.DateTimeFormat call
* here ignored the profile and showed 9/2/2026 to a DD/MM/YYYY user). */
@@ -390,16 +403,20 @@ export class MaintenanceBatteryFleetSection extends LitElement {
);
}
/** Purely visual level bar next to the number — scannable at a glance.
* Colored against the battery's OWN low threshold: red at/below it,
* amber inside a 20-point approach band, green above. */
/** How a level reads against the battery's OWN low threshold: "bad"
* at/below it, "warn" inside a 20-point approach band, "good" above.
* ONE judgement for the bar and the number beside it. */
private _levelTone(level: number, b: BatteryRow): "bad" | "warn" | "good" {
const t = b.low_threshold ?? 20;
return level <= t ? "bad" : level <= t + 20 ? "warn" : "good";
}
/** Purely visual level bar next to the number — scannable at a glance. */
private _levelBar(b: BatteryRow) {
const level = b.level;
if (level == null) return nothing;
const t = b.low_threshold ?? 20;
const cls = level <= t ? "bad" : level <= t + 20 ? "warn" : "good";
return html`<span class="bf-bar" aria-hidden="true"
><span class="bf-bar-fill bf-bar-${cls}" style="width: ${Math.min(100, Math.max(0, level))}%"></span
><span class="bf-bar-fill bf-bar-${this._levelTone(level, b)}" style="width: ${Math.min(100, Math.max(0, level))}%"></span
></span>`;
}
@@ -425,8 +442,7 @@ export class MaintenanceBatteryFleetSection extends LitElement {
*
* DOM order is load-bearing for the grid CSS: `.bf-status + .bf-type +
* .bf-offline` (the roster's chip sits AFTER the type, the low/soon
* lists' BEFORE it), `.bf-level + .bf-mark.bf-replaced`, and the phone
* layout's column assignments — keep it. */
* lists' BEFORE it) and the phone layout's column assignments — keep it. */
private _renderRow(
b: FleetRow,
L: string,
@@ -456,6 +472,7 @@ export class MaintenanceBatteryFleetSection extends LitElement {
const type = html`<span class="bf-type" title=${typeText}>${typeText}</span>`;
const writer = this._canWrite;
const showMark = writer && (o.mark === "always" || b.no_sensor || b.can_mark_replaced);
const days = this._forecastDays(b);
return html`
<div class="bf-row">
<span class="bf-dev">${b.device_name}</span>
@@ -473,7 +490,7 @@ export class MaintenanceBatteryFleetSection extends LitElement {
: nothing}
${o.sparkline ? this._sparkline(b) : nothing}
${this._levelBar(b)}
${b.level != null ? html`<span class="bf-level">${b.level}%</span>` : nothing}
${b.level != null ? html`<span class="bf-level bf-level-${this._levelTone(b.level, b)}">${b.level}%</span>` : nothing}
${showMark
? html`<button
class="bf-mark${o.mark === "replaced" ? " bf-replaced" : ""}"
@@ -485,11 +502,11 @@ export class MaintenanceBatteryFleetSection extends LitElement {
</button>`
: nothing}
${o.jump && writer ? this._jumpButton(b, L) : nothing}
${o.predicted && b.days_until != null
${o.predicted && days != null
? html`<span
class="bf-predicted ${b.predicted_source === "trend" ? "bf-trend" : ""} ${b.forecast_overdue ? "bf-overdue" : ""}"
title=${this._predictedTitle(b, L)}
>${b.forecast_overdue ? html`<ha-icon icon="mdi:calendar-alert"></ha-icon>` : nothing}~${this._predictedDate(b.days_until)}</span
>${b.forecast_overdue ? html`<ha-icon icon="mdi:calendar-alert"></ha-icon>` : nothing}~${this._predictedDate(days)}</span
>`
: nothing}
${o.exclude && writer
@@ -821,7 +838,14 @@ export class MaintenanceBatteryFleetSection extends LitElement {
* has no status chip, no sparkline, no date). */
.bf-rows {
display: grid;
grid-template-columns: minmax(0, 1fr) repeat(9, max-content);
/* The right-hand columns (percentage, action, date, eye) have a
* minimum width, so they line up across the three lists ("Buy now",
* "Needed soon", the roster) although each list is a grid of its own
* and fills different columns - only the roster has the eye, and its
* dates used to end 32 px left of the soon list's. */
grid-template-columns:
minmax(0, 1fr) repeat(5, max-content)
minmax(40px, max-content) minmax(32px, max-content) minmax(92px, max-content) minmax(32px, max-content);
column-gap: 8px;
}
.bf-row {
@@ -867,24 +891,16 @@ export class MaintenanceBatteryFleetSection extends LitElement {
grid-column: 7;
justify-self: end;
}
/* Every row action, the Replaced one included, sits in column 8. The
* Replaced action used to take the empty percentage slot on sensorless
* rows (D#162, while nothing else filled column 8) and column 8 on rows
* with a percentage (since 2.79), so it jumped 40 px between
* neighbouring rows (prod report 2026-10-13). Rows with a percentage
* fill column 8 anyway, so one column for all costs no width. The
* narrow layout below gives Replaced a slot of its own. */
.bf-row .bf-mark {
grid-column: 8;
}
/* D#162: the sensorless row's Replaced action sits in the percentage
* slot (always empty for such a row) instead of the row-action column
* - a new max-content track there widens EVERY row via the subgrid
* and pushed the phone roster 34 px past its edge. */
.bf-row .bf-mark.bf-replaced {
grid-column: 7;
justify-self: end;
}
/* D#162 (maisun's iPhone): since 2.79 rows WITH a level offer Replaced
* too — level and button shared column 7 and overlapped. With a
* percentage present the button takes the row-action column instead. */
.bf-row .bf-level + .bf-mark.bf-replaced {
grid-column: 8;
justify-self: start;
}
.bf-predicted {
grid-column: 9;
justify-self: end;
@@ -1003,7 +1019,7 @@ export class MaintenanceBatteryFleetSection extends LitElement {
.bf-soon-rows .bf-row:has(.bf-level) .bf-dev {
grid-column: 1 / 9;
}
.bf-row .bf-level + .bf-mark.bf-replaced {
.bf-row .bf-mark.bf-replaced {
grid-row: 2;
grid-column: 7;
justify-self: end;
@@ -1090,10 +1106,19 @@ export class MaintenanceBatteryFleetSection extends LitElement {
border-color: var(--primary-color);
color: var(--primary-color);
}
/* The number takes the bar's colour. It was red on every row, from the
* days it only showed in the low list; the soon list and the roster
* show it too, and a full battery read as an alarm there (prod report
* 2026-10-13). A healthy level is plain text. */
.bf-level {
font-size: 12px;
}
.bf-level-bad {
color: var(--error-color, #f44336);
}
.bf-level-warn {
color: var(--warning-color, #ff9800);
}
.bf-mark {
background: transparent;
border: none;
@@ -8,7 +8,6 @@ import { UserService } from "../user-service";
import { partLinkKey } from "../helpers/shared-parts";
import { MAX_READING_SLOTS, cleanReadingSlots, duplicateReadingSlotIds, newReadingSlotId } from "../helpers/reading-slots";
import {
ENVIRONMENTAL_PICKER_DEVICE_CLASSES,
ENVIRONMENTAL_PICKER_DOMAINS,
TRIGGER_PICKER_DOMAINS,
} from "../helpers/trigger-domains";
@@ -3216,10 +3215,7 @@ export class MaintenanceTaskDialog extends LitElement {
.hass=${this.hass}
.schema=${[{
name: "environmental_entity",
selector: { entity: {
domain: ENVIRONMENTAL_PICKER_DOMAINS,
device_class: ENVIRONMENTAL_PICKER_DEVICE_CLASSES,
} },
selector: { entity: { domain: ENVIRONMENTAL_PICKER_DOMAINS } },
}]}
.data=${{ environmental_entity: this._environmentalEntity }}
.computeLabel=${() => t("environmental_entity_optional", L)}
@@ -67,6 +67,7 @@
"last_performed": "Naposledy provedeno",
"last_done_days_ago": "hotovo před {n} d",
"last_done_today": "hotovo dnes",
"no_due_date": "Bez termínu",
"next_due": "Další termín",
"days_until_due": "Dnů do termínu",
"avg_duration": "Prům. trvání",
@@ -69,6 +69,7 @@
"last_performed": "Senest udført",
"last_done_days_ago": "udført for {n} d siden",
"last_done_today": "udført i dag",
"no_due_date": "Ingen forfaldsdato",
"next_due": "Næste forfald",
"days_until_due": "Dage til forfald",
"avg_duration": "Gns. varighed",
@@ -69,6 +69,7 @@
"last_performed": "Zuletzt durchgeführt",
"last_done_days_ago": "vor {n} T erledigt",
"last_done_today": "heute erledigt",
"no_due_date": "Ohne Fälligkeit",
"next_due": "Nächste Fälligkeit",
"days_until_due": "Tage bis fällig",
"avg_duration": "Ø Dauer",
@@ -69,6 +69,7 @@
"last_performed": "Last performed",
"last_done_days_ago": "done {n} d ago",
"last_done_today": "done today",
"no_due_date": "No due date",
"next_due": "Next due",
"days_until_due": "Days until due",
"avg_duration": "Avg duration",
@@ -67,6 +67,7 @@
"last_performed": "Última ejecución",
"last_done_days_ago": "hecho hace {n} d",
"last_done_today": "hecho hoy",
"no_due_date": "Sin vencimiento",
"next_due": "Próximo vencimiento",
"days_until_due": "Días hasta vencimiento",
"avg_duration": "Ø Duración",
@@ -69,6 +69,7 @@
"last_performed": "Viimeksi suoritettu",
"last_done_days_ago": "tehty {n} pv sitten",
"last_done_today": "tehty tänään",
"no_due_date": "Ei eräpäivää",
"next_due": "Seuraava eräpäivä",
"days_until_due": "Päiviä eräpäivään",
"avg_duration": "Keskim. kesto",
@@ -67,6 +67,7 @@
"last_performed": "Dernière exécution",
"last_done_days_ago": "fait il y a {n} j",
"last_done_today": "fait aujourd’hui",
"no_due_date": "Sans échéance",
"next_due": "Prochaine échéance",
"days_until_due": "Jours restants",
"avg_duration": "Ø Durée",
@@ -69,6 +69,7 @@
"last_performed": "अंतिम बार किया गया",
"last_done_days_ago": "{n} दिन पहले किया",
"last_done_today": "आज किया",
"no_due_date": "कोई देय तिथि नहीं",
"next_due": "अगली देय",
"days_until_due": "देय तक दिन",
"avg_duration": "औसत अवधि",
@@ -69,6 +69,7 @@
"last_performed": "Utoljára elvégezve",
"last_done_days_ago": "{n} napja elvégezve",
"last_done_today": "ma elvégezve",
"no_due_date": "Nincs határidő",
"next_due": "Következő esedékesség",
"days_until_due": "Napok az esedékességig",
"avg_duration": "Átl. időtartam",
@@ -67,6 +67,7 @@
"last_performed": "Ultima esecuzione",
"last_done_days_ago": "fatto {n} g fa",
"last_done_today": "fatto oggi",
"no_due_date": "Senza scadenza",
"next_due": "Prossima scadenza",
"days_until_due": "Giorni alla scadenza",
"avg_duration": "Ø Durata",
@@ -69,6 +69,7 @@
"last_performed": "最終実施",
"last_done_days_ago": "{n}日前に実施",
"last_done_today": "今日実施",
"no_due_date": "期限なし",
"next_due": "次回期限",
"days_until_due": "期限までの日数",
"avg_duration": "平均所要時間",
@@ -69,6 +69,7 @@
"last_performed": "마지막 수행일",
"last_done_days_ago": "{n}일 전 완료",
"last_done_today": "오늘 완료",
"no_due_date": "예정일 없음",
"next_due": "다음 예정일",
"days_until_due": "남은 일수",
"avg_duration": "평균 소요 시간",
@@ -69,6 +69,7 @@
"last_performed": "Sist utført",
"last_done_days_ago": "utført for {n} d siden",
"last_done_today": "utført i dag",
"no_due_date": "Ingen forfallsdato",
"next_due": "Neste forfall",
"days_until_due": "Dager til forfall",
"avg_duration": "Gj.snittlig varighet",
@@ -67,6 +67,7 @@
"last_performed": "Laatst uitgevoerd",
"last_done_days_ago": "{n} d geleden gedaan",
"last_done_today": "vandaag gedaan",
"no_due_date": "Geen vervaldatum",
"next_due": "Volgende keer",
"days_until_due": "Dagen tot vervaldatum",
"avg_duration": "Ø Duur",
@@ -67,6 +67,7 @@
"last_performed": "Ostatnio wykonane",
"last_done_days_ago": "wykonano {n} d temu",
"last_done_today": "wykonano dziś",
"no_due_date": "Bez terminu",
"next_due": "Następny termin",
"days_until_due": "Dni do terminu",
"avg_duration": "Śr. czas trwania",
@@ -69,6 +69,7 @@
"last_performed": "Última execução",
"last_done_days_ago": "feito há {n} d",
"last_done_today": "feito hoje",
"no_due_date": "Sem vencimento",
"next_due": "Próximo vencimento",
"days_until_due": "Dias até o vencimento",
"avg_duration": "Duração média",
@@ -67,6 +67,7 @@
"last_performed": "Última execução",
"last_done_days_ago": "feito há {n} d",
"last_done_today": "feito hoje",
"no_due_date": "Sem vencimento",
"next_due": "Próximo vencimento",
"days_until_due": "Dias até vencimento",
"avg_duration": "Ø Duração",
@@ -67,6 +67,7 @@
"last_performed": "Последнее выполнение",
"last_done_days_ago": "выполнено {n} дн. назад",
"last_done_today": "выполнено сегодня",
"no_due_date": "Без срока",
"next_due": "Следующий срок",
"days_until_due": "Дней до срока",
"avg_duration": "Ср. длительность",
@@ -67,6 +67,7 @@
"last_performed": "Senast utförd",
"last_done_days_ago": "klar för {n} d sedan",
"last_done_today": "klar i dag",
"no_due_date": "Inget förfallodatum",
"next_due": "Nästa förfallodatum",
"days_until_due": "Dagar till förfallodatum",
"avg_duration": "Snittlig varaktighet",
@@ -69,6 +69,7 @@
"last_performed": "Son yapılma",
"last_done_days_ago": "{n} g önce yapıldı",
"last_done_today": "bugün yapıldı",
"no_due_date": "Vade tarihi yok",
"next_due": "Sonraki vade",
"days_until_due": "Vadeye kalan gün",
"avg_duration": "Ort. süre",
@@ -67,6 +67,7 @@
"last_performed": "Останнє виконання",
"last_done_days_ago": "виконано {n} д тому",
"last_done_today": "виконано сьогодні",
"no_due_date": "Без терміну",
"next_due": "Наступний термін",
"days_until_due": "Днів до терміну",
"avg_duration": "Сер. тривалість",
@@ -69,6 +69,7 @@
"last_performed": "上次执行",
"last_done_days_ago": "{n} 天前完成",
"last_done_today": "今天完成",
"no_due_date": "无到期日期",
"next_due": "下次到期",
"days_until_due": "距离到期天数",
"avg_duration": "平均耗时",
@@ -5,7 +5,7 @@ import { applySubscriptionEvent, type SubscriptionEvent } from "./helpers/subscr
import { hydrateObjects } from "./helpers/hydrate-objects";
import { taskIsFor } from "./helpers/assignees";
import { property, state } from "lit/decorators.js";
import { syncLocaleFromHass, sharedStyles, STATUS_COLORS, t, ensureLocale, isLocaleLoaded, setProfilePrefs, formatDueDays, formatLastDone, formatDate, langOf, currencySymbolOf, syncCurrencyDecimals} from "./styles";
import { syncLocaleFromHass, sharedStyles, t, ensureLocale, isLocaleLoaded, setProfilePrefs, formatDueDays, formatLastDone, formatDate, langOf, currencySymbolOf, syncCurrencyDecimals} from "./styles";
import { openSignedDocument } from "./helpers/document-url";
import { isPhotoDocument } from "./helpers/document-categories";
import { isSafeLink } from "./helpers/url";
@@ -28,6 +28,7 @@ import { renderEventTitles } from "./helpers/event-titles";
import { buildCompleteDialogArgs, fillAndOpenCompleteDialog } from "./helpers/complete-dialog-args";
import { fetchSettingsOnce, FALLBACK_SETTINGS, type SettingsCache } from "./helpers/settings-cache";
import { canWrite } from "./helpers/permissions";
import { statusColor } from "./renderers/status";
import { ACTIONABLE_STATUSES, isActionableStatus, statusRank } from "./status-constants";
import "./maintenance-card-editor";
import "./components/complete-dialog";
@@ -579,7 +580,7 @@ export class MaintenanceSupporterCard extends LitElement {
<div class="task-item clickable"
@click=${() => this._openTaskDetail(entry_id, task.id)}
title="${t("open_task", L)}">
<div class="status-dot" style="background: ${STATUS_COLORS[task.status] || "#ccc"}"></div>
<div class="status-dot" style="background: ${statusColor(task)}"></div>
<div class="task-info">
<div class="task-name">
${task.name}${renderEventTitles(task.next_event_titles)}
@@ -130,7 +130,7 @@ import { INITIAL_STICKY, nextStickyState, stickyStateOnSelect, stickyTop, type S
import { invalidateSettingsCache, parseSettings, type SettingsWire } from "./helpers/settings-cache";
import type { PartsCostMode } from "./helpers/parts-cost";
import { canWrite } from "./helpers/permissions";
import { renderStatusBadge } from "./renderers/status";
import { renderStatusBadge, statusKey, statusLabel, type StatusLike } from "./renderers/status";
import { TOAST_MS, ACTION_TOAST_MS } from "./helpers/toast";
import { primaryTriggerEntity } from "./helpers/trigger-entities";
import { buildHistoryEntryDraft } from "./helpers/history-draft";
@@ -355,6 +355,9 @@ export class MaintenanceSupporterPanel extends LitElement {
@state() private _virtStart = 0;
@state() private _virtEnd = 0; // 0 = uninitialized → initial slice
private _virtRowHeight = 53;
/** Set by the first render and by every scroll/resize: the next window
* pass may re-measure the row height (see _updateVirtualWindow). */
private _virtMeasure = true;
private _virtTotalRows = 0;
private _virtScrollAttached = false;
private _virtRaf = 0;
@@ -619,8 +622,9 @@ export class MaintenanceSupporterPanel extends LitElement {
// Virtualized task table: attach the scroll listener once `.content`
// exists (lit keeps the node stable across renders), then reconcile the
// window with the just-rendered DOM (row-height probe + clamps). Only
// sets state when the window actually moved, so this can't loop.
// window with the just-rendered DOM (clamps). Only sets state when the
// window actually moved, and the row-height estimate holds still until
// the next scroll or resize, so this can't loop.
const content = this.shadowRoot?.querySelector(".content");
if (content && !this._virtScrollAttached) {
content.addEventListener("scroll", this._onVirtualScroll, { passive: true });
@@ -715,6 +719,7 @@ export class MaintenanceSupporterPanel extends LitElement {
/** rAF-throttled scroll/resize handler for the virtualized task table. */
private _onVirtualScroll = (): void => {
this._virtMeasure = true;
if (this._virtRaf) return;
this._virtRaf = requestAnimationFrame(() => {
this._virtRaf = 0;
@@ -728,10 +733,22 @@ export class MaintenanceSupporterPanel extends LitElement {
const content = this.shadowRoot?.querySelector<HTMLElement>(".content");
const table = this.shadowRoot?.querySelector<HTMLElement>(".task-table.virtual");
if (!content || !table) return;
// Probe the real row height from a rendered row (uniform rows; the
// hidden sizer row is height 0 and excluded).
const probe = table.querySelector<HTMLElement>(".task-row:not(.virt-sizer)");
if (probe && probe.offsetHeight > 20) this._virtRowHeight = probe.offsetHeight;
// The row-height estimate is the mean of the rendered rows (the hidden
// sizer row is height 0 and excluded), taken on the first render and
// after each scroll or resize, never from the render the new window
// caused. Rows are not uniform: a sensor row carries a value and a
// sparkline under its date. Probing the first row after every render
// fed a loop: window A measured a short row, the new estimate moved the
// window to B, B's first row was a tall one, the estimate moved it back
// to A, and the page froze (2026-10-13, a 125-task install).
if (this._virtMeasure) {
const rows = [...table.querySelectorAll<HTMLElement>(".task-row:not(.virt-sizer)")];
const mean = rows.length ? rows.reduce((n, r) => n + r.offsetHeight, 0) / rows.length : 0;
if (mean > 20) {
if (Math.abs(mean - this._virtRowHeight) >= 2) this._virtRowHeight = Math.round(mean);
this._virtMeasure = false;
}
}
const listTop =
table.getBoundingClientRect().top - content.getBoundingClientRect().top + content.scrollTop;
const w = computeWindow({
@@ -3495,8 +3512,8 @@ export class MaintenanceSupporterPanel extends LitElement {
* (colour-blind accessibility) — icon + text + colour together. The label
* is its own span so narrow/tight rows can drop it (#150: the pill ate a
* third of a phone row) while `title`/`aria-label` keep the text reachable. */
private _statusBadge(archived: boolean, isDone: boolean, status: string) {
return renderStatusBadge({ archived, is_done: isDone, status }, this._lang);
private _statusBadge(task: StatusLike) {
return renderStatusBadge(task, this._lang);
}
private _setOverviewTab(tab: OverviewTab): void {
@@ -3869,7 +3886,7 @@ export class MaintenanceSupporterPanel extends LitElement {
let anyNfc = false;
let anyPriority = false;
for (const r of rows) {
const label = r.archived ? t("archived", L) : (r.is_done ? t("completed", L) : t(r.status, L));
const label = statusLabel(statusKey(r), L);
if (label.length > widest.length) widest = label;
if (!r.enabled) anyDisabled = true;
if (r.nfc_tag_id) anyNfc = true;
@@ -4625,7 +4642,7 @@ export class MaintenanceSupporterPanel extends LitElement {
</label>
` : nothing}
<span class="cell-badges">
${this._statusBadge(!!row.archived, row.is_done, row.status)}
${this._statusBadge(row)}
${!row.enabled ? html`<span class="badge-disabled">${t("disabled", L)}</span>` : nothing}
${row.nfc_tag_id ? html`<span class="nfc-badge" title="${t("nfc_linked", L)}"><ha-icon icon="mdi:nfc-variant"></ha-icon></span>` : nothing}
${row.priority === "high" ? html`<span class="priority-badge priority-high" title="${t("priority_high", L)}"><ha-icon icon="mdi:chevron-double-up"></ha-icon></span>` : nothing}
@@ -4871,7 +4888,7 @@ export class MaintenanceSupporterPanel extends LitElement {
).map((task) => html`
<div class="task-row${!task.enabled ? ' task-disabled' : ''}">
<span class="cell-badges">
${this._statusBadge(!!task.archived, !!task.is_done, task.status)}
${this._statusBadge(task)}
${!task.enabled ? html`<span class="badge-disabled">${t("disabled", L)}</span>` : nothing}
${task.nfc_tag_id ? html`<span class="nfc-badge" title="${t("nfc_linked", L)}"><ha-icon icon="mdi:nfc-variant"></ha-icon></span>` : nothing}
${task.document_count
@@ -221,6 +221,29 @@ export const panelStyles = css`
transition: background 0.15s;
}
/* One-line rows line up on their FIRST TEXT LINE, not on their middles.
The due cell is a stack (date, then bar, sensor value, sparkline) and
"center" centred the whole stack, so in sensor rows the date sat 11-24 px
above the type next to it. Baseline alignment puts status, object, task,
type, date and the button labels on one line; whatever a cell stacks
below hangs underneath. Only the one-line layout: narrow, tight and the
768 px fallback lay their rows out in two lines of their own. */
@media (min-width: 769px) {
:host(:not([narrow]):not([tight])) .task-row { align-items: first baseline; }
/* The status pill starts with its icon, which has no text, so the pill
took its baseline from the icon's bottom edge and sat 3.5 px off the
line. The label is the taller item, so this does not move it inside
the pill. */
:host(:not([narrow]):not([tight])) .task-row .status-label { align-self: baseline; }
/* Icon-only actions have no text, so they would hang off their bottom
edge. A zero-width character in their vertical middle gives the pair
a baseline, and the buttons centre on the text line. */
:host(:not([narrow]):not([tight])) .row-actions:not(.as-buttons)::before {
content: "\\200b";
margin-inline-end: -2px;
}
}
/* Virtualized task table (large installs): only the scroll window of rows
is in the DOM. The spacers span all columns and carry the off-window
height so the scrollbar stays honest. The sizer row is invisible and
@@ -826,6 +849,13 @@ export const panelStyles = css`
color: white;
}
/* D#203: a manual task without a due date (outline, muted grey). */
.status-chip.no_due {
background: var(--maint-no-due-background, transparent);
color: var(--secondary-text-color, #727272);
box-shadow: inset 0 0 0 1px currentColor;
}
/* (#67) Warranty status chip — object detail meta + objects table */
.warranty-chip {
display: inline-block;
@@ -541,6 +541,9 @@ export const sharedStyles = css`
.status-badge.triggered { background-color: var(--maint-triggered-color); }
/* Completed one-time task ("done") — muted blue-grey. */
.status-badge.done { background-color: var(--maint-done-color, #78909c); }
/* D#203: a manual task without a due date — an outline in the muted grey of
its "done 23 d ago", so it does not read as a green, on-schedule OK. */
.status-badge.no_due { background-color: var(--maint-no-due-background, transparent); color: var(--secondary-text-color, #727272); box-shadow: inset 0 0 0 1px currentColor; }
/* v2.10.0: archived (retire-but-retain) — neutral grey, clearly inert. */
.status-badge.archived { background-color: var(--disabled-color, #9e9e9e); color: #000; }
/* v2.20 (N3): paused — frozen but present, info blue. */
@@ -1057,6 +1060,10 @@ export const sharedStyles = css`
}
.due-text { font-size: 13px; }
/* The cell is right-aligned; a date that wraps in a narrow column
("63 d / overdue") ends flush right like the bar and the value under it,
instead of starting each line at a different spot from row to row. */
.due-cell .due-text { text-align: end; }
/* D#203: a task without a due date shows when it was last done — muted,
so it never reads as a status. */
.due-text.last-done { color: var(--secondary-text-color); }
@@ -67,6 +67,7 @@
"last_performed": "Naposledy provedeno",
"last_done_days_ago": "hotovo před {n} d",
"last_done_today": "hotovo dnes",
"no_due_date": "Bez termínu",
"next_due": "Další termín",
"days_until_due": "Dnů do termínu",
"avg_duration": "Prům. trvání",
@@ -69,6 +69,7 @@
"last_performed": "Senest udført",
"last_done_days_ago": "udført for {n} d siden",
"last_done_today": "udført i dag",
"no_due_date": "Ingen forfaldsdato",
"next_due": "Næste forfald",
"days_until_due": "Dage til forfald",
"avg_duration": "Gns. varighed",
@@ -69,6 +69,7 @@
"last_performed": "Zuletzt durchgeführt",
"last_done_days_ago": "vor {n} T erledigt",
"last_done_today": "heute erledigt",
"no_due_date": "Ohne Fälligkeit",
"next_due": "Nächste Fälligkeit",
"days_until_due": "Tage bis fällig",
"avg_duration": "Ø Dauer",
@@ -69,6 +69,7 @@
"last_performed": "Last performed",
"last_done_days_ago": "done {n} d ago",
"last_done_today": "done today",
"no_due_date": "No due date",
"next_due": "Next due",
"days_until_due": "Days until due",
"avg_duration": "Avg duration",
@@ -67,6 +67,7 @@
"last_performed": "Última ejecución",
"last_done_days_ago": "hecho hace {n} d",
"last_done_today": "hecho hoy",
"no_due_date": "Sin vencimiento",
"next_due": "Próximo vencimiento",
"days_until_due": "Días hasta vencimiento",
"avg_duration": "Ø Duración",
@@ -69,6 +69,7 @@
"last_performed": "Viimeksi suoritettu",
"last_done_days_ago": "tehty {n} pv sitten",
"last_done_today": "tehty tänään",
"no_due_date": "Ei eräpäivää",
"next_due": "Seuraava eräpäivä",
"days_until_due": "Päiviä eräpäivään",
"avg_duration": "Keskim. kesto",
@@ -67,6 +67,7 @@
"last_performed": "Dernière exécution",
"last_done_days_ago": "fait il y a {n} j",
"last_done_today": "fait aujourd’hui",
"no_due_date": "Sans échéance",
"next_due": "Prochaine échéance",
"days_until_due": "Jours restants",
"avg_duration": "Ø Durée",
@@ -69,6 +69,7 @@
"last_performed": "अंतिम बार किया गया",
"last_done_days_ago": "{n} दिन पहले किया",
"last_done_today": "आज किया",
"no_due_date": "कोई देय तिथि नहीं",
"next_due": "अगली देय",
"days_until_due": "देय तक दिन",
"avg_duration": "औसत अवधि",
@@ -69,6 +69,7 @@
"last_performed": "Utoljára elvégezve",
"last_done_days_ago": "{n} napja elvégezve",
"last_done_today": "ma elvégezve",
"no_due_date": "Nincs határidő",
"next_due": "Következő esedékesség",
"days_until_due": "Napok az esedékességig",
"avg_duration": "Átl. időtartam",
@@ -67,6 +67,7 @@
"last_performed": "Ultima esecuzione",
"last_done_days_ago": "fatto {n} g fa",
"last_done_today": "fatto oggi",
"no_due_date": "Senza scadenza",
"next_due": "Prossima scadenza",
"days_until_due": "Giorni alla scadenza",
"avg_duration": "Ø Durata",
@@ -69,6 +69,7 @@
"last_performed": "最終実施",
"last_done_days_ago": "{n}日前に実施",
"last_done_today": "今日実施",
"no_due_date": "期限なし",
"next_due": "次回期限",
"days_until_due": "期限までの日数",
"avg_duration": "平均所要時間",
@@ -69,6 +69,7 @@
"last_performed": "마지막 수행일",
"last_done_days_ago": "{n}일 전 완료",
"last_done_today": "오늘 완료",
"no_due_date": "예정일 없음",
"next_due": "다음 예정일",
"days_until_due": "남은 일수",
"avg_duration": "평균 소요 시간",
@@ -69,6 +69,7 @@
"last_performed": "Sist utført",
"last_done_days_ago": "utført for {n} d siden",
"last_done_today": "utført i dag",
"no_due_date": "Ingen forfallsdato",
"next_due": "Neste forfall",
"days_until_due": "Dager til forfall",
"avg_duration": "Gj.snittlig varighet",
@@ -67,6 +67,7 @@
"last_performed": "Laatst uitgevoerd",
"last_done_days_ago": "{n} d geleden gedaan",
"last_done_today": "vandaag gedaan",
"no_due_date": "Geen vervaldatum",
"next_due": "Volgende keer",
"days_until_due": "Dagen tot vervaldatum",
"avg_duration": "Ø Duur",
@@ -67,6 +67,7 @@
"last_performed": "Ostatnio wykonane",
"last_done_days_ago": "wykonano {n} d temu",
"last_done_today": "wykonano dziś",
"no_due_date": "Bez terminu",
"next_due": "Następny termin",
"days_until_due": "Dni do terminu",
"avg_duration": "Śr. czas trwania",
@@ -69,6 +69,7 @@
"last_performed": "Última execução",
"last_done_days_ago": "feito há {n} d",
"last_done_today": "feito hoje",
"no_due_date": "Sem vencimento",
"next_due": "Próximo vencimento",
"days_until_due": "Dias até o vencimento",
"avg_duration": "Duração média",
@@ -67,6 +67,7 @@
"last_performed": "Última execução",
"last_done_days_ago": "feito há {n} d",
"last_done_today": "feito hoje",
"no_due_date": "Sem vencimento",
"next_due": "Próximo vencimento",
"days_until_due": "Dias até vencimento",
"avg_duration": "Ø Duração",
@@ -67,6 +67,7 @@
"last_performed": "Последнее выполнение",
"last_done_days_ago": "выполнено {n} дн. назад",
"last_done_today": "выполнено сегодня",
"no_due_date": "Без срока",
"next_due": "Следующий срок",
"days_until_due": "Дней до срока",
"avg_duration": "Ср. длительность",
@@ -67,6 +67,7 @@
"last_performed": "Senast utförd",
"last_done_days_ago": "klar för {n} d sedan",
"last_done_today": "klar i dag",
"no_due_date": "Inget förfallodatum",
"next_due": "Nästa förfallodatum",
"days_until_due": "Dagar till förfallodatum",
"avg_duration": "Snittlig varaktighet",
@@ -69,6 +69,7 @@
"last_performed": "Son yapılma",
"last_done_days_ago": "{n} g önce yapıldı",
"last_done_today": "bugün yapıldı",
"no_due_date": "Vade tarihi yok",
"next_due": "Sonraki vade",
"days_until_due": "Vadeye kalan gün",
"avg_duration": "Ort. süre",
@@ -67,6 +67,7 @@
"last_performed": "Останнє виконання",
"last_done_days_ago": "виконано {n} д тому",
"last_done_today": "виконано сьогодні",
"no_due_date": "Без терміну",
"next_due": "Наступний термін",
"days_until_due": "Днів до терміну",
"avg_duration": "Сер. тривалість",
@@ -69,6 +69,7 @@
"last_performed": "上次执行",
"last_done_days_ago": "{n} 天前完成",
"last_done_today": "今天完成",
"no_due_date": "无到期日期",
"next_due": "下次到期",
"days_until_due": "距离到期天数",
"avg_duration": "平均耗时",
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,5 +1,5 @@
/*! maintenance_supporter frontend 2.103.0 */
import{e as b}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-DPJSGXJS.js";import{d as h}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-2BQZ33DM.js";import{a as x,b as w,c as $}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-QVOLUT24.js";import{a as m}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-3LDXEWBF.js";import{I as y,a as l,b as u,c as n,f as p,h as g,m as v,n as d,t as i,v as f}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-OWNF6K2Z.js";var j=3,o=class extends g{constructor(){super(...arguments);this._open=!1;this._loading=!1;this._adopting=!1;this._error="";this._sensors=[];this._selected=new Set;this._objectNames={};this._objects=[];this._covered={};this._users=[];this._responsible="";this._forMinutes="0";this._summaryOpen=!0;this._localeReady=!1;this._userService=null;this._toggle=e=>{let s=new Set(this._selected);s.has(e)?s.delete(e):s.add(e),this._selected=s};this._toggleAll=()=>{this._selected.size===this._sensors.length?this._selected=new Set:this._selected=new Set(this._sensors.map(e=>e.entity_id))};this._adopt=async()=>{if(this._selected.size===0||this._adopting)return;this._error="";let e=this._sensors.filter(t=>this._selected.has(t.entity_id)).map(t=>({entity_id:t.entity_id,name:t.name,entry_id:this._existingEntryFor(t)??void 0,object_name:this._effectiveName(t),device_id:t.device_id??void 0,part_id:t.suggested_part_id??void 0,responsible_user_id:this._responsible||void 0,for_minutes:parseInt(this._forMinutes,10)>0?parseInt(this._forMinutes,10):void 0})),s=await m(this,{type:"maintenance_supporter/problem_sensors/adopt",selections:e},{busy:t=>{this._adopting=t},onError:t=>{this._error=t}});s!==void 0&&(this.dispatchEvent(new CustomEvent("problem-sensors-adopted",{bubbles:!0,composed:!0,detail:s})),this._open=!1)}}get _lang(){return y(this.hass)}updated(e){e.has("hass")&&this.hass&&!this._localeReady&&(this._localeReady=!0,f(this._lang).then(()=>this.requestUpdate())),e.has("_open")&&this._open&&$(this.shadowRoot)}async open(){this._open=!0,this._loading=!0,this._error="",this._sensors=[],this._selected=new Set,this._objectNames={},this._responsible="",this._forMinutes="0",this._userService?this._userService.updateHass(this.hass):this._userService=new b(this.hass);let[e,s,t]=await Promise.all([m(this,{type:"maintenance_supporter/problem_sensors/discover"},{onError:r=>{this._error=r}}),this._userService.getUsers().catch(()=>[]),this.hass.connection.sendMessagePromise({type:"maintenance_supporter/objects"}).catch(()=>({objects:[]}))]);this._loading=!1,e!==void 0&&(this._objects=(t?.objects||[]).filter(r=>!r.object.archived_at).map(r=>({entry_id:r.entry_id,name:r.object.name,tasks:(r.tasks||[]).length})),this._sensors=e?.sensors||[],this._covered=Object.fromEntries(this._sensors.map(r=>[r.entity_id,r.covered_by??null])),this._selected=new Set(this._sensors.filter(r=>!r.covered_by).map(r=>r.entity_id)),this._users=s,this._summaryOpen=this._summaryGroups().length<=j)}_defaultName(e){return e.suggested_entry_id?e.suggested_object_name:e.candidate?.name??e.suggested_object_name}_effectiveName(e){return(this._objectNames[e.entity_id]??"").trim()||this._defaultName(e)}_existingEntryFor(e){let s=this._effectiveName(e),t=e.suggested_entry_id||e.target_entry_id||null;if(s===this._defaultName(e)&&t)return t;let r=this._objects.find(a=>a.name.trim().toLowerCase()===s.toLowerCase());return r?r.entry_id:null}_schedulePreview(){clearTimeout(this._previewTimer),this._previewTimer=setTimeout(()=>{this._preview()},350)}async _preview(){let e=this._sensors.map(t=>({entity_id:t.entity_id,name:t.name,entry_id:this._existingEntryFor(t)})),s=await m(this,{type:"maintenance_supporter/problem_sensors/preview",selections:e},{onError:()=>{}});s?.covered&&(this._covered={...this._covered,...s.covered})}_summaryGroups(){let e=new Map;for(let s of this._sensors){if(!this._selected.has(s.entity_id))continue;let t=this._existingEntryFor(s),r=this._effectiveName(s),a=t??`new:${r.toLowerCase()}`,c=e.get(a)??{name:r,entry:t,adding:0};c.adding+=1,e.set(a,c)}return[...e.values()]}_summary(e){let s=this._summaryGroups();if(s.length===0)return p;let t=s.filter(a=>!a.entry).length,r=i(t?"adopt_summary_totals_new":"adopt_summary_totals",e).replace("{tasks}",String(s.reduce((a,c)=>a+c.adding,0))).replace("{objects}",String(s.length)).replace("{new}",String(t));return n`
/*! maintenance_supporter frontend 2.103.4 */
import{e as b}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-RHQP4L4Q.js";import{d as h}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-SDECZFD6.js";import{a as x,b as w,c as $}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-OEIFCESH.js";import{a as m}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-3SWZBEHR.js";import{I as y,a as l,b as u,c as n,f as p,h as g,m as v,n as d,t as i,v as f}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-VXQI3S2Y.js";var j=3,o=class extends g{constructor(){super(...arguments);this._open=!1;this._loading=!1;this._adopting=!1;this._error="";this._sensors=[];this._selected=new Set;this._objectNames={};this._objects=[];this._covered={};this._users=[];this._responsible="";this._forMinutes="0";this._summaryOpen=!0;this._localeReady=!1;this._userService=null;this._toggle=e=>{let s=new Set(this._selected);s.has(e)?s.delete(e):s.add(e),this._selected=s};this._toggleAll=()=>{this._selected.size===this._sensors.length?this._selected=new Set:this._selected=new Set(this._sensors.map(e=>e.entity_id))};this._adopt=async()=>{if(this._selected.size===0||this._adopting)return;this._error="";let e=this._sensors.filter(t=>this._selected.has(t.entity_id)).map(t=>({entity_id:t.entity_id,name:t.name,entry_id:this._existingEntryFor(t)??void 0,object_name:this._effectiveName(t),device_id:t.device_id??void 0,part_id:t.suggested_part_id??void 0,responsible_user_id:this._responsible||void 0,for_minutes:parseInt(this._forMinutes,10)>0?parseInt(this._forMinutes,10):void 0})),s=await m(this,{type:"maintenance_supporter/problem_sensors/adopt",selections:e},{busy:t=>{this._adopting=t},onError:t=>{this._error=t}});s!==void 0&&(this.dispatchEvent(new CustomEvent("problem-sensors-adopted",{bubbles:!0,composed:!0,detail:s})),this._open=!1)}}get _lang(){return y(this.hass)}updated(e){e.has("hass")&&this.hass&&!this._localeReady&&(this._localeReady=!0,f(this._lang).then(()=>this.requestUpdate())),e.has("_open")&&this._open&&$(this.shadowRoot)}async open(){this._open=!0,this._loading=!0,this._error="",this._sensors=[],this._selected=new Set,this._objectNames={},this._responsible="",this._forMinutes="0",this._userService?this._userService.updateHass(this.hass):this._userService=new b(this.hass);let[e,s,t]=await Promise.all([m(this,{type:"maintenance_supporter/problem_sensors/discover"},{onError:r=>{this._error=r}}),this._userService.getUsers().catch(()=>[]),this.hass.connection.sendMessagePromise({type:"maintenance_supporter/objects"}).catch(()=>({objects:[]}))]);this._loading=!1,e!==void 0&&(this._objects=(t?.objects||[]).filter(r=>!r.object.archived_at).map(r=>({entry_id:r.entry_id,name:r.object.name,tasks:(r.tasks||[]).length})),this._sensors=e?.sensors||[],this._covered=Object.fromEntries(this._sensors.map(r=>[r.entity_id,r.covered_by??null])),this._selected=new Set(this._sensors.filter(r=>!r.covered_by).map(r=>r.entity_id)),this._users=s,this._summaryOpen=this._summaryGroups().length<=j)}_defaultName(e){return e.suggested_entry_id?e.suggested_object_name:e.candidate?.name??e.suggested_object_name}_effectiveName(e){return(this._objectNames[e.entity_id]??"").trim()||this._defaultName(e)}_existingEntryFor(e){let s=this._effectiveName(e),t=e.suggested_entry_id||e.target_entry_id||null;if(s===this._defaultName(e)&&t)return t;let r=this._objects.find(a=>a.name.trim().toLowerCase()===s.toLowerCase());return r?r.entry_id:null}_schedulePreview(){clearTimeout(this._previewTimer),this._previewTimer=setTimeout(()=>{this._preview()},350)}async _preview(){let e=this._sensors.map(t=>({entity_id:t.entity_id,name:t.name,entry_id:this._existingEntryFor(t)})),s=await m(this,{type:"maintenance_supporter/problem_sensors/preview",selections:e},{onError:()=>{}});s?.covered&&(this._covered={...this._covered,...s.covered})}_summaryGroups(){let e=new Map;for(let s of this._sensors){if(!this._selected.has(s.entity_id))continue;let t=this._existingEntryFor(s),r=this._effectiveName(s),a=t??`new:${r.toLowerCase()}`,c=e.get(a)??{name:r,entry:t,adding:0};c.adding+=1,e.set(a,c)}return[...e.values()]}_summary(e){let s=this._summaryGroups();if(s.length===0)return p;let t=s.filter(a=>!a.entry).length,r=i(t?"adopt_summary_totals_new":"adopt_summary_totals",e).replace("{tasks}",String(s.reduce((a,c)=>a+c.adding,0))).replace("{objects}",String(s.length)).replace("{new}",String(t));return n`
<details
class="summary"
.open=${this._summaryOpen}
@@ -1,301 +0,0 @@
/*! maintenance_supporter frontend 2.102.0 */
import{e as y}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-KFG47RQD.js";import{d as m}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-UKOBV3FF.js";import{a as h}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-RIYW54TU.js";import{H as b,a as o,b as u,c as n,f as l,h as v,l as g,m as d,s as r,u as f}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-4CECVKQ7.js";var a=class extends v{constructor(){super(...arguments);this._open=!1;this._loading=!1;this._adopting=!1;this._error="";this._sensors=[];this._selected=new Set;this._objectNames={};this._objects=[];this._covered={};this._users=[];this._responsible="";this._forMinutes="0";this._localeReady=!1;this._userService=null;this._toggle=t=>{let i=new Set(this._selected);i.has(t)?i.delete(t):i.add(t),this._selected=i};this._toggleAll=()=>{this._selected.size===this._sensors.length?this._selected=new Set:this._selected=new Set(this._sensors.map(t=>t.entity_id))};this._adopt=async()=>{if(this._selected.size===0||this._adopting)return;this._error="";let t=this._sensors.filter(e=>this._selected.has(e.entity_id)).map(e=>({entity_id:e.entity_id,name:e.name,entry_id:this._existingEntryFor(e)??void 0,object_name:this._effectiveName(e),device_id:e.device_id??void 0,part_id:e.suggested_part_id??void 0,responsible_user_id:this._responsible||void 0,for_minutes:parseInt(this._forMinutes,10)>0?parseInt(this._forMinutes,10):void 0})),i=await h(this,{type:"maintenance_supporter/problem_sensors/adopt",selections:t},{busy:e=>{this._adopting=e},onError:e=>{this._error=e}});i!==void 0&&(this.dispatchEvent(new CustomEvent("problem-sensors-adopted",{bubbles:!0,composed:!0,detail:i})),this._open=!1)}}get _lang(){return b(this.hass)}updated(t){t.has("hass")&&this.hass&&!this._localeReady&&(this._localeReady=!0,f(this._lang).then(()=>this.requestUpdate()))}async open(){this._open=!0,this._loading=!0,this._error="",this._sensors=[],this._selected=new Set,this._objectNames={},this._responsible="",this._forMinutes="0",this._userService?this._userService.updateHass(this.hass):this._userService=new y(this.hass);let[t,i,e]=await Promise.all([h(this,{type:"maintenance_supporter/problem_sensors/discover"},{onError:s=>{this._error=s}}),this._userService.getUsers().catch(()=>[]),this.hass.connection.sendMessagePromise({type:"maintenance_supporter/objects"}).catch(()=>({objects:[]}))]);this._loading=!1,t!==void 0&&(this._objects=(e?.objects||[]).filter(s=>!s.object.archived_at).map(s=>({entry_id:s.entry_id,name:s.object.name,tasks:(s.tasks||[]).length})),this._sensors=t?.sensors||[],this._covered=Object.fromEntries(this._sensors.map(s=>[s.entity_id,s.covered_by??null])),this._selected=new Set(this._sensors.filter(s=>!s.covered_by).map(s=>s.entity_id)),this._users=i)}_defaultName(t){return t.suggested_entry_id?t.suggested_object_name:t.candidate?.name??t.suggested_object_name}_effectiveName(t){return(this._objectNames[t.entity_id]??"").trim()||this._defaultName(t)}_existingEntryFor(t){let i=this._effectiveName(t),e=t.suggested_entry_id||t.target_entry_id||null;if(i===this._defaultName(t)&&e)return e;let s=this._objects.find(c=>c.name.trim().toLowerCase()===i.toLowerCase());return s?s.entry_id:null}_schedulePreview(){clearTimeout(this._previewTimer),this._previewTimer=setTimeout(()=>{this._preview()},350)}async _preview(){let t=this._sensors.map(e=>({entity_id:e.entity_id,name:e.name,entry_id:this._existingEntryFor(e)})),i=await h(this,{type:"maintenance_supporter/problem_sensors/preview",selections:t},{onError:()=>{}});i?.covered&&(this._covered={...this._covered,...i.covered})}_summary(t){let i=new Map;for(let e of this._sensors){if(!this._selected.has(e.entity_id))continue;let s=this._existingEntryFor(e),c=this._effectiveName(e),_=s??`new:${c.toLowerCase()}`,p=i.get(_)??{name:c,entry:s,adding:0};p.adding+=1,i.set(_,p)}return i.size===0?l:n`
<div class="summary">
<div class="summary-title">${r("adopt_summary_title",t)}</div>
${[...i.values()].map(e=>{if(!e.entry)return n`<div class="summary-line new">
${r("setups_new_object_count",t).replace("{name}",e.name).replace("{count}",String(e.adding))}
</div>`;let s=this._objects.find(c=>c.entry_id===e.entry)?.tasks??0;return n`<div class="summary-line">
${r("adopt_summary_existing",t).replace("{name}",e.name).replace("{before}",String(s)).replace("{after}",String(s+e.adding))}
</div>`})}
</div>
`}_close(){clearTimeout(this._previewTimer),this._open=!1}_targetLabel(t,i){if(t.candidate&&this._existingEntryFor(t)===t.candidate.entry_id){let e=t.candidate.reasons.map(s=>r(`setups_reason_${s}`,i)).join(", ");return r("setups_target_match",i).replace("{name}",t.candidate.name).replace("{reasons}",e)}return this._effectiveName(t)}render(){if(!this._open)return n``;let t=this._lang,i=this._sensors.length>0&&this._selected.size===this._sensors.length;return n`
<div class="overlay" @click=${this._close}>
<div class="card" @click=${e=>e.stopPropagation()}>
<div class="title">${r("adopt_problem_title",t)}</div>
<div class="hint">${r("adopt_problem_hint",t)}</div>
${this._error?n`<div class="error">${this._error}</div>`:l}
${this._loading?n`<div class="loading">…</div>`:this._sensors.length===0?n`<div class="empty">${r("adopt_problem_none",t)}</div>`:n`
<label class="select-all">
<input
type="checkbox"
.checked=${i}
@change=${this._toggleAll}
/>
<span>${r("selected",t)}: ${this._selected.size} / ${this._sensors.length}</span>
</label>
<div class="row-sub adopt-object-hint">${r("adopt_object_hint",t)}</div>
<datalist id="adopt-object-names">
${this._objects.map(e=>n`<option value=${e.name}></option>`)}
</datalist>
<div class="list">
${this._sensors.map(e=>{let s=this._selected.has(e.entity_id),c=e.state==="on",_=[e.device_name,e.area_name].filter(Boolean).join(" \xB7 ");return n`
<label class="row">
<input
type="checkbox"
.checked=${s}
@change=${()=>this._toggle(e.entity_id)}
/>
<div class="row-main">
<div class="row-top">
<span class="row-name">${e.name}</span>
<span class="chip ${c?"chip-active":"chip-ok"}">
${c?r("adopt_problem_active",t):r("adopt_problem_ok",t)}
</span>
</div>
${_?n`<div class="row-sub">${_}</div>`:l}
${s?n`<div class="row-object" @click=${p=>p.stopPropagation()}>
<input
class="adopt-object"
list="adopt-object-names"
aria-label=${r("object",t)}
placeholder=${this._defaultName(e)}
.value=${this._objectNames[e.entity_id]??this._defaultName(e)}
@input=${p=>{this._objectNames={...this._objectNames,[e.entity_id]:p.target.value},this._schedulePreview()}}
/>
</div>`:l}
<div class="row-target">
→ ${this._targetLabel(e,t)}${this._existingEntryFor(e)?l:n` <span class="new-tag">${r("adopt_problem_new_object",t)}</span>`}
</div>
${this._covered[e.entity_id]?n`<div class="maybe">
${r("setups_maybe_covered",t).replace("{name}",this._covered[e.entity_id].name)}
</div>`:l}
${e.suggested_part_name?n`<div class="row-part">
<ha-icon icon="mdi:package-variant-closed"></ha-icon>
${r("adopt_problem_part",t).replace("{name}",e.suggested_part_name)}
</div>`:l}
</div>
</label>
`})}
</div>
`}
${!this._loading&&this._sensors.length>0?this._summary(t):l}
${!this._loading&&this._sensors.length>0?n`
<label class="responsible">
<span>${r("for_at_least_minutes",t)}</span>
<input
class="for-input"
type="number"
min=${m[0]}
max=${m[1]}
.value=${this._forMinutes}
@input=${e=>this._forMinutes=e.target.value}
/>
</label>
<div class="for-hint">${r("adopt_for_minutes_hint",t)}</div>
`:l}
${!this._loading&&this._sensors.length>0&&this._users.length>0?n`
<label class="responsible">
<span>${r("adopt_problem_responsible",t)}</span>
<select
.value=${this._responsible}
@change=${e=>{this._responsible=e.target.value}}
>
<option value="" ?selected=${!this._responsible}>${r("no_user_assigned",t)}</option>
${this._users.map(e=>n`<option value=${e.id} ?selected=${e.id===this._responsible}>${e.name}</option>`)}
</select>
</label>
`:l}
<div class="actions">
<ha-button appearance="plain" @click=${this._close}>
${r("cancel",t)}
</ha-button>
<ha-button
@click=${this._adopt}
.disabled=${this._selected.size===0||this._adopting}
>
${r("adopt_problem_adopt",t)}
</ha-button>
</div>
</div>
</div>
`}};a.styles=u`
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.card {
background: var(--card-background-color, #fff);
color: var(--primary-text-color);
border-radius: 12px;
padding: 20px;
display: flex;
flex-direction: column;
gap: 12px;
min-width: min(360px, calc(100vw - 24px));
max-width: 560px;
width: 90vw;
max-height: 80vh;
overflow: hidden;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
}
.title {
font-size: 18px;
font-weight: 500;
}
.hint {
color: var(--secondary-text-color);
font-size: 13px;
}
.error {
color: var(--error-color, #f44336);
font-size: 13px;
}
.loading,
.empty {
color: var(--secondary-text-color);
font-size: 14px;
padding: 12px 0;
}
.select-all {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--secondary-text-color);
cursor: pointer;
}
.select-all input {
cursor: pointer;
}
.list {
display: flex;
flex-direction: column;
gap: 6px;
overflow-y: auto;
max-height: 50vh;
}
.row {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 8px;
border: 1px solid var(--divider-color);
border-radius: 6px;
cursor: pointer;
}
.row input {
margin-top: 2px;
cursor: pointer;
}
.row-main {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
flex: 1;
}
.row-top {
display: flex;
align-items: center;
gap: 8px;
}
.row-name {
font-weight: 500;
font-size: 13px;
}
.row-sub {
color: var(--secondary-text-color);
font-size: 12px;
}
.row-object { margin-top: 6px; }
.row-object input {
width: 100%; box-sizing: border-box; padding: 6px 8px; font: inherit;
border: 1px solid var(--divider-color); border-radius: 6px;
background: var(--card-background-color); color: var(--primary-text-color);
}
.adopt-object-hint { margin: 0 0 8px; }
.row-target {
color: var(--secondary-text-color);
font-size: 12px;
}
.row-part {
color: var(--secondary-text-color);
font-size: 12px;
display: flex;
align-items: center;
gap: 4px;
}
.row-part ha-icon {
--mdc-icon-size: 14px;
}
.new-tag {
font-style: italic;
}
.maybe {
font-size: 11px;
color: var(--warning-color, #ff9800);
}
.summary {
font-size: 12px;
border-top: 1px dashed var(--divider-color);
padding-top: 6px;
}
.summary-title {
font-weight: 500;
margin-bottom: 2px;
}
.summary-line {
color: var(--primary-color);
}
.chip {
font-size: 11px;
padding: 1px 8px;
border-radius: 10px;
white-space: nowrap;
}
.chip-active {
background: var(--error-color, #f44336);
color: #fff;
}
.chip-ok {
background: var(--divider-color);
color: var(--secondary-text-color);
}
.responsible {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--secondary-text-color);
flex-wrap: wrap;
}
.for-input {
width: 70px;
background: var(--card-background-color);
color: var(--primary-text-color);
border: 1px solid var(--divider-color);
border-radius: 4px;
padding: 4px 6px;
}
.for-hint {
font-size: 11px;
color: var(--secondary-text-color);
margin: -4px 0 2px;
}
.responsible select {
flex: 1;
min-width: 140px;
padding: 4px 6px;
border-radius: 4px;
border: 1px solid var(--divider-color);
background: var(--card-background-color, #fff);
color: var(--primary-text-color);
font-size: 13px;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 8px;
}
`,o([g({attribute:!1})],a.prototype,"hass",2),o([d()],a.prototype,"_open",2),o([d()],a.prototype,"_loading",2),o([d()],a.prototype,"_adopting",2),o([d()],a.prototype,"_error",2),o([d()],a.prototype,"_sensors",2),o([d()],a.prototype,"_selected",2),o([d()],a.prototype,"_objectNames",2),o([d()],a.prototype,"_objects",2),o([d()],a.prototype,"_covered",2),o([d()],a.prototype,"_users",2),o([d()],a.prototype,"_responsible",2),o([d()],a.prototype,"_forMinutes",2);customElements.get("maintenance-adopt-problem-sensors-dialog")||customElements.define("maintenance-adopt-problem-sensors-dialog",a);export{a as MaintenanceAdoptProblemSensorsDialog};
@@ -1,279 +0,0 @@
/*! maintenance_supporter frontend 2.102.0 */
import{a as X}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-VWOWFA6G.js";import{a as o}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-GH6PGWHU.js";import{f as E,g as M,h as z,i as Q,j as U,k as Y,l as q,m as G,n as R,o as A,p as W}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-7W4GNWDJ.js";import{a as B}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-DVPW5S27.js";import{e as P,f as K}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-XWO2WENS.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-ZXKKAVZ3.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-7SOUIRZ7.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-OGIYAEPJ.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-U4NJYI3P.js";import{B as S,H as F,K as g,L as v,O as N,P as I,U as w,W as T,a as $,c as p,f as y,h as H,l as k,m as j,n as C,q as D,s as a,u as L,y as O}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-4CECVKQ7.js";function J(b,i,t,s){let l=b.entries.filter(e=>e.type==="completed").sort((e,n)=>e.ts-n.ts),d=`${b.from?t.date(b.from):"\u2026"} \u2013 ${b.to?t.date(b.to):"\u2026"}`,r=b.totals,h=[[i.completions,t.number(r.completions)],[i.totalCost,t.cost(r.totalCost)],[i.avgCost,r.avgCost!=null?t.cost(r.avgCost):i.none],[i.totalTime,r.totalDuration?t.duration(r.totalDuration):i.none]].map(([e,n])=>`<div class="kpi"><div class="k">${o(e)}</div><div class="v">${o(n)}</div></div>`).join(""),u=b.byObject.map(e=>`<tr>
<td>${o(e.objectName)}</td>
<td class="num">${o(t.number(e.completions))}</td>
<td class="num">${o(t.cost(e.cost))}</td>
<td class="num">${o(t.share(e.share))}</td>
</tr>`).join(""),f=b.buckets.buckets.reduce((e,n)=>Math.max(e,n.cost),0),c=b.buckets.buckets.map(e=>{let n=f>0?Math.round(e.cost/f*100):0;return`<tr>
<td class="nowrap">${o(t.bucket(e))}</td>
<td class="num">${o(t.number(e.completions))}</td>
<td class="bar-cell">${n>0?`<span class="bar" style="width:${n}%"></span>`:""}</td>
<td class="num">${o(t.cost(e.cost))}</td>
</tr>`}).join(""),m=l.map(e=>{let n=[e.notes,e.completedBy?`${i.completedBy}: ${e.completedBy}`:null].filter(Boolean).join(" \xB7 "),x=e.phaseName?`${e.taskName} \xB7 ${e.phaseName}`:e.taskName;return`<tr>
<td class="nowrap">${o(t.date(e.timestamp))}</td>
<td>${o(e.objectName)}</td>
<td>${o(x)}</td>
<td class="num">${e.cost!=null?o(t.cost(e.cost)):o(i.none)}</td>
<td class="num">${e.duration!=null?o(t.duration(e.duration)):o(i.none)}</td>
<td class="notes">${o(n)||o(i.none)}</td>
</tr>`}).join(`
`);return`<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="color-scheme" content="light">
<title>${o(i.title)} \u2014 ${o(b.areaName)}</title>
<style>
/* Printable sheet: it opens as a blob in whatever viewer the OS supplies
(Companion = WebView, dark phones paint a dark default canvas), so the
document states its own light scheme and paints its background. */
:root { color-scheme: light; }
* { box-sizing: border-box; }
body { font: 13px/1.5 -apple-system, Segoe UI, Roboto, sans-serif; color: #1a1a1a; background: #fff; margin: 32px; }
h1 { font-size: 22px; margin: 0 0 2px; }
h2 { font-size: 15px; margin: 24px 0 8px; }
.sub { color: #666; margin: 0 0 16px; }
.kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin: 0 0 8px; }
.kpi { border: 1px solid #ddd; border-radius: 6px; padding: 8px 10px; }
.kpi .k { color: #777; font-size: 10.5px; text-transform: uppercase; letter-spacing: .04em; }
.kpi .v { font-size: 16px; font-weight: 600; }
table { border-collapse: collapse; width: 100%; }
th { text-align: left; font-size: 11px; text-transform: uppercase; letter-spacing: .04em; color: #666; border-bottom: 2px solid #ccc; padding: 6px 8px; }
td { border-bottom: 1px solid #e5e5e5; padding: 6px 8px; vertical-align: top; }
td.num, th.num { text-align: right; white-space: nowrap; }
td.nowrap { white-space: nowrap; }
td.notes { color: #444; white-space: pre-wrap; }
td.bar-cell { width: 40%; vertical-align: middle; }
.bar { display: block; height: 10px; background: #9aa7b8; border-radius: 0 3px 3px 0;
-webkit-print-color-adjust: exact; print-color-adjust: exact; }
tfoot td { border-bottom: none; border-top: 2px solid #ccc; font-weight: 600; }
tr { break-inside: avoid; }
.cap-note { margin-top: 14px; color: #888; font-size: 11px; }
@media print { body { margin: 12mm; } }
</style>
</head>
<body>
<h1>${o(i.title)} \u2014 ${o(b.areaName)}</h1>
<p class="sub">${o(i.period)}: ${o(d)} \xB7 ${o(i.generated)} ${o(t.date(s))}</p>
<div class="kpis">${h}</div>
<h2>${o(i.costPerObject)}</h2>
<table>
<thead><tr>
<th>${o(i.colObject)}</th><th class="num">${o(i.completions)}</th>
<th class="num">${o(i.colCost)}</th><th class="num">${o(i.share)}</th>
</tr></thead>
<tbody>${u||`<tr><td colspan="4">${o(i.none)}</td></tr>`}</tbody>
<tfoot><tr>
<td>${o(i.totalCost)}</td><td class="num">${o(t.number(r.completions))}</td>
<td class="num">${o(t.cost(r.totalCost))}</td><td></td>
</tr></tfoot>
</table>
<h2>${o(i.costPerBucket)}</h2>
<table>
<thead><tr>
<th>${o(i.period)}</th><th class="num">${o(i.completions)}</th>
<th></th><th class="num">${o(i.colCost)}</th>
</tr></thead>
<tbody>${c||`<tr><td colspan="4">${o(i.none)}</td></tr>`}</tbody>
</table>
<h2>${o(i.historyHeading)} (${o(t.number(l.length))})</h2>
<table>
<thead><tr>
<th>${o(i.colDate)}</th><th>${o(i.colObject)}</th><th>${o(i.colTask)}</th>
<th class="num">${o(i.colCost)}</th><th class="num">${o(i.colDuration)}</th><th>${o(i.colNotes)}</th>
</tr></thead>
<tbody>
${m||`<tr><td colspan="6">${o(i.none)}</td></tr>`}
</tbody>
<tfoot><tr>
<td colspan="3">${o(i.totalCost)}</td>
<td class="num">${o(t.cost(r.totalCost))}</td>
<td class="num">${r.totalDuration?o(t.duration(r.totalDuration)):""}</td><td></td>
</tr></tfoot>
</table>
${b.capped?`<p class="cap-note">${o(i.capNote)}</p>`:""}
</body>
</html>`}var Z=25,V=5,tt={completed:C.ok,missed:C.overdue,reset:C.paused,skipped:"var(--secondary-text-color)"},_=class extends H{constructor(){super(...arguments);this.areaKey="";this.objects=[];this.showArchived=!1;this.currencySymbol="";this.userName=()=>null;this._from="";this._to="";this._entryFilter="";this._taskQuery="";this._expanded=!1;this._printing=!1;this._histories=new X(this);this._localeReady=!1;this._filtersFor=null;this._memo=null}createRenderRoot(){return this}get _lang(){return F(this.hass)}willUpdate(t){if(super.willUpdate(t),t.has("areaKey")&&this._filtersFor!==this.areaKey){this._filtersFor=this.areaKey;let s=R();this._from=s.from,this._to=s.to,this._entryFilter="",this._taskQuery="",this._expanded=!1}if(t.has("objects")||t.has("areaKey")||t.has("showArchived")||t.has("hass")&&t.get("hass")===void 0){let s=this._areaObjects().flatMap(l=>l.tasks.map(d=>({entryId:l.entry_id,task:d})));this._histories.sync(this.hass,s)}}updated(t){super.updated(t),!this._localeReady&&this.hass&&(this._localeReady=!0,L(this._lang).then(()=>this.requestUpdate()))}_areaObjects(){return z(this.objects,this.areaKey,this.showArchived).sort((t,s)=>t.object.name.localeCompare(s.object.name))}_entries(t){let s=[this.objects,this.areaKey,this.showArchived,this._histories.version];if(this._memo&&this._memo.deps.every((d,r)=>d===s[r]))return this._memo.entries;let l=Q(t,(d,r)=>this._histories.historyOf(d,r));return this._memo={deps:s,entries:l},l}_figures(){let t=this._areaObjects(),s=this._entries(t),l={from:this._from||null,to:this._to||null},d=U(s,{...l,entryId:this._entryFilter||null,taskQuery:this._taskQuery}),r=this._entryFilter?t.filter(h=>h.entry_id===this._entryFilter):t;return{objects:t,entries:s,filtered:d,totals:Y(d),buckets:G(d,l),byObject:q(d,r)}}_setRange(t){this._from=t.from,this._to=t.to,this._expanded=!1}_openObject(t){this.dispatchEvent(new CustomEvent("open-object",{detail:{entryId:t},bubbles:!0,composed:!0}))}_openTask(t,s){this.dispatchEvent(new CustomEvent("open-task",{detail:{entryId:t,taskId:s},bubbles:!0,composed:!0}))}_bucketLabel(t,s){return t.month==null?String(t.year):`${T(t.month,this._lang,s)} ${t.year}`}_countText(t,s,l){let d=this._lang;return l===1?a(s,d):a(t,d).replace("{n}",g(l,d))}async _print(){if(this._printing)return;let t=K();this._printing=!0;try{await this._histories.settled()}finally{this._printing=!1}let s=this._lang,l=this.currencySymbol,{filtered:d,totals:r,buckets:h,byObject:u}=this._figures(),f={title:a("area_report_title",s),generated:a("report_generated",s),period:a("area_report_period",s),completions:a("area_kpi_completions",s),totalCost:a("total_cost",s),avgCost:a("avg_cost",s),totalTime:a("area_kpi_total_time",s),costPerObject:a("area_cost_per_object",s),costPerBucket:h.unit==="month"?a("area_cost_per_month",s):a("area_cost_per_year",s),share:a("area_cost_share",s),historyHeading:a("area_history_section",s),colDate:a("date",s),colObject:a("object",s),colTask:a("task_name",s),colCost:a("cost",s),colDuration:a("duration",s),colNotes:a("notes_label",s),completedBy:a("completed_by",s),capNote:a("object_history_cap_note",s),none:"\u2014"},c=d.filter(n=>n.type==="completed"),m=d.map(n=>({...n,completedBy:n.completedBy?this.userName(n.completedBy):null,notes:n.notes?B(n.notes,s):null})),e=J({areaName:E(this.areaKey,this.hass?.areas,a("no_area",s)),from:this._from||S(c[c.length-1]?.timestamp)||null,to:this._to||O(),totals:r,byObject:u,buckets:h,entries:m,capped:this._histories.capped},f,{date:n=>N(n,s),cost:n=>v(n,l,s),duration:n=>w(n,s),bucket:n=>this._bucketLabel(n,"long"),share:n=>`${g(n*100,s,0)} %`,number:n=>g(n,s)},new Date().toISOString());P(e,t)}_renderRangeChips(t){let s=this._lang,l=O(),d=R(l),r=u=>this._from===u.from&&this._to===u.to,h=W(t,Number(l.slice(0,4))).slice(0,V);return p`
<div class="filter-chips area-range-chips">
<button class="filter-chip ${r(d)?"active":""}" @click=${()=>this._setRange(R())}>
${a("area_range_12m",s)}
</button>
${h.map(u=>p`
<button class="filter-chip ${r(A(u))?"active":""}" @click=${()=>this._setRange(A(u))}>${u}</button>
`)}
<button class="filter-chip ${!this._from&&!this._to?"active":""}" @click=${()=>this._setRange({from:"",to:""})}>
${a("area_range_all",s)}
</button>
</div>
`}_renderChart(t){let s=this._lang,l=this.currencySymbol,d=t.reduce((c,m)=>Math.max(c,m.cost),0),r=t.reduce((c,m)=>Math.min(c,m.cost),0);if(d<=0&&r>=0)return p`<p class="area-empty">${a("area_no_costs",s)}</p>`;let h=c=>Math.round(c/(d-r)*1e3)/10,u=Math.max(1,Math.ceil(t.length/12)),f=t.map((c,m)=>({b:c,i:m})).filter(({i:c})=>c%u===0);return p`
<div class="area-chart">
<div class="area-chart-max">${v(d,l,s)}</div>
<div class="area-chart-bars">
${r<0?p`<div class="area-chart-zero" style="bottom: ${h(-r)}%"></div>`:y}
${t.map(c=>{let m=`${this._bucketLabel(c,"long")}: ${v(c.cost,l,s)} \xB7 ${a("area_kpi_completions",s)}: ${g(c.completions,s)}`,e=c.cost<0?h(c.cost-r):h(-r);return p`<div class="area-bar" title=${m} aria-label=${m}>
${c.cost!==0?p`<div class="area-bar-fill ${c.cost<0?"credit":""}" style="height: ${h(Math.abs(c.cost))}%${e?`; bottom: ${e}%`:""}"></div>`:y}
</div>`})}
</div>
${r<0?p`<div class="area-chart-min">${v(r,l,s)}</div>`:y}
<div class="area-chart-axis ${u>1?"grouped":""}">
${f.map(({b:c,i:m})=>p`<span class="area-bar-label" style="flex-grow: ${Math.min(u,t.length-m)}">${c.month==null?String(c.year):p`${T(c.month,s,"short")}${c.month===0||m===0?p`<br />${c.year}`:y}`}</span>`)}
</div>
</div>
`}render(){if(!this.hass||!this.areaKey)return y;let t=this._lang,s=this.currencySymbol,{objects:l,entries:d,filtered:r,totals:h,buckets:u,byObject:f}=this._figures(),c=l.reduce((e,n)=>e+n.tasks.filter(x=>this.showArchived||!x.archived).length,0),m=this._expanded?r:r.slice(0,Z);return p`
<div class="detail-section area-view">
<div class="detail-header">
<h2 class="area-title">
<ha-icon .icon=${M(this.areaKey,this.hass.areas)}></ha-icon>
${E(this.areaKey,this.hass.areas,a("no_area",t))}
</h2>
<div class="action-buttons">
<ha-button appearance="plain" class="area-print" .disabled=${this._printing} @click=${()=>this._print()}>
<ha-icon icon="mdi:printer-outline"></ha-icon>
${this._printing?a("loading",t):a("area_report_print",t)}
</ha-button>
</div>
</div>
<p class="meta">
${this._countText("area_object_count","area_object_count_one",l.length)} ·
${this._countText("templates_task_count","templates_task_count_one",c)}
${this._histories.loading?p` · <span class="area-loading">${a("loading",t)}</span>`:y}
</p>
${this._renderRangeChips(d)}
<div class="filter-bar area-filters">
<ms-date-field
kind="date"
clearable
.hass=${this.hass}
.lang=${t}
.label=${a("date_from",t)}
.value=${this._from}
@value-changed=${e=>{this._from=e.detail.value}}
></ms-date-field>
<ms-date-field
kind="date"
clearable
.hass=${this.hass}
.lang=${t}
.label=${a("date_to",t)}
.value=${this._to}
@value-changed=${e=>{this._to=e.detail.value}}
></ms-date-field>
<label class="filter-field">
<span class="filter-label">${a("object",t)}</span>
<select class="area-object-filter" .value=${this._entryFilter}
@change=${e=>{this._entryFilter=e.target.value}}>
<option value="" ?selected=${!this._entryFilter}>${a("all_objects",t)}</option>
${l.map(e=>p`<option value=${e.entry_id} ?selected=${e.entry_id===this._entryFilter}>${e.object.name}</option>`)}
</select>
</label>
<input
type="search"
class="search-input area-task-filter"
aria-label=${a("area_task_filter",t)}
placeholder=${a("area_task_filter",t)}
.value=${this._taskQuery}
@input=${e=>{this._taskQuery=e.target.value}}
/>
</div>
<div class="kpi-bar area-kpis">
<div class="kpi-card">
<div class="kpi-label">${a("area_kpi_completions",t)}</div>
<div class="kpi-value-large area-kpi-completions">${g(h.completions,t)}</div>
</div>
<div class="kpi-card">
<div class="kpi-label">${a("total_cost",t)}</div>
<div class="kpi-value-large area-kpi-cost">${v(h.totalCost,s,t)}</div>
</div>
<div class="kpi-card">
<div class="kpi-label">${a("avg_cost",t)}</div>
<div class="kpi-value-large">${h.avgCost!=null?v(h.avgCost,s,t):"\u2014"}</div>
</div>
<div class="kpi-card">
<div class="kpi-label">${a("area_kpi_total_time",t)}</div>
<div class="kpi-value-large">${h.totalDuration?w(h.totalDuration,t):"\u2014"}</div>
</div>
</div>
<h3>${u.unit==="month"?a("area_cost_per_month",t):a("area_cost_per_year",t)}</h3>
${this._renderChart(u.buckets)}
<h3>${a("area_cost_per_object",t)}</h3>
<div class="objects-table-wrap">
<table class="objects-table area-object-table">
<thead>
<tr>
<th>${a("object",t)}</th>
<th class="num">${a("area_kpi_completions",t)}</th>
<th class="num">${a("cost",t)}</th>
<th class="num">${a("area_cost_share",t)}</th>
</tr>
</thead>
<tbody>
${f.map(e=>p`
<tr class="objects-table-row" @click=${()=>this._openObject(e.entryId)}>
<td><span class="objects-table-name">${e.objectName}</span></td>
<td class="num">${g(e.completions,t)}</td>
<td class="num">${v(e.cost,s,t)}</td>
<td class="num">
<span class="area-share"><span class="area-share-fill" style="width: ${Math.round(e.share*100)}%"></span></span>
${g(e.share*100,t,0)} %
</td>
</tr>
`)}
</tbody>
</table>
</div>
<h3>
${a("area_history_section",t)}
<span class="area-count">${g(r.length,t)}</span>
</h3>
${r.length===0?p`<p class="area-empty">${a("object_history_empty",t)}</p>`:p`
<div class="objects-table-wrap">
<table class="objects-table area-history-table">
<thead>
<tr>
<th>${a("date",t)}</th>
<th></th>
<th>${a("object",t)}</th>
<th>${a("task_name",t)}</th>
<th class="num">${a("cost",t)}</th>
<th class="num">${a("duration",t)}</th>
<th>${a("notes_label",t)}</th>
</tr>
</thead>
<tbody>
${m.map(e=>{let n=e.notes?B(e.notes,t):"";return p`
<tr class="objects-table-row area-history-row" @click=${()=>this._openTask(e.entryId,e.taskId)}>
<td title=${I(e.timestamp,t)}>${N(e.timestamp,t)}</td>
<td>
<span class="area-entry-type" style="color: ${tt[e.type]??"inherit"}">
<ha-icon .icon=${D[e.type]||"mdi:circle"}></ha-icon>${a(e.type,t)}
</span>
</td>
<td>
<button class="area-object-link" @click=${x=>{x.stopPropagation(),this._openObject(e.entryId)}}>
${e.objectName}
</button>
</td>
<td><button class="area-task-link">${e.taskName}${e.phaseName?` \xB7 ${e.phaseName}`:""}</button></td>
<td class="num">${e.cost!=null?v(e.cost,s,t):"\u2014"}</td>
<td class="num">${e.duration!=null?w(e.duration,t):"\u2014"}</td>
<td class="oc-notes" title=${n}>${n||"\u2014"}</td>
</tr>
`})}
</tbody>
</table>
</div>
${r.length>m.length?p`<ha-button appearance="plain" class="area-more" @click=${()=>{this._expanded=!0}}>
${a("show_all",t)} (${g(r.length,t)})
</ha-button>`:y}
`}
${this._histories.capped?p`<p class="area-cap-note">${a("object_history_cap_note",t)}</p>`:y}
</div>
`}};$([k({attribute:!1})],_.prototype,"hass",2),$([k()],_.prototype,"areaKey",2),$([k({attribute:!1})],_.prototype,"objects",2),$([k({type:Boolean})],_.prototype,"showArchived",2),$([k()],_.prototype,"currencySymbol",2),$([k({attribute:!1})],_.prototype,"userName",2),$([j()],_.prototype,"_from",2),$([j()],_.prototype,"_to",2),$([j()],_.prototype,"_entryFilter",2),$([j()],_.prototype,"_taskQuery",2),$([j()],_.prototype,"_expanded",2),$([j()],_.prototype,"_printing",2);customElements.get("maintenance-area-view")||customElements.define("maintenance-area-view",_);export{_ as MaintenanceAreaView};
@@ -1,5 +1,5 @@
/*! maintenance_supporter frontend 2.103.0 */
import{a as X}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-H44SM3WA.js";import{a as o}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-274MLN75.js";import{f as E,g as M,h as z,i as Q,j as U,k as Y,l as q,m as G,n as R,o as A,p as W}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-FH35SLMS.js";import{a as B}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-KZ3OKTLK.js";import{e as P,f as K}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-DR2AFVDA.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-EOMYEBQ4.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-MMIPM7EE.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-R77C34J4.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-IRRUA3DS.js";import{C as S,I as F,L as g,M as v,P as N,Q as I,V as w,X as T,a as $,c as p,f as y,h as H,m as k,n as j,o as C,r as D,t as a,v as L,z as O}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-OWNF6K2Z.js";function J(b,i,t,s){let l=b.entries.filter(e=>e.type==="completed").sort((e,n)=>e.ts-n.ts),d=`${b.from?t.date(b.from):"\u2026"} \u2013 ${b.to?t.date(b.to):"\u2026"}`,r=b.totals,h=[[i.completions,t.number(r.completions)],[i.totalCost,t.cost(r.totalCost)],[i.avgCost,r.avgCost!=null?t.cost(r.avgCost):i.none],[i.totalTime,r.totalDuration?t.duration(r.totalDuration):i.none]].map(([e,n])=>`<div class="kpi"><div class="k">${o(e)}</div><div class="v">${o(n)}</div></div>`).join(""),u=b.byObject.map(e=>`<tr>
/*! maintenance_supporter frontend 2.103.4 */
import{a as X}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-KVV6LE36.js";import{a as o}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-CEMSNJOA.js";import{f as E,g as M,h as z,i as Q,j as U,k as Y,l as q,m as G,n as R,o as A,p as W}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-XGDOC3LZ.js";import{a as B}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-4L3M4JJ2.js";import{e as P,f as K}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-KWB4V7O6.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-5I66TXQ6.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-FWV5U3XL.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-IBP22ZP4.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-OW3M7YB7.js";import{C as S,I as F,L as g,M as v,P as N,Q as I,V as w,X as T,a as $,c as p,f as y,h as H,m as k,n as j,o as C,r as D,t as a,v as L,z as O}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-VXQI3S2Y.js";function J(b,i,t,s){let l=b.entries.filter(e=>e.type==="completed").sort((e,n)=>e.ts-n.ts),d=`${b.from?t.date(b.from):"\u2026"} \u2013 ${b.to?t.date(b.to):"\u2026"}`,r=b.totals,h=[[i.completions,t.number(r.completions)],[i.totalCost,t.cost(r.totalCost)],[i.avgCost,r.avgCost!=null?t.cost(r.avgCost):i.none],[i.totalTime,r.totalDuration?t.duration(r.totalDuration):i.none]].map(([e,n])=>`<div class="kpi"><div class="k">${o(e)}</div><div class="v">${o(n)}</div></div>`).join(""),u=b.byObject.map(e=>`<tr>
<td>${o(e.objectName)}</td>
<td class="num">${o(t.number(e.completions))}</td>
<td class="num">${o(t.cost(e.cost))}</td>
@@ -1,54 +0,0 @@
/*! maintenance_supporter frontend 2.102.0 */
import{a as O}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-VWOWFA6G.js";import{q as j,r as E,s as L,t as R,u as C}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-7W4GNWDJ.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-ZXKKAVZ3.js";import{a as _,b as S,c as A}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-Y6A3V724.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-7SOUIRZ7.js";import{H as g,K as k,L as v,O as w,a as l,c as a,f as m,h as y,l as p,m as b,s as r,u as $,y as f}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-4CECVKQ7.js";var q=[{key:"name",label:"area",num:!1},{key:"objects",label:"objects",num:!0},{key:"tasks",label:"tasks",num:!0},{key:"overdue",label:"overdue",num:!0},{key:"due_soon",label:"due_soon",num:!0},{key:"cost_year",label:"area_cost_year",num:!0},{key:"cost_total",label:"total_cost",num:!0},{key:"last",label:"last_performed",num:!1}],i=class extends y{constructor(){super(...arguments);this.objects=[];this.showArchived=!1;this.currencySymbol="";this._query="";this._sort=E(S(_.areaSort));this._histories=new O(this);this._localeReady=!1;this._memo=null}createRenderRoot(){return this}get _lang(){return g(this.hass)}willUpdate(t){if(super.willUpdate(t),t.has("objects")||t.has("showArchived")||t.has("hass")&&t.get("hass")===void 0){let o=this.objects.filter(s=>this.showArchived||!s.object.archived).flatMap(s=>s.tasks.map(n=>({entryId:s.entry_id,task:n})));this._histories.sync(this.hass,o)}}updated(t){super.updated(t),!this._localeReady&&this.hass&&(this._localeReady=!0,$(this._lang).then(()=>this.requestUpdate()))}_rows(t){let o=this._lang,s=[this.objects,this.showArchived,this._histories.version,this.hass?.areas,o,t];if(this._memo&&this._memo.deps.every((c,h)=>c===s[h]))return this._memo.rows;let n=j(this.objects,{showArchived:this.showArchived,areas:this.hass?.areas,noAreaLabel:r("no_area",o),year:t,historyOf:(c,h)=>this._histories.historyOf(c,h)});return this._memo={deps:s,rows:n},n}_setSort(t){this._sort=L(this._sort,t),A(_.areaSort,`${this._sort.key}:${this._sort.dir}`)}_open(t){this.dispatchEvent(new CustomEvent("open-area",{detail:{areaKey:t},bubbles:!0,composed:!0}))}_toggleArchived(){this.dispatchEvent(new CustomEvent("archived-toggle",{bubbles:!0,composed:!0}))}_headerLabel(t,o,s){return t==="area_cost_year"?r("area_cost_year",s).replace("{year}",String(o)):r(t,s)}render(){if(!this.hass)return m;let t=this._lang,o=Number(f().slice(0,4)),s=this.currencySymbol,n=this._rows(o),c=R(C(n,this._query),this._sort),h=this.objects.filter(e=>e.object.archived).length,d=e=>k(e,t);return a`
<div class="filter-bar area-filter-bar">
<input
type="search"
class="search-input"
aria-label=${r("areas_filter",t)}
placeholder=${r("areas_filter",t)}
.value=${this._query}
@input=${e=>{this._query=e.target.value}}
/>
${this._histories.loading?a`<span class="area-loading">${r("loading",t)}</span>`:m}
${h>0?a`<ha-button class="archived-toggle ${this.showArchived?"active":""}" @click=${()=>this._toggleArchived()}>
<ha-icon icon="mdi:archive-outline"></ha-icon>
${this.showArchived?r("hide_archived",t):`${r("show_archived",t)} (${h})`}
</ha-button>`:m}
</div>
${n.length===0?a`<div class="empty-state">${r("no_objects",t)}</div>`:c.length===0?a`<div class="empty-state">${r("areas_filter_none",t)}</div>`:a`
<div class="objects-table-wrap">
<table class="objects-table areas-table">
<thead>
<tr>
${q.map(e=>{let u=this._sort.key===e.key;return a`<th class=${e.num?"num":""}
aria-sort=${u?this._sort.dir==="asc"?"ascending":"descending":"none"}>
<button class="area-sort ${u?"active":""}" title=${r("sort_label",t)} @click=${()=>this._setSort(e.key)}>
${this._headerLabel(e.label,o,t)}
${u?a`<ha-icon icon=${this._sort.dir==="asc"?"mdi:arrow-up":"mdi:arrow-down"}></ha-icon>`:m}
</button>
</th>`})}
</tr>
</thead>
<tbody>
${c.map(e=>a`
<tr class="objects-table-row" tabindex="0"
@click=${()=>this._open(e.key)}
@keydown=${u=>{u.key==="Enter"&&this._open(e.key)}}>
<td>
<ha-icon class="area-row-icon" .icon=${e.icon}></ha-icon>
<span class="objects-table-name">${e.name}</span>
</td>
<td class="num">${d(e.objects)}</td>
<td class="num">${d(e.tasks)}</td>
<td class="num">${e.overdue?a`<span class="area-count overdue">${d(e.overdue)}</span>`:d(0)}</td>
<td class="num">${e.dueSoon?a`<span class="area-count due-soon">${d(e.dueSoon)}</span>`:d(0)}</td>
<td class="num">${v(e.costYear,s,t)}</td>
<td class="num">${v(e.costTotal,s,t)}</td>
<td>${e.lastCompletion?w(e.lastCompletion,t):"\u2014"}</td>
</tr>
`)}
</tbody>
</table>
</div>
`}
`}};l([p({attribute:!1})],i.prototype,"hass",2),l([p({attribute:!1})],i.prototype,"objects",2),l([p({type:Boolean})],i.prototype,"showArchived",2),l([p()],i.prototype,"currencySymbol",2),l([b()],i.prototype,"_query",2),l([b()],i.prototype,"_sort",2);customElements.get("maintenance-areas-view")||customElements.define("maintenance-areas-view",i);export{i as MaintenanceAreasView};
@@ -1,5 +1,5 @@
/*! maintenance_supporter frontend 2.103.0 */
import{a as O}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-H44SM3WA.js";import{q as j,r as E,s as L,t as R,u as C}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-FH35SLMS.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-EOMYEBQ4.js";import{a as _,b as S,c as A}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-VZUMUVFP.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-MMIPM7EE.js";import{I as g,L as k,M as v,P as w,a as l,c as a,f as m,h as y,m as p,n as b,t as r,v as $,z as f}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-OWNF6K2Z.js";var q=[{key:"name",label:"area",num:!1},{key:"objects",label:"objects",num:!0},{key:"tasks",label:"tasks",num:!0},{key:"overdue",label:"overdue",num:!0},{key:"due_soon",label:"due_soon",num:!0},{key:"cost_year",label:"area_cost_year",num:!0},{key:"cost_total",label:"total_cost",num:!0},{key:"last",label:"last_performed",num:!1}],i=class extends y{constructor(){super(...arguments);this.objects=[];this.showArchived=!1;this.currencySymbol="";this._query="";this._sort=E(S(_.areaSort));this._histories=new O(this);this._localeReady=!1;this._memo=null}createRenderRoot(){return this}get _lang(){return g(this.hass)}willUpdate(t){if(super.willUpdate(t),t.has("objects")||t.has("showArchived")||t.has("hass")&&t.get("hass")===void 0){let o=this.objects.filter(s=>this.showArchived||!s.object.archived).flatMap(s=>s.tasks.map(n=>({entryId:s.entry_id,task:n})));this._histories.sync(this.hass,o)}}updated(t){super.updated(t),!this._localeReady&&this.hass&&(this._localeReady=!0,$(this._lang).then(()=>this.requestUpdate()))}_rows(t){let o=this._lang,s=[this.objects,this.showArchived,this._histories.version,this.hass?.areas,o,t];if(this._memo&&this._memo.deps.every((c,h)=>c===s[h]))return this._memo.rows;let n=j(this.objects,{showArchived:this.showArchived,areas:this.hass?.areas,noAreaLabel:r("no_area",o),year:t,historyOf:(c,h)=>this._histories.historyOf(c,h)});return this._memo={deps:s,rows:n},n}_setSort(t){this._sort=L(this._sort,t),A(_.areaSort,`${this._sort.key}:${this._sort.dir}`)}_open(t){this.dispatchEvent(new CustomEvent("open-area",{detail:{areaKey:t},bubbles:!0,composed:!0}))}_toggleArchived(){this.dispatchEvent(new CustomEvent("archived-toggle",{bubbles:!0,composed:!0}))}_headerLabel(t,o,s){return t==="area_cost_year"?r("area_cost_year",s).replace("{year}",String(o)):r(t,s)}render(){if(!this.hass)return m;let t=this._lang,o=Number(f().slice(0,4)),s=this.currencySymbol,n=this._rows(o),c=R(C(n,this._query),this._sort),h=this.objects.filter(e=>e.object.archived).length,d=e=>k(e,t);return a`
/*! maintenance_supporter frontend 2.103.4 */
import{a as O}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-KVV6LE36.js";import{q as j,r as E,s as L,t as R,u as C}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-XGDOC3LZ.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-5I66TXQ6.js";import{a as _,b as S,c as A}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-3RPKQDNU.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-FWV5U3XL.js";import{I as g,L as k,M as v,P as w,a as l,c as a,f as m,h as y,m as p,n as b,t as r,v as $,z as f}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-VXQI3S2Y.js";var q=[{key:"name",label:"area",num:!1},{key:"objects",label:"objects",num:!0},{key:"tasks",label:"tasks",num:!0},{key:"overdue",label:"overdue",num:!0},{key:"due_soon",label:"due_soon",num:!0},{key:"cost_year",label:"area_cost_year",num:!0},{key:"cost_total",label:"total_cost",num:!0},{key:"last",label:"last_performed",num:!1}],i=class extends y{constructor(){super(...arguments);this.objects=[];this.showArchived=!1;this.currencySymbol="";this._query="";this._sort=E(S(_.areaSort));this._histories=new O(this);this._localeReady=!1;this._memo=null}createRenderRoot(){return this}get _lang(){return g(this.hass)}willUpdate(t){if(super.willUpdate(t),t.has("objects")||t.has("showArchived")||t.has("hass")&&t.get("hass")===void 0){let o=this.objects.filter(s=>this.showArchived||!s.object.archived).flatMap(s=>s.tasks.map(n=>({entryId:s.entry_id,task:n})));this._histories.sync(this.hass,o)}}updated(t){super.updated(t),!this._localeReady&&this.hass&&(this._localeReady=!0,$(this._lang).then(()=>this.requestUpdate()))}_rows(t){let o=this._lang,s=[this.objects,this.showArchived,this._histories.version,this.hass?.areas,o,t];if(this._memo&&this._memo.deps.every((c,h)=>c===s[h]))return this._memo.rows;let n=j(this.objects,{showArchived:this.showArchived,areas:this.hass?.areas,noAreaLabel:r("no_area",o),year:t,historyOf:(c,h)=>this._histories.historyOf(c,h)});return this._memo={deps:s,rows:n},n}_setSort(t){this._sort=L(this._sort,t),A(_.areaSort,`${this._sort.key}:${this._sort.dir}`)}_open(t){this.dispatchEvent(new CustomEvent("open-area",{detail:{areaKey:t},bubbles:!0,composed:!0}))}_toggleArchived(){this.dispatchEvent(new CustomEvent("archived-toggle",{bubbles:!0,composed:!0}))}_headerLabel(t,o,s){return t==="area_cost_year"?r("area_cost_year",s).replace("{year}",String(o)):r(t,s)}render(){if(!this.hass)return m;let t=this._lang,o=Number(f().slice(0,4)),s=this.currencySymbol,n=this._rows(o),c=R(C(n,this._query),this._sort),h=this.objects.filter(e=>e.object.archived).length,d=e=>k(e,t);return a`
<div class="filter-bar area-filter-bar">
<input
type="search"
@@ -1,113 +0,0 @@
/*! maintenance_supporter frontend 2.102.0 */
import"/maintenance_supporter_panelfiles/panel-chunks/chunk-I7RCCBTV.js";import{H as u,a as n,b as _,c as o,f as p,h as c,l as g,m as a,s}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-4CECVKQ7.js";var d=["round_robin","least_completed","random"],v=["low","normal","high"],m=365,i=class extends c{constructor(){super(...arguments);this._open=!1;this._mode="assign";this._count=0;this._users=[];this._present=[];this._assignKind="one";this._person="";this._pool=new Set;this._rotation=d[0];this._addText="";this._remove=new Set;this._setWarning=!1;this._warning=7;this._setPriority=!1;this._priority="normal";this._setMute=!1;this._mute=!1;this._resolve=null}get _lang(){return u(this.hass)}open(t,e,r){return this._resolve?.(null),this._mode=t,this._count=e.length,this._users=[...r].sort((l,h)=>l.name.localeCompare(h.name)),this._present=[...new Set(e.flatMap(l=>l.labels||[]))].sort((l,h)=>l.localeCompare(h)),this._assignKind="one",this._person="",this._pool=new Set,this._rotation=d[0],this._addText="",this._remove=new Set,this._setWarning=!1,this._warning=7,this._setPriority=!1,this._priority="normal",this._setMute=!1,this._mute=!1,this._open=!0,new Promise(l=>{this._resolve=l})}_finish(t){this._open=!1;let e=this._resolve;this._resolve=null,e?.(t)}changes(){if(this._mode==="assign"){if(this._assignKind==="one")return{responsible_user_id:this._person||null,assignee_pool:null,rotation_strategy:null};let e=this._users.map(r=>r.id).filter(r=>this._pool.has(r));return e.length<2?null:{assignee_pool:e,rotation_strategy:this._rotation,responsible_user_id:null}}if(this._mode==="labels"){let e=[...new Set(this._addText.split(",").map(l=>l.trim()).filter(Boolean))],r=[...this._remove];return e.length||r.length?{labels_add:e,labels_remove:r}:null}let t={};return this._setWarning&&(t.warning_days=this._warning),this._setPriority&&(t.priority=this._priority),this._setMute&&(t.notify_enabled=!this._mute),Object.keys(t).length?t:null}_toggle(t,e){let r=new Set(t);return r.has(e)?r.delete(e):r.add(e),r}_renderAssign(t){return o`
<div class="hint">${s("bulk_assign_hint",t)}</div>
<label class="choice">
<input type="radio" name="kind" .checked=${this._assignKind==="one"} @change=${()=>this._assignKind="one"} />
${s("bulk_assign_one",t)}
</label>
${this._assignKind==="one"?o`<select class="person" .value=${this._person} @change=${e=>this._person=e.target.value}>
<option value="" ?selected=${!this._person}>${s("unassigned",t)}</option>
${this._users.map(e=>o`<option value=${e.id} ?selected=${this._person===e.id}>${e.name}</option>`)}
</select>`:p}
<label class="choice">
<input type="radio" name="kind" .checked=${this._assignKind==="rotate"} @change=${()=>this._assignKind="rotate"} />
${s("bulk_assign_rotate",t)}
</label>
${this._assignKind==="rotate"?o`<div class="pool">
${this._users.map(e=>o`<label class="row">
<input type="checkbox" .checked=${this._pool.has(e.id)} @change=${()=>this._pool=this._toggle(this._pool,e.id)} />
${e.name}
</label>`)}
</div>
<label class="field">
<span>${s("rotation_strategy",t)}</span>
<select class="rotation" .value=${this._rotation} @change=${e=>this._rotation=e.target.value}>
${d.map(e=>o`<option value=${e} ?selected=${this._rotation===e}>${s(`rotation_${e}`,t)}</option>`)}
</select>
</label>
${this._pool.size<2?o`<div class="hint">${s("bulk_rotation_min_two",t)}</div>`:p}`:p}
`}_renderLabels(t){return o`
<ms-textfield
class="labels-add"
label=${s("bulk_labels_add",t)}
.helper=${s("labels_help",t)}
.value=${this._addText}
@input=${e=>this._addText=e.target.value}
></ms-textfield>
${this._present.length?o`<div class="section-title">${s("bulk_labels_remove",t)}</div>
<div class="pool">
${this._present.map(e=>o`<label class="row">
<input type="checkbox" .checked=${this._remove.has(e)} @change=${()=>this._remove=this._toggle(this._remove,e)} />
${e}
</label>`)}
</div>`:p}
`}_renderEdit(t){return o`
<div class="hint">${s("bulk_edit_hint",t)}</div>
<div class="row edit-row">
<input type="checkbox" class="set-warning" .checked=${this._setWarning} @change=${e=>this._setWarning=e.target.checked} />
<span class="grow">${s("warning_days",t)}</span>
<input type="number" class="warning" min="0" max=${m} .value=${String(this._warning)} ?disabled=${!this._setWarning}
@change=${e=>{let r=parseInt(e.target.value,10);this._warning=Number.isInteger(r)?Math.min(Math.max(r,0),m):this._warning}} />
</div>
<div class="row edit-row">
<input type="checkbox" class="set-priority" .checked=${this._setPriority} @change=${e=>this._setPriority=e.target.checked} />
<span class="grow">${s("priority",t)}</span>
<select class="priority" .value=${this._priority} ?disabled=${!this._setPriority} @change=${e=>this._priority=e.target.value}>
${v.map(e=>o`<option value=${e} ?selected=${this._priority===e}>${s(`priority_${e}`,t)}</option>`)}
</select>
</div>
<div class="row edit-row">
<input type="checkbox" class="set-mute" .checked=${this._setMute} @change=${e=>this._setMute=e.target.checked} />
<span class="grow">${s("bulk_no_notifications",t)}</span>
<input type="checkbox" class="mute" .checked=${this._mute} ?disabled=${!this._setMute} @change=${e=>this._mute=e.target.checked} />
</div>
`}render(){if(!this._open)return o``;let t=this._lang,e=this._mode==="assign"?s("bulk_assign",t):this._mode==="labels"?s("labels",t):s("edit",t),r=this.changes()!==null;return o`
<ha-dialog open @closed=${()=>this._finish(null)}>
<div class="dialog-title">${e}</div>
<div class="content">
<div class="count">${s("bulk_n_selected",t).replace("{n}",String(this._count))}</div>
${this._mode==="assign"?this._renderAssign(t):this._mode==="labels"?this._renderLabels(t):this._renderEdit(t)}
</div>
<div class="dialog-actions">
<ha-button appearance="plain" @click=${()=>this._finish(null)}>${s("cancel",t)}</ha-button>
<ha-button class="apply" .disabled=${!r} @click=${()=>this._finish(this.changes())}>${s("save",t)}</ha-button>
</div>
</ha-dialog>
`}};i.styles=_`
.dialog-title { font-size: 18px; font-weight: 500; padding-bottom: 12px; }
.content {
display: flex;
flex-direction: column;
gap: 10px;
min-width: min(360px, calc(100vw - 24px));
max-width: 520px;
max-height: 60vh;
overflow-y: auto;
}
@media (max-width: 600px) {
.content { min-width: 0; max-width: none; max-height: none; }
}
.count, .hint { color: var(--secondary-text-color); font-size: 13px; }
.section-title {
font-size: 14px;
font-weight: 500;
padding-bottom: 4px;
border-bottom: 1px solid var(--divider-color);
}
.choice, .row { display: flex; align-items: center; gap: 8px; font-size: 14px; cursor: pointer; }
.pool { display: flex; flex-direction: column; gap: 4px; padding-left: 26px; }
.field { display: flex; align-items: center; gap: 8px; justify-content: space-between; font-size: 14px; }
.grow { flex: 1; min-width: 0; }
select, input[type="number"] {
font: inherit;
padding: 4px 6px;
border: 1px solid var(--divider-color);
border-radius: 4px;
background: var(--card-background-color, #fff);
color: var(--primary-text-color);
max-width: 100%;
}
input[type="number"] { width: 72px; }
select.person { margin-left: 26px; }
.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; padding-top: 16px; }
`,n([g({attribute:!1})],i.prototype,"hass",2),n([a()],i.prototype,"_open",2),n([a()],i.prototype,"_mode",2),n([a()],i.prototype,"_count",2),n([a()],i.prototype,"_users",2),n([a()],i.prototype,"_present",2),n([a()],i.prototype,"_assignKind",2),n([a()],i.prototype,"_person",2),n([a()],i.prototype,"_pool",2),n([a()],i.prototype,"_rotation",2),n([a()],i.prototype,"_addText",2),n([a()],i.prototype,"_remove",2),n([a()],i.prototype,"_setWarning",2),n([a()],i.prototype,"_warning",2),n([a()],i.prototype,"_setPriority",2),n([a()],i.prototype,"_priority",2),n([a()],i.prototype,"_setMute",2),n([a()],i.prototype,"_mute",2);customElements.get("maintenance-bulk-edit-dialog")||customElements.define("maintenance-bulk-edit-dialog",i);export{i as MaintenanceBulkEditDialog};
@@ -1,5 +1,5 @@
/*! maintenance_supporter frontend 2.103.0 */
import"/maintenance_supporter_panelfiles/panel-chunks/chunk-5TWB2KD3.js";import{I as u,a as n,b as _,c as o,f as p,h as c,m as g,n as a,t as s}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-OWNF6K2Z.js";var d=["round_robin","least_completed","random"],v=["low","normal","high"],m=365,i=class extends c{constructor(){super(...arguments);this._open=!1;this._mode="assign";this._count=0;this._users=[];this._present=[];this._assignKind="one";this._person="";this._pool=new Set;this._rotation=d[0];this._addText="";this._remove=new Set;this._setWarning=!1;this._warning=7;this._setPriority=!1;this._priority="normal";this._setMute=!1;this._mute=!1;this._resolve=null}get _lang(){return u(this.hass)}open(t,e,r){return this._resolve?.(null),this._mode=t,this._count=e.length,this._users=[...r].sort((l,h)=>l.name.localeCompare(h.name)),this._present=[...new Set(e.flatMap(l=>l.labels||[]))].sort((l,h)=>l.localeCompare(h)),this._assignKind="one",this._person="",this._pool=new Set,this._rotation=d[0],this._addText="",this._remove=new Set,this._setWarning=!1,this._warning=7,this._setPriority=!1,this._priority="normal",this._setMute=!1,this._mute=!1,this._open=!0,new Promise(l=>{this._resolve=l})}_finish(t){this._open=!1;let e=this._resolve;this._resolve=null,e?.(t)}changes(){if(this._mode==="assign"){if(this._assignKind==="one")return{responsible_user_id:this._person||null,assignee_pool:null,rotation_strategy:null};let e=this._users.map(r=>r.id).filter(r=>this._pool.has(r));return e.length<2?null:{assignee_pool:e,rotation_strategy:this._rotation,responsible_user_id:null}}if(this._mode==="labels"){let e=[...new Set(this._addText.split(",").map(l=>l.trim()).filter(Boolean))],r=[...this._remove];return e.length||r.length?{labels_add:e,labels_remove:r}:null}let t={};return this._setWarning&&(t.warning_days=this._warning),this._setPriority&&(t.priority=this._priority),this._setMute&&(t.notify_enabled=!this._mute),Object.keys(t).length?t:null}_toggle(t,e){let r=new Set(t);return r.has(e)?r.delete(e):r.add(e),r}_renderAssign(t){return o`
/*! maintenance_supporter frontend 2.103.4 */
import"/maintenance_supporter_panelfiles/panel-chunks/chunk-7UBCSMVS.js";import{I as u,a as n,b as _,c as o,f as p,h as c,m as g,n as a,t as s}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-VXQI3S2Y.js";var d=["round_robin","least_completed","random"],v=["low","normal","high"],m=365,i=class extends c{constructor(){super(...arguments);this._open=!1;this._mode="assign";this._count=0;this._users=[];this._present=[];this._assignKind="one";this._person="";this._pool=new Set;this._rotation=d[0];this._addText="";this._remove=new Set;this._setWarning=!1;this._warning=7;this._setPriority=!1;this._priority="normal";this._setMute=!1;this._mute=!1;this._resolve=null}get _lang(){return u(this.hass)}open(t,e,r){return this._resolve?.(null),this._mode=t,this._count=e.length,this._users=[...r].sort((l,h)=>l.name.localeCompare(h.name)),this._present=[...new Set(e.flatMap(l=>l.labels||[]))].sort((l,h)=>l.localeCompare(h)),this._assignKind="one",this._person="",this._pool=new Set,this._rotation=d[0],this._addText="",this._remove=new Set,this._setWarning=!1,this._warning=7,this._setPriority=!1,this._priority="normal",this._setMute=!1,this._mute=!1,this._open=!0,new Promise(l=>{this._resolve=l})}_finish(t){this._open=!1;let e=this._resolve;this._resolve=null,e?.(t)}changes(){if(this._mode==="assign"){if(this._assignKind==="one")return{responsible_user_id:this._person||null,assignee_pool:null,rotation_strategy:null};let e=this._users.map(r=>r.id).filter(r=>this._pool.has(r));return e.length<2?null:{assignee_pool:e,rotation_strategy:this._rotation,responsible_user_id:null}}if(this._mode==="labels"){let e=[...new Set(this._addText.split(",").map(l=>l.trim()).filter(Boolean))],r=[...this._remove];return e.length||r.length?{labels_add:e,labels_remove:r}:null}let t={};return this._setWarning&&(t.warning_days=this._warning),this._setPriority&&(t.priority=this._priority),this._setMute&&(t.notify_enabled=!this._mute),Object.keys(t).length?t:null}_toggle(t,e){let r=new Set(t);return r.has(e)?r.delete(e):r.add(e),r}_renderAssign(t){return o`
<div class="hint">${s("bulk_assign_hint",t)}</div>
<label class="choice">
<input type="radio" name="kind" .checked=${this._assignKind==="one"} @change=${()=>this._assignKind="one"} />
@@ -1,2 +1,2 @@
/*! maintenance_supporter frontend 2.103.0 */
/*! maintenance_supporter frontend 2.103.4 */
var r={objectSections:"msp-object-sections",printOptions:"msp-print-options",batteryRosterOpen:"msp-bf-roster-open",docSort:"msp-doc-sort",cameraLabel:"msp-camera-label",overviewTab:"msp-overview-tab",collapsedSections:"msp-collapsed-sections",chartRange:"msp-chart-range",chartHideOutliers:"msp-chart-hide-outliers",taskSort:"maintenance_supporter_sort",objectSort:"maintenance_supporter_object_sort",groupBy:"maintenance_supporter_groupby",objectView:"maintenance_supporter_object_view",objectsCache:"msp-objects-cache",gettingStartedDismissed:"msp-gs-dismissed",batteryRosterSort:"ms_bf_roster_sort",areaSort:"msp-area-sort"};function s(t){try{return localStorage.getItem(t)}catch{return null}}function o(t,e){try{localStorage.setItem(t,e)}catch{}}export{r as a,s as b,o as c};
File diff suppressed because one or more lines are too long
@@ -1,2 +1,2 @@
/*! maintenance_supporter frontend 2.103.0 */
import{P as n,t as o}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-OWNF6K2Z.js";var l={"Completed from dashboard button":"hist_note_button","Completed from the To-do list":"hist_note_todo","Completed by voice":"hist_note_voice","Completed from the notification":"hist_note_notification","Completed via NFC tag":"hist_note_nfc","Completed from the shopping list":"hist_note_shopping_list","Completed from a mirrored to-do list":"hist_note_todo_mirror","Skipped from notification":"hist_note_skipped_notification","Skipped from dashboard button":"hist_note_skipped_button","Sensor trigger activated":"hist_note_sensor_triggered","Initial value set during task creation":"hist_note_initial"},m=[[/^Auto-completed: sensor recovered \((.+)\)$/,"hist_note_auto_recovered",t=>({value:t[1]})],[/^Reset to (\d{4}-\d{2}-\d{2})$/,"hist_note_reset",(t,e)=>({date:n(t[1],e)})],[/^Trigger entity replaced: (.+) → (.+)$/,"hist_note_trigger_replaced",t=>({old:t[1],new:t[2]})],[/^Sensor trigger removed \(entity was: (.+)\)\. Schedule converted to (\w+)\.$/,"hist_note_trigger_removed",(t,e)=>({entity:t[1],schedule:o(t[2],e)})],[/^Entity (.+) removed from compound trigger; (\d+) conditions remain\.$/,"hist_note_compound_removed",t=>({entity:t[1],count:t[2]})]];function h(t,e){if(!t)return"";let i=l[t.trim()];if(i)return o(i,e);for(let[s,d,_]of m){let r=t.trim().match(s);if(r){let c=_(r,e);return o(d,e).replace(/\{(\w+)\}/g,(a,g)=>c[g]??a)}}return t}export{h as a};
/*! maintenance_supporter frontend 2.103.4 */
import{P as n,t as o}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-VXQI3S2Y.js";var l={"Completed from dashboard button":"hist_note_button","Completed from the To-do list":"hist_note_todo","Completed by voice":"hist_note_voice","Completed from the notification":"hist_note_notification","Completed via NFC tag":"hist_note_nfc","Completed from the shopping list":"hist_note_shopping_list","Completed from a mirrored to-do list":"hist_note_todo_mirror","Skipped from notification":"hist_note_skipped_notification","Skipped from dashboard button":"hist_note_skipped_button","Sensor trigger activated":"hist_note_sensor_triggered","Initial value set during task creation":"hist_note_initial"},m=[[/^Auto-completed: sensor recovered \((.+)\)$/,"hist_note_auto_recovered",t=>({value:t[1]})],[/^Reset to (\d{4}-\d{2}-\d{2})$/,"hist_note_reset",(t,e)=>({date:n(t[1],e)})],[/^Trigger entity replaced: (.+) → (.+)$/,"hist_note_trigger_replaced",t=>({old:t[1],new:t[2]})],[/^Sensor trigger removed \(entity was: (.+)\)\. Schedule converted to (\w+)\.$/,"hist_note_trigger_removed",(t,e)=>({entity:t[1],schedule:o(t[2],e)})],[/^Entity (.+) removed from compound trigger; (\d+) conditions remain\.$/,"hist_note_compound_removed",t=>({entity:t[1],count:t[2]})]];function h(t,e){if(!t)return"";let i=l[t.trim()];if(i)return o(i,e);for(let[s,d,_]of m){let r=t.trim().match(s);if(r){let c=_(r,e);return o(d,e).replace(/\{(\w+)\}/g,(a,g)=>c[g]??a)}}return t}export{h as a};
@@ -1,5 +1,5 @@
/*! maintenance_supporter frontend 2.103.0 */
import{a as q}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-IRRUA3DS.js";import{I as k,a,b as f,c as n,f as u,h as x,m as h,n as c,t as e}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-OWNF6K2Z.js";function d(r){return r.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;")}function _(r){return!r.startsWith("data:image/svg+xml,")&&!r.startsWith("data:image/png;base64,")?"":d(r)}function y(r){return r.replace(/[/\\:*?"<>|#%]+/g,"").replace(/\s+/g,"-").toLowerCase().substring(0,100)}var l=class extends x{constructor(){super(...arguments);this.lang="";this._open=!1;this._loading=!1;this._error="";this._viewResult=null;this._completeResult=null;this._quickResult=null;this._urlMode="companion";this._entryId="";this._taskId=null;this._objectName="";this._taskName="";this._generateSeq=0}get _dialogLang(){return this.lang||k(this.hass)}openForObject(t,i){this._entryId=t,this._taskId=null,this._objectName=i,this._taskName="",this._urlMode="companion",this._error="",this._viewResult=null,this._completeResult=null,this._quickResult=null,this._open=!0,this._generate()}openForTask(t,i,s,o){this._entryId=t,this._taskId=i,this._objectName=s,this._taskName=o,this._urlMode="companion",this._error="",this._viewResult=null,this._completeResult=null,this._quickResult=null,this._open=!0,this._generate()}async _generate(){let t=++this._generateSeq;this._loading=!0,this._error="",this._viewResult=null,this._completeResult=null,this._quickResult=null;try{let i={type:"maintenance_supporter/qr/generate",entry_id:this._entryId,url_mode:this._urlMode};this._taskId&&(i.task_id=this._taskId);let s=[this.hass.connection.sendMessagePromise({...i,action:"view"})];this._taskId&&(s.push(this.hass.connection.sendMessagePromise({...i,action:"complete"})),await this._hasQuickCompleteDefaults()&&s.push(this.hass.connection.sendMessagePromise({...i,action:"quick_complete"})));let o=await Promise.all(s);if(t!==this._generateSeq)return;this._viewResult=o[0],o.length>1&&(this._completeResult=o[1]),o.length>2&&(this._quickResult=o[2])}catch(i){if(t!==this._generateSeq)return;let s=i?.code,o=i?.message;this._error=s==="no_url"||typeof o=="string"&&o.includes("No Home Assistant URL")?e("qr_error_no_url",this._dialogLang):e("qr_error",this._dialogLang)}finally{t===this._generateSeq&&(this._loading=!1)}}async _hasQuickCompleteDefaults(){try{let i=((await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/object",entry_id:this._entryId})).tasks||[]).find(s=>s.id===this._taskId);return!!i?.quick_complete_defaults&&Object.keys(i.quick_complete_defaults).length>0}catch{return!1}}_setUrlMode(t){this._urlMode!==t&&(this._urlMode=t,this._generate())}_print(){if(!this._viewResult)return;let t=this._viewResult,i=t.label.task_name?`${t.label.object_name} \u2014 ${t.label.task_name}`:t.label.object_name,s=[t.label.manufacturer,t.label.model].filter(Boolean).join(" "),o=window.open("","_blank","width=600,height=500");if(!o)return;let p=this._dialogLang,m=d(i),g=d(s),v=!!this._completeResult,b=!!this._quickResult,w=d(e("qr_action_view",p)),$=d(e("qr_action_complete",p)),R=d(e("qr_action_quick_complete",p));o.document.write(`<!DOCTYPE html><html><head><meta charset="utf-8">
/*! maintenance_supporter frontend 2.103.4 */
import{a as q}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-OW3M7YB7.js";import{I as k,a,b as f,c as n,f as u,h as x,m as h,n as c,t as e}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-VXQI3S2Y.js";function d(r){return r.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;")}function _(r){return!r.startsWith("data:image/svg+xml,")&&!r.startsWith("data:image/png;base64,")?"":d(r)}function y(r){return r.replace(/[/\\:*?"<>|#%]+/g,"").replace(/\s+/g,"-").toLowerCase().substring(0,100)}var l=class extends x{constructor(){super(...arguments);this.lang="";this._open=!1;this._loading=!1;this._error="";this._viewResult=null;this._completeResult=null;this._quickResult=null;this._urlMode="companion";this._entryId="";this._taskId=null;this._objectName="";this._taskName="";this._generateSeq=0}get _dialogLang(){return this.lang||k(this.hass)}openForObject(t,i){this._entryId=t,this._taskId=null,this._objectName=i,this._taskName="",this._urlMode="companion",this._error="",this._viewResult=null,this._completeResult=null,this._quickResult=null,this._open=!0,this._generate()}openForTask(t,i,s,o){this._entryId=t,this._taskId=i,this._objectName=s,this._taskName=o,this._urlMode="companion",this._error="",this._viewResult=null,this._completeResult=null,this._quickResult=null,this._open=!0,this._generate()}async _generate(){let t=++this._generateSeq;this._loading=!0,this._error="",this._viewResult=null,this._completeResult=null,this._quickResult=null;try{let i={type:"maintenance_supporter/qr/generate",entry_id:this._entryId,url_mode:this._urlMode};this._taskId&&(i.task_id=this._taskId);let s=[this.hass.connection.sendMessagePromise({...i,action:"view"})];this._taskId&&(s.push(this.hass.connection.sendMessagePromise({...i,action:"complete"})),await this._hasQuickCompleteDefaults()&&s.push(this.hass.connection.sendMessagePromise({...i,action:"quick_complete"})));let o=await Promise.all(s);if(t!==this._generateSeq)return;this._viewResult=o[0],o.length>1&&(this._completeResult=o[1]),o.length>2&&(this._quickResult=o[2])}catch(i){if(t!==this._generateSeq)return;let s=i?.code,o=i?.message;this._error=s==="no_url"||typeof o=="string"&&o.includes("No Home Assistant URL")?e("qr_error_no_url",this._dialogLang):e("qr_error",this._dialogLang)}finally{t===this._generateSeq&&(this._loading=!1)}}async _hasQuickCompleteDefaults(){try{let i=((await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/object",entry_id:this._entryId})).tasks||[]).find(s=>s.id===this._taskId);return!!i?.quick_complete_defaults&&Object.keys(i.quick_complete_defaults).length>0}catch{return!1}}_setUrlMode(t){this._urlMode!==t&&(this._urlMode=t,this._generate())}_print(){if(!this._viewResult)return;let t=this._viewResult,i=t.label.task_name?`${t.label.object_name} \u2014 ${t.label.task_name}`:t.label.object_name,s=[t.label.manufacturer,t.label.model].filter(Boolean).join(" "),o=window.open("","_blank","width=600,height=500");if(!o)return;let p=this._dialogLang,m=d(i),g=d(s),v=!!this._completeResult,b=!!this._quickResult,w=d(e("qr_action_view",p)),$=d(e("qr_action_complete",p)),R=d(e("qr_action_quick_complete",p));o.document.write(`<!DOCTYPE html><html><head><meta charset="utf-8">
<meta name="color-scheme" content="light">
<title>${m}</title>
<style>
@@ -1,234 +0,0 @@
/*! maintenance_supporter frontend 2.102.0 */
import{a as q}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-U4NJYI3P.js";import{H as k,a,b as f,c as n,f as u,h as x,l as h,m as c,s as e}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-4CECVKQ7.js";function d(r){return r.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;")}function _(r){return!r.startsWith("data:image/svg+xml,")&&!r.startsWith("data:image/png;base64,")?"":d(r)}function y(r){return r.replace(/[/\\:*?"<>|#%]+/g,"").replace(/\s+/g,"-").toLowerCase().substring(0,100)}var l=class extends x{constructor(){super(...arguments);this.lang="";this._open=!1;this._loading=!1;this._error="";this._viewResult=null;this._completeResult=null;this._quickResult=null;this._urlMode="companion";this._entryId="";this._taskId=null;this._objectName="";this._taskName="";this._generateSeq=0}get _dialogLang(){return this.lang||k(this.hass)}openForObject(t,i){this._entryId=t,this._taskId=null,this._objectName=i,this._taskName="",this._urlMode="companion",this._error="",this._viewResult=null,this._completeResult=null,this._quickResult=null,this._open=!0,this._generate()}openForTask(t,i,s,o){this._entryId=t,this._taskId=i,this._objectName=s,this._taskName=o,this._urlMode="companion",this._error="",this._viewResult=null,this._completeResult=null,this._quickResult=null,this._open=!0,this._generate()}async _generate(){let t=++this._generateSeq;this._loading=!0,this._error="",this._viewResult=null,this._completeResult=null,this._quickResult=null;try{let i={type:"maintenance_supporter/qr/generate",entry_id:this._entryId,url_mode:this._urlMode};this._taskId&&(i.task_id=this._taskId);let s=[this.hass.connection.sendMessagePromise({...i,action:"view"})];this._taskId&&(s.push(this.hass.connection.sendMessagePromise({...i,action:"complete"})),await this._hasQuickCompleteDefaults()&&s.push(this.hass.connection.sendMessagePromise({...i,action:"quick_complete"})));let o=await Promise.all(s);if(t!==this._generateSeq)return;this._viewResult=o[0],o.length>1&&(this._completeResult=o[1]),o.length>2&&(this._quickResult=o[2])}catch(i){if(t!==this._generateSeq)return;let s=i?.code,o=i?.message;this._error=s==="no_url"||typeof o=="string"&&o.includes("No Home Assistant URL")?e("qr_error_no_url",this._dialogLang):e("qr_error",this._dialogLang)}finally{t===this._generateSeq&&(this._loading=!1)}}async _hasQuickCompleteDefaults(){try{let i=((await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/object",entry_id:this._entryId})).tasks||[]).find(s=>s.id===this._taskId);return!!i?.quick_complete_defaults&&Object.keys(i.quick_complete_defaults).length>0}catch{return!1}}_setUrlMode(t){this._urlMode!==t&&(this._urlMode=t,this._generate())}_print(){if(!this._viewResult)return;let t=this._viewResult,i=t.label.task_name?`${t.label.object_name} \u2014 ${t.label.task_name}`:t.label.object_name,s=[t.label.manufacturer,t.label.model].filter(Boolean).join(" "),o=window.open("","_blank","width=600,height=500");if(!o)return;let p=this._dialogLang,m=d(i),g=d(s),v=!!this._completeResult,b=!!this._quickResult,w=d(e("qr_action_view",p)),$=d(e("qr_action_complete",p)),R=d(e("qr_action_quick_complete",p));o.document.write(`<!DOCTYPE html><html><head><meta charset="utf-8">
<meta name="color-scheme" content="light">
<title>${m}</title>
<style>
/* Printable sheet \u2014 must not inherit the phone's dark theme. The QR images
carry their own white quiet zone and stay scannable either way, but the
labels below are explicit dark greys and would vanish on a WebView's dark
canvas. Same reasoning as helpers/report.ts. */
:root{color-scheme:light}
body{font-family:sans-serif;text-align:center;padding:20px;background:#fff;color:#1a1a1a}
h2{margin:0 0 4px}
.sub{color:#666;font-size:14px;margin-bottom:16px}
.qr-row{display:flex;justify-content:center;gap:24px;margin:12px 0}
.qr-col{display:flex;flex-direction:column;align-items:center;gap:6px}
.qr-col img{width:${b?"170px":v?"200px":"280px"}}
.qr-label{font-size:13px;font-weight:500;color:#333}
.url{font-size:10px;color:#999;word-break:break-all;margin-top:8px;max-width:480px}
</style></head><body>
<h2>${m}</h2>
${g?`<div class="sub">${g}</div>`:""}
<div class="qr-row">
<div class="qr-col">
<img src="${_(this._viewResult.svg_data_uri)}" alt="QR Info" />
<div class="qr-label">${w}</div>
</div>
${v?`<div class="qr-col">
<img src="${_(this._completeResult.svg_data_uri)}" alt="QR Complete" />
<div class="qr-label">${$}</div>
</div>`:""}
${b?`<div class="qr-col">
<img src="${_(this._quickResult.svg_data_uri)}" alt="QR Quick-complete" />
<div class="qr-label">${R}</div>
</div>`:""}
</div>
<div class="url">${d(this._viewResult.url)}</div>
<script>setTimeout(()=>window.print(),300)<\/script>
</body></html>`),o.document.close()}_downloadSvg(t,i){let s=decodeURIComponent(t.svg_data_uri.replace("data:image/svg+xml,","")),o=this._taskName?`${this._objectName}-${this._taskName}`:this._objectName;q(s,`qr-${y(o)}-${i}.svg`,"image/svg+xml")}_close(){this._open=!1,this._viewResult=null,this._completeResult=null,this._quickResult=null,this._error="",this._loading=!1}render(){if(!this._open)return n``;let t=this._dialogLang,i=this._taskName?`${e("qr_code",t)}: ${this._objectName} \u2014 ${this._taskName}`:`${e("qr_code",t)}: ${this._objectName}`,s=!!this._viewResult;return n`
<ha-dialog open @closed=${this._close}>
<div class="dialog-title">${i}</div>
<div class="content">
${this._loading?n`<div class="loading">${e("qr_generating",t)}</div>`:this._error?n`<div class="error">${this._error}</div>`:s?n`
<div class="qr-pair">
<div class="qr-item">
<img
class="qr-image ${this._completeResult?"small":""}"
src="${this._viewResult.svg_data_uri}"
alt="QR Info"
/>
<div class="qr-item-label">${e("qr_action_view",t)}</div>
<button class="dl-btn"
@click=${()=>this._downloadSvg(this._viewResult,"info")}>
<ha-icon icon="mdi:download"></ha-icon>
${e("qr_download",t)}
</button>
</div>
${this._completeResult?n`
<div class="qr-item">
<img
class="qr-image small"
src="${this._completeResult.svg_data_uri}"
alt="QR Complete"
/>
<div class="qr-item-label">${e("qr_action_complete",t)}</div>
<button class="dl-btn"
@click=${()=>this._downloadSvg(this._completeResult,"complete")}>
<ha-icon icon="mdi:download"></ha-icon>
${e("qr_download",t)}
</button>
</div>
`:u}
${this._quickResult?n`
<div class="qr-item quick">
<img
class="qr-image small"
src="${this._quickResult.svg_data_uri}"
alt="QR Quick-complete"
/>
<div class="qr-item-label">${e("qr_action_quick_complete",t)}</div>
<button class="dl-btn"
@click=${()=>this._downloadSvg(this._quickResult,"quick-complete")}>
<ha-icon icon="mdi:download"></ha-icon>
${e("qr_download",t)}
</button>
</div>
`:u}
</div>
<div class="url-display">${this._viewResult.url}</div>
`:u}
<div class="action-row">
<label>${e("qr_url_mode",t)}</label>
<div class="action-toggle">
<button class="toggle-btn ${this._urlMode==="companion"?"active":""}"
@click=${()=>this._setUrlMode("companion")}>${e("qr_mode_companion",t)}</button>
<button class="toggle-btn ${this._urlMode==="local"?"active":""}"
@click=${()=>this._setUrlMode("local")}>${e("qr_mode_local",t)}</button>
<button class="toggle-btn ${this._urlMode==="server"?"active":""}"
@click=${()=>this._setUrlMode("server")}>${e("qr_mode_server",t)}</button>
</div>
</div>
</div>
<div class="dialog-actions">
<ha-button appearance="plain" @click=${this._close}>
${e("cancel",t)}
</ha-button>
<ha-button
@click=${this._print}
.disabled=${!s}
>
${e("qr_print",t)}
</ha-button>
</div>
</ha-dialog>
`}};l.styles=f`
.dialog-title {
font-size: 18px;
font-weight: 500;
padding-bottom: 12px;
}
.content {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
min-width: 300px;
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 16px;
}
.qr-pair {
display: flex;
flex-wrap: wrap; /* three codes (quick-complete, #192) wrap on a phone */
gap: 20px;
justify-content: center;
width: 100%;
}
.qr-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.qr-image {
width: 240px;
height: 240px;
image-rendering: pixelated;
}
.qr-image.small {
width: 180px;
height: 180px;
}
.qr-item-label {
max-width: 180px;
font-size: 12px;
font-weight: 500;
color: var(--secondary-text-color);
text-align: center;
}
.dl-btn {
display: inline-flex;
align-items: center;
gap: 6px;
background: none;
border: 1px solid var(--divider-color, #e0e0e0);
cursor: pointer;
font-size: 13px;
color: var(--primary-text-color);
padding: 6px 14px;
border-radius: 18px;
transition: background 0.2s, border-color 0.2s;
}
.dl-btn:hover {
background: var(--secondary-background-color, #f5f5f5);
border-color: var(--primary-color);
}
.dl-btn ha-icon {
--mdc-icon-size: 18px;
}
.url-display {
font-size: 11px;
color: var(--secondary-text-color);
word-break: break-all;
text-align: center;
max-width: 400px;
}
.loading {
padding: 40px 0;
color: var(--secondary-text-color);
}
.error {
padding: 20px 0;
color: var(--error-color, #f44336);
}
.action-row {
display: flex;
flex-direction: column;
gap: 6px;
width: 100%;
}
.action-row label {
font-size: 13px;
color: var(--secondary-text-color);
}
.action-toggle {
display: flex;
gap: 4px;
background: var(--divider-color, #e0e0e0);
border-radius: 6px;
padding: 3px;
}
.toggle-btn {
flex: 1;
padding: 8px 12px;
border: none;
background: transparent;
color: var(--primary-text-color);
cursor: pointer;
border-radius: 4px;
font-size: 13px;
transition: all 0.2s;
line-height: 1.3;
}
.toggle-btn:hover {
background: rgba(0, 0, 0, 0.05);
}
.toggle-btn.active {
background: var(--primary-color);
color: var(--text-primary-color, #fff);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
}
`,a([h({attribute:!1})],l.prototype,"hass",2),a([h()],l.prototype,"lang",2),a([c()],l.prototype,"_open",2),a([c()],l.prototype,"_loading",2),a([c()],l.prototype,"_error",2),a([c()],l.prototype,"_viewResult",2),a([c()],l.prototype,"_completeResult",2),a([c()],l.prototype,"_quickResult",2),a([c()],l.prototype,"_urlMode",2);customElements.get("maintenance-qr-dialog")||customElements.define("maintenance-qr-dialog",l);export{l as a};
@@ -1,2 +1,2 @@
/*! maintenance_supporter frontend 2.103.0 */
/*! maintenance_supporter frontend 2.103.4 */
var E={\u00DF:"s","\u1E9E":"s",\u00E6:"a",\u0153:"o",\u00F8:"o",\u0111:"d",\u00F0:"d",\u00FE:"t",\u0142:"l",\u0131:"i"},d=10,R=7,g=4,h=2,S=5,x=3,_=/[a-z0-9]+/g;function u(r){let e="";for(let t of r.toLowerCase()){if(t.charCodeAt(0)<128&&t.length===1){e+=t;continue}let n=E[t];if(n){e+=n;continue}let o=t.normalize("NFKD")[0]||t;e+=o.charCodeAt(0)<128?o:t}return e}function m(r){return u(r).match(_)||[]}function C(r){return u(r).replace(/[^a-z0-9]+/g,"")}function b(r){return r.replace(/ue/g,"u").replace(/oe/g,"o").replace(/ae/g,"a").replace(/ss/g,"s")}function F(r){let e=[];for(let t of m(r)){let n=[t],o=b(t);o&&o!==t&&n.push(o),e.push(n)}return e}function p(r,e){if(r===e)return!0;let t=r.length,n=e.length;if(Math.abs(t-n)>1)return!1;if(t===n){let l=[];for(let i=0;i<t;i++)r[i]!==e[i]&&l.push(i);if(l.length===1)return!0;if(l.length===2&&l[1]===l[0]+1){let i=l[0];return r[i]===e[i+1]&&r[i+1]===e[i]}return!1}let[o,s]=t>n?[r,e]:[e,r],c=0,f=0,a=!1;for(;c<o.length&&f<s.length;)if(o[c]===s[f])c++,f++;else{if(a)return!1;a=!0,c++}return!0}function N(r,e){let t=0;for(let n of r)if(n){if(e===n)return d;if(e.startsWith(n)){t=Math.max(t,R);continue}if(n.length>=x&&e.includes(n)){t=Math.max(t,g);continue}n.length>=S&&t<h&&(p(n,e)||e.length>n.length&&p(n,e.slice(0,n.length)))&&(t=h)}return t}function I(r,e){let t=0;for(let n of m(e))if(t=Math.max(t,N(r,n)),t===d)return t;if(t<g){let n=C(e);for(let o of r)if(o.length>=x&&n.includes(o))return g}return t}function O(r,e){if(!r)return!1;let t=F(e);if(!t.length){let n=u(e.trim());return n.length>0&&u(r).includes(n)}return M(t,[{text:r,weight:1}])>0}function M(r,e){if(!r.length)return 0;let t=e.filter(o=>o.text),n=0;for(let o of r){let s=0;for(let c of t){let f=I(o,c.text)*c.weight;f>s&&(s=f)}if(s===0)return 0;n+=s}return n}export{F as a,O as b,M as c};
@@ -1,5 +1,5 @@
/*! maintenance_supporter frontend 2.103.0 */
import{B as k,L as R,N as x,a as m,b as h,ba as w,c as g,f as v,h as b,m as y,n as _,t as l}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-OWNF6K2Z.js";function E(i){return`${i.entry_id??""}\0${i.part_id}`}function S(i,t,e,o){let n=!!i.entry_id&&i.entry_id!==t,r=n?i.entry_id:t,s=e.find(p=>p.entry_id===r),a=(s?.parts||[]).find(p=>p.id===i.part_id)||null,c=n&&s?.object?.name||"",u=a?.name||l("shared_part_unknown",o);return{part:a,foreign:n,ownerName:c,label:c?`${u} (${c})`:u}}function N(i,t,e,o){let{part:n,label:r}=S(i,t,e,o),s=n&&n.stock!==null&&n.stock!==void 0?` (${x(n.stock,n.unit,o)})`:"",a=n?.storage_location?` \u2014 ${n.storage_location}`:"";return`${R(i.quantity,o)}\xD7 ${r}${s}${a}`}function V(i,t,e,o){let r=(e.find(a=>a.entry_id===t)?.parts||[]).map(a=>({...a})),s=new Set(r.map(a=>E({part_id:a.id})));for(let a of i?.consumes_parts||[]){if(!a.entry_id||a.entry_id===t)continue;let c=E(a);if(s.has(c))continue;s.add(c);let{part:u,ownerName:p}=S(a,t,e,o);r.push({id:a.part_id,name:u?.name||l("shared_part_unknown",o),unit:u?.unit,stock:u?.stock??null,storage_location:u?.storage_location,entry_id:a.entry_id,owner_name:p})}return r}var L=20;function T(){let i=new Uint8Array(4);return(globalThis.crypto??{getRandomValues:t=>t.map(()=>Math.floor(Math.random()*256))}).getRandomValues(i),Array.from(i,t=>t.toString(16).padStart(2,"0")).join("")}function f(i){let t=i?.reading_values;if(!Array.isArray(t))return[];let e=[],o=new Set;for(let n of t){if(!n||typeof n!="object")continue;let r=n;typeof r.id!="string"||!r.id||o.has(r.id)||typeof r.value!="number"||!Number.isFinite(r.value)||(o.add(r.id),e.push({id:r.id,name:typeof r.name=="string"?r.name:r.id,unit:typeof r.unit=="string"?r.unit:null,value:r.value}))}return e}function P(i){return i.filter(t=>t.type==="completed"&&(t.reading_value!=null||f(t).length>0)).sort((t,e)=>t.timestamp.localeCompare(e.timestamp))}function M(i){return P(i??[]).map(t=>({timestamp:t.timestamp,values:f(t)})).filter(t=>t.values.length>0)}function A(i,t,e){let o;for(let n of i){if(e!==void 0){let s=k(n.timestamp);if(!isNaN(s)&&s>=e)break}let r=n.values.find(s=>s.id===t);r&&(o=r)}return o}function C(i,t,e){let o=f(t).find(r=>r.id===e);if(!o)return null;let n=null;for(let r of P(i)){if(r.timestamp>=t.timestamp)break;let s=f(r).find(a=>a.id===e);s&&(n=s.value)}return n==null?null:o.value-n}function D(i){let t=new Set,e=new Set;for(let o of i){let n=(o.name||"").trim().toLowerCase();n&&(t.has(n)?e.add(o.id):t.add(n))}return e}function F(i){let t=[],e=new Set,o=new Set;for(let n of i){let r=(n.name||"").trim();if(!(!r||e.has(n.id)||o.has(r.toLowerCase()))&&(e.add(n.id),o.add(r.toLowerCase()),t.push({id:n.id,name:r,unit:(n.unit||"").trim()||null}),t.length>=L))break}return t}function j(i){let t=(i??"").trim();if(t==="")return null;let e=Number(t.replace(",","."));return!Number.isFinite(e)||e<0?null:Math.round(e)}var B=["notes","cost","duration","photo","user"],I={notes:"notes_label",cost:"cost",duration:"duration",photo:"photo_label",user:"user_label"};var d=class extends b{constructor(){super(...arguments);this.value="";this.lang="en";this._credit=!1;this._amount=""}willUpdate(e){if(!e.has("value"))return;let o=(this.value??"").trim();if(o===this._signed())return;let n=parseFloat(o);if(o===""||Number.isNaN(n)){this._amount="",this._credit=!1;return}this._credit=n<0,this._amount=String(Math.abs(n))}_signed(){let e=this._amount.trim();return e===""?"":this._credit?`-${e}`:e}_emit(){this.value=this._signed(),this.dispatchEvent(new CustomEvent("value-changed",{detail:{value:this.value},bubbles:!0,composed:!0}))}_setCredit(e){this._credit!==e&&(this._credit=e,this._emit())}_onInput(e){let o=e.target.value;o.startsWith("-")&&(this._credit=!0),this._amount=o.replace(/^-+/,""),this._emit()}render(){let e=this.lang;return g`
/*! maintenance_supporter frontend 2.103.4 */
import{B as k,L as R,N as x,a as m,b as h,ba as w,c as g,f as v,h as b,m as y,n as _,t as l}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-VXQI3S2Y.js";function E(i){return`${i.entry_id??""}\0${i.part_id}`}function S(i,t,e,o){let n=!!i.entry_id&&i.entry_id!==t,r=n?i.entry_id:t,s=e.find(p=>p.entry_id===r),a=(s?.parts||[]).find(p=>p.id===i.part_id)||null,c=n&&s?.object?.name||"",u=a?.name||l("shared_part_unknown",o);return{part:a,foreign:n,ownerName:c,label:c?`${u} (${c})`:u}}function N(i,t,e,o){let{part:n,label:r}=S(i,t,e,o),s=n&&n.stock!==null&&n.stock!==void 0?` (${x(n.stock,n.unit,o)})`:"",a=n?.storage_location?` \u2014 ${n.storage_location}`:"";return`${R(i.quantity,o)}\xD7 ${r}${s}${a}`}function V(i,t,e,o){let r=(e.find(a=>a.entry_id===t)?.parts||[]).map(a=>({...a})),s=new Set(r.map(a=>E({part_id:a.id})));for(let a of i?.consumes_parts||[]){if(!a.entry_id||a.entry_id===t)continue;let c=E(a);if(s.has(c))continue;s.add(c);let{part:u,ownerName:p}=S(a,t,e,o);r.push({id:a.part_id,name:u?.name||l("shared_part_unknown",o),unit:u?.unit,stock:u?.stock??null,storage_location:u?.storage_location,entry_id:a.entry_id,owner_name:p})}return r}var L=20;function T(){let i=new Uint8Array(4);return(globalThis.crypto??{getRandomValues:t=>t.map(()=>Math.floor(Math.random()*256))}).getRandomValues(i),Array.from(i,t=>t.toString(16).padStart(2,"0")).join("")}function f(i){let t=i?.reading_values;if(!Array.isArray(t))return[];let e=[],o=new Set;for(let n of t){if(!n||typeof n!="object")continue;let r=n;typeof r.id!="string"||!r.id||o.has(r.id)||typeof r.value!="number"||!Number.isFinite(r.value)||(o.add(r.id),e.push({id:r.id,name:typeof r.name=="string"?r.name:r.id,unit:typeof r.unit=="string"?r.unit:null,value:r.value}))}return e}function P(i){return i.filter(t=>t.type==="completed"&&(t.reading_value!=null||f(t).length>0)).sort((t,e)=>t.timestamp.localeCompare(e.timestamp))}function M(i){return P(i??[]).map(t=>({timestamp:t.timestamp,values:f(t)})).filter(t=>t.values.length>0)}function A(i,t,e){let o;for(let n of i){if(e!==void 0){let s=k(n.timestamp);if(!isNaN(s)&&s>=e)break}let r=n.values.find(s=>s.id===t);r&&(o=r)}return o}function C(i,t,e){let o=f(t).find(r=>r.id===e);if(!o)return null;let n=null;for(let r of P(i)){if(r.timestamp>=t.timestamp)break;let s=f(r).find(a=>a.id===e);s&&(n=s.value)}return n==null?null:o.value-n}function D(i){let t=new Set,e=new Set;for(let o of i){let n=(o.name||"").trim().toLowerCase();n&&(t.has(n)?e.add(o.id):t.add(n))}return e}function F(i){let t=[],e=new Set,o=new Set;for(let n of i){let r=(n.name||"").trim();if(!(!r||e.has(n.id)||o.has(r.toLowerCase()))&&(e.add(n.id),o.add(r.toLowerCase()),t.push({id:n.id,name:r,unit:(n.unit||"").trim()||null}),t.length>=L))break}return t}function j(i){let t=(i??"").trim();if(t==="")return null;let e=Number(t.replace(",","."));return!Number.isFinite(e)||e<0?null:Math.round(e)}var B=["notes","cost","duration","photo","user"],I={notes:"notes_label",cost:"cost",duration:"duration",photo:"photo_label",user:"user_label"};var d=class extends b{constructor(){super(...arguments);this.value="";this.lang="en";this._credit=!1;this._amount=""}willUpdate(e){if(!e.has("value"))return;let o=(this.value??"").trim();if(o===this._signed())return;let n=parseFloat(o);if(o===""||Number.isNaN(n)){this._amount="",this._credit=!1;return}this._credit=n<0,this._amount=String(Math.abs(n))}_signed(){let e=this._amount.trim();return e===""?"":this._credit?`-${e}`:e}_emit(){this.value=this._signed(),this.dispatchEvent(new CustomEvent("value-changed",{detail:{value:this.value},bubbles:!0,composed:!0}))}_setCredit(e){this._credit!==e&&(this._credit=e,this._emit())}_onInput(e){let o=e.target.value;o.startsWith("-")&&(this._credit=!0),this._amount=o.replace(/^-+/,""),this._emit()}render(){let e=this.lang;return g`
<div class="wrap">
<div class="sign" role="group">
<button type="button" class="kind cost ${this._credit?"":"on"}" aria-pressed=${String(!this._credit)}
@@ -1,5 +1,5 @@
/*! maintenance_supporter frontend 2.103.0 */
import{a as r,b as l,c as i,f as o,h as n,m as t}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-OWNF6K2Z.js";var e=class extends n{constructor(){super(...arguments);this.label="";this.value="";this.placeholder="";this.type="text";this.required=!1;this.disabled=!1;this.multiline=!1;this.rows=3}_onInput(s){let a=s.target.value;this.value=a,this.dispatchEvent(new CustomEvent("input",{bubbles:!0,composed:!0,detail:{value:a}}))}render(){return i`
/*! maintenance_supporter frontend 2.103.4 */
import{a as r,b as l,c as i,f as o,h as n,m as t}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-VXQI3S2Y.js";var e=class extends n{constructor(){super(...arguments);this.label="";this.value="";this.placeholder="";this.type="text";this.required=!1;this.disabled=!1;this.multiline=!1;this.rows=3}_onInput(s){let a=s.target.value;this.value=a,this.dispatchEvent(new CustomEvent("input",{bubbles:!0,composed:!0,detail:{value:a}}))}render(){return i`
<label class="field">
${this.label?i`<span class="label">${this.label}${this.required?i`<span class="req">*</span>`:o}</span>`:o}
${this.multiline?i`
File diff suppressed because one or more lines are too long
@@ -1,2 +1,2 @@
/*! maintenance_supporter frontend 2.103.0 */
/*! maintenance_supporter frontend 2.103.4 */
function r(t){return String(t??"").replace(/[&<>"']/g,n=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[n])}export{r as a};
@@ -1,2 +1,2 @@
/*! maintenance_supporter frontend 2.102.0 */
/*! maintenance_supporter frontend 2.103.4 */
async function i(o,n,e){let t=o.auth;if(t?.expired&&t.refreshAccessToken)try{await t.refreshAccessToken()}catch{}return fetch(n,{...e,headers:{...e.headers??{},Authorization:`Bearer ${t?.data?.access_token??""}`}})}var d=100*1024*1024,l=500*1024*1024;async function c(o,n,e,t="doc_too_large",s=[]){let r=await i(o,n,{method:"POST",body:e});if(r.status===413)throw new Error(t);if(!r.ok){let a=await r.json().catch(()=>null);throw typeof a?.code=="string"&&s.includes(a.code)?new Error(a.code):new Error("doc_upload_failed")}return await r.json()}async function p(o,n,e,t){if(e.size>d)throw new Error("doc_too_large");let s=new FormData;s.append("entry_id",n);for(let a of t)s.append("tags",a);s.append("file",e,e.name);let r=await c(o,"/api/maintenance_supporter/document/upload",s);if(!r.id)throw new Error("doc_upload_failed");return{id:r.id,deduped:!!r.deduped,duplicate_in_object:r.duplicate_in_object??null}}async function m(o,n,e){return(await p(o,n,e,["photo"])).id}async function _(o,n,e){await Promise.all(e.map(t=>o.connection.sendMessagePromise({type:"maintenance_supporter/documents/discard_upload",entry_id:n,doc_id:t}).catch(()=>{})))}export{l as a,c as b,p as c,m as d,_ as e};
@@ -1,2 +0,0 @@
/*! maintenance_supporter frontend 2.102.0 */
import{O as n,s as o}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-4CECVKQ7.js";var l={"Completed from dashboard button":"hist_note_button","Completed from the To-do list":"hist_note_todo","Completed by voice":"hist_note_voice","Completed from the notification":"hist_note_notification","Completed via NFC tag":"hist_note_nfc","Completed from the shopping list":"hist_note_shopping_list","Completed from a mirrored to-do list":"hist_note_todo_mirror","Skipped from notification":"hist_note_skipped_notification","Skipped from dashboard button":"hist_note_skipped_button","Sensor trigger activated":"hist_note_sensor_triggered","Initial value set during task creation":"hist_note_initial"},m=[[/^Auto-completed: sensor recovered \((.+)\)$/,"hist_note_auto_recovered",t=>({value:t[1]})],[/^Reset to (\d{4}-\d{2}-\d{2})$/,"hist_note_reset",(t,e)=>({date:n(t[1],e)})],[/^Trigger entity replaced: (.+) → (.+)$/,"hist_note_trigger_replaced",t=>({old:t[1],new:t[2]})],[/^Sensor trigger removed \(entity was: (.+)\)\. Schedule converted to (\w+)\.$/,"hist_note_trigger_removed",(t,e)=>({entity:t[1],schedule:o(t[2],e)})],[/^Entity (.+) removed from compound trigger; (\d+) conditions remain\.$/,"hist_note_compound_removed",t=>({entity:t[1],count:t[2]})]];function h(t,e){if(!t)return"";let i=l[t.trim()];if(i)return o(i,e);for(let[s,d,_]of m){let r=t.trim().match(s);if(r){let c=_(r,e);return o(d,e).replace(/\{(\w+)\}/g,(a,g)=>c[g]??a)}}return t}export{h as a};

Some files were not shown because too many files have changed in this diff Show More