diff --git a/.ha_run.lock b/.ha_run.lock index 4c76df85..da0465a2 100644 --- a/.ha_run.lock +++ b/.ha_run.lock @@ -1 +1 @@ -{"pid": 73, "version": 1, "ha_version": "2026.10.1", "start_ts": 1792001005.2570755} \ No newline at end of file +{"pid": 71, "version": 1, "ha_version": "2026.10.1", "start_ts": 1792153632.14175} \ No newline at end of file diff --git a/.storage/lovelace_resources b/.storage/lovelace_resources index 9ee90105..db307fa4 100644 --- a/.storage/lovelace_resources +++ b/.storage/lovelace_resources @@ -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" } ] diff --git a/custom_components/album_slideshow/__init__.py b/custom_components/album_slideshow/__init__.py index 89795476..3728b9b4 100644 --- a/custom_components/album_slideshow/__init__.py +++ b/custom_components/album_slideshow/__init__.py @@ -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() diff --git a/custom_components/album_slideshow/camera.py b/custom_components/album_slideshow/camera.py index 11251be5..ff5749ea 100644 --- a/custom_components/album_slideshow/camera.py +++ b/custom_components/album_slideshow/camera.py @@ -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) diff --git a/custom_components/album_slideshow/config_flow.py b/custom_components/album_slideshow/config_flow.py index ffc799be..2d749db8 100644 --- a/custom_components/album_slideshow/config_flow.py +++ b/custom_components/album_slideshow/config_flow.py @@ -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: diff --git a/custom_components/album_slideshow/const.py b/custom_components/album_slideshow/const.py index b63aaba8..e9d95f58 100644 --- a/custom_components/album_slideshow/const.py +++ b/custom_components/album_slideshow/const.py @@ -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" diff --git a/custom_components/album_slideshow/coordinator.py b/custom_components/album_slideshow/coordinator.py index e9d877da..df45740b 100644 --- a/custom_components/album_slideshow/coordinator.py +++ b/custom_components/album_slideshow/coordinator.py @@ -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: diff --git a/custom_components/album_slideshow/immich.py b/custom_components/album_slideshow/immich.py index fe0072d2..25ddf2cd 100644 --- a/custom_components/album_slideshow/immich.py +++ b/custom_components/album_slideshow/immich.py @@ -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): diff --git a/custom_components/album_slideshow/manifest.json b/custom_components/album_slideshow/manifest.json index dab734e4..dcaa49ae 100644 --- a/custom_components/album_slideshow/manifest.json +++ b/custom_components/album_slideshow/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "issue_tracker": "https://github.com/eyalgal/album_slideshow/issues", "requirements": ["PyNaCl>=1.5.0"], - "version": "1.15.0" + "version": "1.15.1" } diff --git a/custom_components/album_slideshow/number.py b/custom_components/album_slideshow/number.py index 14526732..078136de 100644 --- a/custom_components/album_slideshow/number.py +++ b/custom_components/album_slideshow/number.py @@ -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 diff --git a/custom_components/album_slideshow/store.py b/custom_components/album_slideshow/store.py index 682428dc..c8103a07 100644 --- a/custom_components/album_slideshow/store.py +++ b/custom_components/album_slideshow/store.py @@ -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 diff --git a/custom_components/album_slideshow/strings.json b/custom_components/album_slideshow/strings.json index 492900dd..5fa89b53 100644 --- a/custom_components/album_slideshow/strings.json +++ b/custom_components/album_slideshow/strings.json @@ -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" } diff --git a/custom_components/album_slideshow/switch.py b/custom_components/album_slideshow/switch.py index 072c9058..3bba79ab 100644 --- a/custom_components/album_slideshow/switch.py +++ b/custom_components/album_slideshow/switch.py @@ -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.""" diff --git a/custom_components/album_slideshow/translations/en.json b/custom_components/album_slideshow/translations/en.json index 492900dd..5fa89b53 100644 --- a/custom_components/album_slideshow/translations/en.json +++ b/custom_components/album_slideshow/translations/en.json @@ -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" } diff --git a/custom_components/album_slideshow/www/album-slideshow-card.js b/custom_components/album_slideshow/www/album-slideshow-card.js index 0bdf2473..7b5038e0 100644 --- a/custom_components/album_slideshow/www/album-slideshow-card.js +++ b/custom_components/album_slideshow/www/album-slideshow-card.js @@ -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 = ` - - -

${o(i.title)} \u2014 ${o(b.areaName)}

-

${o(i.period)}: ${o(d)} \xB7 ${o(i.generated)} ${o(t.date(s))}

-
${h}
- -

${o(i.costPerObject)}

- - - - - - ${u||``} - - - - -
${o(i.colObject)}${o(i.completions)}${o(i.colCost)}${o(i.share)}
${o(i.none)}
${o(i.totalCost)}${o(t.number(r.completions))}${o(t.cost(r.totalCost))}
- -

${o(i.costPerBucket)}

- - - - - - ${c||``} -
${o(i.period)}${o(i.completions)}${o(i.colCost)}
${o(i.none)}
- -

${o(i.historyHeading)} (${o(t.number(l.length))})

- - - - - - -${m||``} - - - - - - -
${o(i.colDate)}${o(i.colObject)}${o(i.colTask)}${o(i.colCost)}${o(i.colDuration)}${o(i.colNotes)}
${o(i.none)}
${o(i.totalCost)}${o(t.cost(r.totalCost))}${r.totalDuration?o(t.duration(r.totalDuration)):""}
-${b.capped?`

${o(i.capNote)}

`:""} - -`}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` -
- - ${h.map(u=>p` - - `)} - -
- `}_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`

${a("area_no_costs",s)}

`;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` -
-
${v(d,l,s)}
-
- ${r<0?p`
`: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`
- ${c.cost!==0?p`
`:y} -
`})} -
- ${r<0?p`
${v(r,l,s)}
`:y} -
- ${f.map(({b:c,i:m})=>p`${c.month==null?String(c.year):p`${T(c.month,s,"short")}${c.month===0||m===0?p`
${c.year}`:y}`}
`)} -
-
- `}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` -
-
-

- - ${E(this.areaKey,this.hass.areas,a("no_area",t))} -

-
- this._print()}> - - ${this._printing?a("loading",t):a("area_report_print",t)} - -
-
-

- ${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` ยท ${a("loading",t)}`:y} -

- - ${this._renderRangeChips(d)} -
- {this._from=e.detail.value}} - > - {this._to=e.detail.value}} - > - - {this._taskQuery=e.target.value}} - /> -
- -
-
-
${a("area_kpi_completions",t)}
-
${g(h.completions,t)}
-
-
-
${a("total_cost",t)}
-
${v(h.totalCost,s,t)}
-
-
-
${a("avg_cost",t)}
-
${h.avgCost!=null?v(h.avgCost,s,t):"\u2014"}
-
-
-
${a("area_kpi_total_time",t)}
-
${h.totalDuration?w(h.totalDuration,t):"\u2014"}
-
-
- -

${u.unit==="month"?a("area_cost_per_month",t):a("area_cost_per_year",t)}

- ${this._renderChart(u.buckets)} - -

${a("area_cost_per_object",t)}

-
- - - - - - - - - - - ${f.map(e=>p` - this._openObject(e.entryId)}> - - - - - - `)} - -
${a("object",t)}${a("area_kpi_completions",t)}${a("cost",t)}${a("area_cost_share",t)}
${e.objectName}${g(e.completions,t)}${v(e.cost,s,t)} - - ${g(e.share*100,t,0)} % -
-
- -

- ${a("area_history_section",t)} - ${g(r.length,t)} -

- ${r.length===0?p`

${a("object_history_empty",t)}

`:p` -
- - - - - - - - - - - - - - ${m.map(e=>{let n=e.notes?B(e.notes,t):"";return p` - this._openTask(e.entryId,e.taskId)}> - - - - - - - - - `})} - -
${a("date",t)}${a("object",t)}${a("task_name",t)}${a("cost",t)}${a("duration",t)}${a("notes_label",t)}
${N(e.timestamp,t)} - - ${a(e.type,t)} - - - - ${e.cost!=null?v(e.cost,s,t):"\u2014"}${e.duration!=null?w(e.duration,t):"\u2014"}${n||"\u2014"}
-
- ${r.length>m.length?p`{this._expanded=!0}}> - ${a("show_all",t)} (${g(r.length,t)}) - `:y} - `} - ${this._histories.capped?p`

${a("object_history_cap_note",t)}

`:y} -
- `}};$([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}; diff --git a/custom_components/maintenance_supporter/frontend/panel-chunks/area-view-P5KXKEW2.js b/custom_components/maintenance_supporter/frontend/panel-chunks/area-view-W4UHFQ5E.js similarity index 96% rename from custom_components/maintenance_supporter/frontend/panel-chunks/area-view-P5KXKEW2.js rename to custom_components/maintenance_supporter/frontend/panel-chunks/area-view-W4UHFQ5E.js index 56497731..e4a4e3f9 100644 --- a/custom_components/maintenance_supporter/frontend/panel-chunks/area-view-P5KXKEW2.js +++ b/custom_components/maintenance_supporter/frontend/panel-chunks/area-view-W4UHFQ5E.js @@ -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])=>`
${o(e)}
${o(n)}
`).join(""),u=b.byObject.map(e=>` +/*! 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])=>`
${o(e)}
${o(n)}
`).join(""),u=b.byObject.map(e=>` ${o(e.objectName)} ${o(t.number(e.completions))} ${o(t.cost(e.cost))} diff --git a/custom_components/maintenance_supporter/frontend/panel-chunks/areas-view-5II3GSSX.js b/custom_components/maintenance_supporter/frontend/panel-chunks/areas-view-5II3GSSX.js deleted file mode 100644 index 9ef50c4c..00000000 --- a/custom_components/maintenance_supporter/frontend/panel-chunks/areas-view-5II3GSSX.js +++ /dev/null @@ -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` -
- {this._query=e.target.value}} - /> - ${this._histories.loading?a`${r("loading",t)}`:m} - ${h>0?a`this._toggleArchived()}> - - ${this.showArchived?r("hide_archived",t):`${r("show_archived",t)} (${h})`} - `:m} -
- ${n.length===0?a`
${r("no_objects",t)}
`:c.length===0?a`
${r("areas_filter_none",t)}
`:a` -
- - - - ${q.map(e=>{let u=this._sort.key===e.key;return a``})} - - - - ${c.map(e=>a` - this._open(e.key)} - @keydown=${u=>{u.key==="Enter"&&this._open(e.key)}}> - - - - - - - - - - `)} - -
- -
- - ${e.name} - ${d(e.objects)}${d(e.tasks)}${e.overdue?a`${d(e.overdue)}`:d(0)}${e.dueSoon?a`${d(e.dueSoon)}`:d(0)}${v(e.costYear,s,t)}${v(e.costTotal,s,t)}${e.lastCompletion?w(e.lastCompletion,t):"\u2014"}
-
- `} - `}};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}; diff --git a/custom_components/maintenance_supporter/frontend/panel-chunks/areas-view-FZCJIN5V.js b/custom_components/maintenance_supporter/frontend/panel-chunks/areas-view-YIQCFZ7A.js similarity index 91% rename from custom_components/maintenance_supporter/frontend/panel-chunks/areas-view-FZCJIN5V.js rename to custom_components/maintenance_supporter/frontend/panel-chunks/areas-view-YIQCFZ7A.js index aa6f7e37..58a8a3b4 100644 --- a/custom_components/maintenance_supporter/frontend/panel-chunks/areas-view-FZCJIN5V.js +++ b/custom_components/maintenance_supporter/frontend/panel-chunks/areas-view-YIQCFZ7A.js @@ -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`
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` -
${s("bulk_assign_hint",t)}
- - ${this._assignKind==="one"?o``:p} - - ${this._assignKind==="rotate"?o`
- ${this._users.map(e=>o``)} -
- - ${this._pool.size<2?o`
${s("bulk_rotation_min_two",t)}
`:p}`:p} - `}_renderLabels(t){return o` - this._addText=e.target.value} - > - ${this._present.length?o`
${s("bulk_labels_remove",t)}
-
- ${this._present.map(e=>o``)} -
`:p} - `}_renderEdit(t){return o` -
${s("bulk_edit_hint",t)}
-
- this._setWarning=e.target.checked} /> - ${s("warning_days",t)} - {let r=parseInt(e.target.value,10);this._warning=Number.isInteger(r)?Math.min(Math.max(r,0),m):this._warning}} /> -
-
- this._setPriority=e.target.checked} /> - ${s("priority",t)} - -
-
- this._setMute=e.target.checked} /> - ${s("bulk_no_notifications",t)} - this._mute=e.target.checked} /> -
- `}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` - this._finish(null)}> -
${e}
-
-
${s("bulk_n_selected",t).replace("{n}",String(this._count))}
- ${this._mode==="assign"?this._renderAssign(t):this._mode==="labels"?this._renderLabels(t):this._renderEdit(t)} -
-
- this._finish(null)}>${s("cancel",t)} - this._finish(this.changes())}>${s("save",t)} -
-
- `}};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}; diff --git a/custom_components/maintenance_supporter/frontend/panel-chunks/bulk-edit-dialog-UWZRWM23.js b/custom_components/maintenance_supporter/frontend/panel-chunks/bulk-edit-dialog-MLLN2JVZ.js similarity index 97% rename from custom_components/maintenance_supporter/frontend/panel-chunks/bulk-edit-dialog-UWZRWM23.js rename to custom_components/maintenance_supporter/frontend/panel-chunks/bulk-edit-dialog-MLLN2JVZ.js index 10cdc618..d7ed1809 100644 --- a/custom_components/maintenance_supporter/frontend/panel-chunks/bulk-edit-dialog-UWZRWM23.js +++ b/custom_components/maintenance_supporter/frontend/panel-chunks/bulk-edit-dialog-MLLN2JVZ.js @@ -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`
${s("bulk_assign_hint",t)}
- `}};v.styles=[Ut,x` + `}};v.styles=[Ut,E` .dialog-title { font-size: 18px; font-weight: 500; @@ -87,11 +63,11 @@ import{a as Zt,c as Yt,d as $t,e as Jt,f as pt,g as V,j as R,k as ie,l as ae}fro ha-button.danger { --mdc-theme-primary: var(--error-color, #f44336); } - `],u([A({attribute:!1})],v.prototype,"hass",2),u([m()],v.prototype,"_open",2),u([m()],v.prototype,"_title",2),u([m()],v.prototype,"_message",2),u([m()],v.prototype,"_confirmText",2),u([m()],v.prototype,"_danger",2),u([m()],v.prototype,"_inputLabel",2),u([m()],v.prototype,"_inputType",2),u([m()],v.prototype,"_options",2),u([m()],v.prototype,"_inputValue",2);customElements.get("maintenance-confirm-dialog")||customElements.define("maintenance-confirm-dialog",v);var oe="maintenance-confirm-dialog",le="data-ms-lovelace-confirm";function ze(){return document.querySelector("home-assistant")?.shadowRoot??document.body}function N(e,s){let t=ze(),i=t.querySelector(`${oe}[${le}]`);return i||(i=document.createElement(oe),i.setAttribute(le,""),t.appendChild(i)),i.hass=e,i.confirm(s)}var D=class extends S{constructor(){super(...arguments);this.docId="";this._url="";this._failed=!1;this._signedFor=""}updated(){this.hass&&this.docId&&this._signedFor!==this.docId&&(this._signedFor=this.docId,this._url="",this._failed=!1,this._sign())}async _sign(){try{this._url=await Kt(this.hass,this.docId)}catch{this._failed=!0}}render(){return this._failed||!this.docId?d:this._url?l` + `],u([w({attribute:!1})],v.prototype,"hass",2),u([m()],v.prototype,"_open",2),u([m()],v.prototype,"_title",2),u([m()],v.prototype,"_message",2),u([m()],v.prototype,"_confirmText",2),u([m()],v.prototype,"_danger",2),u([m()],v.prototype,"_inputLabel",2),u([m()],v.prototype,"_inputType",2),u([m()],v.prototype,"_options",2),u([m()],v.prototype,"_inputValue",2);customElements.get("maintenance-confirm-dialog")||customElements.define("maintenance-confirm-dialog",v);var oe="maintenance-confirm-dialog",le="data-ms-lovelace-confirm";function Oe(){return document.querySelector("home-assistant")?.shadowRoot??document.body}function N(e,s){let t=Oe(),n=t.querySelector(`${oe}[${le}]`);return n||(n=document.createElement(oe),n.setAttribute(le,""),t.appendChild(n)),n.hass=e,n.confirm(s)}var D=class extends S{constructor(){super(...arguments);this.docId="";this._url="";this._failed=!1;this._signedFor=""}updated(){this.hass&&this.docId&&this._signedFor!==this.docId&&(this._signedFor=this.docId,this._url="",this._failed=!1,this._sign())}async _sign(){try{this._url=await Kt(this.hass,this.docId)}catch{this._failed=!0}}render(){return this._failed||!this.docId?d:this._url?l` this._failed=!0} /> - `:l`
`}};D.styles=x` + `:l`
`}};D.styles=E` .wrap { display: inline-block; margin-top: 4px; } /* #161: uniform 96px tiles โ€” several photos sit in a strip, so a tiny or portrait shot must not collapse its slot. */ @@ -111,11 +87,11 @@ import{a as Zt,c as Yt,d as $t,e as Jt,f as pt,g as V,j as R,k as ie,l as ae}fro background: var(--secondary-background-color, rgba(0,0,0,0.06)); margin-top: 4px; } - `,u([A({attribute:!1})],D.prototype,"hass",2),u([A()],D.prototype,"docId",2),u([m()],D.prototype,"_url",2),u([m()],D.prototype,"_failed",2);customElements.get("maintenance-history-photo")||customElements.define("maintenance-history-photo",D);var b=class extends S{constructor(){super(...arguments);this._open=!1;this._saving=!1;this._error="";this._draft=null;this._originalSnapshot=null;this._partOptions=null;this._partQty={};this._partQtyOriginal="";this._openGen=0;this._photos=new re(this,{entryId:()=>this._draft?.entry_id??"",hass:()=>this.hass});this._photosOriginal="";this._readingRows=[];this._readingText={};this._readingsOriginal=""}get _lang(){return T(this.hass)}openEdit(t){this._openGen++,this._draft={...t},this._originalSnapshot={...t},this._error="",this._open=!0,this._partOptions=null,this._partQty={},this._partQtyOriginal="",this._photos.reset(t.photo_doc_ids??[]),this._photosOriginal=JSON.stringify(this._photos.ids),this._seedReadings(t),this._loadPartOptions()}_seedReadings(t){let i=[],n=new Set;for(let a of t.readings??[])n.has(a.id)||(n.add(a.id),i.push({id:a.id,name:a.name,unit:a.unit??null}));for(let a of t.reading_values??[])n.has(a.id)||(n.add(a.id),i.push({id:a.id,name:a.name,unit:a.unit??null}));this._readingRows=i;let o={};for(let a of t.reading_values??[])o[a.id]=String(a.value);this._readingText=o,this._readingsOriginal=JSON.stringify(this._readingNumbers())}_readingNumbers(){let t={};for(let i of this._readingRows){let n=(this._readingText[i.id]??"").trim();if(n==="")continue;let o=parseFloat(n.replace(",","."));isNaN(o)||(t[i.id]=o)}return t}async _loadPartOptions(){let t=this._draft;if(!t)return;let i=this._openGen;try{let n=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/parts/overview"});if(i!==this._openGen)return;let o=[];for(let c of n.parts||[]){let p=c.entry_id===t.entry_id,h=c.consumers.some(f=>f.entry_id===t.entry_id&&f.task_id===t.task_id);!p&&!h||o.push({part_id:c.part_id,name:c.name,entry_id:c.entry_id,foreign:!p,object_name:c.object_name})}for(let c of t.used_parts||[]){let p=c.entry_id||t.entry_id;o.some(h=>h.part_id===c.part_id&&h.entry_id===p)||o.push({part_id:c.part_id,name:c.name||c.part_id,entry_id:p,foreign:p!==t.entry_id,object_name:null})}let a={};for(let c of t.used_parts||[])a[`${c.entry_id||t.entry_id}:${c.part_id}`]=c.quantity??1;this._partOptions=o,this._partQty=a,this._partQtyOriginal=this._partSelectionKey()}catch{if(i!==this._openGen)return;this._partOptions=[]}}_partSelectionKey(){return JSON.stringify(Object.entries(this._partQty).filter(([,t])=>t>0).sort(([t],[i])=>t.localeCompare(i)))}close(){this._openGen++,this._open=!1,this._error="",this._draft=null,this._originalSnapshot=null,this._photos.discardOrphans()}_set(t,i){this._draft&&(this._draft={...this._draft,[t]:i})}async _delete(){if(!this._draft||!this._originalSnapshot)return;let t=this._lang,n=(this._originalSnapshot.used_parts||[]).filter(p=>p.quantity>0).map(p=>`${y(p.quantity,t)}\xD7 ${p.name||p.part_id}`).join(", ");if(!await N(this.hass,{title:r("history_delete_entry",t),message:n?`${r("history_delete_confirm_entry",t)} ${r("history_delete_returns_parts",t).replace("{parts}",n)}`:r("history_delete_confirm_entry",t),confirmText:r("delete",t),danger:!0})||!this._draft||!this._originalSnapshot)return;let a=this._draft;this._error="",await this._runWs({type:"maintenance_supporter/task/history/delete",entry_id:a.entry_id,task_id:a.task_id,timestamp:this._originalSnapshot.original_timestamp})!==void 0&&(this.dispatchEvent(new CustomEvent("history-entry-saved",{detail:{entry_id:a.entry_id,task_id:a.task_id,deleted:!0},bubbles:!0,composed:!0})),this.close())}_runWs(t){return I(this,t,{busy:i=>{this._saving=i},onError:i=>{this._error=i}})}async _save(){if(!this._draft||!this._originalSnapshot||this._saving||this._photos.uploading)return;this._error="",this._photos.clearError();let t=this._draft,i=this._originalSnapshot,n={type:"maintenance_supporter/task/history/update",entry_id:t.entry_id,task_id:t.task_id,original_timestamp:i.original_timestamp};if(t.timestamp!==i.timestamp&&(n.timestamp=t.timestamp),t.notes!==i.notes&&(n.notes=t.notes),t.cost!==i.cost&&(n.cost=t.cost),t.duration!==i.duration&&(n.duration=t.duration),t.completed_by!==i.completed_by&&(n.completed_by=t.completed_by),this._partOptions!==null&&this._partSelectionKey()!==this._partQtyOriginal&&(n.used_parts=(this._partOptions||[]).filter(a=>(this._partQty[`${a.entry_id}:${a.part_id}`]||0)>0).map(a=>({part_id:a.part_id,quantity:this._partQty[`${a.entry_id}:${a.part_id}`],...a.foreign?{entry_id:a.entry_id}:{}}))),t.reading_value!==i.reading_value&&(n.reading_value=t.reading_value??null),this._readingRows.length>0&&JSON.stringify(this._readingNumbers())!==this._readingsOriginal){let a=this._readingNumbers(),c={};for(let p of this._readingRows)c[p.id]=a[p.id]??null;n.reading_values=c}if(JSON.stringify(this._photos.ids)!==this._photosOriginal&&(n.photo_doc_ids=this._photos.ids),Object.keys(n).filter(a=>!["type","entry_id","task_id","original_timestamp"].includes(a)).length===0){this.close();return}await this._runWs(n)!==void 0&&(this._photos.markAttached(),this.dispatchEvent(new CustomEvent("history-entry-saved",{detail:{entry_id:t.entry_id,task_id:t.task_id,new_timestamp:t.timestamp},bubbles:!0,composed:!0})),this.close())}render(){if(!this._open||!this._draft)return d;let t=this._lang,i=this._draft,n=this._error||this._photos.errorText(t);return G(()=>this.close(),l` + `,u([w({attribute:!1})],D.prototype,"hass",2),u([w()],D.prototype,"docId",2),u([m()],D.prototype,"_url",2),u([m()],D.prototype,"_failed",2);customElements.get("maintenance-history-photo")||customElements.define("maintenance-history-photo",D);var b=class extends S{constructor(){super(...arguments);this._open=!1;this._saving=!1;this._error="";this._draft=null;this._originalSnapshot=null;this._partOptions=null;this._partQty={};this._partQtyOriginal="";this._openGen=0;this._photos=new re(this,{entryId:()=>this._draft?.entry_id??"",hass:()=>this.hass});this._photosOriginal="";this._readingRows=[];this._readingText={};this._readingsOriginal=""}get _lang(){return A(this.hass)}openEdit(t){this._openGen++,this._draft={...t},this._originalSnapshot={...t},this._error="",this._open=!0,this._partOptions=null,this._partQty={},this._partQtyOriginal="",this._photos.reset(t.photo_doc_ids??[]),this._photosOriginal=JSON.stringify(this._photos.ids),this._seedReadings(t),this._loadPartOptions()}_seedReadings(t){let n=[],i=new Set;for(let a of t.readings??[])i.has(a.id)||(i.add(a.id),n.push({id:a.id,name:a.name,unit:a.unit??null}));for(let a of t.reading_values??[])i.has(a.id)||(i.add(a.id),n.push({id:a.id,name:a.name,unit:a.unit??null}));this._readingRows=n;let o={};for(let a of t.reading_values??[])o[a.id]=String(a.value);this._readingText=o,this._readingsOriginal=JSON.stringify(this._readingNumbers())}_readingNumbers(){let t={};for(let n of this._readingRows){let i=(this._readingText[n.id]??"").trim();if(i==="")continue;let o=parseFloat(i.replace(",","."));isNaN(o)||(t[n.id]=o)}return t}async _loadPartOptions(){let t=this._draft;if(!t)return;let n=this._openGen;try{let i=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/parts/overview"});if(n!==this._openGen)return;let o=[];for(let c of i.parts||[]){let p=c.entry_id===t.entry_id,h=c.consumers.some(f=>f.entry_id===t.entry_id&&f.task_id===t.task_id);!p&&!h||o.push({part_id:c.part_id,name:c.name,entry_id:c.entry_id,foreign:!p,object_name:c.object_name})}for(let c of t.used_parts||[]){let p=c.entry_id||t.entry_id;o.some(h=>h.part_id===c.part_id&&h.entry_id===p)||o.push({part_id:c.part_id,name:c.name||c.part_id,entry_id:p,foreign:p!==t.entry_id,object_name:null})}let a={};for(let c of t.used_parts||[])a[`${c.entry_id||t.entry_id}:${c.part_id}`]=c.quantity??1;this._partOptions=o,this._partQty=a,this._partQtyOriginal=this._partSelectionKey()}catch{if(n!==this._openGen)return;this._partOptions=[]}}_partSelectionKey(){return JSON.stringify(Object.entries(this._partQty).filter(([,t])=>t>0).sort(([t],[n])=>t.localeCompare(n)))}close(){this._openGen++,this._open=!1,this._error="",this._draft=null,this._originalSnapshot=null,this._photos.discardOrphans()}_set(t,n){this._draft&&(this._draft={...this._draft,[t]:n})}async _delete(){if(!this._draft||!this._originalSnapshot)return;let t=this._lang,i=(this._originalSnapshot.used_parts||[]).filter(p=>p.quantity>0).map(p=>`${y(p.quantity,t)}\xD7 ${p.name||p.part_id}`).join(", ");if(!await N(this.hass,{title:r("history_delete_entry",t),message:i?`${r("history_delete_confirm_entry",t)} ${r("history_delete_returns_parts",t).replace("{parts}",i)}`:r("history_delete_confirm_entry",t),confirmText:r("delete",t),danger:!0})||!this._draft||!this._originalSnapshot)return;let a=this._draft;this._error="",await this._runWs({type:"maintenance_supporter/task/history/delete",entry_id:a.entry_id,task_id:a.task_id,timestamp:this._originalSnapshot.original_timestamp})!==void 0&&(this.dispatchEvent(new CustomEvent("history-entry-saved",{detail:{entry_id:a.entry_id,task_id:a.task_id,deleted:!0},bubbles:!0,composed:!0})),this.close())}_runWs(t){return I(this,t,{busy:n=>{this._saving=n},onError:n=>{this._error=n}})}async _save(){if(!this._draft||!this._originalSnapshot||this._saving||this._photos.uploading)return;this._error="",this._photos.clearError();let t=this._draft,n=this._originalSnapshot,i={type:"maintenance_supporter/task/history/update",entry_id:t.entry_id,task_id:t.task_id,original_timestamp:n.original_timestamp};if(t.timestamp!==n.timestamp&&(i.timestamp=t.timestamp),t.notes!==n.notes&&(i.notes=t.notes),t.cost!==n.cost&&(i.cost=t.cost),t.duration!==n.duration&&(i.duration=t.duration),t.completed_by!==n.completed_by&&(i.completed_by=t.completed_by),this._partOptions!==null&&this._partSelectionKey()!==this._partQtyOriginal&&(i.used_parts=(this._partOptions||[]).filter(a=>(this._partQty[`${a.entry_id}:${a.part_id}`]||0)>0).map(a=>({part_id:a.part_id,quantity:this._partQty[`${a.entry_id}:${a.part_id}`],...a.foreign?{entry_id:a.entry_id}:{}}))),t.reading_value!==n.reading_value&&(i.reading_value=t.reading_value??null),this._readingRows.length>0&&JSON.stringify(this._readingNumbers())!==this._readingsOriginal){let a=this._readingNumbers(),c={};for(let p of this._readingRows)c[p.id]=a[p.id]??null;i.reading_values=c}if(JSON.stringify(this._photos.ids)!==this._photosOriginal&&(i.photo_doc_ids=this._photos.ids),Object.keys(i).filter(a=>!["type","entry_id","task_id","original_timestamp"].includes(a)).length===0){this.close();return}await this._runWs(i)!==void 0&&(this._photos.markAttached(),this.dispatchEvent(new CustomEvent("history-entry-saved",{detail:{entry_id:t.entry_id,task_id:t.task_id,new_timestamp:t.timestamp},bubbles:!0,composed:!0})),this.close())}render(){if(!this._open||!this._draft)return d;let t=this._lang,n=this._draft,i=this._error||this._photos.errorText(t);return G(()=>this.close(),l`

${r("history_edit_title",t)}

- ${r(i.type,t)||i.type} + ${r(n.type,t)||n.type}
{let a=o.detail.value;a&&this._set("timestamp",a)}} >
${r("cost",t)} - {let a=o.detail.value;this._set("cost",a?Number(a):null)}}>
- ${this._renderReadings(i,t)} + ${this._renderReadings(n,t)} ${this._partOptions&&this._partOptions.length>0?l`
${r("complete_parts_used",t)} @@ -181,7 +157,7 @@ import{a as Zt,c as Yt,d as $t,e as Jt,f as pt,g as V,j as R,k as ie,l as ae}fro >`} ${r("history_edit_photos_hint",t)}
- ${n?l`
${n}
`:d} + ${i?l`
${i}
`:d}
- `)}updated(t){t.has("_open")&&this._open&&U(this.shadowRoot)}_renderReadings(t,i){return this._readingRows.length>0?l` + `)}updated(t){t.has("_open")&&this._open&&U(this.shadowRoot)}_renderReadings(t,n){return this._readingRows.length>0?l`
- ${r("readings_section",i)} - ${this._readingRows.map(n=>l` + ${r("readings_section",n)} + ${this._readingRows.map(i=>l` `)}
`:t.reading_value==null&&t.task_type!=="reading"?d:l` `}};b.styles=[se,W,x` + @input=${i=>{let o=i.target.value,a=o===""?NaN:Number(o);this._set("reading_value",isNaN(a)?null:a)}} /> + `}};b.styles=[se,W,E` :host { display: contents; --ms-modal-max-height: 90vh; --ms-modal-gap: 12px; } h2 { margin: 0; font-size: 18px; } .entry-type { @@ -301,93 +277,93 @@ import{a as Zt,c as Yt,d as $t,e as Jt,f as pt,g as V,j as R,k as ie,l as ae}fro } /* the camera / gallery pickers come from photoPickerStyles (ms-photo-picker) */ .photos-hint { font-size: 12px; color: var(--secondary-text-color); } - `],u([A({attribute:!1})],b.prototype,"hass",2),u([m()],b.prototype,"_open",2),u([m()],b.prototype,"_saving",2),u([m()],b.prototype,"_error",2),u([m()],b.prototype,"_draft",2),u([m()],b.prototype,"_partOptions",2),u([m()],b.prototype,"_partQty",2),u([m()],b.prototype,"_readingRows",2),u([m()],b.prototype,"_readingText",2);customElements.get("maintenance-history-edit-dialog")||customElements.define("maintenance-history-edit-dialog",b);function de(e,s,t=new Date){let i=e?.hours,n=rt(e?.snoozed_until);if(typeof i!="number"||!Number.isFinite(n))return r("snoozed",s);let o=it(),c=F(n,o)===F(t,o)?at(new Date(n),s):ot(e.snoozed_until,s);return(i===1?r("snoozed_for_one",s):r("snoozed_for",s).replace("{hours}",y(i,s))).replace("{until}",c)}function Ne(e,s){if(s<=0)return 0;let t=typeof e=="number"&&Number.isFinite(e)?Math.trunc(e):0;return t<0?0:t%s}function je(e){return!!(e?.phases&&e.phase_sequence&&e.phase_sequence.length>0)}function wt(e){if(!e||!je(e))return null;let s=e.phase_sequence,t=Ne(e.phase_cursor,s.length),i=s[t],n=e.phases?.[i];return n?{id:i,name:n.name,index:t,count:s.length,notes:n.notes,checklist:n.checklist!==void 0?n.checklist:e.checklist??[],consumesParts:n.consumes_parts!==void 0?n.consumes_parts:e.consumes_parts??[],requiredFields:n.required_completion_fields!==void 0?n.required_completion_fields:e.required_completion_fields??[]}:null}function Z(e){let s=wt(e);return s?`${s.index+1}/${s.count} \xB7 ${s.name}`:""}function qe(e,s){return typeof e=="number"&&Number.isFinite(e)&&e>=.01?e:s}function ce(e){let s=e.task??null,t=s?wt(s):null,i=t?t.consumesParts:s?.consumes_parts||[],n=!!s?.part_ref,o=e.objects.find(f=>f.entry_id===e.entryId)?.parts||[],a=n?o.find(f=>f.id===s.part_ref.part_id):void 0,c=e.features?e.features.checklists:e.checklistsEnabled??!0,p=e.features?e.features.checklists?e.checklist??s?.checklist??[]:[]:e.checklist??[],h=e.features?e.features.adaptive&&(e.adaptiveEnabled??!!s?.adaptive_config?.enabled):!!e.adaptiveEnabled;return{entry_id:e.entryId,task_id:e.taskId,task_name:e.taskName,checklist:t?c?t.checklist:[]:p,adaptive_enabled:h,required_completion_fields:t?t.requiredFields:s?.required_completion_fields||[],task_type:s?.type||"",reading_unit:s?.reading_unit||"",readings:s?.readings||[],reading_history:te(s?.history),parts:n?[]:Xt({consumes_parts:i},e.entryId,e.objects,e.lang),consumes_parts:n?[]:i,phase_label:t?Z(s):"",require_tag_scan:!!s?.require_tag_scan,restock_default:n?qe(a?.restock_quantity,1):null,restock_unit_cost:n?a?.cost??null:null,restock_package:n&&a?.package_size?qt(a.package_size,a.unit,e.lang):"",currency_symbol:B({currency_symbol:e.currencySymbol}),consumes_info:i.map(f=>Qt(f,e.entryId,e.objects,e.lang)),checklist_prefill:s?.checklist_progress||{},via_tag_scan:!!e.viaTagScan,parts_cost_mode:e.partsCostMode??"purchase"}}function pe(e,s,t){e.entryId=s.entry_id,e.taskId=s.task_id,e.taskName=s.task_name,e.lang=t,e.checklist=s.checklist??[],e.adaptiveEnabled=!!s.adaptive_enabled,e.requiredFields=s.required_completion_fields??[],e.taskType=s.task_type??"",e.readingUnit=s.reading_unit??"",e.readings=s.readings??[],e.readingHistory=s.reading_history??[],e.parts=s.parts??[],e.consumesParts=s.consumes_parts??[],e.phaseLabel=s.phase_label??"",e.requireTagScan=!!s.require_tag_scan,e.restockDefault=s.restock_default??null,e.restockUnitCost=s.restock_unit_cost??null,e.restockPackage=s.restock_package??"",e.currencySymbol=B(s),e.consumesInfo=s.consumes_info??[],e.checklistPrefill=s.checklist_prefill??{},e.viaTagScan=!!s.via_tag_scan,e.partsCostMode=s.parts_cost_mode??"purchase",e.open({viaTagScan:!!s.via_tag_scan})}var Y=(e,s)=>{let t=e._$AN;if(t===void 0)return!1;for(let i of t)i._$AO?.(s,!1),Y(i,s);return!0},ut=e=>{let s,t;do{if((s=e._$AM)===void 0)break;t=s._$AN,t.delete(e),e=s}while(t?.size===0)},ue=e=>{for(let s;s=e._$AM;e=s){let t=s._$AN;if(t===void 0)s._$AN=t=new Set;else if(t.has(e))break;t.add(e),We(s)}};function Fe(e){this._$AN!==void 0?(ut(this),this._$AM=e,ue(this)):this._$AM=e}function Ve(e,s=!1,t=0){let i=this._$AH,n=this._$AN;if(n!==void 0&&n.size!==0)if(s)if(Array.isArray(i))for(let o=t;o{e.type==Yt.CHILD&&(e._$AP??=Ve,e._$AQ??=Fe)},ht=class extends Jt{constructor(){super(...arguments),this._$AN=void 0}_$AT(s,t,i){super._$AT(s,t,i),ue(this),this.isConnected=s._$AU}_$AO(s,t=!0){s!==this.isConnected&&(this.isConnected=s,s?this.reconnected?.():this.disconnected?.()),t&&(Y(this,s),ut(this))}setValue(s){if(Zt(this._$Ct))this._$Ct._$AI(s,this);else{let t=[...this._$Ct._$AH];t[this._$Ci]=s,this._$Ct._$AI(t,this,0)}}disconnected(){}reconnected(){}};var At=new WeakMap,he=$t(class extends ht{render(e){return d}update(e,[s]){let t=s!==this.G;return t&&this.G!==void 0&&this.rt(void 0),(t||this.lt!==this.ct)&&(this.G=s,this.ht=e.options?.host,this.rt(this.ct=e.element)),d}rt(e){if(this.isConnected||(e=void 0),typeof this.G=="function"){let s=this.ht??globalThis,t=At.get(s);t===void 0&&(t=new WeakMap,At.set(s,t)),t.get(this.G)!==void 0&&this.G.call(this.ht,void 0),t.set(this.G,e),e!==void 0&&this.G.call(this.ht,e)}else this.G.value=e}get lt(){return typeof this.G=="function"?At.get(this.ht??globalThis)?.get(this.G):this.G?.value}disconnected(){this.lt===this.ct&&this.rt(void 0)}reconnected(){this.rt(this.ct)}});var Ge=new Set(["about","blob","data","file","filesystem","javascript","livescript","ms-appinstaller","ms-cxh","ms-cxh-full","ms-msdt","ms-officecmd","search","search-ms","view-source","vbscript"]);function Tt(e){return e.replace(/[\t\n\r]/g,"").replace(/^[\u0000-\u0020]+|[\u0000-\u0020]+$/g,"")}function Et(e){let s=/^([a-z][a-z0-9+.-]+):/i.exec(Tt(e||""));return s?s[1].toLowerCase():null}function mt(e){let s=Et(e);return s!==null&&!Ge.has(s)}function me(e){return!!e&&/^https?:\/\//i.test(e)}function ft(e){if(!e)return d;if(customElements.get("ha-markdown")){let{content:s,links:t}=Ke(e);return l`0)}function wt(e){if(!e||!Ne(e))return null;let s=e.phase_sequence,t=ze(e.phase_cursor,s.length),n=s[t],i=e.phases?.[n];return i?{id:n,name:i.name,index:t,count:s.length,notes:i.notes,checklist:i.checklist!==void 0?i.checklist:e.checklist??[],consumesParts:i.consumes_parts!==void 0?i.consumes_parts:e.consumes_parts??[],requiredFields:i.required_completion_fields!==void 0?i.required_completion_fields:e.required_completion_fields??[]}:null}function Z(e){let s=wt(e);return s?`${s.index+1}/${s.count} \xB7 ${s.name}`:""}function je(e,s){return typeof e=="number"&&Number.isFinite(e)&&e>=.01?e:s}function ce(e){let s=e.task??null,t=s?wt(s):null,n=t?t.consumesParts:s?.consumes_parts||[],i=!!s?.part_ref,o=e.objects.find(f=>f.entry_id===e.entryId)?.parts||[],a=i?o.find(f=>f.id===s.part_ref.part_id):void 0,c=e.features?e.features.checklists:e.checklistsEnabled??!0,p=e.features?e.features.checklists?e.checklist??s?.checklist??[]:[]:e.checklist??[],h=e.features?e.features.adaptive&&(e.adaptiveEnabled??!!s?.adaptive_config?.enabled):!!e.adaptiveEnabled;return{entry_id:e.entryId,task_id:e.taskId,task_name:e.taskName,checklist:t?c?t.checklist:[]:p,adaptive_enabled:h,required_completion_fields:t?t.requiredFields:s?.required_completion_fields||[],task_type:s?.type||"",reading_unit:s?.reading_unit||"",readings:s?.readings||[],reading_history:te(s?.history),parts:i?[]:Xt({consumes_parts:n},e.entryId,e.objects,e.lang),consumes_parts:i?[]:n,phase_label:t?Z(s):"",require_tag_scan:!!s?.require_tag_scan,restock_default:i?je(a?.restock_quantity,1):null,restock_unit_cost:i?a?.cost??null:null,restock_package:i&&a?.package_size?qt(a.package_size,a.unit,e.lang):"",currency_symbol:B({currency_symbol:e.currencySymbol}),consumes_info:n.map(f=>Qt(f,e.entryId,e.objects,e.lang)),checklist_prefill:s?.checklist_progress||{},via_tag_scan:!!e.viaTagScan,parts_cost_mode:e.partsCostMode??"purchase"}}function pe(e,s,t){e.entryId=s.entry_id,e.taskId=s.task_id,e.taskName=s.task_name,e.lang=t,e.checklist=s.checklist??[],e.adaptiveEnabled=!!s.adaptive_enabled,e.requiredFields=s.required_completion_fields??[],e.taskType=s.task_type??"",e.readingUnit=s.reading_unit??"",e.readings=s.readings??[],e.readingHistory=s.reading_history??[],e.parts=s.parts??[],e.consumesParts=s.consumes_parts??[],e.phaseLabel=s.phase_label??"",e.requireTagScan=!!s.require_tag_scan,e.restockDefault=s.restock_default??null,e.restockUnitCost=s.restock_unit_cost??null,e.restockPackage=s.restock_package??"",e.currencySymbol=B(s),e.consumesInfo=s.consumes_info??[],e.checklistPrefill=s.checklist_prefill??{},e.viaTagScan=!!s.via_tag_scan,e.partsCostMode=s.parts_cost_mode??"purchase",e.open({viaTagScan:!!s.via_tag_scan})}var Y=(e,s)=>{let t=e._$AN;if(t===void 0)return!1;for(let n of t)n._$AO?.(s,!1),Y(n,s);return!0},ut=e=>{let s,t;do{if((s=e._$AM)===void 0)break;t=s._$AN,t.delete(e),e=s}while(t?.size===0)},ue=e=>{for(let s;s=e._$AM;e=s){let t=s._$AN;if(t===void 0)s._$AN=t=new Set;else if(t.has(e))break;t.add(e),Ve(s)}};function qe(e){this._$AN!==void 0?(ut(this),this._$AM=e,ue(this)):this._$AM=e}function Fe(e,s=!1,t=0){let n=this._$AH,i=this._$AN;if(i!==void 0&&i.size!==0)if(s)if(Array.isArray(n))for(let o=t;o{e.type==Yt.CHILD&&(e._$AP??=Fe,e._$AQ??=qe)},ht=class extends Jt{constructor(){super(...arguments),this._$AN=void 0}_$AT(s,t,n){super._$AT(s,t,n),ue(this),this.isConnected=s._$AU}_$AO(s,t=!0){s!==this.isConnected&&(this.isConnected=s,s?this.reconnected?.():this.disconnected?.()),t&&(Y(this,s),ut(this))}setValue(s){if(Zt(this._$Ct))this._$Ct._$AI(s,this);else{let t=[...this._$Ct._$AH];t[this._$Ci]=s,this._$Ct._$AI(t,this,0)}}disconnected(){}reconnected(){}};var At=new WeakMap,he=$t(class extends ht{render(e){return d}update(e,[s]){let t=s!==this.G;return t&&this.G!==void 0&&this.rt(void 0),(t||this.lt!==this.ct)&&(this.G=s,this.ht=e.options?.host,this.rt(this.ct=e.element)),d}rt(e){if(this.isConnected||(e=void 0),typeof this.G=="function"){let s=this.ht??globalThis,t=At.get(s);t===void 0&&(t=new WeakMap,At.set(s,t)),t.get(this.G)!==void 0&&this.G.call(this.ht,void 0),t.set(this.G,e),e!==void 0&&this.G.call(this.ht,e)}else this.G.value=e}get lt(){return typeof this.G=="function"?At.get(this.ht??globalThis)?.get(this.G):this.G?.value}disconnected(){this.lt===this.ct&&this.rt(void 0)}reconnected(){this.rt(this.ct)}});var We=new Set(["about","blob","data","file","filesystem","javascript","livescript","ms-appinstaller","ms-cxh","ms-cxh-full","ms-msdt","ms-officecmd","search","search-ms","view-source","vbscript"]);function Tt(e){return e.replace(/[\t\n\r]/g,"").replace(/^[\u0000-\u0020]+|[\u0000-\u0020]+$/g,"")}function Lt(e){let s=/^([a-z][a-z0-9+.-]+):/i.exec(Tt(e||""));return s?s[1].toLowerCase():null}function mt(e){let s=Lt(e);return s!==null&&!We.has(s)}function me(e){return!!e&&/^https?:\/\//i.test(e)}function ft(e){if(!e)return d;if(customElements.get("ha-markdown")){let{content:s,links:t}=Be(e);return l`Ze(i,t))} - >`}return l`${e}`}var St="https://app-link.invalid/",Ue=new Set(["http","https","mailto","tel","ftp"]),Be=/(```[\s\S]*?```|~~~[\s\S]*?~~~|`[^`\n]*`)/,_t="[a-zA-Z][a-zA-Z0-9+.-]+:";function Ke(e){let s=[],t=o=>{let a=Et(o);return!a||Ue.has(a)||!mt(o)?null:(s.push(Tt(o)),`${St}${s.length-1}`)},i=o=>o.replace(new RegExp(`(!?)(\\[[^\\]\\n]*\\]\\(\\s*)<(${_t}[^>\\n]*)>`,"g"),(a,c,p,h)=>{let f=c?null:t(h);return f?`${p}${f}`:a}).replace(new RegExp(`(!?)(\\[[^\\]\\n]*\\]\\(\\s*)(${_t}[^\\s)]*)`,"g"),(a,c,p,h)=>{let f=c?null:t(h);return f?`${p}${f}`:a}).replace(new RegExp(`(^|[^(])<(${_t}[^\\s<>]*)>`,"gm"),(a,c,p)=>{let h=t(p);return h?`${c}[${p.replace(/([\\[\]])/g,"\\$1")}](${h})`:a}).replace(new RegExp(`^( {0,3}\\[[^\\]\\n]+\\]:[ \\t]*)]*)>?`,"gm"),(a,c,p)=>{let h=t(p);return h?`${c}${h}`:a});return{content:e.split(Be).map((o,a)=>a%2?o:i(o)).join(""),links:s}}function Lt(e,s){for(let t of e.querySelectorAll(`a[href^="${St}"]`)){let i=s[Number(t.getAttribute("href").slice(St.length))];i&&t.setAttribute("href",i)}}var _e=new WeakMap;function Ze(e,s){if(!e)return;let t=_e.get(e);if(!t&&s.length===0)return;if(t){t.links=s,e.shadowRoot&&Lt(e.shadowRoot,s);return}let i={links:s};_e.set(e,i),(async()=>{await e.updateComplete;let n=e.shadowRoot;n&&(new MutationObserver(()=>Lt(n,i.links)).observe(n,{childList:!0,subtree:!0}),Lt(n,i.links))})()}function Pt(e,s,t,i){let n=t,o=p=>typeof p=="string"?p:null,a=p=>typeof p=="number"?p:null,c=o(n.timestamp)??"";return{entry_id:e,task_id:s,original_timestamp:c,type:o(n.type)||"completed",timestamp:c,notes:o(n.notes),cost:a(n.cost),duration:a(n.duration),completed_by:o(n.completed_by),used_parts:Array.isArray(n.used_parts)?n.used_parts:null,photo_doc_ids:ct(n),reading_value:a(n.reading_value),reading_values:dt(n),readings:i?.readings??[],task_type:i?.type??null,reading_unit:i?.reading_unit??null}}async function yi(e,s,t,i){let o=(await e.connection.sendMessagePromise({type:"maintenance_supporter/object",entry_id:s})).tasks?.find(c=>c.id===t);if(!o)return null;let a=o.history?.find(c=>c.timestamp===i);return a||(a=(await e.connection.sendMessagePromise({type:"maintenance_supporter/task/history",entry_id:s,task_id:t})).history?.find(p=>p.timestamp===i)),a?Pt(s,t,a,o):null}var fe=e=>typeof e=="number"&&Number.isInteger(e)&&e>0;function Ye(e){return e&&fe(e.ref_no)?String(e.ref_no):null}function ge(e,s){let t=Ye(e);return t&&s&&fe(s.ref_no)?`${t}.${s.ref_no}`:null}function $i(e){let s=/^(\d+)(?:\.(\d+)(?:-(\d+))?)?$/.exec(e.trim());return s?{object:Number(s[1]),task:s[2]?Number(s[2]):null,entry:s[3]?Number(s[3]):null}:null}function ye(e,s){return e?l`#${e}`:d}var Je=["completed","reset","skipped"];function Ht(e){let s=t=>{let i=rt(t.timestamp);return Number.isNaN(i)?-1/0:i};return(e??[]).map((t,i)=>({entry:t,index:i,at:s(t)})).sort((t,i)=>i.at-t.at||i.index-t.index).map(t=>t.entry)}var Qe=["completed","skipped","missed","reset","triggered","trigger_replaced","trigger_removed"];function Ri(e,s){let t=s.lang;return l` + ${he(n=>Ke(n,t))} + >`}return l`${e}`}var St="https://app-link.invalid/",Ge=new Set(["http","https","mailto","tel","ftp"]),Ue=/(```[\s\S]*?```|~~~[\s\S]*?~~~|`[^`\n]*`)/,_t="[a-zA-Z][a-zA-Z0-9+.-]+:";function Be(e){let s=[],t=o=>{let a=Lt(o);return!a||Ge.has(a)||!mt(o)?null:(s.push(Tt(o)),`${St}${s.length-1}`)},n=o=>o.replace(new RegExp(`(!?)(\\[[^\\]\\n]*\\]\\(\\s*)<(${_t}[^>\\n]*)>`,"g"),(a,c,p,h)=>{let f=c?null:t(h);return f?`${p}${f}`:a}).replace(new RegExp(`(!?)(\\[[^\\]\\n]*\\]\\(\\s*)(${_t}[^\\s)]*)`,"g"),(a,c,p,h)=>{let f=c?null:t(h);return f?`${p}${f}`:a}).replace(new RegExp(`(^|[^(])<(${_t}[^\\s<>]*)>`,"gm"),(a,c,p)=>{let h=t(p);return h?`${c}[${p.replace(/([\\[\]])/g,"\\$1")}](${h})`:a}).replace(new RegExp(`^( {0,3}\\[[^\\]\\n]+\\]:[ \\t]*)]*)>?`,"gm"),(a,c,p)=>{let h=t(p);return h?`${c}${h}`:a});return{content:e.split(Ue).map((o,a)=>a%2?o:n(o)).join(""),links:s}}function Et(e,s){for(let t of e.querySelectorAll(`a[href^="${St}"]`)){let n=s[Number(t.getAttribute("href").slice(St.length))];n&&t.setAttribute("href",n)}}var _e=new WeakMap;function Ke(e,s){if(!e)return;let t=_e.get(e);if(!t&&s.length===0)return;if(t){t.links=s,e.shadowRoot&&Et(e.shadowRoot,s);return}let n={links:s};_e.set(e,n),(async()=>{await e.updateComplete;let i=e.shadowRoot;i&&(new MutationObserver(()=>Et(i,n.links)).observe(i,{childList:!0,subtree:!0}),Et(i,n.links))})()}function Pt(e,s,t,n){let i=t,o=p=>typeof p=="string"?p:null,a=p=>typeof p=="number"?p:null,c=o(i.timestamp)??"";return{entry_id:e,task_id:s,original_timestamp:c,type:o(i.type)||"completed",timestamp:c,notes:o(i.notes),cost:a(i.cost),duration:a(i.duration),completed_by:o(i.completed_by),used_parts:Array.isArray(i.used_parts)?i.used_parts:null,photo_doc_ids:ct(i),reading_value:a(i.reading_value),reading_values:dt(i),readings:n?.readings??[],task_type:n?.type??null,reading_unit:n?.reading_unit??null}}async function yn(e,s,t,n){let o=(await e.connection.sendMessagePromise({type:"maintenance_supporter/object",entry_id:s})).tasks?.find(c=>c.id===t);if(!o)return null;let a=o.history?.find(c=>c.timestamp===n);return a||(a=(await e.connection.sendMessagePromise({type:"maintenance_supporter/task/history",entry_id:s,task_id:t})).history?.find(p=>p.timestamp===n)),a?Pt(s,t,a,o):null}var fe=e=>typeof e=="number"&&Number.isInteger(e)&&e>0;function Ze(e){return e&&fe(e.ref_no)?String(e.ref_no):null}function ge(e,s){let t=Ze(e);return t&&s&&fe(s.ref_no)?`${t}.${s.ref_no}`:null}function $n(e){let s=/^(\d+)(?:\.(\d+)(?:-(\d+))?)?$/.exec(e.trim());return s?{object:Number(s[1]),task:s[2]?Number(s[2]):null,entry:s[3]?Number(s[3]):null}:null}function ye(e,s){return e?l`#${e}`:d}var Ye=["completed","reset","skipped"];function Ht(e){let s=t=>{let n=rt(t.timestamp);return Number.isNaN(n)?-1/0:n};return(e??[]).map((t,n)=>({entry:t,index:n,at:s(t)})).sort((t,n)=>n.at-t.at||n.index-t.index).map(t=>t.entry)}var Je=["completed","skipped","missed","reset","triggered","trigger_replaced","trigger_removed"];function Rn(e,s){let t=s.lang;return l`
- ${Qe.map(i=>{let n=e.history.filter(o=>o.type===i).length;return n===0?d:l` - s.setFilter(s.filter===i?null:i)}> - ${r(i,t)} (${n}) + ${Je.map(n=>{let i=e.history.filter(o=>o.type===n).length;return i===0?d:l` + s.setFilter(s.filter===n?null:n)}> + ${r(n,t)} (${i}) `})} ${s.filter?l`s.setFilter(null)}>${r("show_all",t)}`:d}
- s.setSearch(i.target.value)} /> + s.setSearch(n.target.value)} />
- `}function Ci(e,s){let t=s.lang,i=s.filter?e.history.filter(n=>n.type===s.filter):e.history;if(s.search){let n=s.search.toLowerCase();i=i.filter(o=>vt(o.notes,s.search)||vt(xt(o.notes,t),s.search)||(ve(s,o)??"").toLowerCase().endsWith(n))}return i.length===0?l`

${r("no_history",t)}

`:l` + `}function Cn(e,s){let t=s.lang,n=s.filter?e.history.filter(i=>i.type===s.filter):e.history;if(s.search){let i=s.search.toLowerCase();n=n.filter(o=>vt(o.notes,s.search)||vt(xt(o.notes,t),s.search)||(ve(s,o)??"").toLowerCase().endsWith(i))}return n.length===0?l`

${r("no_history",t)}

`:l`
- ${Ht(i).map(n=>Rt(n,s))} + ${Ht(n).map(i=>Rt(i,s))}
- `}function ve(e,s){return e.taskRef&&s.ref_no?`${e.taskRef}-${s.ref_no}`:null}function Xe(e,s){let t=ct(s);return t.length>0?l`
- ${t.map(i=>l``)} -
`:d}function ts(e,s){let t=s.lang,i=a=>y(a,t,{maximumFractionDigits:3}),n=a=>a==null?"":` (${a>=0?"+":""}${i(a)})`,o=dt(e);return o.length>0?l`
+ `}function ve(e,s){return e.taskRef&&s.ref_no?`${e.taskRef}-${s.ref_no}`:null}function Qe(e,s){let t=ct(s);return t.length>0?l`
+ ${t.map(n=>l``)} +
`:d}function Xe(e,s){let t=s.lang,n=a=>y(a,t,{maximumFractionDigits:3}),i=a=>a==null?"":` (${a>=0?"+":""}${n(a)})`,o=dt(e);return o.length>0?l`
${o.map(a=>l` ${a.name} - ${i(a.value)}${a.unit?` ${a.unit}`:""}${n(s.readingSlotDelta?.(e,a.id))} + ${n(a.value)}${a.unit?` ${a.unit}`:""}${i(s.readingSlotDelta?.(e,a.id))} `)}
`:e.reading_value!=null?l`
${r("reading_label",t)} - ${i(e.reading_value)}${s.readingUnit?` ${s.readingUnit}`:""}${n(s.readingDelta?.(e))} -
`:d}function Rt(e,s,t={}){let i=s.lang,{compact:n=!1,showRef:o=!0,showBadges:a=!0,showEdit:c=!0}=t,p=s.openEdit,h=c&&!!p&&Je.includes(e.type),f=typeof e.parts_cost=="number"?e.parts_cost:null,k=e.cost_basis==="use",O=k&&!!e.purchase;return l` -
- ${n?d:l`
+ ${n(e.reading_value)}${s.readingUnit?` ${s.readingUnit}`:""}${i(s.readingDelta?.(e))} +
`:d}function Rt(e,s,t={}){let n=s.lang,{compact:i=!1,showRef:o=!0,showBadges:a=!0,showEdit:c=!0}=t,p=s.openEdit,h=c&&!!p&&Ye.includes(e.type),f=typeof e.parts_cost=="number"?e.parts_cost:null,x=e.cost_basis==="use",O=x&&!!e.purchase;return l` +
+ ${i?d:l`
`}
- ${r(e.type,i)} - ${o?ye(ve(s,e),r("ref_number",i)):d} + ${r(e.type,n)} + ${o?ye(ve(s,e),r("ref_number",n)):d} ${a&&e.phase_id?l`${s.phaseNames?.[e.phase_id]||e.phase_id}`:d} - ${a&&e.auto?l`${r("history_auto",i)}`:d} + ${a&&e.auto?l`${r("history_auto",n)}`:d} ${h?l``:d}
-
${ot(e.timestamp,i)}
- ${e.notes?l`
${xt(e.notes,i)}
`:d} - ${Xe(s.hass,e)} - ${ts(e,s)} +
${ot(e.timestamp,n)}
+ ${e.notes?l`
${xt(e.notes,n)}
`:d} + ${Qe(s.hass,e)} + ${Xe(e,s)} ${e.cost!=null||e.duration!=null||e.trigger_value!=null||f!=null?l`
- ${e.cost!=null?O?l`${r("history_purchase_stock",i).replace("{amount}",z(e.cost,s.currencySymbol,i))}`:e.cost<0?l`${r("cost_kind_credit",i)}: ${z(-e.cost,s.currencySymbol,i)}`:l`${r("cost",i)}: ${z(e.cost,s.currencySymbol,i)}`:d} - ${f!=null?l`${r(k?"history_parts_counted":"history_parts_info",i).replace("{amount}",z(f,s.currencySymbol,i))}`:d} - ${e.duration!=null?l`${r("duration",i)}: ${lt(e.duration,i)}`:d} - ${e.trigger_value!=null?l`${r("trigger_val",i)}: ${e.trigger_value}`:d} + ${e.cost!=null?O?l`${r("history_purchase_stock",n).replace("{amount}",z(e.cost,s.currencySymbol,n))}`:e.cost<0?l`${r("cost_kind_credit",n)}: ${z(-e.cost,s.currencySymbol,n)}`:l`${r("cost",n)}: ${z(e.cost,s.currencySymbol,n)}`:d} + ${f!=null?l`${r(x?"history_parts_counted":"history_parts_info",n).replace("{amount}",z(f,s.currencySymbol,n))}`:d} + ${e.duration!=null?l`${r("duration",n)}: ${lt(e.duration,n)}`:d} + ${e.trigger_value!=null?l`${r("trigger_val",n)}: ${e.trigger_value}`:d}
`:d}
- `}var es="var(--maint-done-color, #78909c)";function J(e){return e.archived?"archived":e.is_done?"done":e.status||"ok"}function Ct(e,s){return r(e==="done"?"completed":e,s)}function gt(e){let s=J(e);return s==="done"?es:It[s]||"var(--disabled-color, #9e9e9e)"}function ss(e){return st[e==="done"?"completed":e]||"mdi:circle-medium"}function be(e,s,t="pill"){let i=J(e),n=Ct(i,s);return t==="chip"?l`${n}`:l`${n}`}function _(e){return e.toFixed(1)}function qi(e,s,t=4){if(!isFinite(e)||!isFinite(s))return{ticks:[],niceMin:0,niceMax:1};if(e===s){let h=Math.abs(e)*.1||1;e-=h,s+=h}let i=s-e,n=Math.pow(10,Math.floor(Math.log10(i/Math.max(1,t)))),o=n;for(let h of[1,2,5,10])if(o=n*h,i/o<=t+.5)break;let a=Math.floor(e/o)*o,c=Math.ceil(s/o)*o,p=[];for(let h=a;h<=c+o*1e-6;h+=o)p.push(Math.abs(h)({maximumFractionDigits:n});return t>=1e6?y(e/1e6,s,i(t>=1e7?0:1))+"M":t>=1e4?y(e/1e3,s,i(0))+"k":t>=1e3?y(e/1e3,s,i(1))+"k":t>=100?y(e,s,i(0)):t>=1?y(e,s,i(1)):t===0?"0":y(e,s,i(2))}function Vi(e,s,t){let i=y(e,t,{maximumFractionDigits:Math.abs(e)>=100?0:1});return s?`${i} ${s}`:i}function Wi(e,s,t){return bt(new Date(e),s,t)}function Gi(e,s){let t=new Date(e);return`${bt(t,s)}, ${at(t,s)}`}function Ui(e,s){if(!Number.isFinite(e)||!Number.isFinite(s))return!1;let t=it();return F(e,t).slice(0,4)!==F(s,t).slice(0,4)}function Bi(e,s,t){if(t<2||s<=e)return[e,s];let i=[];for(let n=0;n${i}`:l`${i}`}function _(e){return e.toFixed(1)}function qn(e,s,t=4){if(!isFinite(e)||!isFinite(s))return{ticks:[],niceMin:0,niceMax:1};if(e===s){let h=Math.abs(e)*.1||1;e-=h,s+=h}let n=s-e,i=Math.pow(10,Math.floor(Math.log10(n/Math.max(1,t)))),o=i;for(let h of[1,2,5,10])if(o=i*h,n/o<=t+.5)break;let a=Math.floor(e/o)*o,c=Math.ceil(s/o)*o,p=[];for(let h=a;h<=c+o*1e-6;h+=o)p.push(Math.abs(h)({maximumFractionDigits:i});return t>=1e6?y(e/1e6,s,n(t>=1e7?0:1))+"M":t>=1e4?y(e/1e3,s,n(0))+"k":t>=1e3?y(e/1e3,s,n(1))+"k":t>=100?y(e,s,n(0)):t>=1?y(e,s,n(1)):t===0?"0":y(e,s,n(2))}function Vn(e,s,t){let n=y(e,t,{maximumFractionDigits:Math.abs(e)>=100?0:1});return s?`${n} ${s}`:n}function Wn(e,s,t){return bt(new Date(e),s,t)}function Gn(e,s){let t=new Date(e);return`${bt(t,s)}, ${at(t,s)}`}function Un(e,s){if(!Number.isFinite(e)||!Number.isFinite(s))return!1;let t=nt();return F(e,t).slice(0,4)!==F(s,t).slice(0,4)}function Bn(e,s,t){if(t<2||s<=e)return[e,s];let n=[];for(let i=0;i
${r("weibull_reliability_curve",s)} - ${is(i,s)} + ${is(n,s)}
- ${ns(i,n,o,a,s)} - ${rs(t,s)} - ${t?.confidence_interval_low!=null?as(t,e,s):d} + ${rs(n,i,o,a,s)} + ${as(t,s)} + ${t?.confidence_interval_low!=null?os(t,e,s):d}
- `}function is(e,s){let t,i,n;return e<.8?(t="early_failures",i="M13,14H11V10H13M13,18H11V16H13M1,21H23L12,2L1,21Z",n="beta_early_failures"):e<=1.2?(t="random_failures",i="M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M13,17H11V15H13V17M13,13H11V7H13V13Z",n="beta_random_failures"):e<=3.5?(t="wear_out",i="M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2M12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20A8,8 0 0,0 20,12A8,8 0 0,0 12,4M12,6A6,6 0 0,1 18,12H12V6Z",n="beta_wear_out"):(t="highly_predictable",i="M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2M11,16.5L18,9.5L16.59,8.09L11,13.67L7.91,10.59L6.5,12L11,16.5Z",n="beta_highly_predictable"),l` + `}function is(e,s){let t,n,i;return e<.8?(t="early_failures",n="M13,14H11V10H13M13,18H11V16H13M1,21H23L12,2L1,21Z",i="beta_early_failures"):e<=1.2?(t="random_failures",n="M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M13,17H11V15H13V17M13,13H11V7H13V13Z",i="beta_random_failures"):e<=3.5?(t="wear_out",n="M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2M12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20A8,8 0 0,0 20,12A8,8 0 0,0 12,4M12,6A6,6 0 0,1 18,12H12V6Z",i="beta_wear_out"):(t="highly_predictable",n="M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2M11,16.5L18,9.5L16.59,8.09L11,13.67L7.91,10.59L6.5,12L11,16.5Z",i="beta_highly_predictable"),l` - - ${r(n,s)} (\u03B2=${y(e,s,2)}) + + ${r(i,s)} (\u03B2=${y(e,s,2)}) - `}function ns(e,s,t,i,n){let $=Math.max(t,i,s,1)*1.3,P=50,H=[];for(let w=0;w<=P;w++){let L=w/P*$,Me=1-Math.exp(-Math.pow(L/s,e)),Ie=32+L/$*260,De=136-Me*128;H.push([Ie,De])}let X=H.map(([w,L])=>`${_(w)},${_(L)}`).join(" "),yt="M32,136 "+H.map(([w,L])=>`L${_(w)},${_(L)}`).join(" ")+` L${_(H[P][0])},136 Z`,j=32+t/$*260,tt=1-Math.exp(-Math.pow(t/s,e)),et=136-tt*128,Re=y((1-tt)*100,n,0),Mt=32+i/$*260,Ce=[0,.25,.5,.75,1];return l` + `}function rs(e,s,t,n,i){let $=Math.max(t,n,s,1)*1.3,P=50,H=[];for(let k=0;k<=P;k++){let L=k/P*$,Me=1-Math.exp(-Math.pow(L/s,e)),Ie=32+L/$*260,De=136-Me*128;H.push([Ie,De])}let X=H.map(([k,L])=>`${_(k)},${_(L)}`).join(" "),yt="M32,136 "+H.map(([k,L])=>`L${_(k)},${_(L)}`).join(" ")+` L${_(H[P][0])},136 Z`,j=32+t/$*260,tt=1-Math.exp(-Math.pow(t/s,e)),et=136-tt*128,Re=y((1-tt)*100,i,0),Mt=32+n/$*260,Ce=[0,.25,.5,.75,1];return l`
- - ${Ce.map(w=>{let L=136-w*128;return q` + + ${Ce.map(k=>{let L=136-k*128;return q` + stroke="var(--divider-color)" stroke-width="0.5" stroke-dasharray="${k===.5?"4,3":d}" /> ${y(w*100,n,0)}% + font-size="8" text-anchor="end">${y(k*100,i,0)}% `})} 0 @@ -407,7 +383,7 @@ import{a as Zt,c as Yt,d as $t,e as Jt,f as pt,g as V,j as R,k as ie,l as ae}fro font-size="9" font-weight="600">R=${Re}% `:d} - ${i>0&&i!==t?q` + ${n>0&&n!==t?q` `:d} @@ -419,11 +395,11 @@ import{a as Zt,c as Yt,d as $t,e as Jt,f as pt,g as V,j as R,k as ie,l as ae}fro
- ${r("weibull_failure_probability",n)} - ${t>0?l` ${r("current_interval_marker",n)}`:d} - ${i>0&&i!==t?l` ${r("recommended_marker",n)}`:d} + ${r("weibull_failure_probability",i)} + ${t>0?l` ${r("current_interval_marker",i)}`:d} + ${n>0&&n!==t?l` ${r("recommended_marker",i)}`:d}
- `}function rs(e,s){return l` + `}function as(e,s){return l`
${r("characteristic_life",s)} @@ -436,22 +412,22 @@ import{a as Zt,c as Yt,d as $t,e as Jt,f as pt,g as V,j as R,k as ie,l as ae}fro
`:d}
- `}function as(e,s,t){let i=e.confidence_interval_low,n=e.confidence_interval_high,o=s.suggested_interval??s.interval_days??0,a=s.interval_days??0,c=Math.max(0,i-5),h=n+5-c,f=(i-c)/h*100,k=(n-i)/h*100,O=(o-c)/h*100,$=a>0?(a-c)/h*100:-1;return l` + `}function os(e,s,t){let n=e.confidence_interval_low,i=e.confidence_interval_high,o=s.suggested_interval??s.interval_days??0,a=s.interval_days??0,c=Math.max(0,n-5),h=i+5-c,f=(n-c)/h*100,x=(i-n)/h*100,O=(o-c)/h*100,$=a>0?(a-c)/h*100:-1;return l`
- ${r("confidence_interval",t)}: ${o} ${r("days",t)} (${i}\u2013${n}) + ${r("confidence_interval",t)}: ${o} ${r("days",t)} (${n}\u2013${i})
-
+
${$>=0?l`
`:d}
- ${r("confidence_conservative",t)} (${i}${r("days",t).charAt(0)}) - ${r("confidence_aggressive",t)} (${n}${r("days",t).charAt(0)}) + ${r("confidence_conservative",t)} (${n}${r("days",t).charAt(0)}) + ${r("confidence_aggressive",t)} (${i}${r("days",t).charAt(0)})
- `}function xe(e,s,t){let i=e.degradation_trend!=null&&e.degradation_trend!=="insufficient_data",n=e.days_until_threshold!=null,o=e.environmental_factor!=null&&e.environmental_factor!==1;if(!i&&!n&&!o)return d;let a=e.degradation_trend==="rising"?"M16,6L18.29,8.29L13.41,13.17L9.41,9.17L2,16.59L3.41,18L9.41,12L13.41,16L19.71,9.71L22,12V6H16Z":e.degradation_trend==="falling"?"M16,18L18.29,15.71L13.41,10.83L9.41,14.83L2,7.41L3.41,6L9.41,12L13.41,8L19.71,14.29L22,12V18H16Z":"M22,12L18,8V11H3V13H18V16L22,12Z";return l` + `}function xe(e,s,t){let n=e.degradation_trend!=null&&e.degradation_trend!=="insufficient_data",i=e.days_until_threshold!=null,o=e.environmental_factor!=null&&e.environmental_factor!==1;if(!n&&!i&&!o)return d;let a=e.degradation_trend==="rising"?"M16,6L18.29,8.29L13.41,13.17L9.41,9.17L2,16.59L3.41,18L9.41,12L13.41,16L19.71,9.71L22,12V6H16Z":e.degradation_trend==="falling"?"M16,18L18.29,15.71L13.41,10.83L9.41,14.83L2,7.41L3.41,6L9.41,12L13.41,8L19.71,14.29L22,12V18H16Z":"M22,12L18,8V11H3V13H18V16L22,12Z";return l`
${e.sensor_prediction_urgency?l`
@@ -464,7 +440,7 @@ import{a as Zt,c as Yt,d as $t,e as Jt,f as pt,g as V,j as R,k as ie,l as ae}fro ${r("sensor_prediction",s)}
- ${i?l` + ${n?l`
${r("degradation_trend",s)} @@ -472,7 +448,7 @@ import{a as Zt,c as Yt,d as $t,e as Jt,f as pt,g as V,j as R,k as ie,l as ae}fro ${e.degradation_rate!=null?l`${e.degradation_rate>0?"+":""}${y(e.degradation_rate,s,Math.abs(e.degradation_rate)>=10?0:1)} ${e.trigger_entity_info?.unit_of_measurement||""}/${r("day_short",s)}`:d}
`:d} - ${n?l` + ${i?l`
${r("days_until_threshold",s)} @@ -492,32 +468,32 @@ import{a as Zt,c as Yt,d as $t,e as Jt,f as pt,g as V,j as R,k as ie,l as ae}fro `:d}
- `}function ke(e,s,t,i){let n=Math.max(e||1,s);return l` + `}function ke(e,s,t,n){let i=Math.max(e||1,s);return l`
- ${r("current",i)}: ${e??"\u2014"} ${e!=null?r("days",i):""} + ${r("current",n)}: ${e??"\u2014"} ${e!=null?r("days",n):""}
+ style="width: ${e!=null?Math.min(e/i*100,100):0}%">
- ${r("recommended",i)}: ${s} ${r("days",i)} - ${r(`confidence_${t}`,i)} + ${r("recommended",n)}: ${s} ${r("days",n)} + ${r(`confidence_${t}`,n)}
+ style="width: ${Math.min(s/i*100,100)}%">
- `}var we=["month_jan","month_feb","month_mar","month_apr","month_may","month_jun","month_jul","month_aug","month_sep","month_oct","month_nov","month_dec"];function Ae(){return Number(nt().slice(5,7))-1}function Te(e,s,t){if(!t.seasonal||!e.seasonal_factor||e.seasonal_factor===1)return d;let i=we.map(c=>r(c,s)),n=Ae(),o=e.seasonal_factors||e.interval_analysis?.seasonal_factors||null,a=o&&o.length===12?o:i.map((c,p)=>{let h=e.seasonal_factor||1,f=Math.sin((p-6)*Math.PI/6)*.3;return Math.max(.7,Math.min(1.3,h+f))});return l` + `}var we=["month_jan","month_feb","month_mar","month_apr","month_may","month_jun","month_jul","month_aug","month_sep","month_oct","month_nov","month_dec"];function Ae(){return Number(it().slice(5,7))-1}function Te(e,s,t){if(!t.seasonal||!e.seasonal_factor||e.seasonal_factor===1)return d;let n=we.map(c=>r(c,s)),i=Ae(),o=e.seasonal_factors||e.interval_analysis?.seasonal_factors||null,a=o&&o.length===12?o:n.map((c,p)=>{let h=e.seasonal_factor||1,f=Math.sin((p-6)*Math.PI/6)*.3;return Math.max(.7,Math.min(1.3,h+f))});return l`

${r("seasonal_awareness",s)}

${a.map((c,p)=>{let h=c*40,f=c<.9?"low":c>1.1?"high":"normal";return l` -
+ title="${n[p]}: ${y(c,s,2)}x">
`})}
@@ -527,101 +503,101 @@ import{a as Zt,c as Yt,d as $t,e as Jt,f as pt,g as V,j as R,k as ie,l as ae}fro ${r("longer",s)}
- `}function Ee(e,s){return os(e,s)}function os(e,s){let t=e.seasonal_factors??e.interval_analysis?.seasonal_factors;if(!t||t.length!==12)return d;let i=e.interval_analysis?.seasonal_reason,n=Ae(),o=300,a=100,c=8,h=a-c-4,f=Math.max(...t,1.5),k=o/12,O=k*.65,$=c+h-1/f*h;return l` + `}function Le(e,s){return ls(e,s)}function ls(e,s){let t=e.seasonal_factors??e.interval_analysis?.seasonal_factors;if(!t||t.length!==12)return d;let n=e.interval_analysis?.seasonal_reason,i=Ae(),o=300,a=100,c=8,h=a-c-4,f=Math.max(...t,1.5),x=o/12,O=x*.65,$=c+h-1/f*h;return l`
${r("seasonal_chart_title",s)} - ${i?l`${i==="learned"?r("seasonal_learned",s):r("seasonal_manual",s)}`:d} + ${n?l`${n==="learned"?r("seasonal_learned",s):r("seasonal_manual",s)}`:d}
- ${t.map((P,H)=>{let X=P/f*h,yt=H*k+(k-O)/2,j=c+h-X,tt=H===n,et=P<1?"var(--success-color, #4caf50)":P>1?"var(--warning-color, #ff9800)":"var(--secondary-text-color)";return q` + ${t.map((P,H)=>{let X=P/f*h,yt=H*x+(x-O)/2,j=c+h-X,tt=H===i,et=P<1?"var(--success-color, #4caf50)":P>1?"var(--warning-color, #ff9800)":"var(--secondary-text-color)";return q` `})}
- ${we.map((P,H)=>l`${r(P,s)}`)} + ${we.map((P,H)=>l`${r(P,s)}`)}
- `}var g=class extends S{constructor(){super(...arguments);this._open=!1;this._entryId=null;this._taskId=null;this._task=null;this._objectName="";this._taskRef=null;this._busy=!1;this._error="";this._showSkip=!1;this._showReset=!1;this._showPostpone=!1;this._showDetails=!1;this._showAdaptive=!1;this._skipReason="";this._resetDate="";this._postponeDate="";this._features={adaptive:!1,seasonal:!1,environmental:!1,budget:!1,groups:!1,checklists:!1,schedule_time:!1,completion_actions:!1,places:!1};this._toast="";this._toastTimer=new ae;this._access=pt;this._currencySymbol="";this._partsCostMode="purchase";this._loadSeq=0}get _lang(){return T(this.hass)}async openFor(t,i){(t!==this._entryId||i!==this._taskId)&&(this._task=null),this._entryId=t,this._taskId=i,this._error="",this._showSkip=!1,this._showReset=!1,this._showPostpone=!1,this._showAdaptive=!1,this._skipReason="",this._postponeDate="",this._resetDate=nt(),this._open=!0,await Promise.all([this._loadTask(),this._loadFeatures()])}async _loadFeatures(){let t=await R(this.hass);this._features={...this._features,...t.features},this._access=t.access,this._currencySymbol=B(t.budget??void 0),Nt(t.budget??void 0),this._partsCostMode=t.partsCostMode}close(){this._loadSeq++,this._open=!1,this._task=null,this._error="",this._toastTimer.clear(),this._toast=""}_showToast(t,i){this._toast=t,this._toastTimer.schedule(()=>{this._toast=""},i)}async _loadTask(){let t=this._entryId,i=this._taskId;if(!t||!i)return;let n=++this._loadSeq,o="",a=await I(this,{type:"maintenance_supporter/object",entry_id:t},{onError:p=>{o=p}});if(n!==this._loadSeq)return;if(a===void 0){this._error=o;return}this._objectName=a?.object?.name||"";let c=(a?.tasks||[]).find(p=>p.id===i);this._task=c??null,this._taskRef=ge(a?.object,c),c||(this._error=r("ws_err_not_found",this._lang))}_runWs(t){return this._error="",I(this,t,{busy:i=>{this._busy=i},onError:i=>{this._error=i}})}_notifyChanged(t){this.dispatchEvent(new CustomEvent("task-action-fired",{detail:{entry_id:this._entryId,task_id:this._taskId,action:t},bubbles:!0,composed:!0}))}_onComplete(){!this._entryId||!this._taskId||!this._task||import("/maintenance_supporter_panelfiles/panel-chunks/dialog-mount-4OSOFBP3.js").then(async({openCompleteDialog:t})=>{let i=this._task,n=[];try{n=(await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/objects",compact:!0})).objects||[]}catch{}t(ce({entryId:this._entryId,taskId:this._taskId,taskName:i.name,task:i,objects:n,lang:this._lang,features:this._features,currencySymbol:this._currencySymbol,partsCostMode:this._partsCostMode}))&&(this._notifyChanged("complete"),this.close())})}async _onSkipConfirm(){if(!this._entryId||!this._taskId)return;await this._runWs({type:"maintenance_supporter/task/skip",entry_id:this._entryId,task_id:this._taskId,reason:this._skipReason.trim()||null})!==void 0&&(this._notifyChanged("skip"),this.close())}async _onResetConfirm(){if(!this._entryId||!this._taskId)return;await this._runWs({type:"maintenance_supporter/task/reset",entry_id:this._entryId,task_id:this._taskId,date:this._resetDate||void 0})!==void 0&&(this._notifyChanged("reset"),this.close())}async _onPostponeConfirm(){if(!this._entryId||!this._taskId||!this._postponeDate)return;await this._runWs({type:"maintenance_supporter/task/postpone",entry_id:this._entryId,task_id:this._taskId,until:this._postponeDate})!==void 0&&(this._notifyChanged("postpone"),this.close())}async _onSnooze(){if(!this._entryId||!this._taskId)return;let t=await this._runWs({type:"maintenance_supporter/task/snooze",entry_id:this._entryId,task_id:this._taskId});t!==void 0&&(this._notifyChanged("snooze"),this.dispatchEvent(new CustomEvent("hass-notification",{detail:{message:de(t,this._lang)},bubbles:!0,composed:!0})),this.close())}_onEdit(){!this._entryId||!this._taskId||import("/maintenance_supporter_panelfiles/panel-chunks/dialog-mount-4OSOFBP3.js").then(({openEditTaskDialog:t})=>{t(this._entryId,this._taskId),this.close()})}_onQr(){!this._entryId||!this._taskId||!this._task||import("/maintenance_supporter_panelfiles/panel-chunks/dialog-mount-4OSOFBP3.js").then(({openQrDialog:t})=>{t({entry_id:this._entryId,task_id:this._taskId,task_name:this._task.name,object_name:this._objectName}),this.close()})}async _onDelete(){if(!this._entryId||!this._taskId||!await N(this.hass,{title:r("delete",this._lang),message:r("delete_task_confirm",this._lang),confirmText:r("delete",this._lang),danger:!0}))return;await this._runWs({type:"maintenance_supporter/task/delete",entry_id:this._entryId,task_id:this._taskId})!==void 0&&(this._notifyChanged("delete"),this.close())}async _onArchive(){if(!this._entryId||!this._taskId)return;await this._runWs({type:"maintenance_supporter/task/archive",entry_id:this._entryId,task_id:this._taskId})!==void 0&&(this._notifyChanged("archive"),this.close())}async _onUnarchive(){if(!this._entryId||!this._taskId)return;await this._runWs({type:"maintenance_supporter/task/unarchive",entry_id:this._entryId,task_id:this._taskId})!==void 0&&(this._notifyChanged("unarchive"),this.close())}_onOpenInPanel(){if(!this._entryId||!this._taskId)return;let t=`/maintenance-supporter?entry_id=${encodeURIComponent(this._entryId)}&task_id=${encodeURIComponent(this._taskId)}`;history.pushState(null,"",t),window.dispatchEvent(new CustomEvent("location-changed")),this.close()}async _applySuggestion(){if(!this._entryId||!this._taskId||!this._task?.suggested_interval)return;await this._runWs({type:"maintenance_supporter/task/apply_suggestion",entry_id:this._entryId,task_id:this._taskId,interval:this._task.suggested_interval})!==void 0&&(this._showToast(r("suggestion_applied",this._lang)),this._notifyChanged("apply_suggestion"),await this._loadTask())}async _reanalyzeInterval(){if(!this._entryId||!this._taskId)return;let t=await this._runWs({type:"maintenance_supporter/task/analyze_interval",entry_id:this._entryId,task_id:this._taskId});t!==void 0&&(this._showToast(t?.recommended_interval?`${r("reanalyze_result",this._lang)}: ${Vt(t.recommended_interval,"days",this._lang)} (${t.data_points} ${r("data_points",this._lang)})`:r("reanalyze_insufficient_data",this._lang)),await this._loadTask())}_onEditHistoryEntry(t){if(!this._entryId||!this._taskId)return;let i=Pt(this._entryId,this._taskId,t,this._task);import("/maintenance_supporter_panelfiles/panel-chunks/dialog-mount-4OSOFBP3.js").then(({openHistoryEditDialog:n})=>n(i))}_renderRecommendation(t){if(!this._features.adaptive||!t.suggested_interval||t.suggested_interval===t.interval_days)return d;let i=this._lang;return l` + `}var g=class extends S{constructor(){super(...arguments);this._open=!1;this._entryId=null;this._taskId=null;this._task=null;this._objectName="";this._taskRef=null;this._busy=!1;this._error="";this._showSkip=!1;this._showReset=!1;this._showPostpone=!1;this._showDetails=!1;this._showAdaptive=!1;this._skipReason="";this._resetDate="";this._postponeDate="";this._features={adaptive:!1,seasonal:!1,environmental:!1,budget:!1,groups:!1,checklists:!1,schedule_time:!1,completion_actions:!1,places:!1};this._toast="";this._toastTimer=new ae;this._access=pt;this._currencySymbol="";this._partsCostMode="purchase";this._loadSeq=0}get _lang(){return A(this.hass)}async openFor(t,n){(t!==this._entryId||n!==this._taskId)&&(this._task=null),this._entryId=t,this._taskId=n,this._error="",this._showSkip=!1,this._showReset=!1,this._showPostpone=!1,this._showAdaptive=!1,this._skipReason="",this._postponeDate="",this._resetDate=it(),this._open=!0,await Promise.all([this._loadTask(),this._loadFeatures()])}async _loadFeatures(){let t=await R(this.hass);this._features={...this._features,...t.features},this._access=t.access,this._currencySymbol=B(t.budget??void 0),Nt(t.budget??void 0),this._partsCostMode=t.partsCostMode}close(){this._loadSeq++,this._open=!1,this._task=null,this._error="",this._toastTimer.clear(),this._toast=""}_showToast(t,n){this._toast=t,this._toastTimer.schedule(()=>{this._toast=""},n)}async _loadTask(){let t=this._entryId,n=this._taskId;if(!t||!n)return;let i=++this._loadSeq,o="",a=await I(this,{type:"maintenance_supporter/object",entry_id:t},{onError:p=>{o=p}});if(i!==this._loadSeq)return;if(a===void 0){this._error=o;return}this._objectName=a?.object?.name||"";let c=(a?.tasks||[]).find(p=>p.id===n);this._task=c??null,this._taskRef=ge(a?.object,c),c||(this._error=r("ws_err_not_found",this._lang))}_runWs(t){return this._error="",I(this,t,{busy:n=>{this._busy=n},onError:n=>{this._error=n}})}_notifyChanged(t){this.dispatchEvent(new CustomEvent("task-action-fired",{detail:{entry_id:this._entryId,task_id:this._taskId,action:t},bubbles:!0,composed:!0}))}_onComplete(){!this._entryId||!this._taskId||!this._task||import("/maintenance_supporter_panelfiles/panel-chunks/dialog-mount-3S3JR273.js").then(async({openCompleteDialog:t})=>{let n=this._task,i=[];try{i=(await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/objects",compact:!0})).objects||[]}catch{}t(ce({entryId:this._entryId,taskId:this._taskId,taskName:n.name,task:n,objects:i,lang:this._lang,features:this._features,currencySymbol:this._currencySymbol,partsCostMode:this._partsCostMode}))&&(this._notifyChanged("complete"),this.close())})}async _onSkipConfirm(){if(!this._entryId||!this._taskId)return;await this._runWs({type:"maintenance_supporter/task/skip",entry_id:this._entryId,task_id:this._taskId,reason:this._skipReason.trim()||null})!==void 0&&(this._notifyChanged("skip"),this.close())}async _onResetConfirm(){if(!this._entryId||!this._taskId)return;await this._runWs({type:"maintenance_supporter/task/reset",entry_id:this._entryId,task_id:this._taskId,date:this._resetDate||void 0})!==void 0&&(this._notifyChanged("reset"),this.close())}async _onPostponeConfirm(){if(!this._entryId||!this._taskId||!this._postponeDate)return;await this._runWs({type:"maintenance_supporter/task/postpone",entry_id:this._entryId,task_id:this._taskId,until:this._postponeDate})!==void 0&&(this._notifyChanged("postpone"),this.close())}async _onSnooze(){if(!this._entryId||!this._taskId)return;let t=await this._runWs({type:"maintenance_supporter/task/snooze",entry_id:this._entryId,task_id:this._taskId});t!==void 0&&(this._notifyChanged("snooze"),this.dispatchEvent(new CustomEvent("hass-notification",{detail:{message:de(t,this._lang)},bubbles:!0,composed:!0})),this.close())}_onEdit(){!this._entryId||!this._taskId||import("/maintenance_supporter_panelfiles/panel-chunks/dialog-mount-3S3JR273.js").then(({openEditTaskDialog:t})=>{t(this._entryId,this._taskId),this.close()})}_onQr(){!this._entryId||!this._taskId||!this._task||import("/maintenance_supporter_panelfiles/panel-chunks/dialog-mount-3S3JR273.js").then(({openQrDialog:t})=>{t({entry_id:this._entryId,task_id:this._taskId,task_name:this._task.name,object_name:this._objectName}),this.close()})}async _onDelete(){if(!this._entryId||!this._taskId||!await N(this.hass,{title:r("delete",this._lang),message:r("delete_task_confirm",this._lang),confirmText:r("delete",this._lang),danger:!0}))return;await this._runWs({type:"maintenance_supporter/task/delete",entry_id:this._entryId,task_id:this._taskId})!==void 0&&(this._notifyChanged("delete"),this.close())}async _onArchive(){if(!this._entryId||!this._taskId)return;await this._runWs({type:"maintenance_supporter/task/archive",entry_id:this._entryId,task_id:this._taskId})!==void 0&&(this._notifyChanged("archive"),this.close())}async _onUnarchive(){if(!this._entryId||!this._taskId)return;await this._runWs({type:"maintenance_supporter/task/unarchive",entry_id:this._entryId,task_id:this._taskId})!==void 0&&(this._notifyChanged("unarchive"),this.close())}_onOpenInPanel(){if(!this._entryId||!this._taskId)return;let t=`/maintenance-supporter?entry_id=${encodeURIComponent(this._entryId)}&task_id=${encodeURIComponent(this._taskId)}`;history.pushState(null,"",t),window.dispatchEvent(new CustomEvent("location-changed")),this.close()}async _applySuggestion(){if(!this._entryId||!this._taskId||!this._task?.suggested_interval)return;await this._runWs({type:"maintenance_supporter/task/apply_suggestion",entry_id:this._entryId,task_id:this._taskId,interval:this._task.suggested_interval})!==void 0&&(this._showToast(r("suggestion_applied",this._lang)),this._notifyChanged("apply_suggestion"),await this._loadTask())}async _reanalyzeInterval(){if(!this._entryId||!this._taskId)return;let t=await this._runWs({type:"maintenance_supporter/task/analyze_interval",entry_id:this._entryId,task_id:this._taskId});t!==void 0&&(this._showToast(t?.recommended_interval?`${r("reanalyze_result",this._lang)}: ${Vt(t.recommended_interval,"days",this._lang)} (${t.data_points} ${r("data_points",this._lang)})`:r("reanalyze_insufficient_data",this._lang)),await this._loadTask())}_onEditHistoryEntry(t){if(!this._entryId||!this._taskId)return;let n=Pt(this._entryId,this._taskId,t,this._task);import("/maintenance_supporter_panelfiles/panel-chunks/dialog-mount-3S3JR273.js").then(({openHistoryEditDialog:i})=>i(n))}_renderRecommendation(t){if(!this._features.adaptive||!t.suggested_interval||t.suggested_interval===t.interval_days)return d;let n=this._lang;return l`
-

${r("suggested_interval",i)}

- ${ke(t.interval_days,t.suggested_interval,t.interval_confidence||"medium",i)} +

${r("suggested_interval",n)}

+ ${ke(t.interval_days,t.suggested_interval,t.interval_confidence||"medium",n)}
${V(this.hass?.user,this._access)?l``:d}
- `}_renderAdaptive(t){let i=this._lang,n=this._features.adaptive&&t.suggested_interval&&t.suggested_interval!==t.interval_days,o=t.degradation_trend!=null&&t.degradation_trend!=="insufficient_data"||t.days_until_threshold!=null||t.environmental_factor!=null&&t.environmental_factor!==1,a=this._features.adaptive&&t.interval_analysis?.weibull_beta!=null&&t.interval_analysis?.weibull_eta!=null,c=this._features.seasonal&&t.seasonal_factor&&t.seasonal_factor!==1;return!n&&!o&&!a&&!c?l`
- ${r("adaptive_no_data",i)} + `}_renderAdaptive(t){let n=this._lang,i=this._features.adaptive&&t.suggested_interval&&t.suggested_interval!==t.interval_days,o=t.degradation_trend!=null&&t.degradation_trend!=="insufficient_data"||t.days_until_threshold!=null||t.environmental_factor!=null&&t.environmental_factor!==1,a=this._features.adaptive&&t.interval_analysis?.weibull_beta!=null&&t.interval_analysis?.weibull_eta!=null,c=this._features.seasonal&&t.seasonal_factor&&t.seasonal_factor!==1;return!i&&!o&&!a&&!c?l`
+ ${r("adaptive_no_data",n)}
`:l`
${this._toast?l`
${this._toast}
`:d} - ${n?this._renderRecommendation(t):d} - ${o?xe(t,i,this._features):d} - ${a?$e(t,i):d} + ${i?this._renderRecommendation(t):d} + ${o?xe(t,n,this._features):d} + ${a?$e(t,n):d} ${c?l` - ${Te(t,i,this._features)} - ${t.seasonal_factors?.length===12||t.interval_analysis?.seasonal_factors?.length===12?Ee(t,i):d} + ${Te(t,n,this._features)} + ${t.seasonal_factors?.length===12||t.interval_analysis?.seasonal_factors?.length===12?Le(t,n):d} `:d}
- `}_renderDetails(t){let i=this._lang,n=t.history||[],o=t.history_count??n.length,a=Ht(n).slice(0,20);return l` + `}_renderDetails(t){let n=this._lang,i=t.history||[],o=t.history_count??i.length,a=Ht(i).slice(0,20);return l`
- ${r("times_performed",i)} + ${r("times_performed",n)} ${t.times_performed??0}
- ${r("total_cost",i)} - ${z(t.total_cost??0,this._currencySymbol,i)} + ${r("total_cost",n)} + ${z(t.total_cost??0,this._currencySymbol,n)}
- ${r("avg_duration",i)} - ${lt(t.average_duration!=null?Math.round(t.average_duration):null,i)} + ${r("avg_duration",n)} + ${lt(t.average_duration!=null?Math.round(t.average_duration):null,n)}
- ${r("history",i)} + ${r("history",n)} ${o}
- ${n.length===0?l`
${r("history_empty",i)}
`:l` + ${i.length===0?l`
${r("history_empty",n)}
`:l`
- ${a.map(c=>Rt(c,{lang:i,hass:this.hass,currencySymbol:this._currencySymbol,openEdit:V(this.hass?.user,this._access)?p=>this._onEditHistoryEntry(p):void 0,readingUnit:t.reading_unit,readingSlotDelta:(p,h)=>ee(n,p,h),taskRef:this._taskRef},{compact:!0}))} - ${o>a.length?l`
โ€ฆ +${o-a.length} ${r("older_entries",i)}
`:d} + ${a.map(c=>Rt(c,{lang:n,hass:this.hass,currencySymbol:this._currencySymbol,openEdit:V(this.hass?.user,this._access)?p=>this._onEditHistoryEntry(p):void 0,readingUnit:t.reading_unit,readingSlotDelta:(p,h)=>ee(i,p,h),taskRef:this._taskRef},{compact:!0}))} + ${o>a.length?l`
โ€ฆ +${o-a.length} ${r("older_entries",n)}
`:d}
`}
- `}render(){if(!this._open)return d;let t=this._lang,i=this._task,n=V(this.hass?.user,this._access),o=!!i&&(!!i.archived||i.enabled===!1||i.status==="paused");return G(()=>this.close(),l` - ${i?l` + `}render(){if(!this._open)return d;let t=this._lang,n=this._task,i=V(this.hass?.user,this._access),o=!!n&&(!!n.archived||n.enabled===!1||n.status==="paused");return G(()=>this.close(),l` + ${n?l`
- - ${i.name} - ${be(i,t)} + + ${n.name} + ${be(n,t)}
- +
- ${i.next_due?l`${r("next_due",t)}: ${K(i.next_due,t)}`:d} - ${i.last_performed?l`${r("last_performed",t)}: ${K(i.last_performed,t)}`:d} - ${i.schedule?.kind&&!["manual","one_time"].includes(i.schedule.kind)||i.interval_days!=null?l`${r("interval",t)}: ${Wt(i,t)}`:d} - ${Z(i)?l`${r("phase_current",t)}: ${Z(i)}`:d} + ${n.next_due?l`${r("next_due",t)}: ${K(n.next_due,t)}`:d} + ${n.last_performed?l`${r("last_performed",t)}: ${K(n.last_performed,t)}`:d} + ${n.schedule?.kind&&!["manual","one_time"].includes(n.schedule.kind)||n.interval_days!=null?l`${r("interval",t)}: ${Wt(n,t)}`:d} + ${Z(n)?l`${r("phase_current",t)}: ${Z(n)}`:d}
- ${i.notes?l`
${ft(i.notes)}
`:d} + ${n.notes?l`
${ft(n.notes)}
`:d}
${this._error?l`
${this._error}
`:d} @@ -685,7 +661,7 @@ import{a as Zt,c as Yt,d as $t,e as Jt,f as pt,g as V,j as R,k as ie,l as ae}fro ${r("complete",t)} - ${i.allow_skip!==!1?l` + ${n.allow_skip!==!1?l` {this._showSkip=!0}} .disabled=${this._busy}> ${r("skip",t)} @@ -700,7 +676,7 @@ import{a as Zt,c as Yt,d as $t,e as Jt,f as pt,g as V,j as R,k as ie,l as ae}fro Edit / Archive / Delete follow canWrite() โ€” the panel's operator delegation, not is_admin alone. -->
- ${n?l` + ${i?l` ${r("edit",t)} `:d} @@ -717,11 +693,11 @@ import{a as Zt,c as Yt,d as $t,e as Jt,f as pt,g as V,j as R,k as ie,l as ae}fro ${r("snooze",t)} `} - ${n?l` - - ${i.archived?r("unarchive",t):r("archive",t)} + + ${n.archived?r("unarchive",t):r("archive",t)} @@ -738,8 +714,8 @@ import{a as Zt,c as Yt,d as $t,e as Jt,f as pt,g as V,j as R,k as ie,l as ae}fro ${this._showAdaptive?r("hide_stats",t):r("show_stats",t)} `:d}
- ${this._showDetails?this._renderDetails(i):d} - ${this._showAdaptive?this._renderAdaptive(i):d} + ${this._showDetails?this._renderDetails(n):d} + ${this._showAdaptive?this._renderAdaptive(n):d} `} `:this._error?l`
${this._error}
`:l`
${r("loading",t)}
`} - `)}updated(t){t.has("_open")&&this._open&&U(this.shadowRoot)}};g.styles=[Bt,W,x` + `)}updated(t){t.has("_open")&&this._open&&U(this.shadowRoot)}};g.styles=[Bt,W,E` :host { display: contents; --ms-modal-max-width: 460px; } .header { display: flex; flex-direction: column; gap: 6px; } .title { display: flex; align-items: center; gap: 10px; } @@ -906,11 +882,11 @@ import{a as Zt,c as Yt,d as $t,e as Jt,f as pt,g as V,j as R,k as ie,l as ae}fro .weibull-section, .seasonal-card-compact { max-width: 100%; } .weibull-chart svg { max-width: 100%; height: auto; } .details-toggle { gap: 12px; flex-wrap: wrap; } - `],u([A({attribute:!1})],g.prototype,"hass",2),u([m()],g.prototype,"_open",2),u([m()],g.prototype,"_entryId",2),u([m()],g.prototype,"_taskId",2),u([m()],g.prototype,"_task",2),u([m()],g.prototype,"_objectName",2),u([m()],g.prototype,"_taskRef",2),u([m()],g.prototype,"_busy",2),u([m()],g.prototype,"_error",2),u([m()],g.prototype,"_showSkip",2),u([m()],g.prototype,"_showReset",2),u([m()],g.prototype,"_showPostpone",2),u([m()],g.prototype,"_showDetails",2),u([m()],g.prototype,"_showAdaptive",2),u([m()],g.prototype,"_skipReason",2),u([m()],g.prototype,"_resetDate",2),u([m()],g.prototype,"_postponeDate",2),u([m()],g.prototype,"_features",2),u([m()],g.prototype,"_toast",2),u([m()],g.prototype,"_access",2),u([m()],g.prototype,"_partsCostMode",2);customElements.get("maintenance-task-quick-actions-dialog")||customElements.define("maintenance-task-quick-actions-dialog",g);var E=class extends S{constructor(){super(...arguments);this._open=!1;this._entryId=null;this._data=null;this._busy=!1;this._error="";this._access=pt}get _lang(){return T(this.hass)}async openFor(t){this._entryId=t,this._error="",this._open=!0,await Promise.all([this._load(),R(this.hass).then(i=>{this._access=i.access})])}close(){this._open=!1,this._data=null,this._error=""}async _load(){if(!this._entryId)return;let t=await I(this,{type:"maintenance_supporter/object",entry_id:this._entryId},{onError:i=>{this._error=i}});t!==void 0&&(this._data=t)}_runWs(t){return this._error="",I(this,t,{busy:i=>{this._busy=i},onError:i=>{this._error=i}})}_onEditObject(){!this._entryId||!this._data||import("/maintenance_supporter_panelfiles/panel-chunks/dialog-mount-4OSOFBP3.js").then(({openEditObjectDialog:t})=>{t(this._entryId,this._data.object),this.close()})}_onAddTask(){this._entryId&&import("/maintenance_supporter_panelfiles/panel-chunks/dialog-mount-4OSOFBP3.js").then(({openCreateTaskDialog:t})=>{t(this._entryId),this.close()})}async _onDelete(){if(!this._entryId||!this._data||!await N(this.hass,{title:r("delete",this._lang),message:r("delete_object_confirm",this._lang),confirmText:r("delete",this._lang),danger:!0}))return;let i=this._entryId;await this._runWs({type:"maintenance_supporter/object/delete",entry_id:i})!==void 0&&(this.dispatchEvent(new CustomEvent("object-deleted",{detail:{entry_id:i},bubbles:!0,composed:!0})),this.close())}async _onArchiveObject(){if(!this._entryId||!this._data)return;let t=!!this._data.object.archived;if(!t&&!await N(this.hass,{title:r("archive_object",this._lang),message:r("confirm_archive_object",this._lang),confirmText:r("archive_object",this._lang)}))return;let i=this._entryId;await this._runWs({type:t?"maintenance_supporter/object/unarchive":"maintenance_supporter/object/archive",entry_id:i})!==void 0&&(this.dispatchEvent(new CustomEvent("object-changed",{detail:{entry_id:i},bubbles:!0,composed:!0})),this.close())}_onTaskClick(t){this._entryId&&import("/maintenance_supporter_panelfiles/panel-chunks/dialog-mount-4OSOFBP3.js").then(({openTaskQuickActions:i})=>{i(this._entryId,t)})}render(){if(!this._open)return d;let t=this._lang,i=this._data,n=i?.object,o=i?.tasks||[],a=V(this.hass?.user,this._access);return G(()=>this.close(),l` - ${i&&n?l` + `],u([w({attribute:!1})],g.prototype,"hass",2),u([m()],g.prototype,"_open",2),u([m()],g.prototype,"_entryId",2),u([m()],g.prototype,"_taskId",2),u([m()],g.prototype,"_task",2),u([m()],g.prototype,"_objectName",2),u([m()],g.prototype,"_taskRef",2),u([m()],g.prototype,"_busy",2),u([m()],g.prototype,"_error",2),u([m()],g.prototype,"_showSkip",2),u([m()],g.prototype,"_showReset",2),u([m()],g.prototype,"_showPostpone",2),u([m()],g.prototype,"_showDetails",2),u([m()],g.prototype,"_showAdaptive",2),u([m()],g.prototype,"_skipReason",2),u([m()],g.prototype,"_resetDate",2),u([m()],g.prototype,"_postponeDate",2),u([m()],g.prototype,"_features",2),u([m()],g.prototype,"_toast",2),u([m()],g.prototype,"_access",2),u([m()],g.prototype,"_partsCostMode",2);customElements.get("maintenance-task-quick-actions-dialog")||customElements.define("maintenance-task-quick-actions-dialog",g);var T=class extends S{constructor(){super(...arguments);this._open=!1;this._entryId=null;this._data=null;this._busy=!1;this._error="";this._access=pt}get _lang(){return A(this.hass)}async openFor(t){this._entryId=t,this._error="",this._open=!0,await Promise.all([this._load(),R(this.hass).then(n=>{this._access=n.access})])}close(){this._open=!1,this._data=null,this._error=""}async _load(){if(!this._entryId)return;let t=await I(this,{type:"maintenance_supporter/object",entry_id:this._entryId},{onError:n=>{this._error=n}});t!==void 0&&(this._data=t)}_runWs(t){return this._error="",I(this,t,{busy:n=>{this._busy=n},onError:n=>{this._error=n}})}_onEditObject(){!this._entryId||!this._data||import("/maintenance_supporter_panelfiles/panel-chunks/dialog-mount-3S3JR273.js").then(({openEditObjectDialog:t})=>{t(this._entryId,this._data.object),this.close()})}_onAddTask(){this._entryId&&import("/maintenance_supporter_panelfiles/panel-chunks/dialog-mount-3S3JR273.js").then(({openCreateTaskDialog:t})=>{t(this._entryId),this.close()})}async _onDelete(){if(!this._entryId||!this._data||!await N(this.hass,{title:r("delete",this._lang),message:r("delete_object_confirm",this._lang),confirmText:r("delete",this._lang),danger:!0}))return;let n=this._entryId;await this._runWs({type:"maintenance_supporter/object/delete",entry_id:n})!==void 0&&(this.dispatchEvent(new CustomEvent("object-deleted",{detail:{entry_id:n},bubbles:!0,composed:!0})),this.close())}async _onArchiveObject(){if(!this._entryId||!this._data)return;let t=!!this._data.object.archived;if(!t&&!await N(this.hass,{title:r("archive_object",this._lang),message:r("confirm_archive_object",this._lang),confirmText:r("archive_object",this._lang)}))return;let n=this._entryId;await this._runWs({type:t?"maintenance_supporter/object/unarchive":"maintenance_supporter/object/archive",entry_id:n})!==void 0&&(this.dispatchEvent(new CustomEvent("object-changed",{detail:{entry_id:n},bubbles:!0,composed:!0})),this.close())}_onTaskClick(t){this._entryId&&import("/maintenance_supporter_panelfiles/panel-chunks/dialog-mount-3S3JR273.js").then(({openTaskQuickActions:n})=>{n(this._entryId,t)})}render(){if(!this._open)return d;let t=this._lang,n=this._data,i=n?.object,o=n?.tasks||[],a=V(this.hass?.user,this._access);return G(()=>this.close(),l` + ${n&&i?l`
-
${n.name}
- ${this._renderMetaRow(n)} +
${i.name}
+ ${this._renderMetaRow(i)}
${this._error?l`
${this._error}
`:d} @@ -933,10 +909,10 @@ import{a as Zt,c as Yt,d as $t,e as Jt,f as pt,g as V,j as R,k as ie,l as ae}fro `}
- ${n.notes?l` + ${i.notes?l`
${r("object_notes_label",t)} -
${ft(n.notes)}
+
${ft(i.notes)}
`:d} @@ -951,8 +927,8 @@ import{a as Zt,c as Yt,d as $t,e as Jt,f as pt,g as V,j as R,k as ie,l as ae}fro ${r("edit",t)}