291 files

This commit is contained in:
Home Assistant Version Control
2026-10-14 18:10:18 +00:00
parent c4e0178d8a
commit 280501d2e8
291 changed files with 32540 additions and 3280 deletions
@@ -481,7 +481,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return
cam = data.get("camera")
if cam:
await cam.async_force_next()
await cam.async_force_next(_displayed_frame_id(call))
async def _previous_slide(call) -> None:
entry_id = call.data.get(ATTR_ENTRY_ID)
@@ -492,7 +492,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return
cam = data.get("camera")
if cam:
await cam.async_force_prev()
await cam.async_force_prev(_displayed_frame_id(call))
async def _refresh_album(call) -> None:
entry_id = call.data.get(ATTR_ENTRY_ID)
@@ -529,6 +529,12 @@ async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> Non
await hass.config_entries.async_reload(entry.entry_id)
def _displayed_frame_id(call) -> int | None:
"""Optional frame a card is showing, so navigation starts from it."""
frame_id = call.data.get("frame_id")
return frame_id if type(frame_id) is int and frame_id >= 0 else None
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
+180 -45
View File
@@ -2,8 +2,9 @@ from __future__ import annotations
import asyncio
from collections import OrderedDict, deque
from dataclasses import dataclass, replace
from dataclasses import dataclass, field, replace
from datetime import datetime, timezone
import itertools
import logging
import math
import random
@@ -64,6 +65,31 @@ _MAX_RENDER_ATTEMPTS = 10
# a small percentage rounds down to zero.
_PAIR_GAP_FLOOR = 2
# Recent frame IDs remembered so navigation can start from a card's displayed slide.
_FRAME_ID_MEMORY = 64
# Store settings that change how slides look or which photos play. Changing any
# of them discards rendered slides; other settings (interval, pause, buffer
# size, cache size) keep the navigation history.
_RENDER_SETTINGS = (
"fill_mode",
"portrait_mode",
"order_mode",
"shuffle_age_bias",
"aspect_ratio",
"pair_divider_px",
"pair_divider_color",
"pair_min_gap_percent",
"max_resolution",
"date_filter",
"custom_lookback_days",
"missing_date_mode",
"face_debug",
"hidden_revision",
)
_next_frame_serial = itertools.count(1).__next__
def _pair_exclusion_radius(n: int, min_gap_percent: float) -> int:
"""Items to exclude on each side of the current index for pairing.
@@ -100,6 +126,7 @@ class _RenderedFrame:
data: bytes
cursor: _NavigationCursor
meta: dict
serial: int = field(default_factory=_next_frame_serial, compare=False)
def _ts_to_iso(ts_ms: int | None) -> str | None:
@@ -125,6 +152,18 @@ def _camera_metadata_attributes(item: MediaItem | None) -> dict[str, Any]:
}
def _caption_frame(item: MediaItem | None) -> dict[str, Any]:
"""Caption metadata for one photo of a slide."""
return {
"captured_at": _ts_to_iso(getattr(item, "captured_at", None)),
"location": getattr(item, "location", None),
"latitude": getattr(item, "latitude", None),
"longitude": getattr(item, "longitude", None),
"description": getattr(item, "description", None),
**_camera_metadata_attributes(item),
}
def _item_faces(item: MediaItem | None) -> tuple[ip.FaceBox, ...] | None:
"""Return the validated face boxes stored on a media item.
@@ -225,9 +264,6 @@ class AlbumSlideshowCamera(Camera):
)
self._recent_urls: list[str] = []
self._last_is_portrait: bool | None = None
# When the current frame is a paired image, this is [taken_a, taken_b]
# ISO strings (top/left first); None for single frames.
self._last_captured_at_pair: list[str | None] | None = None
# Full per-half caption metadata for a paired frame: a list of two
# dicts (top/left first) each carrying captured_at / location /
# latitude / longitude. None for single frames. Lets the Lovelace
@@ -247,6 +283,8 @@ class AlbumSlideshowCamera(Camera):
# has an unambiguous "new frame ready" signal even when other
# attributes happen not to change between slides.
self._frame_id: int = 0
# Recent frame IDs mapped to frame serials, oldest first.
self._frame_serials: dict[int, int] = {}
self._interrupt_event: asyncio.Event = asyncio.Event()
self._slide_deadline: float | None = None
@@ -276,18 +314,31 @@ class AlbumSlideshowCamera(Camera):
self._last_nav_error: str | None = None
self._consecutive_failures: int = 0
self._render_task: asyncio.Task | None = None
self._render_settings = self._current_render_settings()
def _on_coordinator_update() -> None:
previous_items = self._effective_cache[1] if self._effective_cache else None
self._effective_cache = None
self._invalidate_timeline()
if self._keep_timeline(previous_items):
self.async_write_ha_state()
else:
self._invalidate_timeline()
coordinator.async_add_listener(_on_coordinator_update)
def _on_store_change() -> None:
self._download_cache.resize(self.store.image_cache_mb * 1024 * 1024)
self._effective_cache = None
self._slide_deadline = None
self._invalidate_timeline()
settings = self._current_render_settings()
if settings != self._render_settings:
self._render_settings = settings
self._effective_cache = None
self._invalidate_timeline()
return
# Interval, pause and buffer changes keep the rendered slides.
self._schedule_preload()
self._interrupt_event.set()
self.async_write_ha_state()
store.add_listener(_on_store_change)
@@ -344,7 +395,11 @@ class AlbumSlideshowCamera(Camera):
items: list[MediaItem] = self._effective_items()
cur = items[self._index] if items and 0 <= self._index < len(items) else None
captured_at = _ts_to_iso(getattr(cur, "captured_at", None))
captured_at_pair = self._last_captured_at_pair
caption_frames = self._caption_frames(cur)
captured_at_pair = (
[frame["captured_at"] for frame in caption_frames]
if self._last_pair_frames else None
)
return {
"entry_id": self.entry.entry_id,
"displayed_photo_ids": (
@@ -389,7 +444,7 @@ class AlbumSlideshowCamera(Camera):
# so the card can overlay an accurate date/location on each half.
# ``pair_orientation`` tells the card how the two halves are laid
# out: ``horizontal`` (left/right) or ``vertical`` (top/bottom).
"caption_frames": self._caption_frames(cur, captured_at),
"caption_frames": caption_frames,
"pair_orientation": self._last_pair_orientation,
"slide_interval": int(self.store.slide_interval),
"fill_mode": self.store.fill_mode,
@@ -422,7 +477,7 @@ class AlbumSlideshowCamera(Camera):
"pagination_debug": data.get("pagination_debug"),
}
def _caption_frames(self, cur, captured_at: str | None) -> list[dict]:
def _caption_frames(self, cur) -> list[dict]:
"""Per-image caption metadata for the current slide.
Returns a list with one dict for a normal slide, or two (top/left
@@ -430,18 +485,21 @@ class AlbumSlideshowCamera(Camera):
string or ``None``), ``location`` (human label or ``None``), and
``latitude`` / ``longitude``. The card reads this to overlay an
accurate caption on each image, including each half of a pair.
Paired captions are read from the live items so metadata found after
the slide was rendered still shows.
"""
if self._last_pair_frames:
if not self._last_pair_frames:
return [_caption_frame(cur)]
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
live = {
item.photo_id: item for item in self._effective_items()
if item.photo_id and item.photo_id in photo_ids
}
return [
{
"captured_at": captured_at,
"location": getattr(cur, "location", None),
"latitude": getattr(cur, "latitude", None),
"longitude": getattr(cur, "longitude", None),
"description": getattr(cur, "description", None),
**_camera_metadata_attributes(cur),
}
_caption_frame(live[photo_id]) if photo_id in live else frame
for photo_id, frame in zip(photo_ids, self._last_pair_frames)
]
@property
@@ -496,15 +554,19 @@ class AlbumSlideshowCamera(Camera):
self._effective_cache = (hash(cache_key), ordered)
return ordered
async def async_force_next(self) -> None:
"""Display the next buffered slide immediately."""
await self._async_navigate(1)
async def async_force_next(self, frame_id: int | None = None) -> None:
"""Display the next buffered slide immediately.
async def async_force_prev(self) -> None:
``frame_id`` is the frame a card is showing; when it is still retained,
the step starts from that slide rather than the camera's latest one.
"""
await self._async_navigate(1, frame_id)
async def async_force_prev(self, frame_id: int | None = None) -> None:
"""Display the previous retained slide immediately."""
await self._async_navigate(-1)
await self._async_navigate(-1, frame_id)
async def _async_navigate(self, direction: int) -> None:
async def _async_navigate(self, direction: int, frame_id: int | None = None) -> None:
"""Serialise a manual navigation request and execute it directly."""
direction_name = "next" if direction > 0 else "previous"
self._navigation_pending += 1
@@ -522,11 +584,15 @@ class AlbumSlideshowCamera(Camera):
self._last_nav_started_at = _utc_now_iso()
if self._timeline_dirty:
await self._rebuild_current_frame()
rewound = self._rewind_to_displayed(frame_id)
changed = (
await self._show_next_frame()
if direction > 0
else await self._show_previous_frame()
)
if not changed and rewound:
# Nothing older than the displayed slide: stay on it.
self._apply_frame(self._current_frame)
if changed:
self._last_nav_committed_at = _utc_now_iso()
self._last_nav_outcome = "displayed"
@@ -678,6 +744,7 @@ class AlbumSlideshowCamera(Camera):
"captured_at_pair": None,
"pair_frames": None,
"pair_orientation": None,
"faces": faces[half:half + 1] if (faces := frame.meta.get("faces")) else None,
}
self._timeline_dirty = False
self._apply_frame(_RenderedFrame(encoded, cursor, meta))
@@ -693,7 +760,6 @@ class AlbumSlideshowCamera(Camera):
self._framebuffer = None
self.store.last_frame = None
self._last_is_portrait = None
self._last_captured_at_pair = None
self._last_pair_frames = None
self._last_pair_orientation = None
@@ -809,6 +875,8 @@ class AlbumSlideshowCamera(Camera):
composed, meta = await renderer._compose_for_index(items)
meta = dict(meta or {})
meta.setdefault("photo_ids", [getattr(items[renderer._index], "photo_id", None)])
# Faces used for the crop, so later face data can be detected.
meta.setdefault("faces", (_item_faces(items[renderer._index]),))
cursor = self._capture_cursor(renderer)
if composed is None:
raise RuntimeError("Image composition returned no frame")
@@ -845,10 +913,12 @@ class AlbumSlideshowCamera(Camera):
meta = frame.meta
self._last_is_portrait = meta.get("is_portrait")
self._last_captured_at_pair = meta.get("captured_at_pair")
self._last_pair_frames = meta.get("pair_frames")
self._last_pair_orientation = meta.get("pair_orientation")
self._frame_id += 1
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))]
_LOGGER.debug(
"Album Slideshow %s: displayed buffered frame_id=%d index=%d "
@@ -886,6 +956,84 @@ class AlbumSlideshowCamera(Camera):
self._interrupt_event.set()
self.async_write_ha_state()
def _current_render_settings(self) -> tuple:
return tuple(getattr(self.store, name) for name in _RENDER_SETTINGS)
def _keep_timeline(self, previous_items: list[MediaItem] | None) -> bool:
"""Keep rendered slides that are still valid after a coordinator update.
Metadata-only updates (dates, places, captions) keep every slide. If the
playlist itself changed, slides already shown are kept and re-anchored
to their new positions, and upcoming slides are rendered again. Returns
False when the timeline has to be rebuilt instead.
"""
current = self._current_frame
if current is None or self._timeline_dirty or self._navigation_lock.locked():
return False
items = self._effective_items()
found = {item.photo_id: (index, item) for index, item in enumerate(items) if item.photo_id}
def faces_changed(frame: _RenderedFrame) -> bool:
recorded = frame.meta.get("faces")
photo_ids = frame.meta.get("photo_ids") or []
return (
recorded is not None
and all(photo_id in found for photo_id in photo_ids)
and recorded != tuple(_item_faces(found[photo_id][1]) for photo_id in photo_ids)
)
if previous_items is not None and [
item.photo_id or item.url for item in previous_items
] == [item.photo_id or item.url for item in items]:
return not any(
faces_changed(frame)
for frame in (*self._previous_frames, current, *self._next_frames)
)
def remap(frame: _RenderedFrame) -> _RenderedFrame | None:
photo_ids = frame.meta.get("photo_ids") or []
if not photo_ids or any(photo_id not in found for photo_id in photo_ids) or faces_changed(frame):
return None
return replace(frame, cursor=replace(frame.cursor, index=found[photo_ids[0]][0]))
current = remap(current)
if current is None:
return False
previous = [frame for frame in map(remap, self._previous_frames) if frame is not None]
self._timeline_generation += 1
self._cancel_preload()
self._previous_frames.clear()
self._previous_frames.extend(previous)
self._next_frames.clear()
self._current_frame = current
self._index = current.cursor.index
self._schedule_preload()
return True
def _rewind_to_displayed(self, frame_id: int | None) -> bool:
"""Make the retained slide a card is still showing the current one.
A card holds its photo while its controls are open, so the camera may
already be further ahead. Navigation then starts from what is on screen.
"""
serial = self._frame_serials.get(frame_id) if frame_id is not None else None
current = self._current_frame
if serial is None or current is None or current.serial == serial:
return False
timeline = [*self._previous_frames, current, *self._next_frames]
position = next(
(index for index, frame in enumerate(timeline) if frame.serial == serial),
None,
)
if position is None:
return False
self._previous_frames.clear()
self._previous_frames.extend(timeline[:position])
self._next_frames.clear()
self._next_frames.extend(timeline[position + 1:])
self._current_frame = timeline[position]
return True
def _schedule_preload(self) -> None:
"""Start the single per-camera worker that fills future frames."""
self._trim_timeline()
@@ -1255,24 +1403,7 @@ class AlbumSlideshowCamera(Camera):
is_portrait_canvas, divider, divider_fill, transparent_divider,
self._crop_hints(cur), self._crop_hints(other_item),
)
pair_frames = [
{
"captured_at": _ts_to_iso(getattr(cur, "captured_at", None)),
"location": getattr(cur, "location", None),
"latitude": getattr(cur, "latitude", None),
"longitude": getattr(cur, "longitude", None),
"description": getattr(cur, "description", None),
**_camera_metadata_attributes(cur),
},
{
"captured_at": _ts_to_iso(getattr(other_item, "captured_at", None)),
"location": getattr(other_item, "location", None),
"latitude": getattr(other_item, "latitude", None),
"longitude": getattr(other_item, "longitude", None),
"description": getattr(other_item, "description", None),
**_camera_metadata_attributes(other_item),
},
]
pair_frames = [_caption_frame(cur), _caption_frame(other_item)]
pair_meta = [f["captured_at"] for f in pair_frames]
else:
composed = await self._async_image_job(
@@ -1285,6 +1416,10 @@ class AlbumSlideshowCamera(Camera):
[getattr(cur, "photo_id", None), getattr(other_item, "photo_id", None)]
if pair_frames else [getattr(cur, "photo_id", None)]
),
"faces": (
(_item_faces(cur), _item_faces(other_item))
if pair_frames else (_item_faces(cur),)
),
"is_portrait": cur_is_portrait,
"captured_at_pair": pair_meta,
"pair_frames": pair_frames,
@@ -29,6 +29,8 @@ from .const import (
CONF_MEDIA_CONTENT_ID,
CONF_RECURSIVE,
CONF_REVERSE_GEOCODE,
CONF_HIDE_HOME_COUNTRY,
DEFAULT_HIDE_HOME_COUNTRY,
CONF_IMMICH_URL,
CONF_IMMICH_API_KEY,
CONF_IMMICH_SELECTION_TYPE,
@@ -1509,6 +1511,7 @@ class GoogleOptionsFlow(config_entries.OptionsFlow):
CONF_GOOGLE_METADATA: DEFAULT_GOOGLE_METADATA,
CONF_GOOGLE_LOCATION: DEFAULT_GOOGLE_LOCATION,
CONF_REVERSE_GEOCODE: DEFAULT_GOOGLE_REVERSE_GEOCODE,
CONF_HIDE_HOME_COUNTRY: DEFAULT_HIDE_HOME_COUNTRY,
}
if user_input is not None:
options = {
@@ -1531,9 +1534,9 @@ class GoogleOptionsFlow(config_entries.OptionsFlow):
class LocalFolderOptionsFlow(config_entries.OptionsFlow):
"""Options for local-folder entries.
Currently exposes a single toggle: ``reverse_geocode``. Users with
Exposes the ``reverse_geocode`` toggle and ``hide_home_country``. Users with
privacy concerns about handing EXIF GPS coordinates to an external
OSM endpoint can turn this off; the GPS coordinates remain available
OSM endpoint can turn geocoding off; the GPS coordinates remain available
as ``latitude``/``longitude`` attributes regardless.
``self.config_entry`` is provided by ``OptionsFlow`` as a managed
@@ -1550,11 +1553,17 @@ class LocalFolderOptionsFlow(config_entries.OptionsFlow):
current = self.config_entry.options.get(
CONF_REVERSE_GEOCODE, DEFAULT_REVERSE_GEOCODE
)
hide_home = self.config_entry.options.get(
CONF_HIDE_HOME_COUNTRY, DEFAULT_HIDE_HOME_COUNTRY
)
schema = vol.Schema(
{
vol.Required(
CONF_REVERSE_GEOCODE, default=bool(current)
): bool,
vol.Required(
CONF_HIDE_HOME_COUNTRY, default=bool(hide_home)
): bool,
}
)
return self.async_show_form(step_id="init", data_schema=schema)
@@ -1684,6 +1693,10 @@ class ImmichOptionsFlow(config_entries.OptionsFlow):
CONF_REVERSE_GEOCODE,
entry.options.get(CONF_REVERSE_GEOCODE, DEFAULT_REVERSE_GEOCODE),
),
CONF_HIDE_HOME_COUNTRY: user_input.get(
CONF_HIDE_HOME_COUNTRY,
entry.options.get(CONF_HIDE_HOME_COUNTRY, DEFAULT_HIDE_HOME_COUNTRY),
),
}
self.hass.config_entries.async_update_entry(
entry, data=new_data, title=fields[CONF_ALBUM_NAME], options=options
@@ -1705,6 +1718,9 @@ class ImmichOptionsFlow(config_entries.OptionsFlow):
vol.Required(CONF_REVERSE_GEOCODE, default=bool(entry.options.get(
CONF_REVERSE_GEOCODE, DEFAULT_REVERSE_GEOCODE,
))): bool,
vol.Required(CONF_HIDE_HOME_COUNTRY, default=bool(entry.options.get(
CONF_HIDE_HOME_COUNTRY, DEFAULT_HIDE_HOME_COUNTRY,
))): bool,
})
return self.async_show_form(
step_id="immich_select",
@@ -65,6 +65,10 @@ DEFAULT_IMMICH_IMAGE_SIZE = IMMICH_IMAGE_PREVIEW
# integration's options dialog.
CONF_REVERSE_GEOCODE = "reverse_geocode"
DEFAULT_REVERSE_GEOCODE = True
# Leave the country out of OpenStreetMap place names when it matches Home
# Assistant's configured country.
CONF_HIDE_HOME_COUNTRY = "hide_home_country"
DEFAULT_HIDE_HOME_COUNTRY = False
PROVIDER_GOOGLE_SHARED = "google_shared"
PROVIDER_LOCAL_FOLDER = "local_folder"
+238 -99
View File
@@ -34,6 +34,8 @@ from .const import (
CONF_MEDIA_CONTENT_ID,
CONF_RECURSIVE,
CONF_REVERSE_GEOCODE,
CONF_HIDE_HOME_COUNTRY,
DEFAULT_HIDE_HOME_COUNTRY,
CONF_IMMICH_URL,
CONF_IMMICH_API_KEY,
CONF_IMMICH_SELECTION_TYPE,
@@ -157,6 +159,9 @@ class MediaItem:
face_scanned: bool = False
camera_metadata: dict[str, Any] | None = None
google_location_scanned: bool = False
# 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
def __post_init__(self) -> None:
if self.photo_id is None:
@@ -483,6 +488,22 @@ _HEIF_EXIF_RE = re.compile(rb"Exif\x00\x00(?:MM\x00\x2a|II\x2a\x00)")
_NOMINATIM_ENDPOINT = "https://nominatim.openstreetmap.org/reverse"
_NOMINATIM_MIN_INTERVAL_S = 1.1
_NOMINATIM_TIMEOUT_S = 20
# Next free request time, shared so several albums together stay within the limit.
_nominatim_next_slot = 0.0
# Nominatim address parts used for place labels, most specific first.
_LOCALITY_KEYS = (
"city", "town", "village", "hamlet", "city_district", "suburb", "municipality",
)
_REGION_KEYS = ("state", "province", "region")
_ADDRESS_KEYS = (*_LOCALITY_KEYS, *_REGION_KEYS, "country", "country_code")
_PLACE_KEYS = (*_ADDRESS_KEYS, "display_name")
# Administrative prefixes some areas carry in OpenStreetMap, longest first.
_NAME_PREFIXES = (
"regional municipality of ", "rural municipality of ", "municipal district of ",
"municipality of ", "township of ", "district of ", "borough of ",
"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.
@@ -882,41 +903,68 @@ def _jpeg_exif_segment(data: bytes) -> bytes | None:
raise ValueError("JPEG header ends before image data")
def _format_nominatim_location(payload: dict[str, Any]) -> str | None:
"""Turn a Nominatim reverse-geocode response into a short label.
def _place_name(value: Any) -> str | None:
"""A town or city name without prefixes like "City of"; parishes don't count."""
if not isinstance(value, str) or not value.strip():
return None
name = value.strip()
folded = name.casefold()
if folded.endswith(" parish") or folded.startswith("parish of "):
return None
for prefix in _NAME_PREFIXES:
if folded.startswith(prefix) and len(name) > len(prefix):
return name[len(prefix):].strip()
return name
Prefers ``city`` > ``town`` > ``village`` > ``municipality`` >
``county`` for the locality portion, plus ``country`` when present.
Falls back to Nominatim's pre-formatted ``display_name`` (truncated
to the first two comma-separated parts) when no recognisable
locality fields are present.
def _nominatim_place(payload: Any) -> dict[str, str] | None:
"""Keep the parts of a Nominatim reverse-geocode response that labels use.
Caching these instead of a finished label lets label options change
without new lookups.
"""
if not isinstance(payload, dict):
return None
address = payload.get("address") if isinstance(payload.get("address"), dict) else {}
locality = None
for key in ("city", "town", "village", "hamlet", "municipality", "county", "state"):
val = address.get(key)
if isinstance(val, str) and val.strip():
locality = val.strip()
break
country = address.get("country") if isinstance(address.get("country"), str) else None
if locality and country:
return f"{locality}, {country}"
if locality:
return locality
if isinstance(country, str) and country.strip():
return country.strip()
place = {
key: value.strip()
for key in _ADDRESS_KEYS
if isinstance(value := address.get(key), str) and value.strip()
}
display = payload.get("display_name")
if isinstance(display, str) and display.strip():
parts = [p.strip() for p in display.split(",") if p.strip()]
if len(parts) >= 2:
return ", ".join(parts[:2])
if parts:
return parts[0]
return None
if not place and isinstance(display, str) and display.strip():
place["display_name"] = display.strip()
return place or None
def _format_place(place: dict[str, str], home_country: str | None = None) -> str | None:
"""Short label such as ``"Lisbon, Portugal"`` from cached address parts.
Uses the town or city, else the province/state, then the country, which
is left out when its code matches ``home_country``.
"""
area = next(
(name for key in _LOCALITY_KEYS if (name := _place_name(place.get(key)))), None
) or next((place[key] for key in _REGION_KEYS if place.get(key)), None)
country = place.get("country")
if home_country and place.get("country_code", "").upper() == home_country.upper():
country = None
parts = [part for part in (area, country) if part]
if len(parts) == 2 and parts[0].casefold() == parts[1].casefold():
parts.pop()
if parts:
return ", ".join(parts)
shown = [part.strip() for part in place.get("display_name", "").split(",") if part.strip()]
return ", ".join(shown[:2]) or None
async def _wait_for_nominatim_slot() -> None:
global _nominatim_next_slot
now = asyncio.get_running_loop().time()
slot = max(now, _nominatim_next_slot)
_nominatim_next_slot = slot + _NOMINATIM_MIN_INTERVAL_S
if slot > now:
await asyncio.sleep(slot - now)
async def _nominatim_lookup(
@@ -924,18 +972,21 @@ async def _nominatim_lookup(
lat: float,
lon: float,
user_agent: str,
) -> str | None:
) -> dict[str, str] | None:
"""Reverse-geocode a coordinate pair via Nominatim.
Returns a human-readable label or ``None`` if the lookup failed or
the response had no useful address. Never raises - geocoding is
best-effort and a failure must never stop the slideshow.
Returns the cached address parts (see :func:`_nominatim_place`) or
``None`` if the lookup failed or the response had no useful address.
Never raises - geocoding is best-effort and a failure must never stop
the slideshow.
"""
params = {
"format": "jsonv2",
"lat": f"{lat:.5f}",
"lon": f"{lon:.5f}",
"zoom": "10", # Roughly city-level - we don't need street precision.
# Neighbourhood level: coarser levels can return an administrative
# area such as a parish instead of the town.
"zoom": "14",
"addressdetails": "1",
}
headers = {
@@ -969,7 +1020,7 @@ async def _nominatim_lookup(
_LOGGER.debug("Nominatim lookup failed (%s, %s): %s", lat, lon, err)
return None
return _format_nominatim_location(payload)
return _nominatim_place(payload)
def _read_manifest_version(integration_dir: Path) -> str:
@@ -1061,6 +1112,7 @@ def _merge_prior_enrichment(
item.longitude = prev.longitude
if prev.location and not item.location:
item.location = prev.location
item.location_geocoded = prev.location_geocoded
if prev.description and not item.description:
item.description = prev.description
if prev.faces is not None and item.faces is None:
@@ -1126,15 +1178,19 @@ class AlbumCoordinator(DataUpdateCoordinator):
self._items_cache_loaded: bool = False
# Reverse-geocode cache. Coordinates rounded to ~100 m via
# ``_geocode_cache_key``; mapping value -> label string. Shared
# across all items in the album, persisted independently of the
# items cache so changes to one don't invalidate the other.
# ``_geocode_cache_key``; mapping value -> Nominatim address parts
# (see ``_nominatim_place``). Shared across all items in the album,
# persisted independently of the items cache so changes to one don't
# invalidate the other.
self._geocode_cache_store: Store = Store(
hass,
self._GEOCODE_CACHE_VERSION,
f"{DOMAIN}.{entry.entry_id}.geocode",
)
self._geocode_cache: dict[str, str] = {}
self._geocode_cache: dict[str, dict[str, str]] = {}
# Finished labels from the older label-only cache, kept until each
# coordinate has been looked up again.
self._geocode_legacy: dict[str, str] = {}
self._geocode_cache_loaded: bool = False
# Per-coordinator User-Agent so OSM operators can track us if we
# ever misbehave. Resolved lazily because __init__ is sync.
@@ -1224,16 +1280,28 @@ class AlbumCoordinator(DataUpdateCoordinator):
item.latitude = previous.latitude
item.longitude = previous.longitude
item.location = previous.location
item.location_geocoded = previous.location_geocoded
item.google_location_scanned = True
self._apply_google_location_privacy(items)
await self._relabel_from_place_cache(items)
self._schedule_enrichment(data)
elif self.provider in ENRICHING_PROVIDERS and items:
# Carry forward EXIF/geocode metadata for items we've already
# scanned this session; new items get filled in by the
# background worker below.
prior_items = (self.data or {}).get("items") if isinstance(self.data, dict) else None
# scanned, this session or (after a restart) in the items cache
# on disk; new items get filled in by the background worker
# below. Session items come last so they win over the cache.
prior_items = list((self.data or {}).get("items") or []) if isinstance(self.data, dict) else []
if not self._items_cache_loaded:
cached = await self._load_cached_items()
prior_items = (cached or {}).get("items", []) + prior_items
self._items_cache_loaded = True
if prior_items:
_merge_prior_enrichment(items, prior_items)
_LOGGER.debug(
"Album cache: %d of %d items already have their details",
sum(1 for it in items if not self._needs_enrichment(it)), len(items),
)
await self._relabel_from_place_cache(items)
self._schedule_enrichment(data)
if items:
@@ -1294,9 +1362,71 @@ class AlbumCoordinator(DataUpdateCoordinator):
item.latitude = None
item.longitude = None
item.location = None
item.location_geocoded = False
item.google_location_scanned = False
elif not self.google_reverse_geocode_enabled:
item.location = None
item.location_geocoded = False
@property
def reverse_geocode_enabled(self) -> bool:
if getattr(self, "provider", None) == PROVIDER_GOOGLE_SHARED:
return self.google_reverse_geocode_enabled
options = getattr(getattr(self, "entry", None), "options", None) or {}
return bool(options.get(CONF_REVERSE_GEOCODE, DEFAULT_REVERSE_GEOCODE))
@property
def _hidden_home_country(self) -> str | None:
"""Country code to leave out of place labels, if the user asked for that."""
options = getattr(getattr(self, "entry", None), "options", None) or {}
if not options.get(CONF_HIDE_HOME_COUNTRY, DEFAULT_HIDE_HOME_COUNTRY):
return None
return getattr(getattr(self.hass, "config", None), "country", None) or None
def _needs_geocode_lookup(self, item: MediaItem) -> bool:
"""True for items with GPS whose place isn't cached yet."""
if item.latitude is None or item.longitude is None:
return False
if item.location and not item.location_geocoded:
return False # The provider's own label.
return _geocode_cache_key(item.latitude, item.longitude) not in self._geocode_cache
def _label_items_from_geocode_cache(self, items: list[MediaItem]) -> int:
"""Set OpenStreetMap labels from cached places; return how many changed.
Labels from the older label-only cache are adopted as ours, so they
stay visible until a new lookup replaces them.
"""
home = self._hidden_home_country
changed = 0
for item in items:
if item.latitude is None or item.longitude is None:
continue
key = _geocode_cache_key(item.latitude, item.longitude)
legacy = self._geocode_legacy.get(key)
if item.location and not item.location_geocoded:
if item.location != legacy:
continue
item.location_geocoded = True
place = self._geocode_cache.get(key)
if place is None:
if legacy and not item.location:
item.location, item.location_geocoded = legacy, True
changed += 1
continue
label = _format_place(place, home)
if label != item.location or not item.location_geocoded:
item.location, item.location_geocoded = label, True
changed += 1
return changed
async def _relabel_from_place_cache(self, items: list[MediaItem]) -> None:
"""Apply current label options to known places without new lookups."""
if self.reverse_geocode_enabled and any(
it.latitude is not None and it.longitude is not None for it in items
):
await self._ensure_geocode_cache_loaded()
self._label_items_from_geocode_cache(items)
def _needs_enrichment(self, item: MediaItem) -> bool:
if self.provider == PROVIDER_GOOGLE_SHARED:
@@ -1313,11 +1443,8 @@ class AlbumCoordinator(DataUpdateCoordinator):
unscanned = [it for it in items if self._needs_enrichment(it)]
# Providers that decrypt/return GPS inline (Ente) have nothing to scan
# but still need the coordinates turned into a place label.
needs_geocode = (
self.provider != PROVIDER_GOOGLE_SHARED or self.google_reverse_geocode_enabled
) and any(
it.latitude is not None and it.longitude is not None and not it.location
for it in items
needs_geocode = self.reverse_geocode_enabled and any(
self._needs_geocode_lookup(it) for it in items
)
if not unscanned and not needs_geocode:
# Even with nothing to do, mark the phase as ``done`` so the
@@ -1330,7 +1457,12 @@ class AlbumCoordinator(DataUpdateCoordinator):
"geocode_total": 0,
"geocode_done": 0,
}
_LOGGER.debug("Album enrichment: all %d items have their details", len(items))
return
_LOGGER.debug(
"Album enrichment: starting background pass for %d of %d items%s",
len(unscanned), len(items), " plus place names" if needs_geocode else "",
)
self._enrich_progress = {
"phase": "exif",
"exif_total": len(items),
@@ -1351,14 +1483,17 @@ class AlbumCoordinator(DataUpdateCoordinator):
_LOGGER.debug("Album cache: failed to load (%s)", err)
return None
if not isinstance(payload, dict):
_LOGGER.debug("Album cache: nothing saved yet")
return None
cached_source = payload.get("immich_source")
if cached_source is not None and cached_source != getattr(self, "_immich_cache_source", None):
_LOGGER.debug("Album cache: ignoring saved items, the Immich source changed")
return None
raw_items = payload.get("items")
if not isinstance(raw_items, list):
_LOGGER.debug("Album cache: ignoring saved data without an item list")
return None
items: list[MediaItem] = []
@@ -1390,10 +1525,15 @@ class AlbumCoordinator(DataUpdateCoordinator):
if key in CAMERA_METADATA_FIELDS
} 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)),
))
except Exception:
continue
_LOGGER.debug(
"Album cache: loaded %d saved items (%d skipped as unreadable)",
len(items), len(raw_items) - len(items),
)
self._apply_google_location_privacy(items)
return {
"title": payload.get("title"),
@@ -1429,6 +1569,7 @@ class AlbumCoordinator(DataUpdateCoordinator):
"face_scanned": it.face_scanned,
"camera_metadata": it.camera_metadata,
"google_location_scanned": it.google_location_scanned,
"location_geocoded": it.location_geocoded,
}
for it in items
],
@@ -1439,6 +1580,8 @@ class AlbumCoordinator(DataUpdateCoordinator):
await self._items_cache_store.async_save(payload)
except Exception as err: # pragma: no cover - storage layer
_LOGGER.debug("Album cache: failed to save (%s)", err)
else:
_LOGGER.debug("Album cache: saved %d items", len(items))
async def _update_local_folder(self) -> dict[str, Any]:
if not self.local_path:
@@ -2496,6 +2639,7 @@ class AlbumCoordinator(DataUpdateCoordinator):
item.longitude = info["longitude"]
if "location" in info:
item.location = info["location"]
item.location_geocoded = False
if "description" in info:
item.description = info["description"]
item.exif_scanned = True
@@ -2577,6 +2721,7 @@ class AlbumCoordinator(DataUpdateCoordinator):
else:
item.latitude = item.longitude = None
item.location = None
item.location_geocoded = False
item.google_location_scanned = True
else:
if keys is None:
@@ -2650,6 +2795,7 @@ class AlbumCoordinator(DataUpdateCoordinator):
item.longitude = meta["longitude"]
if "location" in meta:
item.location = meta["location"]
item.location_geocoded = False
item.exif_scanned = True
async def _enrich_items_background(self, data: dict[str, Any]) -> None:
@@ -2786,74 +2932,57 @@ class AlbumCoordinator(DataUpdateCoordinator):
self._enrich_progress["phase"] = "done"
async def _geocode_items_background(self, data: dict[str, Any]) -> None:
"""Reverse-geocode items with EXIF GPS but no location label yet.
"""Label items with GPS from the place cache, looking up new places.
Honours the ``reverse_geocode`` option (default on) so privacy-
conscious users can disable the Nominatim calls without losing
the GPS coordinates themselves. Successful labels are written
back into the items in-place and persisted via the items cache.
the GPS coordinates themselves. Labels are written back into the
items in-place and persisted via the items cache.
"""
if getattr(self, "provider", None) == PROVIDER_GOOGLE_SHARED and not self.google_reverse_geocode_enabled:
return
if not bool(
self.entry.options.get(CONF_REVERSE_GEOCODE, DEFAULT_REVERSE_GEOCODE)
if hasattr(self.entry, "options") and self.entry.options is not None
else DEFAULT_REVERSE_GEOCODE
):
if not self.reverse_geocode_enabled:
_LOGGER.debug("Reverse geocode disabled via options; skipping")
return
items: list[MediaItem] = data.get("items") or []
candidates: list[MediaItem] = [
it
for it in items
if it.latitude is not None
and it.longitude is not None
and not it.location
]
if not candidates:
if not any(it.latitude is not None and it.longitude is not None for it in items):
return
await self._ensure_geocode_cache_loaded()
if self._label_items_from_geocode_cache(items):
self.async_set_updated_data(data)
pending: dict[str, list[MediaItem]] = {}
for item in items:
if self._needs_geocode_lookup(item):
key = _geocode_cache_key(item.latitude, item.longitude)
pending.setdefault(key, []).append(item)
if not pending:
return
self._enrich_progress["phase"] = "geocoding"
self._enrich_progress["geocode_total"] = len(candidates)
self._enrich_progress["geocode_total"] = sum(len(group) for group in pending.values())
self._enrich_progress["geocode_done"] = 0
session = async_get_clientsession(self.hass)
user_agent = await self._async_user_agent()
# Last network call wall-time; used to throttle Nominatim to the
# 1 req/sec policy without sleeping for cached lookups.
last_call: float = 0.0
loop = asyncio.get_event_loop()
home = self._hidden_home_country
unsaved = 0
try:
for item in candidates:
key = _geocode_cache_key(item.latitude, item.longitude)
cached_label = self._geocode_cache.get(key)
if cached_label:
item.location = cached_label
self._enrich_progress["geocode_done"] += 1
continue
# Respect the 1 req/sec Nominatim usage policy.
elapsed = loop.time() - last_call
if elapsed < _NOMINATIM_MIN_INTERVAL_S:
await asyncio.sleep(_NOMINATIM_MIN_INTERVAL_S - elapsed)
label = await _nominatim_lookup(
session, item.latitude, item.longitude, user_agent
for key, group in pending.items():
await _wait_for_nominatim_slot()
place = await _nominatim_lookup(
session, group[0].latitude, group[0].longitude, user_agent
)
last_call = loop.time()
if label:
self._geocode_cache[key] = label
item.location = label
if place:
self._geocode_cache[key] = place
label = _format_place(place, home)
for item in group:
item.location, item.location_geocoded = label, True
unsaved += 1
self._enrich_progress["geocode_done"] += 1
self._enrich_progress["geocode_done"] += len(group)
if unsaved >= _GEOCODE_BATCH_SAVE:
unsaved = 0
@@ -2883,18 +3012,28 @@ class AlbumCoordinator(DataUpdateCoordinator):
if isinstance(payload, dict):
entries = payload.get("entries")
if isinstance(entries, dict):
self._geocode_cache = {
str(k): str(v)
for k, v in entries.items()
if isinstance(v, str) and v
}
for key, value in entries.items():
if isinstance(value, dict):
place = {
k: v for k, v in value.items()
if k in _PLACE_KEYS and isinstance(v, str) and v
}
if place:
self._geocode_cache[str(key)] = place
elif isinstance(value, str) and value:
self._geocode_legacy[str(key)] = value
self._geocode_cache_loaded = True
async def _save_geocode_cache(self) -> None:
# Old labels stay until their coordinate is looked up again, so an
# unfinished upgrade still has them after a restart.
entries: dict[str, Any] = {
key: label for key, label in self._geocode_legacy.items()
if key not in self._geocode_cache
}
entries.update(self._geocode_cache)
try:
await self._geocode_cache_store.async_save(
{"entries": self._geocode_cache}
)
await self._geocode_cache_store.async_save({"entries": entries})
except Exception as err: # pragma: no cover - storage layer
_LOGGER.debug("Geocode cache: failed to save (%s)", err)
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"issue_tracker": "https://github.com/eyalgal/album_slideshow/issues",
"requirements": ["PyNaCl>=1.5.0"],
"version": "1.14.0"
"version": "1.15.0"
}
@@ -99,6 +99,13 @@ next_slide:
required: true
selector:
text:
frame_id:
name: Displayed frame ID
description: Optional frame_id of the slide on screen. When it is still in the navigation buffer, the step starts from that slide.
selector:
number:
min: 0
mode: box
previous_slide:
name: Previous slide
@@ -110,6 +117,13 @@ previous_slide:
required: true
selector:
text:
frame_id:
name: Displayed frame ID
description: Optional frame_id of the slide on screen. When it is still in the navigation buffer, the step starts from that slide.
selector:
number:
min: 0
mode: box
refresh_album:
name: Refresh album
@@ -203,14 +203,16 @@
"data": {
"google_metadata": "Fetch photo metadata (experimental)",
"google_location": "Read original photo GPS (opt-in)",
"reverse_geocode": "Look up GPS place names via OpenStreetMap (opt-in)"
"reverse_geocode": "Look up GPS place names via OpenStreetMap (opt-in)",
"hide_home_country": "Hide the country for places in your home country"
}
},
"init": {
"title": "Location & privacy options",
"description": "Reverse geocoding sends your photos' EXIF GPS coordinates to the public OpenStreetMap Nominatim service to look up a human-readable place name. Coordinates are rounded to ~100 m before lookup and cached on disk. Turn this off to keep coordinates entirely local; the latitude and longitude attributes still work either way.",
"data": {
"reverse_geocode": "Reverse-geocode EXIF GPS coordinates via OpenStreetMap"
"reverse_geocode": "Reverse-geocode EXIF GPS coordinates via OpenStreetMap",
"hide_home_country": "Hide the country for places in your home country"
}
},
"immich_select": {
@@ -223,7 +225,8 @@
"favorites": "Include favorites",
"immich_filter": "Extra search filter (JSON, optional)",
"immich_image_size": "Image quality",
"reverse_geocode": "Look up EXIF GPS locations via OpenStreetMap"
"reverse_geocode": "Look up EXIF GPS locations via OpenStreetMap",
"hide_home_country": "Hide the country for places in your home country"
}
},
"connection": {
@@ -203,14 +203,16 @@
"data": {
"google_metadata": "Fetch photo metadata (experimental)",
"google_location": "Read original photo GPS (opt-in)",
"reverse_geocode": "Look up GPS place names via OpenStreetMap (opt-in)"
"reverse_geocode": "Look up GPS place names via OpenStreetMap (opt-in)",
"hide_home_country": "Hide the country for places in your home country"
}
},
"init": {
"title": "Location & privacy options",
"description": "Reverse geocoding sends your photos' EXIF GPS coordinates to the public OpenStreetMap Nominatim service to look up a human-readable place name. Coordinates are rounded to ~100 m before lookup and cached on disk. Turn this off to keep coordinates entirely local; the latitude and longitude attributes still work either way.",
"data": {
"reverse_geocode": "Reverse-geocode EXIF GPS coordinates via OpenStreetMap"
"reverse_geocode": "Reverse-geocode EXIF GPS coordinates via OpenStreetMap",
"hide_home_country": "Hide the country for places in your home country"
}
},
"immich_select": {
@@ -223,7 +225,8 @@
"favorites": "Include favorites",
"immich_filter": "Extra search filter (JSON, optional)",
"immich_image_size": "Image quality",
"reverse_geocode": "Look up EXIF GPS locations via OpenStreetMap"
"reverse_geocode": "Look up EXIF GPS locations via OpenStreetMap",
"hide_home_country": "Hide the country for places in your home country"
}
},
"connection": {
@@ -26,7 +26,7 @@
* tap_action: none # none | more-info
*/
const VERSION = "1.14.0";
const VERSION = "1.15.0";
const ANIMATED_TRANSITIONS = [
"fade",
@@ -87,6 +87,49 @@ const CAPTION_WEIGHT_MAP = {
semibold: 600,
bold: 700,
};
const WEATHER_DISPLAYS = ["condition_temperature", "icon_temperature", "temperature"];
const WEATHER_ICON_STYLES = ["yr", "meteocons", "home_assistant"];
// Icon files in www/weather (MIT, see the LICENSE there) per HA weather condition. Yr has
// no wind or warning symbol, so those come from Meteocons.
const WEATHER_ICON_SETS = {
yr: {
"clear-night": "yr/clearsky_night",
cloudy: "yr/cloudy",
exceptional: "meteocons/code-red",
fog: "yr/fog",
hail: "yr/heavysleet",
lightning: "yr/lightrainandthunder",
"lightning-rainy": "yr/rainandthunder",
partlycloudy: "yr/partlycloudy_day",
"partlycloudy-night": "yr/partlycloudy_night",
pouring: "yr/heavyrain",
rainy: "yr/rain",
snowy: "yr/snow",
"snowy-rainy": "yr/sleet",
sunny: "yr/clearsky_day",
windy: "meteocons/wind",
"windy-variant": "meteocons/wind",
},
meteocons: {
"clear-night": "meteocons/clear-night",
cloudy: "meteocons/cloudy",
exceptional: "meteocons/code-red",
fog: "meteocons/fog",
hail: "meteocons/hail",
lightning: "meteocons/thunderstorms",
"lightning-rainy": "meteocons/thunderstorms-rain",
partlycloudy: "meteocons/partly-cloudy-day",
"partlycloudy-night": "meteocons/partly-cloudy-night",
pouring: "meteocons/extreme-rain",
rainy: "meteocons/rain",
snowy: "meteocons/snow",
"snowy-rainy": "meteocons/sleet",
sunny: "meteocons/clear-day",
windy: "meteocons/wind",
"windy-variant": "meteocons/wind",
},
};
const WEATHER_ICON_PATH = "/album_slideshow_static/weather";
const DATE_FORMAT_PRESETS = {
full: { year: "numeric", month: "long", day: "numeric" },
long: { year: "numeric", month: "long", day: "numeric" },
@@ -216,8 +259,10 @@ class PhotoControls {
async _navigate(service) {
if (this._busy || !this._state.entryId) return false;
const entryId = this._state.entryId;
// Step from the slide on screen, which may be held behind the camera.
const data = Number.isInteger(this._state.frameId) ? { frame_id: this._state.frameId } : {};
this._onNavigate?.("start", entryId);
const success = await this._run(service, {}, entryId);
const success = await this._run(service, data, entryId);
this._onNavigate?.(success ? "complete" : "failed", entryId);
return success;
}
@@ -684,6 +729,8 @@ function createAlbumSlideshowCardClass(Base) {
time_seconds: raw.time_seconds === true,
weather_entity: typeof raw.weather_entity === "string" && /^(weather|sensor)\.[a-z0-9_]+$/.test(raw.weather_entity.trim())
? raw.weather_entity.trim() : null,
weather_display: WEATHER_DISPLAYS.includes(raw.weather_display) ? raw.weather_display : "condition_temperature",
weather_icons: WEATHER_ICON_STYLES.includes(raw.weather_icons) ? raw.weather_icons : "yr",
color,
font_size: fontSize,
font_weight: fontWeight,
@@ -727,7 +774,9 @@ function createAlbumSlideshowCardClass(Base) {
if (!this._rendered) return;
this._maybeSwap();
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))) {
(previous?.states?.[cap.weather_entity] !== hass.states?.[cap.weather_entity] || previous?.locale !== hass.locale ||
(cap.weather_display === "icon_temperature" &&
previous?.states?.["sun.sun"]?.state !== hass.states?.["sun.sun"]?.state)))) {
this._renderCaptions(this._captionData, false);
}
}
@@ -868,6 +917,20 @@ function createAlbumSlideshowCardClass(Base) {
0 1px 2px rgba(0, 0, 0, 0.9),
0 1px 6px rgba(0, 0, 0, 0.55);
}
.cap-icon { display: inline-block; width: 1.8em; height: 1.8em; margin: 0 0.12em 0 -0.1em; vertical-align: middle; }
.cap-icon svg { display: block; width: 100%; height: 100%; }
.cap-shadow .cap-icon { filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.6)); }
.cap-icon .sun { fill: var(--weather-icon-sun-color, #fdd93c); }
.cap-icon .moon { fill: var(--weather-icon-moon-color, #fcf497); }
.cap-icon .cloud-back { fill: var(--weather-icon-cloud-back-color, #d4d4d4); }
.cap-icon .cloud-front { fill: var(--weather-icon-cloud-front-color, #f9f9f9); }
.cap-icon .rain { fill: var(--weather-icon-rain-color, #30b3ff); }
.cap-icon .snow {
fill: var(--weather-icon-snow-color, #f9f9f9);
stroke: var(--weather-icon-snow-stroke-color, #d4d4d4);
stroke-width: 1;
paint-order: stroke;
}
${this._transitionStyles()}
</style>
<ha-card part="card">
@@ -1112,6 +1175,7 @@ function createAlbumSlideshowCardClass(Base) {
this._photoControls?.update({
entryId: attrs.entry_id,
photoIds: this._displayedPhotoIds,
frameId: this._displayedFrameId,
orientation: this._photoOrientation,
hiddenCount: attrs.hidden_photo_count,
canUndo: attrs.undo_hide_available,
@@ -1427,12 +1491,27 @@ function createAlbumSlideshowCardClass(Base) {
? this._hass.formatEntityState(entity)
: isWeather ? humanizeOption(entity.state.replace("partlycloudy", "partly cloudy"))
: [entity.state, attributes.unit_of_measurement].filter(Boolean).join(" ");
if (!isWeather || typeof attributes.temperature !== "number" || !Number.isFinite(attributes.temperature)) return state;
const temperature = this._hass.formatEntityAttributeValue
? this._hass.formatEntityAttributeValue(entity, "temperature")
: [new Intl.NumberFormat(this._locale(), { maximumFractionDigits: 1 }).format(attributes.temperature),
attributes.temperature_unit || this._hass.config?.unit_system?.temperature].filter(Boolean).join(" ");
return `${state}, ${temperature}`;
if (!isWeather) return state;
let temperature = "";
if (typeof attributes.temperature === "number" && Number.isFinite(attributes.temperature)) {
temperature = this._hass.formatEntityAttributeValue
? this._hass.formatEntityAttributeValue(entity, "temperature")
: [new Intl.NumberFormat(this._locale(), { maximumFractionDigits: 1 }).format(attributes.temperature),
attributes.temperature_unit || this._hass.config?.unit_system?.temperature].filter(Boolean).join(" ");
}
if (cap.weather_display === "temperature") return temperature;
if (cap.weather_display === "icon_temperature") {
const night = entity.state === "partlycloudy" && this._hass.states["sun.sun"]?.state === "below_horizon";
if (cap.weather_icons === "home_assistant") {
const svg = homeAssistantWeatherSvg(entity.state, night);
if (svg) return { svg, label: state, text: temperature };
} else {
const icons = WEATHER_ICON_SETS[cap.weather_icons] || WEATHER_ICON_SETS.yr;
const icon = icons[night ? "partlycloudy-night" : entity.state];
if (icon) return { icon, label: state, text: temperature };
}
}
return temperature ? `${state}, ${temperature}` : state;
}
/** Build one positioned caption block. ``orientation`` is ``null`` for a
@@ -1497,7 +1576,19 @@ function createAlbumSlideshowCardClass(Base) {
for (const line of lines) {
const el = document.createElement("div");
el.className = "cap-line";
el.textContent = line;
if (typeof line === "string") {
el.textContent = line;
} else {
const icon = document.createElement(line.svg ? "span" : "img");
icon.className = "cap-icon";
if (line.svg) {
icon.innerHTML = line.svg;
} else {
icon.alt = line.label;
icon.src = `${WEATHER_ICON_PATH}/${line.icon}.svg?v=${VERSION}`;
}
el.append(icon, line.text);
}
box.appendChild(el);
}
region._captionStack.appendChild(box);
@@ -1739,6 +1830,18 @@ const CAPTION_WEIGHT_OPTIONS = [
{ value: "bold", label: "Bold" },
];
const WEATHER_DISPLAY_OPTIONS = [
{ value: "condition_temperature", label: "Condition and temperature" },
{ value: "icon_temperature", label: "Icon and temperature" },
{ value: "temperature", label: "Temperature only" },
];
const WEATHER_ICON_OPTIONS = [
{ value: "yr", label: "Yr" },
{ value: "meteocons", label: "Meteocons" },
{ value: "home_assistant", label: "Home Assistant" },
];
const CAPTION_DATE_FORMAT_OPTIONS = [
{ value: "medium", label: "Medium (Aug 16, 2014)" },
{ value: "full", label: "Full (August 16, 2014)" },
@@ -1772,6 +1875,8 @@ const CAPTION_DEFAULTS = {
date_format: "medium",
time_format: "auto",
time_seconds: false,
weather_display: "condition_temperature",
weather_icons: "yr",
color: "#ffffff",
font_size: "14px",
font_weight: "medium",
@@ -2197,6 +2302,8 @@ function createAlbumSlideshowCardEditorClass(Base) {
caption_time_format: c.time_format || CAPTION_DEFAULTS.time_format,
caption_time_seconds: c.time_seconds === true,
caption_weather_entity: c.weather_entity || "",
caption_weather_display: c.weather_display || CAPTION_DEFAULTS.weather_display,
caption_weather_icons: c.weather_icons || CAPTION_DEFAULTS.weather_icons,
caption_color: c.color || CAPTION_DEFAULTS.color,
caption_font_size: c.font_size || CAPTION_DEFAULTS.font_size,
caption_font_weight: c.font_weight || CAPTION_DEFAULTS.font_weight,
@@ -2221,6 +2328,16 @@ function createAlbumSlideshowCardEditorClass(Base) {
if (data.caption_show.includes("weather")) {
schema.push({ name: "caption_weather_entity", required: true,
selector: { entity: { filter: [{ domain: ["weather", "sensor"] }] } } });
if (data.caption_weather_entity.startsWith("weather.")) {
schema.push({ name: "caption_weather_display", selector: { select: {
mode: "dropdown", options: WEATHER_DISPLAY_OPTIONS,
} } });
if (data.caption_weather_display === "icon_temperature") {
schema.push({ name: "caption_weather_icons", selector: { select: {
mode: "dropdown", options: WEATHER_ICON_OPTIONS,
} } });
}
}
}
if (data.caption_show.includes("date")) {
schema.push({ name: "caption_date_format", selector: { select: {
@@ -2305,7 +2422,7 @@ function createAlbumSlideshowCardEditorClass(Base) {
if (!Array.isArray(show)) show = typeof show === "string" ? show.split(/[,\s]+/) : [];
const caption = { show: [...new Set(show.filter((field) => CAPTION_FIELDS.includes(field)))] };
if (data.caption_enabled === false) caption.enabled = false;
for (const field of ["position", "date_format", "time_format", "color", "font_size", "font_weight"]) {
for (const field of ["position", "date_format", "time_format", "weather_display", "weather_icons", "color", "font_size", "font_weight"]) {
const value = String(data[`caption_${field}`] || CAPTION_DEFAULTS[field]).trim();
if (value && value !== CAPTION_DEFAULTS[field]) caption[field] = value;
}
@@ -2561,6 +2678,8 @@ function createAlbumSlideshowCardEditorClass(Base) {
caption_time_format: "Time format",
caption_time_seconds: "Show seconds",
caption_weather_entity: "Weather source",
caption_weather_display: "Weather display",
caption_weather_icons: "Weather icons",
caption_color: "Text color",
caption_font_size: "Font size",
caption_font_weight: "Font weight",
@@ -2883,6 +3002,58 @@ function createAlbumSlideshowCardEditorClass(Base) {
};
}
// Home Assistant's own weather icon shapes, adapted from the frontend's src/data/weather.ts
// (Apache-2.0, see www/weather/LICENSE). Colors follow the --weather-icon-* theme variables.
const HA_WEATHER_PATHS = {
sun: "m 14.39303,8.4033507 c 0,3.3114723 -2.684145,5.9956173 -5.9956169,5.9956173 -3.3114716,0 -5.9956168,-2.684145 -5.9956168,-5.9956173 0,-3.311471 2.6841452,-5.995617 5.9956168,-5.995617 3.3114719,0 5.9956169,2.684146 5.9956169,5.995617",
moon: "m 13.502891,11.382935 c -1.011285,1.859223 -2.976664,3.121381 -5.2405751,3.121381 -3.289929,0 -5.953329,-2.663833 -5.953329,-5.9537625 0,-2.263911 1.261724,-4.228856 3.120948,-5.240575 -0.452782,0.842738 -0.712753,1.806363 -0.712753,2.832381 0,3.289928 2.663833,5.9533275 5.9533291,5.9533275 1.026017,0 1.989641,-0.259969 2.83238,-0.712752",
partly: "m14.981 4.2112c0 1.9244-1.56 3.4844-3.484 3.4844-1.9244 0-3.4844-1.56-3.4844-3.4844s1.56-3.484 3.4844-3.484c1.924 0 3.484 1.5596 3.484 3.484",
cloudBack: "m3.8863 5.035c-0.54892 0.16898-1.04 0.46637-1.4372 0.8636-0.63077 0.63041-1.0206 1.4933-1.0206 2.455 0 1.9251 1.5589 3.4682 3.4837 3.4682h6.9688c1.9251 0 3.484-1.5981 3.484-3.5232 0-1.9251-1.5589-3.5232-3.484-3.5232h-1.0834c-0.25294-1.6916-1.6986-2.9083-3.4463-2.9083-1.7995 0-3.2805 1.4153-3.465 3.1679",
cloudFront: "m4.1996 7.6995c-0.33902 0.10407-0.64276 0.28787-0.88794 0.5334-0.39017 0.38982-0.63147 0.92322-0.63147 1.5176 0 1.1896 0.96414 2.1431 2.1537 2.1431h4.3071c1.1896 0 2.153-0.98742 2.153-2.1777 0-1.1896-0.96344-2.1777-2.153-2.1777h-0.66992c-0.15593-1.0449-1.0499-1.7974-2.1297-1.7974-1.112 0-2.0274 0.87524-2.1417 1.9586",
rain: [
"m5.2852 14.734c-0.22401 0.24765-0.57115 0.2988-0.77505 0.11395-0.20391-0.1845-0.18732-0.53481 0.036689-0.78281 0.14817-0.16298 0.59126-0.32914 0.87559-0.42369 0.12453-0.04092 0.22684 0.05186 0.19791 0.17956-0.065617 0.2921-0.18732 0.74965-0.33514 0.91299",
"m11.257 14.163c-0.22437 0.24765-0.57115 0.2988-0.77505 0.11395-0.2039-0.1845-0.18768-0.53481 0.03669-0.78281 0.14817-0.16298 0.59126-0.32914 0.8756-0.42369 0.12453-0.04092 0.22684 0.05186 0.19791 0.17956-0.06562 0.2921-0.18732 0.74965-0.33514 0.91299",
"m8.432 15.878c-0.15452 0.17039-0.3937 0.20567-0.53446 0.07867-0.14041-0.12735-0.12876-0.36865 0.025753-0.53975 0.10195-0.11218 0.40711-0.22684 0.60325-0.29175 0.085725-0.02858 0.15628 0.03563 0.13652 0.12382-0.045508 0.20108-0.12912 0.51647-0.23107 0.629",
"m7.9991 14.118c-0.19226 0.21237-0.49001 0.25612-0.66499 0.09737-0.17462-0.15804-0.16051-0.45861 0.03175-0.67098 0.12665-0.14005 0.50729-0.28293 0.75071-0.36336 0.10689-0.03563 0.19473 0.0441 0.17004 0.15346-0.056092 0.25082-0.16051 0.64347-0.28751 0.78352",
],
pouring: [
"m10.648 16.448c-0.19226 0.21449-0.49001 0.25894-0.66499 0.09878-0.17498-0.16016-0.16087-0.4639 0.03175-0.67874 0.12665-0.14146 0.50694-0.2854 0.75071-0.36724 0.10689-0.03563 0.19473 0.0448 0.17004 0.15558-0.05645 0.25365-0.16051 0.65017-0.28751 0.79163",
"m5.9383 16.658c-0.22437 0.25012-0.5715 0.30162-0.77505 0.11501-0.20391-0.18627-0.18768-0.54046 0.036689-0.79093 0.14817-0.1651 0.59126-0.33267 0.87559-0.42827 0.12418-0.04127 0.22648 0.05221 0.19791 0.18168-0.065617 0.29528-0.18732 0.75741-0.33514 0.92251",
],
wind: [
"m 13.59616,15.30968 c 0,0 -0.09137,-0.0071 -0.250472,-0.0187 -0.158045,-0.01235 -0.381353,-0.02893 -0.64382,-0.05715 -0.262466,-0.02716 -0.564444,-0.06385 -0.877358,-0.124531 -0.156986,-0.03034 -0.315383,-0.06844 -0.473781,-0.111478 -0.157691,-0.04551 -0.313266,-0.09842 -0.463902,-0.161219 l -0.267406,-0.0949 c -0.09984,-0.02646 -0.205669,-0.04904 -0.305153,-0.06738 -0.193322,-0.02716 -0.3838218,-0.03316 -0.5640912,-0.02011 -0.3626556,0.02611 -0.6847417,0.119239 -0.94615,0.226483 -0.2617611,0.108656 -0.4642556,0.230364 -0.600075,0.324203 -0.1358195,0.09419 -0.2049639,0.160514 -0.2049639,0.160514 0,0 0.089958,-0.01623 0.24765,-0.04445 0.1559278,-0.02575 0.3764139,-0.06174 0.6367639,-0.08714 0.2596444,-0.02646 0.5591527,-0.0441 0.8678333,-0.02328 0.076905,0.0035 0.1538111,0.01658 0.2321278,0.02293 0.077611,0.01058 0.1534581,0.02893 0.2314221,0.04022 0.07267,0.01834 0.1397,0.03986 0.213078,0.05644 l 0.238125,0.08925 c 0.09207,0.03281 0.183444,0.07055 0.275872,0.09878 0.09243,0.0261 0.185208,0.05327 0.277636,0.07161 0.184856,0.0388 0.367947,0.06174 0.543983,0.0702 0.353131,0.01905 0.678745,-0.01341 0.951442,-0.06456 0.27305,-0.05292 0.494595,-0.123119 0.646642,-0.181681 0.152047,-0.05785 0.234597,-0.104069 0.234597,-0.104069",
"m 4.7519154,13.905801 c 0,0 0.091369,-0.0032 0.2511778,-0.0092 0.1580444,-0.0064 0.3820583,-0.01446 0.6455833,-0.03281 0.2631722,-0.01729 0.5662083,-0.04269 0.8812389,-0.09137 0.1576916,-0.02434 0.3175,-0.05609 0.4776611,-0.09384 0.1591027,-0.03951 0.3167944,-0.08643 0.4699,-0.14358 l 0.2702277,-0.08467 c 0.1008945,-0.02222 0.2074334,-0.04127 0.3072695,-0.05574 0.1943805,-0.01976 0.3848805,-0.0187 0.5651499,0.0014 0.3608917,0.03951 0.67945,0.144639 0.936625,0.261761 0.2575278,0.118534 0.4554364,0.247297 0.5873754,0.346781 0.132291,0.09913 0.198966,0.168275 0.198966,0.168275 0,0 -0.08925,-0.01976 -0.245886,-0.05397 C 9.9423347,14.087088 9.7232597,14.042988 9.4639681,14.00736 9.2057347,13.97173 8.9072848,13.94245 8.5978986,13.95162 c -0.077258,7.06e-4 -0.1541638,0.01058 -0.2328333,0.01411 -0.077964,0.0078 -0.1545166,0.02328 -0.2331861,0.03175 -0.073025,0.01588 -0.1404055,0.03422 -0.2141361,0.04798 l -0.2420055,0.08008 c -0.093486,0.02963 -0.1859139,0.06421 -0.2794,0.0889 C 7.3028516,14.23666 7.2093653,14.2603 7.116232,14.27512 6.9303181,14.30722 6.7465209,14.3231 6.5697792,14.32486 6.2166487,14.33046 5.8924459,14.28605 5.6218654,14.224318 5.3505793,14.161565 5.1318571,14.082895 4.9822793,14.01869 4.8327015,13.95519 4.7519154,13.905801 4.7519154,13.905801",
],
snow: [
"m 8.4319893,15.348341 c 0,0.257881 -0.209197,0.467079 -0.467078,0.467079 -0.258586,0 -0.46743,-0.209198 -0.46743,-0.467079 0,-0.258233 0.208844,-0.467431 0.46743,-0.467431 0.257881,0 0.467078,0.209198 0.467078,0.467431",
"m 11.263878,14.358553 c 0,0.364067 -0.295275,0.659694 -0.659695,0.659694 -0.364419,0 -0.6596937,-0.295627 -0.6596937,-0.659694 0,-0.364419 0.2952747,-0.659694 0.6596937,-0.659694 0.36442,0 0.659695,0.295275 0.659695,0.659694",
"m 5.3252173,13.69847 c 0,0.364419 -0.295275,0.660047 -0.659695,0.660047 -0.364067,0 -0.659694,-0.295628 -0.659694,-0.660047 0,-0.364067 0.295627,-0.659694 0.659694,-0.659694 0.36442,0 0.659695,0.295627 0.659695,0.659694",
],
lightning: "m 9.9252695,10.935875 -1.6483986,2.341014 1.1170184,0.05929 -1.2169864,2.02141 3.0450261,-2.616159 H 9.8864918 L 10.97937,11.294651 10.700323,10.79794 h -0.508706 l -0.2663475,0.137936",
};
const HA_CLOUDY_STATES = new Set([
"partlycloudy", "cloudy", "fog", "windy", "windy-variant", "hail", "rainy", "snowy",
"snowy-rainy", "pouring", "lightning", "lightning-rainy",
]);
const HA_RAIN_STATES = new Set(["hail", "rainy", "pouring", "lightning-rainy"]);
function homeAssistantWeatherSvg(state, night) {
const p = HA_WEATHER_PATHS;
const paths = [];
if (state === "sunny") paths.push(["sun", p.sun]);
if (state === "clear-night") paths.push(["moon", p.moon]);
if (state === "partlycloudy") paths.push([night ? "moon" : "sun", p.partly]);
if (HA_CLOUDY_STATES.has(state)) paths.push(["cloud-back", p.cloudBack], ["cloud-front", p.cloudFront]);
if (HA_RAIN_STATES.has(state)) paths.push(...p.rain.map((d) => ["rain", d]));
if (state === "pouring") paths.push(...p.pouring.map((d) => ["rain", d]));
if (state === "windy" || state === "windy-variant") paths.push(...p.wind.map((d) => ["cloud-back", d]));
if (state === "snowy" || state === "snowy-rainy") paths.push(...p.snow.map((d) => ["snow", d]));
if (state === "lightning" || state === "lightning-rainy") paths.push(["sun", p.lightning]);
if (!paths.length) return null;
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 17 17">${
paths.map(([cls, d]) => `<path class="${cls}" d="${d}"/>`).join("")}</svg>`;
}
/**
* Register both elements so they survive the
* ``@webcomponents/scoped-custom-element-registry`` polyfill that
@@ -0,0 +1,194 @@
Apache License
==============
_Version 2.0, January 2004_
_&lt;<http://www.apache.org/licenses/>&gt;_
### Terms and Conditions for use, reproduction, and distribution
#### 1. Definitions
“License” shall mean the terms and conditions for use, reproduction, and
distribution as defined by Sections 1 through 9 of this document.
“Licensor” shall mean the copyright owner or entity authorized by the copyright
owner that is granting the License.
“Legal Entity” shall mean the union of the acting entity and all other entities
that control, are controlled by, or are under common control with that entity.
For the purposes of this definition, “control” means **(i)** the power, direct or
indirect, to cause the direction or management of such entity, whether by
contract or otherwise, or **(ii)** ownership of fifty percent (50%) or more of the
outstanding shares, or **(iii)** beneficial ownership of such entity.
“You” (or “Your”) shall mean an individual or Legal Entity exercising
permissions granted by this License.
“Source” form shall mean the preferred form for making modifications, including
but not limited to software source code, documentation source, and configuration
files.
“Object” form shall mean any form resulting from mechanical transformation or
translation of a Source form, including but not limited to compiled object code,
generated documentation, and conversions to other media types.
“Work” shall mean the work of authorship, whether in Source or Object form, made
available under the License, as indicated by a copyright notice that is included
in or attached to the work (an example is provided in the Appendix below).
“Derivative Works” shall mean any work, whether in Source or Object form, that
is based on (or derived from) the Work and for which the editorial revisions,
annotations, elaborations, or other modifications represent, as a whole, an
original work of authorship. For the purposes of this License, Derivative Works
shall not include works that remain separable from, or merely link (or bind by
name) to the interfaces of, the Work and Derivative Works thereof.
“Contribution” shall mean any work of authorship, including the original version
of the Work and any modifications or additions to that Work or Derivative Works
thereof, that is intentionally submitted to Licensor for inclusion in the Work
by the copyright owner or by an individual or Legal Entity authorized to submit
on behalf of the copyright owner. For the purposes of this definition,
“submitted” means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems, and
issue tracking systems that are managed by, or on behalf of, the Licensor for
the purpose of discussing and improving the Work, but excluding communication
that is conspicuously marked or otherwise designated in writing by the copyright
owner as “Not a Contribution.”
“Contributor” shall mean Licensor and any individual or Legal Entity on behalf
of whom a Contribution has been received by Licensor and subsequently
incorporated within the Work.
#### 2. Grant of Copyright License
Subject to the terms and conditions of this License, each Contributor hereby
grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free,
irrevocable copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the Work and such
Derivative Works in Source or Object form.
#### 3. Grant of Patent License
Subject to the terms and conditions of this License, each Contributor hereby
grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free,
irrevocable (except as stated in this section) patent license to make, have
made, use, offer to sell, sell, import, and otherwise transfer the Work, where
such license applies only to those patent claims licensable by such Contributor
that are necessarily infringed by their Contribution(s) alone or by combination
of their Contribution(s) with the Work to which such Contribution(s) was
submitted. If You institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work or a
Contribution incorporated within the Work constitutes direct or contributory
patent infringement, then any patent licenses granted to You under this License
for that Work shall terminate as of the date such litigation is filed.
#### 4. Redistribution
You may reproduce and distribute copies of the Work or Derivative Works thereof
in any medium, with or without modifications, and in Source or Object form,
provided that You meet the following conditions:
* **(a)** You must give any other recipients of the Work or Derivative Works a copy of
this License; and
* **(b)** You must cause any modified files to carry prominent notices stating that You
changed the files; and
* **(c)** You must retain, in the Source form of any Derivative Works that You distribute,
all copyright, patent, trademark, and attribution notices from the Source form
of the Work, excluding those notices that do not pertain to any part of the
Derivative Works; and
* **(d)** If the Work includes a “NOTICE” text file as part of its distribution, then any
Derivative Works that You distribute must include a readable copy of the
attribution notices contained within such NOTICE file, excluding those notices
that do not pertain to any part of the Derivative Works, in at least one of the
following places: within a NOTICE text file distributed as part of the
Derivative Works; within the Source form or documentation, if provided along
with the Derivative Works; or, within a display generated by the Derivative
Works, if and wherever such third-party notices normally appear. The contents of
the NOTICE file are for informational purposes only and do not modify the
License. You may add Your own attribution notices within Derivative Works that
You distribute, alongside or as an addendum to the NOTICE text from the Work,
provided that such additional attribution notices cannot be construed as
modifying the License.
You may add Your own copyright statement to Your modifications and may provide
additional or different license terms and conditions for use, reproduction, or
distribution of Your modifications, or for any such Derivative Works as a whole,
provided Your use, reproduction, and distribution of the Work otherwise complies
with the conditions stated in this License.
#### 5. Submission of Contributions
Unless You explicitly state otherwise, any Contribution intentionally submitted
for inclusion in the Work by You to the Licensor shall be under the terms and
conditions of this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify the terms of
any separate license agreement you may have executed with Licensor regarding
such Contributions.
#### 6. Trademarks
This License does not grant permission to use the trade names, trademarks,
service marks, or product names of the Licensor, except as required for
reasonable and customary use in describing the origin of the Work and
reproducing the content of the NOTICE file.
#### 7. Disclaimer of Warranty
Unless required by applicable law or agreed to in writing, Licensor provides the
Work (and each Contributor provides its Contributions) on an “AS IS” BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied,
including, without limitation, any warranties or conditions of TITLE,
NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are
solely responsible for determining the appropriateness of using or
redistributing the Work and assume any risks associated with Your exercise of
permissions under this License.
#### 8. Limitation of Liability
In no event and under no legal theory, whether in tort (including negligence),
contract, or otherwise, unless required by applicable law (such as deliberate
and grossly negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special, incidental,
or consequential damages of any character arising as a result of this License or
out of the use or inability to use the Work (including but not limited to
damages for loss of goodwill, work stoppage, computer failure or malfunction, or
any and all other commercial damages or losses), even if such Contributor has
been advised of the possibility of such damages.
#### 9. Accepting Warranty or Additional Liability
While redistributing the Work or Derivative Works thereof, You may choose to
offer, and charge a fee for, acceptance of support, warranty, indemnity, or
other liability obligations and/or rights consistent with this License. However,
in accepting such obligations, You may act only on Your own behalf and on Your
sole responsibility, not on behalf of any other Contributor, and only if You
agree to indemnify, defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason of your
accepting any such warranty or additional liability.
_END OF TERMS AND CONDITIONS_
### APPENDIX: How to apply the Apache License to your work
To apply the Apache License to your work, attach the following boilerplate
notice, with the fields enclosed by brackets `[]` replaced with your own
identifying information. (Don't include the brackets!) The text should be
enclosed in the appropriate comment syntax for the file format. We also
recommend that a file or class name and description of purpose be included on
the same “printed page” as the copyright notice for easier identification within
third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.