updated apps

This commit is contained in:
2026-07-14 23:57:03 -04:00
parent 6cc7212cef
commit 010e828e9c
797 changed files with 45153 additions and 4246 deletions
+64 -92
View File
@@ -148,12 +148,6 @@ class AlbumSlideshowCamera(Camera):
self._framebuffer: bytes | None = None
# MJPEG subscribers. Each open stream owns an asyncio.Queue of JPEG
# byte payloads. The render loop pushes the latest still as soon
# as it's encoded; if a subscriber falls behind we drop frames
# for that subscriber rather than block the whole loop.
self._mjpeg_subscribers: set[asyncio.Queue[bytes]] = set()
# Monotonic counter incremented every time a new still is committed.
# Exposed as the ``frame_id`` state attribute so the Lovelace card
# has an unambiguous "new frame ready" signal even when other
@@ -244,6 +238,7 @@ class AlbumSlideshowCamera(Camera):
"latitude": getattr(cur, "latitude", None),
"longitude": getattr(cur, "longitude", None),
"location": getattr(cur, "location", None),
"description": getattr(cur, "description", None),
# Structured per-image caption metadata. A single-element list for
# normal slides; two elements (top/left first) for paired slides,
# so the card can overlay an accurate date/location on each half.
@@ -256,6 +251,7 @@ class AlbumSlideshowCamera(Camera):
"portrait_mode": self.store.portrait_mode,
"order_mode": self.store.order_mode,
"date_filter": self.store.date_filter,
"missing_date_mode": self.store.missing_date_mode,
"paused": bool(self.store.paused),
"refresh_hours": int(self.store.refresh_hours),
"aspect_ratio": self.store.aspect_ratio,
@@ -282,6 +278,7 @@ class AlbumSlideshowCamera(Camera):
"location": getattr(cur, "location", None),
"latitude": getattr(cur, "latitude", None),
"longitude": getattr(cur, "longitude", None),
"description": getattr(cur, "description", None),
}
]
@@ -318,6 +315,7 @@ class AlbumSlideshowCamera(Camera):
cache_key = (
id(raw),
self.store.date_filter,
self.store.missing_date_mode,
self.store.order_mode,
)
if self._effective_cache is not None and self._effective_cache[0] == hash(cache_key):
@@ -326,6 +324,7 @@ class AlbumSlideshowCamera(Camera):
filtered = playlist.filter_items(
raw,
mode=self.store.date_filter,
missing_date=self.store.missing_date_mode,
)
ordered = playlist.order_items(filtered, self.store.order_mode)
self._effective_cache = (hash(cache_key), ordered)
@@ -343,67 +342,32 @@ class AlbumSlideshowCamera(Camera):
return self._framebuffer
async def handle_async_mjpeg_stream(self, request):
"""Stream the slideshow as multipart MJPEG.
"""Serve the current slide as MJPEG for Home Assistant core surfaces.
Each open client gets a bounded asyncio.Queue that the render loop
pushes JPEG payloads into when a new still is committed. Visible
transitions are now handled by the Lovelace card on the client
side, so this stream just emits the latest still per slide change.
This is what the more-info dialog and picture-glance live view use
(the camera advertises no live stream, so the frontend falls back to
``/api/camera_proxy_stream``). We delegate to HA's still-stream
helper, which polls ``async_camera_image`` at ``frame_interval`` and
writes a correct multipart response.
Crucially it emits frames *continuously* rather than only on slide
change. A browser parsing ``multipart/x-mixed-replace`` holds the
current part until the next boundary arrives, so a stream that sent
one frame and then went quiet until the next slide (potentially many
seconds away, or never while paused) left the more-info view blank.
Polling keeps a boundary coming right away, so the current frame
renders immediately.
"""
# Imported lazily so the module still loads in test environments
# that stub out homeassistant without installing aiohttp.
from aiohttp import web
# that stub out homeassistant.
from homeassistant.components.camera import async_get_still_stream
boundary = "frame"
response = web.StreamResponse(
status=200,
reason="OK",
headers={
"Content-Type": f"multipart/x-mixed-replace;boundary={boundary}",
"Cache-Control": "no-cache, private",
"Pragma": "no-cache",
},
return await async_get_still_stream(
request,
self.async_camera_image,
self.content_type,
self.frame_interval,
)
await response.prepare(request)
# Bounded queue: a slow client should fall behind on slide commits
# rather than balloon memory.
queue: asyncio.Queue[bytes] = asyncio.Queue(maxsize=4)
self._mjpeg_subscribers.add(queue)
# Push the held still immediately so the client renders something
# before the next slide change.
if self._framebuffer is not None:
try:
queue.put_nowait(self._framebuffer)
except asyncio.QueueFull:
pass
try:
while True:
payload = await queue.get()
try:
await response.write(
b"--" + boundary.encode() + b"\r\n"
b"Content-Type: image/jpeg\r\n"
b"Content-Length: " + str(len(payload)).encode() + b"\r\n\r\n"
+ payload + b"\r\n"
)
except (ConnectionResetError, asyncio.CancelledError):
raise
except Exception as err:
_LOGGER.debug("Album Slideshow: mjpeg client write failed: %s", err)
break
except (ConnectionResetError, asyncio.CancelledError):
pass
finally:
self._mjpeg_subscribers.discard(queue)
try:
await response.write_eof()
except Exception:
pass
return response
# Older HA cores may dispatch via the alt name; alias for compatibility.
async def async_handle_async_mjpeg_stream(self, request):
@@ -412,12 +376,17 @@ class AlbumSlideshowCamera(Camera):
async def _wait_or_interrupt(self, timeout: float) -> bool:
"""Wait up to ``timeout`` seconds, returning True if interrupted.
Safe wrapper around clear() + wait_for() - callers don't have to
worry about the ordering of the two operations. The clear() runs
synchronously before the awaitable is created, so no interrupt can
be lost on the single-threaded event loop.
Does NOT clear ``_interrupt_event`` - the render loop clears it once
per cycle, before rendering, so a signal that arrives while we're
rendering (a "next slide" press, a coordinator/store change) survives
until we get here instead of being wiped.
A force-next that's already pending is honored immediately without
sleeping, so a press that landed during the last render (or right at
the loop boundary) isn't held until the full slide interval elapses.
"""
self._interrupt_event.clear()
if self._force_next:
return True
try:
await asyncio.wait_for(self._interrupt_event.wait(), timeout=timeout)
return True
@@ -433,6 +402,11 @@ class AlbumSlideshowCamera(Camera):
raise
should_advance = False # Don't advance on the very first render
while True:
# Clear the wake signal before rendering. Anything that happens
# from here on (a "next slide" press, a coordinator/store change)
# re-sets it and is picked up after this frame commits, so no
# wake is lost while we're mid-render.
self._interrupt_event.clear()
try:
await self._render_cycle(advance=should_advance)
self._consecutive_failures = 0
@@ -453,9 +427,14 @@ class AlbumSlideshowCamera(Camera):
continue
interrupted = await self._wait_or_interrupt(float(int(self.store.slide_interval)))
if interrupted:
should_advance = self._force_next
if self._force_next:
# Explicit "next slide" request: always advance.
should_advance = True
self._force_next = False
elif interrupted:
# A coordinator/store change woke us: re-render the current
# frame (new data or settings) without skipping ahead.
should_advance = False
else:
# Paused slideshows hold the current frame until the user
# un-pauses or hits "next slide" explicitly.
@@ -510,7 +489,6 @@ class AlbumSlideshowCamera(Camera):
self._last_pair_frames = meta.get("pair_frames") if meta else None
self._last_pair_orientation = meta.get("pair_orientation") if meta else None
self._broadcast_frame(encoded)
self.async_write_ha_state()
@property
@@ -528,27 +506,6 @@ class AlbumSlideshowCamera(Camera):
domain_data["compose_semaphore"] = sem
return sem
def _broadcast_frame(self, payload: bytes) -> None:
"""Push a frame to every active MJPEG subscriber.
Slow subscribers get their frame dropped rather than backing up the
queue; the next still emission will catch them up.
"""
for queue in list(self._mjpeg_subscribers):
try:
queue.put_nowait(payload)
except asyncio.QueueFull:
# Drain one and retry once so a wedged client still sees
# the latest frame eventually instead of forever stale.
try:
queue.get_nowait()
except asyncio.QueueEmpty:
pass
try:
queue.put_nowait(payload)
except asyncio.QueueFull:
pass
def _do_advance(self, count: int, items: list) -> None:
"""Advance _index to the next slide and commit random-order position."""
if count <= 0:
@@ -651,12 +608,14 @@ class AlbumSlideshowCamera(Camera):
"location": getattr(cur, "location", None),
"latitude": getattr(cur, "latitude", None),
"longitude": getattr(cur, "longitude", None),
"description": getattr(cur, "description", None),
},
{
"captured_at": _ts_to_iso(getattr(other_item, "captured_at", None)),
"location": getattr(other_item, "location", None),
"latitude": getattr(other_item, "latitude", None),
"longitude": getattr(other_item, "longitude", None),
"description": getattr(other_item, "description", None),
},
]
pair_meta = [f["captured_at"] for f in pair_frames]
@@ -876,11 +835,24 @@ class AlbumSlideshowCamera(Camera):
self._download_cache.put(url, data)
return data
def _image_request_headers(self, url: str) -> dict[str, str] | None:
"""Auth headers required to fetch image bytes for some providers.
The Immich provider stores an ``x-api-key`` header on the coordinator;
it is sent server-side only, so the key never reaches the browser or
the camera's ``current_url`` attribute. Returns ``None`` when no extra
headers are needed (Google, local folder, media source).
"""
headers = getattr(self.coordinator, "image_request_headers", None)
if headers and isinstance(url, str) and url.startswith("http"):
return dict(headers)
return None
async def _http_get(self, url: str) -> bytes | None:
session = async_get_clientsession(self.hass)
try:
async with async_timeout.timeout(30):
async with session.get(url) as resp:
async with session.get(url, headers=self._image_request_headers(url)) as resp:
resp.raise_for_status()
content_type = resp.headers.get("Content-Type", "")
@@ -1,5 +1,6 @@
from __future__ import annotations
import json
import re
from typing import Any
@@ -7,6 +8,11 @@ import voluptuous as vol
from homeassistant import config_entries
from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import selector
# Sentinel values for the "Select all" options in the multi-selects.
_ALL_PEOPLE = "__all_people__"
_ALL_ALBUMS = "__all_albums__"
from .const import (
DOMAIN,
@@ -14,11 +20,23 @@ from .const import (
CONF_ALBUM_NAME,
CONF_ALBUM_URL,
CONF_LOCAL_PATH,
CONF_MEDIA_CONTENT_ID,
CONF_RECURSIVE,
CONF_REVERSE_GEOCODE,
CONF_IMMICH_URL,
CONF_IMMICH_API_KEY,
CONF_IMMICH_SELECTION_TYPE,
CONF_IMMICH_SELECTION_ID,
CONF_IMMICH_IMAGE_SIZE,
CONF_IMMICH_FILTER,
DEFAULT_IMMICH_IMAGE_SIZE,
IMMICH_IMAGE_SIZE_OPTIONS,
IMMICH_SELECTION_COMPOSITE,
DEFAULT_REVERSE_GEOCODE,
PROVIDER_GOOGLE_SHARED,
PROVIDER_LOCAL_FOLDER,
PROVIDER_MEDIA_SOURCE,
PROVIDER_IMMICH,
DEFAULT_RECURSIVE,
)
@@ -52,6 +70,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
def __init__(self) -> None:
self._provider: str | None = None
# Immich flow state carried between steps.
self._immich_url: str | None = None
self._immich_key: str | None = None
# id -> name maps for the Albums and People multi-selects.
self._immich_albums: dict[str, str] = {}
self._immich_people: dict[str, str] = {}
@staticmethod
@callback
@@ -79,6 +103,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
self._provider = user_input[CONF_PROVIDER]
if self._provider == PROVIDER_LOCAL_FOLDER:
return await self.async_step_local_folder()
if self._provider == PROVIDER_MEDIA_SOURCE:
return await self.async_step_media_source()
if self._provider == PROVIDER_IMMICH:
return await self.async_step_immich()
return await self.async_step_google_shared()
schema = vol.Schema(
@@ -86,6 +114,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
vol.Required(CONF_PROVIDER, default=PROVIDER_GOOGLE_SHARED): vol.In({
PROVIDER_GOOGLE_SHARED: "Google Photos",
PROVIDER_LOCAL_FOLDER: "Local Folder",
PROVIDER_IMMICH: "Immich (direct API, full metadata)",
PROVIDER_MEDIA_SOURCE: "Media Source (any source, no metadata)",
})
}
)
@@ -152,6 +182,193 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
)
return self.async_show_form(step_id="local_folder", data_schema=schema, errors=errors)
async def async_step_media_source(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
errors: dict[str, str] = {}
if user_input is not None:
content_id = user_input[CONF_MEDIA_CONTENT_ID].strip()
name = user_input[CONF_ALBUM_NAME].strip()
if not content_id.startswith("media-source://"):
errors[CONF_MEDIA_CONTENT_ID] = "invalid_media_source"
else:
await self.async_set_unique_id(
f"{DOMAIN}:{PROVIDER_MEDIA_SOURCE}:{content_id}"
)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=name,
data={
CONF_PROVIDER: PROVIDER_MEDIA_SOURCE,
CONF_MEDIA_CONTENT_ID: content_id,
CONF_ALBUM_NAME: name,
},
)
schema = vol.Schema(
{
vol.Required(CONF_ALBUM_NAME): str,
vol.Required(CONF_MEDIA_CONTENT_ID): str,
}
)
return self.async_show_form(
step_id="media_source", data_schema=schema, errors=errors
)
async def async_step_immich(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Collect the Immich URL + API key and validate them."""
errors: dict[str, str] = {}
if user_input is not None:
url = user_input[CONF_IMMICH_URL].strip()
key = user_input[CONF_IMMICH_API_KEY].strip()
from . import immich as immich_api
client = immich_api.ImmichClient(self.hass, url, key)
try:
await client.async_validate()
albums = await client.async_list_albums()
people = await client.async_list_people()
except Exception: # noqa: BLE001 - any failure means bad URL/key
errors["base"] = "immich_cannot_connect"
else:
self._immich_url = client.base_url
self._immich_key = key
# id -> name maps for the two multi-select pickers.
self._immich_albums = {
a["id"]: (a.get("albumName") or a["id"])
for a in albums
if a.get("id")
}
self._immich_people = {
p["id"]: p["name"]
for p in people
if p.get("id") and (p.get("name") or "").strip()
}
return await self.async_step_immich_select()
schema = vol.Schema(
{
vol.Required(CONF_IMMICH_URL): str,
vol.Required(CONF_IMMICH_API_KEY): str,
}
)
return self.async_show_form(
step_id="immich", data_schema=schema, errors=errors
)
async def async_step_immich_select(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Build a composite Immich selection and finish the entry.
The user ticks any mix of albums, people and favorites (and may add a
custom JSON filter); the coordinator unions them. Leaving everything
empty means "all photos".
"""
errors: dict[str, str] = {}
if user_input is not None:
name = user_input[CONF_ALBUM_NAME].strip()
size = user_input.get(CONF_IMMICH_IMAGE_SIZE, DEFAULT_IMMICH_IMAGE_SIZE)
raw_filter = (user_input.get(CONF_IMMICH_FILTER) or "").strip()
favorites = bool(user_input.get("favorites"))
chosen_albums = [a for a in user_input.get("albums", []) if a]
if _ALL_ALBUMS in chosen_albums:
chosen_albums = list(self._immich_albums.keys())
else:
chosen_albums = [a for a in chosen_albums if a in self._immich_albums]
chosen_people = [p for p in user_input.get("people", []) if p]
if _ALL_PEOPLE in chosen_people:
chosen_people = list(self._immich_people.keys())
else:
chosen_people = [p for p in chosen_people if p in self._immich_people]
if raw_filter:
try:
parsed = json.loads(raw_filter)
if not isinstance(parsed, dict):
raise ValueError
except ValueError:
errors[CONF_IMMICH_FILTER] = "immich_filter_invalid"
if not errors:
selection = {
"albums": chosen_albums,
"people": chosen_people,
"favorites": favorites,
}
sel_id = json.dumps(selection, sort_keys=True)
unique = (
f"{DOMAIN}:{PROVIDER_IMMICH}:{self._immich_url}:"
f"composite:{sel_id}:{raw_filter}"
)
await self.async_set_unique_id(unique)
self._abort_if_unique_id_configured()
data = {
CONF_PROVIDER: PROVIDER_IMMICH,
CONF_IMMICH_URL: self._immich_url,
CONF_IMMICH_API_KEY: self._immich_key,
CONF_IMMICH_SELECTION_TYPE: IMMICH_SELECTION_COMPOSITE,
CONF_IMMICH_SELECTION_ID: sel_id,
CONF_IMMICH_IMAGE_SIZE: size,
CONF_ALBUM_NAME: name,
}
if raw_filter:
data[CONF_IMMICH_FILTER] = raw_filter
return self.async_create_entry(title=name, data=data)
fields: dict[Any, Any] = {vol.Required(CONF_ALBUM_NAME): str}
if self._immich_albums:
album_options = [
selector.SelectOptionDict(
value=_ALL_ALBUMS, label="Select all albums"
)
] + [
selector.SelectOptionDict(value=aid, label=name)
for aid, name in self._immich_albums.items()
]
fields[vol.Optional("albums")] = selector.SelectSelector(
selector.SelectSelectorConfig(
options=album_options,
multiple=True,
mode=selector.SelectSelectorMode.DROPDOWN,
custom_value=False,
)
)
if self._immich_people:
people_options = [
selector.SelectOptionDict(
value=_ALL_PEOPLE, label="Select all people"
)
] + [
selector.SelectOptionDict(value=pid, label=name)
for pid, name in self._immich_people.items()
]
fields[vol.Optional("people")] = selector.SelectSelector(
selector.SelectSelectorConfig(
options=people_options,
multiple=True,
mode=selector.SelectSelectorMode.DROPDOWN,
custom_value=False,
)
)
fields[vol.Optional("favorites", default=False)] = selector.BooleanSelector()
fields[vol.Optional(CONF_IMMICH_FILTER)] = str
fields[
vol.Optional(CONF_IMMICH_IMAGE_SIZE, default=DEFAULT_IMMICH_IMAGE_SIZE)
] = vol.In(IMMICH_IMAGE_SIZE_OPTIONS)
schema = vol.Schema(fields)
return self.async_show_form(
step_id="immich_select", data_schema=schema, errors=errors
)
class LocalFolderOptionsFlow(config_entries.OptionsFlow):
"""Options for local-folder entries.
@@ -6,6 +6,43 @@ CONF_ALBUM_NAME = "album_name"
CONF_LOCAL_PATH = "local_path"
CONF_RECURSIVE = "recursive"
CONF_IMAGE_CACHE_MB = "image_cache_mb"
# Media Source provider: a ``media-source://...`` content id pointing at a
# folder-like node (e.g. an Immich people/album view, or local media). The
# coordinator browses it, collects the image children, and resolves each to
# a playable URL.
CONF_MEDIA_CONTENT_ID = "media_content_id"
# Immich (direct API) provider.
CONF_IMMICH_URL = "immich_url"
CONF_IMMICH_API_KEY = "immich_api_key"
CONF_IMMICH_SELECTION_TYPE = "immich_selection_type"
CONF_IMMICH_SELECTION_ID = "immich_selection_id"
CONF_IMMICH_IMAGE_SIZE = "immich_image_size"
CONF_IMMICH_FILTER = "immich_filter"
IMMICH_SELECTION_ALBUM = "album"
IMMICH_SELECTION_ALBUMS = "albums"
IMMICH_SELECTION_PERSON = "person"
IMMICH_SELECTION_PEOPLE = "people"
IMMICH_SELECTION_FAVORITES = "favorites"
IMMICH_SELECTION_ALL = "all"
IMMICH_SELECTION_RANDOM = "random"
IMMICH_SELECTION_SEARCH = "search"
# Composite: a client-side union of any mix of albums, people, favorites and a
# custom filter. Immich has no OR operator, so each member is queried on its
# own and the results are merged (see #19). The selection id is a JSON object
# ``{"albums": [...], "people": [...], "favorites": bool}``; an empty composite
# means "all photos".
IMMICH_SELECTION_COMPOSITE = "composite"
IMMICH_IMAGE_PREVIEW = "preview"
IMMICH_IMAGE_FULLSIZE = "fullsize"
IMMICH_IMAGE_ORIGINAL = "original"
IMMICH_IMAGE_SIZE_OPTIONS = [
IMMICH_IMAGE_PREVIEW,
IMMICH_IMAGE_FULLSIZE,
IMMICH_IMAGE_ORIGINAL,
]
DEFAULT_IMMICH_IMAGE_SIZE = IMMICH_IMAGE_PREVIEW
# 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``
@@ -16,6 +53,8 @@ DEFAULT_REVERSE_GEOCODE = True
PROVIDER_GOOGLE_SHARED = "google_shared"
PROVIDER_LOCAL_FOLDER = "local_folder"
PROVIDER_MEDIA_SOURCE = "media_source"
PROVIDER_IMMICH = "immich"
FILL_COVER = "cover"
FILL_CONTAIN = "contain"
@@ -60,6 +99,21 @@ DATE_FILTER_OPTIONS = [
]
DEFAULT_DATE_FILTER = DATE_FILTER_OFF
# How the date filter treats photos that have no EXIF capture date.
# use_uploaded_at - fall back to the upload date (keeps filters meaningful)
# include - keep undated photos (legacy behaviour for windows)
# exclude - drop undated photos entirely
MISSING_DATE_USE_UPLOADED = "use_uploaded_at"
MISSING_DATE_INCLUDE = "include"
MISSING_DATE_EXCLUDE = "exclude"
MISSING_DATE_OPTIONS = [
MISSING_DATE_USE_UPLOADED,
MISSING_DATE_INCLUDE,
MISSING_DATE_EXCLUDE,
]
DEFAULT_MISSING_DATE_MODE = MISSING_DATE_USE_UPLOADED
DEFAULT_SLIDE_INTERVAL = 60
DEFAULT_REFRESH_HOURS = 24
DEFAULT_FILL_MODE = FILL_BLUR
+595 -12
View File
@@ -6,6 +6,7 @@ from datetime import timedelta
import json
import logging
from pathlib import Path
import re
from typing import Any
import async_timeout
@@ -20,12 +21,22 @@ from .const import (
CONF_PROVIDER,
CONF_ALBUM_URL,
CONF_LOCAL_PATH,
CONF_MEDIA_CONTENT_ID,
CONF_RECURSIVE,
CONF_REVERSE_GEOCODE,
CONF_IMMICH_URL,
CONF_IMMICH_API_KEY,
CONF_IMMICH_SELECTION_TYPE,
CONF_IMMICH_SELECTION_ID,
CONF_IMMICH_IMAGE_SIZE,
CONF_IMMICH_FILTER,
DEFAULT_IMMICH_IMAGE_SIZE,
DEFAULT_REVERSE_GEOCODE,
DOMAIN,
PROVIDER_GOOGLE_SHARED,
PROVIDER_LOCAL_FOLDER,
PROVIDER_MEDIA_SOURCE,
PROVIDER_IMMICH,
)
from .store import SlideshowStore
@@ -53,6 +64,13 @@ class MediaItem:
latitude: float | None = None
longitude: float | None = None
location: str | None = None
# Free-text photo description / caption (local-folder provider only),
# read from EXIF ImageDescription, IPTC Caption-Abstract, or XMP
# dc:description. Used by the card's caption overlay when enabled.
description: str | None = None
# Provider-specific source identifier (e.g. the Immich asset id) used by
# background enrichment to fetch per-item metadata.
source_id: str | None = None
# True once the local-folder EXIF reader has visited this file.
# Prevents re-reading EXIF on every coordinator refresh and lets the
# background enrichment task skip already-processed files even after
@@ -63,6 +81,73 @@ class MediaItem:
_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif"}
_VIDEO_EXTS = {".mp4", ".mov", ".m4v", ".avi", ".mkv", ".webm", ".3gp", ".mts", ".m2ts"}
# Media Source browsing limits: cap total collected images and recursion
# depth so a huge or self-referential tree can't hang the coordinator or
# exhaust memory.
_MEDIA_SOURCE_MAX_ITEMS = 5000
_MEDIA_SOURCE_MAX_DEPTH = 8
# System/metadata folders and files that some sources (notably Synology)
# expose but which are never user photos.
_SKIP_MEDIA_TITLES = {"@eadir", ".ds_store", "thumbs.db", "@syno", "#recycle"}
# Image formats a browser cannot render inline; skip so we don't queue
# guaranteed fetch failures.
_NON_WEB_IMAGE_EXTS = {
".psd", ".tif", ".tiff", ".heic", ".heif",
".cr2", ".nef", ".arw", ".dng", ".raw", ".orf", ".rw2",
}
def _is_junk_media_title(title: Any) -> bool:
"""Return True for system folders / non-renderable files to skip."""
if not isinstance(title, str):
return False
t = title.strip().lower()
if not t:
return False
if t in _SKIP_MEDIA_TITLES:
return True
if t.startswith("@") or t.startswith("."):
return True
dot = t.rfind(".")
if dot != -1 and t[dot:] in _NON_WEB_IMAGE_EXTS:
return True
return False
def _media_node_is_image(media_class: Any, media_content_type: Any) -> bool:
"""Return True if a browsed media node looks like a still image.
``media_class`` is Home Assistant's coarse category (e.g. ``image``,
``video``, ``directory``); ``media_content_type`` is the MIME type when
known. We accept a node when either signals an image, and explicitly
reject anything that declares a video type.
"""
mc = str(media_class or "").lower()
mime = str(media_content_type or "").lower()
if mc == "video" or mime.startswith("video/"):
return False
if mc == "image" or mime.startswith("image/"):
return True
return False
def _normalize_resolved_url(url: str, base_url: str) -> str:
"""Make a resolved media URL absolute so the fetch layer can load it.
``async_resolve_media`` returns either an absolute ``http(s)`` URL or a
site-relative path such as ``/media/local/...`` (often already signed via
an ``authSig`` query param). Relative paths are prefixed with the
instance's internal base URL; absolute URLs pass through unchanged.
"""
if not isinstance(url, str) or not url:
return url
if url.startswith("http://") or url.startswith("https://"):
return url
if url.startswith("/") and base_url:
return f"{base_url.rstrip('/')}{url}"
return url
_SKIP_DIR_PREFIXES = (".", "@", "#")
@@ -74,6 +159,66 @@ def _pick_url(item: dict[str, Any]) -> str | None:
return None
# Matches Google's ``=w1920-h1080`` (and variants) size suffix so two URLs for
# the same photo at different sizes collapse to one stable key.
_PHOTO_SIZE_SUFFIX_RE = re.compile(r"=[a-z0-9-]+$", re.IGNORECASE)
def _photo_base_key(url: str | None) -> str | None:
"""Return a stable per-photo key from a Google CDN URL.
Both album sources (``batchexecute`` and publicalbum.org) hand back
``lh3.googleusercontent.com/<id>=w...-h...`` URLs for the same photo, just
at different sizes and sometimes with query params. Dropping the query
string and the size suffix leaves the shared ``<id>`` portion, which lets
us match a publicalbum item to its dated batchexecute twin.
"""
if not isinstance(url, str) or not url:
return None
base = url.split("?", 1)[0]
base = _PHOTO_SIZE_SUFFIX_RE.sub("", base)
return base or None
def _enrich_missing_dates(
api_items: list["MediaItem"], scraped_items: list["MediaItem"]
) -> int:
"""Backfill missing dates on ``api_items`` from dated ``scraped_items``.
Matches photos across the two Google album sources by their stable
per-photo URL key and fills in any ``captured_at`` / ``uploaded_at`` that
the publicalbum item is missing. Mutates ``api_items`` in place and returns
how many items were touched. See issue #18.
"""
if not api_items or not scraped_items:
return 0
dates_by_key: dict[str, tuple[int | None, int | None]] = {}
for it in scraped_items:
key = _photo_base_key(it.url)
if key and (it.captured_at is not None or it.uploaded_at is not None):
dates_by_key.setdefault(key, (it.captured_at, it.uploaded_at))
if not dates_by_key:
return 0
enriched = 0
for it in api_items:
if it.captured_at is not None and it.uploaded_at is not None:
continue
twin = dates_by_key.get(_photo_base_key(it.url))
if not twin:
continue
cap, up = twin
touched = False
if it.captured_at is None and cap is not None:
it.captured_at = cap
touched = True
if it.uploaded_at is None and up is not None:
it.uploaded_at = up
touched = True
if touched:
enriched += 1
return enriched
def _pick_int(d: dict[str, Any], *path: str) -> int | None:
cur: Any = d
for p in path:
@@ -231,6 +376,7 @@ def _looks_like_video(raw: dict[str, Any]) -> bool:
_EXIF_TAG_DATETIME_ORIGINAL = 36867 # DateTimeOriginal
_EXIF_TAG_OFFSET_TIME_ORIGINAL = 36881 # OffsetTimeOriginal (e.g. "+02:00")
_EXIF_TAG_DATETIME = 306 # DateTime (modification time)
_EXIF_TAG_IMAGE_DESCRIPTION = 270 # ImageDescription (free text)
_EXIF_TAG_GPS_IFD = 34853 # Pointer to the GPS IFD
_EXIF_GPS_LAT_REF = 1 # "N" / "S"
_EXIF_GPS_LAT = 2 # rational tuple
@@ -351,6 +497,128 @@ def _parse_exif_datetime(raw: Any, offset_raw: Any) -> int | None:
return None
def _clean_description(value: Any) -> str | None:
"""Normalise a raw description value to trimmed text or ``None``."""
if isinstance(value, bytes):
try:
value = value.decode("utf-8", errors="ignore")
except Exception:
return None
if not isinstance(value, str):
return None
text = value.strip().replace("\x00", "")
return text or None
def _read_photo_description(img: Any, exif: Any) -> str | None:
"""Extract a photo description from EXIF, IPTC, or XMP metadata.
Tries, in order: EXIF ``ImageDescription``, IPTC ``Caption-Abstract``
(record 2, dataset 120), and XMP ``dc:description``. Returns the first
non-empty value, or ``None``. All lookups are defensive because these
metadata blocks are frequently absent or malformed.
"""
try:
desc = _clean_description(exif.get(_EXIF_TAG_IMAGE_DESCRIPTION))
if desc:
return desc
except Exception:
pass
try:
from PIL import IptcImagePlugin
iptc = IptcImagePlugin.getiptcinfo(img)
if iptc:
desc = _clean_description(iptc.get((2, 120)))
if desc:
return desc
except Exception:
pass
try:
xmp = img.getxmp()
except Exception:
xmp = None
if isinstance(xmp, dict):
found = _find_xmp_description(xmp)
if found:
return found
return None
def _find_xmp_description(node: Any) -> str | None:
"""Recursively search a parsed XMP tree for the ``dc:description`` text.
Pillow's ``getxmp()`` returns nested dicts. The relevant path is
``xmpmeta -> RDF -> Description -> description -> Alt -> li``. We match
the ``dc:description`` *field* by its exact lowercase localname
``description`` (case-sensitive) so we don't accidentally match the
``rdf:Description`` *container* (capital ``D``) that wraps it.
"""
if isinstance(node, dict):
for key, value in node.items():
# Case-sensitive: dc:description is lowercase; the rdf:Description
# container is capitalised and must be skipped here.
if isinstance(key, str) and key.split(":")[-1] == "description":
text = _extract_xmp_text(value)
if text:
return text
for value in node.values():
found = _find_xmp_description(value)
if found:
return found
elif isinstance(node, list):
for value in node:
found = _find_xmp_description(value)
if found:
return found
return None
def _extract_xmp_text(value: Any) -> str | None:
"""Pull plain text out of an XMP language-alternative structure.
Handles the shapes Pillow produces for ``dc:description``:
- a plain string
- ``{"Alt"/"Bag"/"Seq": {"li": ...}}`` containers
- an ``li`` that is a string, a ``{"lang", "text"}`` dict, or a list of
such dicts (multiple languages)
Prefers the ``x-default`` language entry when several are present.
"""
if isinstance(value, str):
return _clean_description(value)
if isinstance(value, dict):
# A language-alternative leaf: ``{"lang": ..., "text": ...}``.
if "text" in value:
return _clean_description(value.get("text"))
# Container wrappers: descend only through the known XMP keys so we
# never grab a sibling attribute value (e.g. a ``lang`` code).
for container in ("Alt", "Bag", "Seq"):
if container in value:
text = _extract_xmp_text(value[container])
if text:
return text
if "li" in value:
return _extract_xmp_text(value["li"])
return None
if isinstance(value, list):
# Multiple ``rdf:li`` entries: prefer x-default, else the first
# non-empty one.
default = None
first = None
for v in value:
if isinstance(v, dict) and v.get("lang") == "x-default":
default = _clean_description(v.get("text"))
if default:
return default
if first is None:
first = _extract_xmp_text(v)
return first
return None
def _read_local_exif(path: Path) -> dict[str, Any]:
"""Read EXIF metadata for a local file.
@@ -382,17 +650,25 @@ def _read_local_exif(path: Path) -> dict[str, Any]:
try:
with Image.open(path) as img:
exif = img.getexif()
if exif:
dt_raw = exif.get(_EXIF_TAG_DATETIME_ORIGINAL) or exif.get(
_EXIF_TAG_DATETIME
)
offset_raw = exif.get(_EXIF_TAG_OFFSET_TIME_ORIGINAL)
parsed = _parse_exif_datetime(dt_raw, offset_raw)
if parsed is not None:
out["captured_at"] = parsed
# Description can come from IPTC / XMP even when the file has no
# EXIF IFD, so this runs regardless of ``exif`` being present.
description = _read_photo_description(img, exif)
if description:
out["description"] = description
if not exif:
return out
dt_raw = exif.get(_EXIF_TAG_DATETIME_ORIGINAL) or exif.get(
_EXIF_TAG_DATETIME
)
offset_raw = exif.get(_EXIF_TAG_OFFSET_TIME_ORIGINAL)
parsed = _parse_exif_datetime(dt_raw, offset_raw)
if parsed is not None:
out["captured_at"] = parsed
gps = None
try:
gps = exif.get_ifd(_EXIF_TAG_GPS_IFD) or None
@@ -550,13 +826,18 @@ def _merge_prior_enrichment(
item.longitude = prev.longitude
if prev.location and not item.location:
item.location = prev.location
if prev.description and not item.description:
item.description = prev.description
if prev.exif_scanned:
item.exif_scanned = True
class AlbumCoordinator(DataUpdateCoordinator):
# Bump when the persisted item shape changes incompatibly.
_ITEM_CACHE_VERSION = 2
# v3: added ``description``; forces a re-scan so already-cached items
# (exif_scanned=True) get their description read instead of being
# skipped forever.
_ITEM_CACHE_VERSION = 3
# Bump independently of the items cache - the geocode cache is
# keyed by coordinate and is safe to keep across item-shape changes.
_GEOCODE_CACHE_VERSION = 1
@@ -570,6 +851,10 @@ class AlbumCoordinator(DataUpdateCoordinator):
self.album_url: str | None = entry.data.get(CONF_ALBUM_URL)
self.local_path: str | None = entry.data.get(CONF_LOCAL_PATH)
self.recursive: bool = bool(entry.data.get(CONF_RECURSIVE, True))
self.media_content_id: str | None = entry.data.get(CONF_MEDIA_CONTENT_ID)
# Extra headers the camera must send when fetching image bytes
# (Immich API key). Empty for providers that need no auth.
self.image_request_headers: dict[str, str] = {}
# Persist the most recent successful album fetch so that a transient
# network/Google failure doesn't blank the slideshow on restart.
@@ -631,6 +916,10 @@ class AlbumCoordinator(DataUpdateCoordinator):
data = await self._update_local_folder()
elif self.provider == PROVIDER_GOOGLE_SHARED:
data = await self._update_google_shared()
elif self.provider == PROVIDER_MEDIA_SOURCE:
data = await self._update_media_source()
elif self.provider == PROVIDER_IMMICH:
data = await self._update_immich()
else:
raise UpdateFailed(f"Unsupported provider: {self.provider}")
except UpdateFailed:
@@ -644,9 +933,9 @@ class AlbumCoordinator(DataUpdateCoordinator):
raise
items = data.get("items") or []
if self.provider == PROVIDER_LOCAL_FOLDER and items:
# Carry forward EXIF/geocode metadata for files we've already
# scanned this session; new files get filled in by the
if self.provider in (PROVIDER_LOCAL_FOLDER, PROVIDER_IMMICH) 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
if prior_items:
@@ -740,6 +1029,8 @@ class AlbumCoordinator(DataUpdateCoordinator):
latitude=raw.get("latitude"),
longitude=raw.get("longitude"),
location=raw.get("location"),
description=raw.get("description"),
source_id=raw.get("source_id"),
exif_scanned=bool(raw.get("exif_scanned", False)),
))
except Exception:
@@ -770,6 +1061,8 @@ class AlbumCoordinator(DataUpdateCoordinator):
"latitude": it.latitude,
"longitude": it.longitude,
"location": it.location,
"description": it.description,
"source_id": it.source_id,
"exif_scanned": it.exif_scanned,
}
for it in items
@@ -839,6 +1132,261 @@ class AlbumCoordinator(DataUpdateCoordinator):
"items": items,
}
async def _update_media_source(self) -> dict[str, Any]:
"""Build the item list from a Home Assistant Media Source node.
Browses the configured ``media-source://`` content id recursively,
collecting image children, then resolves each to a playable URL.
Media Source exposes no per-photo EXIF, so date/GPS/description
features do not apply here (same as the Google provider).
"""
content_id = self.media_content_id
if not content_id:
raise UpdateFailed("Missing media source content id")
try:
from homeassistant.components import media_source
except Exception as err: # pragma: no cover - core ships media_source
raise UpdateFailed("media_source integration is not available") from err
collected: list[tuple[str, str | None]] = []
try:
await self._browse_media_source(media_source, content_id, collected, 0)
except UpdateFailed:
raise
except Exception as err:
raise UpdateFailed(f"Error browsing media source: {err}") from err
if not collected:
raise UpdateFailed("No images found in the selected media source")
base_url = self._internal_base_url()
items: list[MediaItem] = []
for cid, title in collected:
resolved = await self._resolve_media(media_source, cid)
if resolved is None:
continue
raw = resolved[0]
# ``async_resolve_media`` returns an UNSIGNED ``/media/...`` path
# when called directly (the frontend's websocket handler is what
# normally signs it). Fetching an unsigned local-media path
# server-side gets a 401, so sign it ourselves before use.
if isinstance(raw, str) and raw.startswith("/"):
raw = self._sign_media_path(raw)
url = _normalize_resolved_url(raw, base_url)
if not url:
continue
items.append(
MediaItem(
url=url,
width=None,
height=None,
mime_type=resolved[1],
filename=title,
)
)
if not items:
raise UpdateFailed("Could not resolve any media source images")
return {
"title": self.entry.title,
"items": items,
}
def _sign_media_path(self, path: str) -> str:
"""Sign a relative ``/media/...`` path so it can be fetched server-side.
Mirrors what Home Assistant's ``media_source/resolve_media`` websocket
handler does: quote the path and append an ``authSig`` signature. Uses
the content user so signing works from a background task with no
request context (the same mechanism that lets Cast devices fetch local
media). Signs for a window comfortably longer than the album refresh
interval; every refresh re-signs, so URLs stay fresh.
"""
from urllib.parse import quote
try:
from homeassistant.components.http.auth import async_sign_path
except Exception: # pragma: no cover - http always present
return path
quoted = quote(path)
expiration = timedelta(hours=max(48, int(self.store.refresh_hours) * 2 + 1))
for kwargs in ({"use_content_user": True}, {}):
try:
return async_sign_path(self.hass, quoted, expiration, **kwargs)
except TypeError:
# Older/newer signature without ``use_content_user``.
continue
except Exception as err:
_LOGGER.debug("media_source: failed to sign %s: %s", path, err)
return path
return path
async def _browse_media_source(
self,
media_source,
content_id: str,
collected: list[tuple[str, str | None]],
depth: int,
) -> None:
"""Recursively walk a media source tree collecting image leaves."""
if len(collected) >= _MEDIA_SOURCE_MAX_ITEMS or depth > _MEDIA_SOURCE_MAX_DEPTH:
return
browsed = await media_source.async_browse_media(self.hass, content_id)
children = getattr(browsed, "children", None) or []
for child in children:
if len(collected) >= _MEDIA_SOURCE_MAX_ITEMS:
break
child_id = getattr(child, "media_content_id", None)
if not child_id:
continue
title = getattr(child, "title", None)
if _is_junk_media_title(title):
continue
if _media_node_is_image(
getattr(child, "media_class", None),
getattr(child, "media_content_type", None),
):
collected.append((child_id, title))
elif getattr(child, "can_expand", False):
await self._browse_media_source(
media_source, child_id, collected, depth + 1
)
async def _resolve_media(
self, media_source, content_id: str
) -> tuple[str, str | None] | None:
"""Resolve a media content id to ``(url, mime_type)`` or ``None``."""
try:
try:
play = await media_source.async_resolve_media(
self.hass, content_id, None
)
except TypeError:
# Older cores: async_resolve_media(hass, content_id).
play = await media_source.async_resolve_media(self.hass, content_id)
except Exception as err:
_LOGGER.debug("media_source: failed to resolve %s: %s", content_id, err)
return None
url = getattr(play, "url", None)
if not url:
return None
return url, getattr(play, "mime_type", None)
def _internal_base_url(self) -> str:
"""Best-effort internal base URL for site-relative media URLs."""
try:
from homeassistant.helpers.network import get_url
return get_url(self.hass, prefer_external=False, allow_ip=True)
except Exception:
return ""
async def _update_immich(self) -> dict[str, Any]:
"""Build the item list from an Immich album or person via its API.
Unlike Media Source, the Immich API exposes per-photo metadata, so
capture date, GPS/location, and description all work. Dates come from
the asset list up front; location and description are filled in by the
background enrichment worker (one asset-detail call each, cached).
"""
from . import immich as immich_api
url = self.entry.data.get(CONF_IMMICH_URL)
api_key = self.entry.data.get(CONF_IMMICH_API_KEY)
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)
if not url or not api_key or not sel_type:
raise UpdateFailed("Immich provider is missing URL, API key, or selection")
# ``album``/``albums`` and ``person``/``people`` need a target id;
# composite/favorites/all/random/search do not (an empty composite
# means "all photos").
if sel_type in ("album", "albums", "person", "people") and not sel_id:
raise UpdateFailed("Immich provider is missing the album/person id")
filter_body = None
raw_filter = self.entry.data.get(CONF_IMMICH_FILTER)
if sel_type in ("search", "composite") and raw_filter:
try:
parsed = json.loads(raw_filter)
if isinstance(parsed, dict):
filter_body = parsed
except (ValueError, TypeError):
raise UpdateFailed("Immich search filter is not valid JSON")
client = immich_api.ImmichClient(self.hass, url, api_key)
# Auth header the camera must send when fetching image bytes. Sent
# server-side only, so the key never reaches the browser or the
# ``current_url`` attribute.
self.image_request_headers = dict(client.image_headers)
try:
assets = await client.async_collect_assets(sel_type, sel_id, filter_body)
except Exception as err:
raise UpdateFailed(f"Error querying Immich: {err}") from err
if not assets:
raise UpdateFailed("No images found for the selected Immich source")
items: list[MediaItem] = []
for a in assets:
aid = a.get("id")
if not aid:
continue
captured = immich_api._to_epoch_ms(
a.get("localDateTime")
) or immich_api._to_epoch_ms(a.get("fileCreatedAt"))
w = a.get("width")
h = a.get("height")
items.append(
MediaItem(
url=immich_api.build_image_url(client.base_url, aid, 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,
)
)
return {
"title": self.entry.title,
"items": items,
}
async def _enrich_immich_item(self, item: MediaItem) -> None:
"""Fetch one Immich asset's detail and fill location/description."""
from . import immich as immich_api
url = self.entry.data.get(CONF_IMMICH_URL)
api_key = self.entry.data.get(CONF_IMMICH_API_KEY)
if not item.source_id or not url or not api_key:
item.exif_scanned = True
return
client = immich_api.ImmichClient(self.hass, url, api_key)
try:
asset = await client.async_get_asset(item.source_id)
except Exception as err:
_LOGGER.debug("Immich: failed to fetch asset %s: %s", item.source_id, err)
item.exif_scanned = True
return
info = immich_api.parse_asset_exif(asset)
if "captured_at" in info:
item.captured_at = info["captured_at"]
if "latitude" in info and "longitude" in info:
item.latitude = info["latitude"]
item.longitude = info["longitude"]
if "location" in info:
item.location = info["location"]
if "description" in info:
item.description = info["description"]
item.exif_scanned = True
async def _enrich_items_background(self, data: dict[str, Any]) -> None:
"""Read EXIF for unscanned local files, then reverse-geocode.
@@ -865,6 +1413,24 @@ class AlbumCoordinator(DataUpdateCoordinator):
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)
continue
url = item.url
if not url.startswith("file://"):
item.exif_scanned = True
@@ -883,6 +1449,8 @@ class AlbumCoordinator(DataUpdateCoordinator):
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"]
@@ -1202,6 +1770,21 @@ class AlbumCoordinator(DataUpdateCoordinator):
byte_size=byte_size,
))
# Cross-source date enrichment: publicalbum.org often returns a fuller
# item list but with no (or partial) date metadata, while batchexecute
# returns dated items. Where the same photo appears in both, backfill
# the publicalbum item's captured_at / uploaded_at from its dated
# batchexecute twin (matched by the stable per-photo URL key). This
# keeps the larger item count while restoring the dates the date
# filter needs. See issue #18.
enriched = _enrich_missing_dates(api_items, scraped_items)
if enriched:
_LOGGER.info(
"Album scraper: enriched %d publicalbum item(s) with "
"batchexecute dates",
enriched,
)
# Pick the source with more items; prefer publicalbum.org on a tie
# because its URLs come pre-decorated with size hints.
if len(scraped_items) > len(api_items):
+375
View File
@@ -0,0 +1,375 @@
"""Immich (direct API) client and pure parsing helpers.
Talks to an Immich server using an API key. HTTP lives in ``ImmichClient``;
the parsing/URL helpers are pure functions so they can be unit-tested without
a live server or aiohttp.
API shape (Immich v1.13x / v3, ``/api`` prefix, ``x-api-key`` header):
- ``GET /api/server/about`` -> ``{version, ...}`` (used to validate URL + key)
- ``GET /api/albums`` -> ``[{id, albumName, assetCount}]``
- ``GET /api/people`` -> ``{people: [{id, name}]}``
- ``POST /api/search/metadata`` ``{albumIds|personIds, type, size, page}``
-> ``{assets: {items: [...], total, nextPage}}``. List items carry
``id``/``type``/``localDateTime``/``fileCreatedAt``/``width``/``height``/
``originalFileName`` but NOT ``exifInfo``.
- ``GET /api/assets/{id}`` -> full asset incl ``exifInfo`` (lat/long, city,
country, description) - used to enrich location/description per asset.
- Image bytes: ``/api/assets/{id}/thumbnail?size=preview|fullsize`` or
``/api/assets/{id}/original`` (all require the ``x-api-key`` header).
"""
from __future__ import annotations
import json
from datetime import datetime, timezone
from typing import Any
import async_timeout
from homeassistant.helpers.aiohttp_client import async_get_clientsession
_TIMEOUT = 30
_PAGE_SIZE = 1000
_MAX_ASSETS = 20_000
def normalize_base_url(url: str) -> str:
"""Strip trailing slashes and a trailing ``/api`` from a base URL."""
u = (url or "").strip().rstrip("/")
if u.endswith("/api"):
u = u[: -len("/api")]
return u
def build_image_url(base_url: str, asset_id: str, size: str) -> str:
"""Build the image URL for an asset at the requested size.
``preview`` / ``fullsize`` map to the thumbnail endpoint; ``original``
fetches the untouched original file. The API key is NOT included here - it
is sent as a request header so it never leaks into logs or the camera's
``current_url`` attribute.
"""
base = normalize_base_url(base_url)
if size == "original":
return f"{base}/api/assets/{asset_id}/original"
thumb_size = "fullsize" if size == "fullsize" else "preview"
return f"{base}/api/assets/{asset_id}/thumbnail?size={thumb_size}"
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:
return None
try:
iso = value.replace("Z", "+00:00")
dt = datetime.fromisoformat(iso)
except ValueError:
return None
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
try:
return int(dt.timestamp() * 1000)
except (OverflowError, OSError, ValueError):
return None
def location_label(city: Any, state: Any, country: Any) -> str | None:
"""Build a short ``"City, Country"`` style label from EXIF place fields.
Prefers ``city`` for the locality, falling back to ``state``. Appends the
country when present. Returns ``None`` when nothing usable is available.
"""
parts: list[str] = []
locality = None
for candidate in (city, state):
if isinstance(candidate, str) and candidate.strip():
locality = candidate.strip()
break
if locality:
parts.append(locality)
if isinstance(country, str) and country.strip():
parts.append(country.strip())
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.
Filters out non-image assets 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)
next_page = assets.get("nextPage")
if isinstance(next_page, str) and next_page.isdigit():
next_page = int(next_page)
if not isinstance(next_page, int):
next_page = None
return out, next_page
def parse_random(payload: Any) -> 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)
# 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 []
def _filter_image_items(items: Any) -> list[dict[str, Any]]:
"""Keep only non-trashed, non-archived image assets with an id."""
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":
continue
if it.get("isTrashed") or it.get("isArchived"):
continue
if not it.get("id"):
continue
out.append(it)
return out
def build_search_body(
selection_type: str, selection_id: str | None, filter_body: dict | None
) -> 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.
"""
body: dict[str, Any] = {"type": "IMAGE"}
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:
body["personIds"] = [selection_id]
elif selection_type == "favorites":
body["isFavorite"] = True
# ``all`` -> no extra filter (whole library).
return body
def parse_composite_selection(selection_id: str | None) -> dict[str, Any]:
"""Parse a composite selection id into ``{albums, people, favorites}``.
The id is a JSON object; anything malformed degrades to an empty
composite (which means "all photos").
"""
albums: list[str] = []
people: list[str] = []
favorites = False
if selection_id:
try:
data = json.loads(selection_id)
except (ValueError, TypeError):
data = None
if isinstance(data, dict):
albums = [a for a in data.get("albums", []) if isinstance(a, str) and a]
people = [p for p in data.get("people", []) if isinstance(p, str) and p]
favorites = bool(data.get("favorites"))
return {"albums": albums, "people": people, "favorites": favorites}
def build_composite_bodies(
selection_id: str | None, filter_body: dict | None = None
) -> list[dict[str, Any]]:
"""Build one ``search/metadata`` body per composite union member.
Immich has no OR, so each album, person, the favorites flag and any
custom filter becomes its own image query; the caller unions the
results. An empty composite yields a single unfiltered query -> the
whole library ("all photos").
"""
sel = parse_composite_selection(selection_id)
bodies: list[dict[str, Any]] = []
for aid in sel["albums"]:
bodies.append({"type": "IMAGE", "albumIds": [aid]})
for pid in sel["people"]:
bodies.append({"type": "IMAGE", "personIds": [pid]})
if sel["favorites"]:
bodies.append({"type": "IMAGE", "isFavorite": True})
if isinstance(filter_body, dict) and filter_body:
member = dict(filter_body)
member["type"] = "IMAGE"
bodies.append(member)
if not bodies:
bodies.append({"type": "IMAGE"})
return bodies
def parse_asset_exif(asset: Any) -> dict[str, Any]:
"""Extract the metadata we surface from a full asset detail response."""
out: dict[str, Any] = {}
if not isinstance(asset, dict):
return out
exif = asset.get("exifInfo")
if not isinstance(exif, dict):
return out
captured = _to_epoch_ms(exif.get("dateTimeOriginal")) or _to_epoch_ms(
asset.get("localDateTime")
)
if captured is not None:
out["captured_at"] = captured
lat = exif.get("latitude")
lon = exif.get("longitude")
if isinstance(lat, (int, float)) and isinstance(lon, (int, float)):
# Immich returns 0/0 or null when there is no fix; treat 0,0 as none.
if not (abs(lat) < 1e-6 and abs(lon) < 1e-6):
out["latitude"] = float(lat)
out["longitude"] = float(lon)
label = location_label(exif.get("city"), exif.get("state"), exif.get("country"))
if label:
out["location"] = label
desc = exif.get("description")
if isinstance(desc, str) and desc.strip():
out["description"] = desc.strip()
return out
class ImmichClient:
"""Thin async wrapper over the Immich REST API."""
def __init__(self, hass, base_url: str, api_key: str) -> None:
self.hass = hass
self.base_url = normalize_base_url(base_url)
self.api_key = api_key
@property
def headers(self) -> dict[str, str]:
return {"x-api-key": self.api_key, "Accept": "application/json"}
@property
def image_headers(self) -> dict[str, str]:
return {"x-api-key": self.api_key}
async def _get(self, path: str) -> Any:
session = async_get_clientsession(self.hass)
async with async_timeout.timeout(_TIMEOUT):
async with session.get(self.base_url + path, headers=self.headers) as resp:
resp.raise_for_status()
return await resp.json()
async def _post(self, path: str, body: dict[str, Any]) -> Any:
session = async_get_clientsession(self.hass)
async with async_timeout.timeout(_TIMEOUT):
async with session.post(
self.base_url + path, headers=self.headers, json=body
) as resp:
resp.raise_for_status()
return await resp.json()
async def async_validate(self) -> str | None:
"""Return the server version if the URL + key work, else raise."""
data = await self._get("/api/server/about")
return data.get("version") if isinstance(data, dict) else None
async def async_list_albums(self) -> list[dict[str, Any]]:
data = await self._get("/api/albums")
return data if isinstance(data, list) else []
async def async_list_people(self) -> list[dict[str, Any]]:
data = await self._get("/api/people")
if isinstance(data, dict):
people = data.get("people")
return people if isinstance(people, list) else []
return data if isinstance(data, list) else []
async def async_collect_assets(
self,
selection_type: str,
selection_id: str | None = None,
filter_body: dict | None = None,
) -> list[dict[str, Any]]:
"""Collect image 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"}
if isinstance(filter_body, dict):
merged = dict(filter_body)
merged.update(body)
body = merged
payload = await self._post("/api/search/random", body)
return parse_random(payload)
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)
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)
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)
base = build_search_body(selection_type, selection_id, filter_body)
return await self._collect_metadata(base)
async def _collect_metadata(self, base: dict[str, Any]) -> list[dict[str, Any]]:
"""Page through ``search/metadata`` for a prebuilt body."""
collected: list[dict[str, Any]] = []
page: int | None = 1
while page is not None and len(collected) < _MAX_ASSETS:
body = dict(base)
body["size"] = _PAGE_SIZE
body["page"] = page
payload = await self._post("/api/search/metadata", body)
items, next_page = parse_search_page(payload)
collected.extend(items)
page = next_page
return collected
async def _collect_union(
self, bodies: list[dict[str, Any]]
) -> list[dict[str, Any]]:
"""Union several ``search/metadata`` queries (OR), deduped by asset id.
Each body is queried on its own so the results are a union (any of),
not Immich's default AND (only assets that match every filter at
once). See #19.
"""
seen: set[str] = set()
out: list[dict[str, Any]] = []
for body in bodies:
if len(out) >= _MAX_ASSETS:
break
items = await self._collect_metadata(body)
for it in items:
aid = it.get("id")
if aid and aid not in seen:
seen.add(aid)
out.append(it)
return out
async def async_get_asset(self, asset_id: str) -> dict[str, Any]:
return await self._get(f"/api/assets/{asset_id}")
@@ -3,10 +3,10 @@
"name": "Album Slideshow Camera",
"codeowners": ["@eyalgal"],
"config_flow": true,
"dependencies": ["http", "frontend"],
"dependencies": ["http", "frontend", "media_source"],
"documentation": "https://github.com/eyalgal/album_slideshow",
"iot_class": "cloud_polling",
"issue_tracker": "https://github.com/eyalgal/album_slideshow/issues",
"requirements": ["Pillow"],
"version": "1.0.0"
"version": "1.2.2"
}
+31 -6
View File
@@ -17,6 +17,9 @@ from .const import (
DATE_FILTER_ON_THIS_DAY,
DATE_FILTER_THIS_MONTH,
DATE_FILTER_THIS_YEAR,
DEFAULT_MISSING_DATE_MODE,
MISSING_DATE_EXCLUDE,
MISSING_DATE_USE_UPLOADED,
ORDER_ALBUM,
ORDER_NEWEST_ADDED,
ORDER_NEWEST_TAKEN,
@@ -72,12 +75,20 @@ def filter_items(
items: Iterable[T],
*,
mode: str,
missing_date: str = DEFAULT_MISSING_DATE_MODE,
now: datetime | None = None,
) -> list[T]:
"""Filter items by ``captured_at`` according to ``mode``.
"""Filter items by date according to ``mode``.
Items with no ``captured_at`` are kept by default unless the mode is
``on_this_day`` (treated as a strict filter).
``missing_date`` decides what happens to photos that have no EXIF
``captured_at``:
- ``use_uploaded_at`` - fall back to ``uploaded_at`` for the comparison
so date windows stay meaningful. Photos with neither timestamp are
kept for window filters and dropped for the strict ``on_this_day``.
- ``include`` - keep undated photos for window filters (the legacy
behaviour); the strict ``on_this_day`` still drops them.
- ``exclude`` - drop undated photos entirely.
``now`` is overridable for deterministic tests.
"""
@@ -93,11 +104,25 @@ def filter_items(
out: list[T] = []
for it in items:
ts = getattr(it, "captured_at", None)
if not isinstance(ts, int):
if not strict:
if isinstance(ts, int):
if pred(ts):
out.append(it)
continue
if pred(ts):
# No capture date: behaviour depends on ``missing_date``.
if missing_date == MISSING_DATE_EXCLUDE:
continue
if missing_date == MISSING_DATE_USE_UPLOADED:
up = getattr(it, "uploaded_at", None)
if isinstance(up, int):
if pred(up):
out.append(it)
continue
# No upload date either: fall through to the lenient default.
# ``include`` (or ``use_uploaded_at`` with no usable date): keep for
# window filters, drop for strict modes like ``on_this_day``.
if not strict:
out.append(it)
return out
@@ -19,6 +19,8 @@ from .const import (
ORDER_RANDOM,
DATE_FILTER_OPTIONS,
DATE_FILTER_OFF,
MISSING_DATE_OPTIONS,
MISSING_DATE_USE_UPLOADED,
)
from .store import SlideshowStore
@@ -39,6 +41,7 @@ async def async_setup_entry(
AspectRatioSelect(entry, store),
MaxResolutionSelect(entry, store),
DateFilterSelect(entry, store),
MissingDateModeSelect(entry, store),
]
)
@@ -239,3 +242,31 @@ class DateFilterSelect(_BaseSelect):
if old and old.state in self.options:
self.store.date_filter = old.state
self.store.notify()
class MissingDateModeSelect(_BaseSelect):
_attr_icon = "mdi:calendar-question"
def __init__(self, entry: ConfigEntry, store: SlideshowStore) -> None:
super().__init__(entry, store)
self._attr_unique_id = f"{entry.entry_id}_missing_date_mode"
self._attr_name = "Missing capture date"
self._attr_options = list(MISSING_DATE_OPTIONS)
@property
def current_option(self):
value = self.store.missing_date_mode
return value if value in self.options else MISSING_DATE_USE_UPLOADED
async def async_select_option(self, option: str) -> None:
if option not in self.options:
return
self.store.missing_date_mode = option
self.store.notify()
async def async_added_to_hass(self) -> None:
await super().async_added_to_hass()
old = await self.async_get_last_state()
if old and old.state in self.options:
self.store.missing_date_mode = old.state
self.store.notify()
@@ -15,6 +15,7 @@ from .const import (
DEFAULT_IMAGE_CACHE_MB,
DEFAULT_MAX_RESOLUTION,
DEFAULT_DATE_FILTER,
DEFAULT_MISSING_DATE_MODE,
)
@@ -37,6 +38,9 @@ class SlideshowStore:
# Date filter mode (preset windows like this_year / on_this_day).
date_filter: str = DEFAULT_DATE_FILTER
# How the date filter treats photos with no EXIF capture date.
missing_date_mode: str = DEFAULT_MISSING_DATE_MODE
# Pause toggle - when True, the slideshow holds on the current frame.
paused: bool = False
+36 -1
View File
@@ -24,11 +24,46 @@
"local_path": "Folder path",
"recursive": "Include subfolders"
}
},
"media_source": {
"title": "Media Source",
"description": "Paste a Media Source content id pointing at a folder-like node, for example media-source://immich/SERVER_ID|people or media-source://media_source/local/Albums. Browse to the folder in the HA media browser and copy its media-source:// id. Note: Media Source photos have no EXIF, so date, GPS and description features do not apply.",
"data": {
"album_name": "Album name",
"media_content_id": "Media Source id (media-source://...)"
}
},
"immich": {
"title": "Immich",
"description": "Connect directly to your Immich server for full photo metadata (date, location, description). Create an API key in Immich under Account Settings > API Keys.",
"data": {
"immich_url": "Immich URL (e.g. http://192.168.1.10:2283)",
"immich_api_key": "API key"
}
},
"immich_select": {
"title": "Immich source",
"description": "Tick any mix of albums, people and favorites - they are combined into one slideshow. Each list is searchable and has a Select all option. Leave everything empty for all photos. Advanced: add an Immich search filter (JSON) to include those results too - see the README for examples.",
"data": {
"album_name": "Name",
"albums": "Albums",
"people": "People",
"favorites": "Include favorites",
"immich_filter": "Extra search filter (JSON, optional)",
"immich_image_size": "Image quality"
}
}
},
"error": {
"invalid_album_url": "That does not look like a Google Photos shared album link.",
"invalid_path": "Path is empty or invalid."
"invalid_path": "Path is empty or invalid.",
"invalid_media_source": "That does not look like a Media Source id (should start with media-source://).",
"immich_cannot_connect": "Could not connect to Immich. Check the URL and API key.",
"immich_no_content": "No albums or named people were found on this Immich server.",
"immich_filter_required": "Custom search needs a JSON filter.",
"immich_filter_invalid": "The filter must be a valid JSON object. See the README for examples.",
"immich_people_required": "Pick at least one person for the People source.",
"immich_albums_required": "Pick at least one album for the Albums source."
}
},
"options": {
@@ -24,11 +24,46 @@
"local_path": "Folder path",
"recursive": "Include subfolders"
}
},
"media_source": {
"title": "Media Source",
"description": "Paste a Media Source content id pointing at a folder-like node, for example media-source://immich/SERVER_ID|people or media-source://media_source/local/Albums. Browse to the folder in the HA media browser and copy its media-source:// id. Note: Media Source photos have no EXIF, so date, GPS and description features do not apply.",
"data": {
"album_name": "Album name",
"media_content_id": "Media Source id (media-source://...)"
}
},
"immich": {
"title": "Immich",
"description": "Connect directly to your Immich server for full photo metadata (date, location, description). Create an API key in Immich under Account Settings > API Keys.",
"data": {
"immich_url": "Immich URL (e.g. http://192.168.1.10:2283)",
"immich_api_key": "API key"
}
},
"immich_select": {
"title": "Immich source",
"description": "Tick any mix of albums, people and favorites - they are combined into one slideshow. Each list is searchable and has a Select all option. Leave everything empty for all photos. Advanced: add an Immich search filter (JSON) to include those results too - see the README for examples.",
"data": {
"album_name": "Name",
"albums": "Albums",
"people": "People",
"favorites": "Include favorites",
"immich_filter": "Extra search filter (JSON, optional)",
"immich_image_size": "Image quality"
}
}
},
"error": {
"invalid_album_url": "That does not look like a Google Photos shared album link.",
"invalid_path": "Path is empty or invalid."
"invalid_path": "Path is empty or invalid.",
"invalid_media_source": "That does not look like a Media Source id (should start with media-source://).",
"immich_cannot_connect": "Could not connect to Immich. Check the URL and API key.",
"immich_no_content": "No albums or named people were found on this Immich server.",
"immich_filter_required": "Custom search needs a JSON filter.",
"immich_filter_invalid": "The filter must be a valid JSON object. See the README for examples.",
"immich_people_required": "Pick at least one person for the People source.",
"immich_albums_required": "Pick at least one album for the Albums source."
}
},
"options": {
@@ -26,7 +26,7 @@
* tap_action: none # none | more-info
*/
const VERSION = "1.0.0";
const VERSION = "1.2.2";
const ANIMATED_TRANSITIONS = [
"fade",
@@ -46,10 +46,10 @@ const TRANSITIONS = new Set(["random", "none", ...ANIMATED_TRANSITIONS]);
const FIT_MODES = new Set(["auto", "cover", "contain"]);
// Caption overlay (date / location). ``show`` is an ordered subset of
// these fields; ``position`` is one of a 3x3 anchor grid; ``date_format``
// is one of the named presets below or a custom token string.
const CAPTION_FIELDS = ["date", "location"];
// Caption overlay (date / location / description). ``show`` is an ordered
// subset of these fields; ``position`` is one of a 3x3 anchor grid;
// ``date_format`` is one of the named presets below or a custom token string.
const CAPTION_FIELDS = ["date", "location", "description"];
const CAPTION_POSITIONS = new Set([
"top-left",
"top-center",
@@ -492,6 +492,7 @@ function createAlbumSlideshowCardClass(Base) {
location: attrs.location,
latitude: attrs.latitude,
longitude: attrs.longitude,
description: attrs.description,
}
: null;
this._loadAndSwap(url, fit, blurBackdrop, captionData);
@@ -644,6 +645,7 @@ function createAlbumSlideshowCardClass(Base) {
location: data.location ?? null,
latitude: data.latitude ?? null,
longitude: data.longitude ?? null,
description: data.description ?? null,
},
];
}
@@ -656,6 +658,8 @@ function createAlbumSlideshowCardClass(Base) {
if (txt) lines.push(txt);
} else if (field === "location") {
if (frame.location) lines.push(String(frame.location));
} else if (field === "description") {
if (frame.description) lines.push(String(frame.description));
}
}
return lines;
@@ -890,6 +894,7 @@ const TAP_OPTIONS = [
const CAPTION_SHOW_OPTIONS = [
{ value: "date", label: "Date" },
{ value: "location", label: "Location" },
{ value: "description", label: "Description" },
];
const CAPTION_POSITION_OPTIONS = [
@@ -952,6 +957,7 @@ const CAPTION_DEFAULTS = {
const LIVE_FIELDS = [
"paused",
"date_filter",
"missing_date_mode",
"portrait_mode",
"order_mode",
"slide_interval",
@@ -962,6 +968,7 @@ const LIVE_FIELDS = [
const LIVE_SUFFIX = {
paused: "_paused",
date_filter: "_date_filter",
missing_date_mode: "_missing_date_mode",
portrait_mode: "_portrait_mode",
order_mode: "_order_mode",
slide_interval: "_interval",
@@ -974,6 +981,7 @@ const LIVE_SUFFIX = {
const LIVE_LABELS = {
live_paused: "Pause slideshow",
live_date_filter: "Date filter",
live_missing_date_mode: "Missing capture date",
live_portrait_mode: "Orientation mismatch mode",
live_order_mode: "Order mode",
live_slide_interval: "Slide interval (seconds)",
@@ -1130,6 +1138,7 @@ function createAlbumSlideshowCardEditorClass(Base) {
}
for (const [field, id] of [
["date_filter", s.date_filter],
["missing_date_mode", s.missing_date_mode],
["portrait_mode", s.portrait_mode],
["order_mode", s.order_mode],
]) {
@@ -1263,7 +1272,7 @@ function createAlbumSlideshowCardEditorClass(Base) {
},
{
type: "expandable",
title: "Caption (date & location)",
title: "Caption (date, location & description)",
icon: "mdi:format-text",
schema: [
{ name: "caption_enabled", selector: { boolean: {} } },
@@ -1377,7 +1386,7 @@ function createAlbumSlideshowCardEditorClass(Base) {
const e = st(s.paused);
out.live_paused = !!e && e.state === "on";
}
for (const f of ["date_filter", "portrait_mode", "order_mode"]) {
for (const f of ["date_filter", "missing_date_mode", "portrait_mode", "order_mode"]) {
if (s[f]) {
const e = st(s[f]);
out[`live_${f}`] = e ? e.state : "";
@@ -1429,12 +1438,16 @@ function createAlbumSlideshowCardEditorClass(Base) {
"How long the card freezes its slide after a tap. 0 disables it.",
caption_date_format:
"Pick a preset or type a custom format (YYYY, MMMM, MMM, MM, DD, D).",
caption_show:
"Description comes from the photo's EXIF/IPTC/XMP caption and is only available with the local-folder provider.",
caption_per_image:
"When a portrait pair is shown, caption each photo with its own date and location.",
"When a portrait pair is shown, caption each photo with its own date, location and description.",
caption_color: "CSS color, e.g. #ffffff or white.",
caption_font_size: "CSS size, e.g. 14px, 1.1em.",
live_paused:
"These control the Album Slideshow integration directly and apply everywhere this album is shown, not only this card.",
live_missing_date_mode:
"What a date filter does with photos that have no capture date: use the upload date, keep them, or drop them.",
};
return helpers[s.name] || "";
};
@@ -1558,6 +1571,7 @@ function createAlbumSlideshowCardEditorClass(Base) {
});
} else if (
field === "date_filter" ||
field === "missing_date_mode" ||
field === "portrait_mode" ||
field === "order_mode"
) {
@@ -1636,7 +1650,9 @@ function createAlbumSlideshowCardEditorClass(Base) {
if (data.caption_enabled) {
let show = data.caption_show;
if (!Array.isArray(show)) show = show ? [show] : [];
show = show.filter((v) => v === "date" || v === "location");
show = show.filter(
(v) => v === "date" || v === "location" || v === "description",
);
if (show.length > 0) {
const cap = { show };
const pos = data.caption_position || CAPTION_DEFAULTS.position;
+13 -14
View File
@@ -6,8 +6,12 @@ import logging
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_RESOURCES
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import (
config_validation as cv,
)
from homeassistant.helpers import (
device_registry as dr,
)
from . import const
from .const import (
@@ -103,6 +107,9 @@ __all__ = [
_LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config_entry: MailAndPackagesConfigEntry): # pylint: disable=unused-argument
"""Disallow configuration via YAML."""
return True
@@ -133,21 +140,13 @@ async def async_setup_entry(
# Setup the data coordinator
coordinator = MailDataUpdateCoordinator(hass, config, config_entry)
# Fetch initial data so we have data when entities subscribe
await coordinator.async_refresh()
# Raise ConfigEntryNotReady if coordinator didn't update
if not coordinator.last_update_success:
if isinstance(coordinator.last_exception, ConfigEntryAuthFailed):
raise coordinator.last_exception
exc = coordinator.last_exception
detail = (str(exc) or type(exc).__name__) if exc else "unknown error"
_LOGGER.error("Error updating sensor data: %s", detail)
raise ConfigEntryNotReady
config_entry.runtime_data = MailAndPackagesData(coordinator=coordinator, cameras=[])
# Fetch initial data in the background so setup doesn't block
hass.async_create_task(coordinator.async_refresh())
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
return True
@@ -78,6 +78,8 @@ class PackagesBinarySensor(CoordinatorEntity, BinarySensorEntity):
@property
def is_on(self) -> bool:
"""Return True if the image is updated."""
if self.coordinator.data is None:
return False
if self._type in self.coordinator.data:
_LOGGER.debug(
"binary_sensor: %s value: %s",
+11 -5
View File
@@ -182,7 +182,13 @@ class MailCam(CoordinatorEntity, Camera):
def _read_file(path: str) -> bytes:
with Path(path).open("rb") as f:
return f.read()
data = f.read()
if not data:
# A 0-byte image (e.g. a failed extraction that wrote an empty
# file) is as unservable as a missing one — raise so it routes
# through the same placeholder fallback below.
raise FileNotFoundError(f"empty image file: {path}")
return data
try:
image_bytes = await self.hass.async_add_executor_job(
@@ -273,7 +279,7 @@ class MailCam(CoordinatorEntity, Camera):
if required_keys.issubset(self.coordinator.data):
image = self.coordinator.data[ATTR_USPS_IMAGE]
path = self.coordinator.data[ATTR_IMAGE_PATH]
self._file_path = f"{self.hass.config.path()}/{path}{image}"
self._file_path = self.hass.config.path(path, image)
self._is_generic = not self.coordinator.data.get("usps_update", False)
_LOGGER.debug(
"usps_camera camera - file path set to: %s",
@@ -313,7 +319,7 @@ class MailCam(CoordinatorEntity, Camera):
return
image_path = self.coordinator.data.get(ATTR_IMAGE_PATH, "")
full_storage_path = Path(f"{self.hass.config.path()}/{image_path}")
full_storage_path = Path(self.hass.config.path(image_path))
gif_path = str(full_storage_path / "generic_deliveries.gif")
resized_images = await self.hass.async_add_executor_job(
@@ -383,7 +389,7 @@ class MailCam(CoordinatorEntity, Camera):
image = self.coordinator.data[image_attr]
path = f"{self.coordinator.data[ATTR_IMAGE_PATH]}{path_suffix}"
delivery_file_path = f"{self.hass.config.path()}/{path}{image}"
delivery_file_path = self.hass.config.path(path, image)
is_no_mail = image.startswith(
no_mail_check,
@@ -448,7 +454,7 @@ class MailCam(CoordinatorEntity, Camera):
image = self.coordinator.data[image_attr]
image_path = self.coordinator.data[ATTR_IMAGE_PATH].rstrip("/") + "/"
path = f"{image_path}{base_name}/"
coordinator_file_path = f"{self.hass.config.path()}/{path}{image}"
coordinator_file_path = self.hass.config.path(path, image)
_LOGGER.debug(
"=== %s CAMERA UPDATE === coordinator %s = '%s'",
@@ -57,6 +57,7 @@ from .const import (
CONF_STORAGE,
CONF_UPS_CUSTOM_IMG,
CONF_UPS_CUSTOM_IMG_FILE,
CONF_USPS_PLACEHOLDER,
CONF_VERIFY_SSL,
CONF_WALMART_CUSTOM_IMG,
CONF_WALMART_CUSTOM_IMG_FILE,
@@ -89,6 +90,7 @@ from .const import (
DEFAULT_STORAGE,
DEFAULT_UPS_CUSTOM_IMG,
DEFAULT_UPS_CUSTOM_IMG_FILE,
DEFAULT_USPS_PLACEHOLDER,
DEFAULT_WALMART_CUSTOM_IMG,
DEFAULT_WALMART_CUSTOM_IMG_FILE,
DOMAIN,
@@ -198,7 +200,9 @@ async def _check_forwarded_emails(user_input: dict[str, Any]) -> list[str]:
return errors
def _validate_path_input(user_input: dict, errors: dict) -> None:
def _validate_path_input(
user_input: dict, errors: dict, hass: HomeAssistant | None = None
) -> None:
"""Validate path and file inputs."""
# List of (Toggle Key, File Key, Error Key)
file_checks = [
@@ -233,11 +237,17 @@ def _validate_path_input(user_input: dict, errors: dict) -> None:
for toggle, file_key, error_key in file_checks:
if user_input.get(toggle) and file_key in user_input:
if not Path(user_input[file_key]).is_file():
path = user_input[file_key]
if hass:
path = hass.config.path(path)
if not Path(path).is_file():
errors[error_key] = "file_not_found"
if CONF_STORAGE in user_input:
if not Path(user_input[CONF_STORAGE]).exists():
path = user_input[CONF_STORAGE]
if hass:
path = hass.config.path(path)
if not Path(path).exists():
errors[CONF_STORAGE] = "path_not_found"
@@ -275,7 +285,9 @@ async def _validate_forwarded_emails(user_input: dict, errors: dict) -> None:
errors[CONF_FORWARDED_EMAILS] = status[0]
async def _validate_user_input(user_input: dict) -> tuple:
async def _validate_user_input(
user_input: dict, hass: HomeAssistant | None = None
) -> tuple:
"""Validate user input from config flow.
Returns tuple with error messages and modified user_input
@@ -303,7 +315,7 @@ async def _validate_user_input(user_input: dict) -> tuple:
errors[CONF_GENERATE_MP4] = "ffmpeg_not_found"
# Validate file paths
_validate_path_input(user_input, errors)
_validate_path_input(user_input, errors, hass)
# Normalize CONF_FOLDER: if it has exactly 1 folder, store as string
if CONF_FOLDER in user_input:
@@ -555,6 +567,10 @@ async def _get_schema_step_2(
CONF_GENERATE_MP4,
default=_get_default(CONF_GENERATE_MP4, False),
): cv.boolean,
vol.Optional(
CONF_USPS_PLACEHOLDER,
default=_get_default(CONF_USPS_PLACEHOLDER, DEFAULT_USPS_PLACEHOLDER),
): cv.boolean,
vol.Optional(
CONF_ALLOW_EXTERNAL,
default=_get_default(CONF_ALLOW_EXTERNAL, False),
@@ -975,7 +991,7 @@ class MailAndPackagesFlowHandler(
"""Configure form step 2."""
self._errors = {}
if user_input is not None:
self._errors, user_input = await _validate_user_input(user_input)
self._errors, user_input = await _validate_user_input(user_input, self.hass)
self._data.update(user_input)
_LOGGER.debug("RESOURCES: %s", self._data[CONF_RESOURCES])
if len(self._errors) == 0:
@@ -1012,12 +1028,13 @@ class MailAndPackagesFlowHandler(
CONF_FOLDER: DEFAULT_FOLDER,
CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL,
CONF_CUSTOM_DAYS: DEFAULT_CUSTOM_DAYS,
CONF_PATH: self.hass.config.path() + DEFAULT_PATH,
CONF_PATH: self.hass.config.path(DEFAULT_PATH),
CONF_DURATION: DEFAULT_GIF_DURATION,
CONF_IMAGE_SECURITY: DEFAULT_IMAGE_SECURITY,
CONF_IMAP_TIMEOUT: DEFAULT_IMAP_TIMEOUT,
CONF_GENERATE_GRID: False,
CONF_GENERATE_MP4: False,
CONF_USPS_PLACEHOLDER: DEFAULT_USPS_PLACEHOLDER,
CONF_ALLOW_EXTERNAL: DEFAULT_ALLOW_EXTERNAL,
CONF_CUSTOM_IMG: DEFAULT_CUSTOM_IMG,
CONF_AMAZON_CUSTOM_IMG: DEFAULT_AMAZON_CUSTOM_IMG,
@@ -1045,7 +1062,7 @@ class MailAndPackagesFlowHandler(
self._errors = {}
if user_input is not None:
self._data.update(user_input)
self._errors, user_input = await _validate_user_input(self._data)
self._errors, user_input = await _validate_user_input(self._data, self.hass)
if len(self._errors) == 0:
return await self.async_step_config_storage()
return await self._show_config_3(user_input)
@@ -1076,7 +1093,7 @@ class MailAndPackagesFlowHandler(
self._errors = {}
if user_input is not None:
self._data.update(user_input)
self._errors, user_input = await _validate_user_input(self._data)
self._errors, user_input = await _validate_user_input(self._data, self.hass)
if len(self._errors) == 0:
if (
self._data.get(CONF_CUSTOM_IMG)
@@ -1117,7 +1134,7 @@ class MailAndPackagesFlowHandler(
self._errors = {}
if user_input is not None:
self._data.update(user_input)
self._errors, user_input = await _validate_user_input(self._data)
self._errors, user_input = await _validate_user_input(self._data, self.hass)
if len(self._errors) == 0:
if any(
sensor in self._data[CONF_RESOURCES] for sensor in AMAZON_SENSORS
@@ -1150,7 +1167,7 @@ class MailAndPackagesFlowHandler(
self._errors = {}
if user_input is not None:
self._data.update(user_input)
self._errors, user_input = await _validate_user_input(self._data)
self._errors, user_input = await _validate_user_input(self._data, self.hass)
if len(self._errors) == 0:
return self.async_create_entry(
title=f"Mail and Packages ({self._data[CONF_HOST]})",
@@ -1254,7 +1271,7 @@ class MailAndPackagesFlowHandler(
_LOGGER.debug("Loading step 2...")
if user_input is not None:
self._data.update(user_input)
self._errors, user_input = await _validate_user_input(user_input)
self._errors, user_input = await _validate_user_input(user_input, self.hass)
if len(self._errors) == 0:
if self._data.get(CONF_ALLOW_FORWARDED_EMAILS, False):
return await self.async_step_reconfig_forwarded_emails()
@@ -1300,7 +1317,7 @@ class MailAndPackagesFlowHandler(
self._errors = {}
if user_input is not None:
self._data.update(user_input)
self._errors, user_input = await _validate_user_input(self._data)
self._errors, user_input = await _validate_user_input(self._data, self.hass)
if len(self._errors) == 0:
return await self.async_step_reconfig_storage()
@@ -1332,7 +1349,7 @@ class MailAndPackagesFlowHandler(
self._errors = {}
if user_input is not None:
self._data.update(user_input)
self._errors, user_input = await _validate_user_input(self._data)
self._errors, user_input = await _validate_user_input(self._data, self.hass)
if len(self._errors) == 0:
has_custom_image = (
self._data.get(CONF_CUSTOM_IMG)
@@ -1375,7 +1392,7 @@ class MailAndPackagesFlowHandler(
self._errors = {}
if user_input is not None:
self._data.update(user_input)
self._errors, user_input = await _validate_user_input(self._data)
self._errors, user_input = await _validate_user_input(self._data, self.hass)
if len(self._errors) == 0:
if any(
sensor in self._data.get(CONF_RESOURCES, [])
@@ -1404,7 +1421,7 @@ class MailAndPackagesFlowHandler(
self._errors = {}
if user_input is not None:
self._data.update(user_input)
self._errors, user_input = await _validate_user_input(self._data)
self._errors, user_input = await _validate_user_input(self._data, self.hass)
if len(self._errors) == 0:
self.hass.config_entries.async_update_entry(
self._entry,
+13 -1
View File
@@ -12,7 +12,7 @@ from .entity import MailandPackagesBinarySensorEntityDescription
DOMAIN = "mail_and_packages"
DOMAIN_DATA = f"{DOMAIN}_data"
VERSION = "0.5.9"
VERSION = "0.5.15"
ISSUE_URL = "http://github.com/moralmunky/Home-Assistant-Mail-And-Packages"
PLATFORM = "sensor"
PLATFORMS = ["binary_sensor", "camera", "sensor"]
@@ -83,6 +83,7 @@ CONF_ALLOW_FORWARDED_EMAILS = "allow_forwarded_emails"
CONF_FORWARDED_EMAILS = "forwarded_emails"
CONF_FORWARDING_HEADER = "forwarding_header"
CONF_CUSTOM_DAYS = "custom_days"
CONF_USPS_PLACEHOLDER = "usps_placeholder"
# Defaults
DEFAULT_CAMERA_NAME = "Mail USPS Camera"
@@ -130,6 +131,7 @@ DEFAULT_STORAGE = "custom_components/mail_and_packages/images/"
DEFAULT_ALLOW_FORWARDED_EMAILS = False
DEFAULT_FORWARDED_EMAILS = "(none)"
DEFAULT_FORWARDING_HEADER = "(none)"
DEFAULT_USPS_PLACEHOLDER = True
# Amazon
AMAZON_DOMAINS = [
@@ -173,6 +175,7 @@ AMAZON_SHIPMENT_SUBJECT = [
"Shipped:",
"Enviado:",
"Out for delivery:",
"Spedito:",
]
AMAZON_ORDERED_SUBJECT = ["Ordered:", "Pedido efetuado:"]
AMAZON_EMAIL = [
@@ -218,6 +221,7 @@ AMAZON_TIME_PATTERN = [
"Chega ",
"Verwachte bezorgdatum:",
"Votre date de livraison prévue est :",
"In arrivo",
]
AMAZON_TIME_PATTERN_END = [
"Previously expected:",
@@ -252,6 +256,10 @@ AMAZON_TIME_PATTERN_REGEX = [
"Wordt bezorgd op (\\w+ \\d+ \\w+)",
"Wordt bezorgd op (\\w+ \\d+)",
"Wordt (\\w+) bezorgd",
"In arrivo (\\w+ \\d+) - (\\w+ \\d+)",
"In arrivo (\\w+ \\d+)",
"In arrivo (\\w+ \\d*)",
"In arrivo (\\w+)",
]
AMAZON_EXCEPTION_SUBJECT = "Delivery update:"
AMAZON_EXCEPTION_BODY = "running late"
@@ -435,6 +443,7 @@ SENSOR_DATA = {
"donotreply_odd@dhl.com",
"NoReply.ODD@dhl.com",
"noreply@dhl.de",
"no-reply@dhl.de",
"pl.no.reply@dhl.com",
"support@dhl.com",
"noreply@dhlecommerce.nl",
@@ -448,6 +457,7 @@ SENSOR_DATA = {
"liegt am gewünschten Ablageort",
"Ihre Sendung liegt im Briefkasten",
"Zustellung an Ablageort",
"Ablageort",
"Sendung zugestellt",
"Paket wurde zugestellt",
"Ihre AliExpress Sendung liegt im Briefkasten",
@@ -474,6 +484,7 @@ SENSOR_DATA = {
"donotreply_odd@dhl.com",
"NoReply.ODD@dhl.com",
"noreply@dhl.de",
"no-reply@dhl.de",
"pl.no.reply@dhl.com",
"support@dhl.com",
"noreply@dhlecommerce.nl",
@@ -500,6 +511,7 @@ SENSOR_DATA = {
"scheduled for delivery TODAY",
"zostanie dziś do Państwa doręczona",
"wird Ihnen heute",
"wird Ihnen voraussichtlich",
"heute zwischen",
" - Shipment is out with courier for delivery - ",
"Shipment is scheduled for delivery",
@@ -23,6 +23,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_entry_oauth2_flow
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.update_coordinator import (
ConfigEntryAuthFailed,
DataUpdateCoordinator,
@@ -249,21 +250,35 @@ class MailDataUpdateCoordinator(DataUpdateCoordinator):
)
except InvalidAuth as err:
_LOGGER.error("Authentication failed: %s", err)
# Create a repairs issue for authentication failure
ir.async_create_issue(
self.hass,
DOMAIN,
"auth_failed",
is_fixable=True,
severity=ir.IssueSeverity.ERROR,
translation_key="auth_failed",
data={"entry_id": self.config_entry.entry_id}
if self.config_entry
else None,
)
raise ConfigEntryAuthFailed from err
except Exception as err:
_LOGGER.error("Error logging into IMAP: %s", err)
raise UpdateFailed(f"Login failed: {err}") from err
# Login succeeded, delete the issue if it exists
issue_registry = ir.async_get(self.hass)
if (DOMAIN, "auth_failed") in issue_registry.issues:
ir.async_delete_issue(self.hass, DOMAIN, "auth_failed")
folders = config.get(CONF_FOLDER)
if not folders:
folders = ["INBOX"]
elif isinstance(folders, str):
if isinstance(folders, str):
folders = [folders]
elif isinstance(folders, (list, tuple, set)):
folders = [f for f in folders if isinstance(f, str) and f]
if not folders:
folders = ["INBOX"]
else:
folders = []
if not folders:
folders = ["INBOX"]
account._folders = folders # noqa: SLF001
account._current_folder = None # noqa: SLF001
@@ -365,9 +380,34 @@ class MailDataUpdateCoordinator(DataUpdateCoordinator):
)
in_transit = self._in_transit_tracking.get(prefix, {})
if in_transit:
# A carrier that reported DELIVERING/EXCEPTION tracking details
# this scan must have its count overridden even when the
# in-transit map ends up EMPTY: when every tracked package has a
# delivered notification, the raw IMAP count (which cannot dedup
# prior-day deliveries) would otherwise leak through as the
# sensor value. Batch-level dedup already zeroes the count for
# shippers that emit tracking details, so this is defense in
# depth at the state-machine layer. Delivered-only details must
# NOT trigger the override: a carrier whose delivering emails
# yielded no extractable tracking numbers has a legitimate
# email-based count that tracking-level dedup cannot verify —
# and carriers with no tracking details at all keep their
# email-count value untouched.
has_details = any(
f"{prefix}_{suffix}" in tracking_details
for suffix in ("delivering", "exception")
)
if in_transit or has_details:
if not in_transit and data.get(f"{prefix}_delivering"):
_LOGGER.debug(
"Prefix '%s': no tracked packages remain in transit — "
"overriding delivering count %s -> 0",
prefix,
data.get(f"{prefix}_delivering"),
)
data[f"{prefix}_tracking"] = list(in_transit.keys())
data[f"{prefix}_delivering"] = len(in_transit)
if in_transit:
delivered_count = data.get(f"{prefix}_delivered", 0)
data[f"{prefix}_packages"] = len(in_transit) + (
delivered_count if isinstance(delivered_count, int) else 0
@@ -523,7 +563,7 @@ class MailDataUpdateCoordinator(DataUpdateCoordinator):
path = f"{image_path}{base_name}/"
# Use absolute path for file existence check
delivery_image_relative = f"{path}{image}"
delivery_image = f"{self.hass.config.path()}/{delivery_image_relative}"
delivery_image = self.hass.config.path(delivery_image_relative)
_LOGGER.debug(
"Full %s image path: %s",
base_name.title(),
@@ -114,7 +114,7 @@ def get_resources(hass: HomeAssistant | None = None) -> dict:
def copy_images(hass: HomeAssistant, config: ConfigEntry) -> None:
"""Copy processed images to www directory."""
image_path = Path(hass.config.path()) / default_image_path(hass, config)
image_path = Path(hass.config.path(default_image_path(hass, config)))
www_path = Path(hass.config.path()) / "www" / "mail_and_packages"
if not www_path.is_dir():
@@ -18,5 +18,5 @@
"dateparser",
"aioimaplib"
],
"version": "0.5.9"
"version": "0.5.15"
}
@@ -0,0 +1,58 @@
"""Repairs platform for Mail and Packages."""
from __future__ import annotations
from typing import Any
import voluptuous as vol
from homeassistant.components.repairs import RepairsFlow
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResult
from .const import DOMAIN
class AuthRepairFlow(RepairsFlow):
"""Handler for repairs flow."""
def __init__(self, entry_id: str | None) -> None:
"""Initialize."""
self.entry_id = entry_id
async def async_step_init(
self, user_input: dict[str, str] | None = None
) -> FlowResult:
"""Handle the first step of a repair flow."""
return await self.async_step_confirm(user_input)
async def async_step_confirm(
self, user_input: dict[str, str] | None = None
) -> FlowResult:
"""Handle confirm step."""
if user_input is not None:
if self.entry_id:
entry = self.hass.config_entries.async_get_entry(self.entry_id)
else:
entries = self.hass.config_entries.async_entries(DOMAIN)
entry = entries[0] if entries else None
if entry:
entry.async_start_reauth(self.hass)
return self.async_create_entry(title="", data={})
return self.async_show_form(
step_id="confirm",
data_schema=vol.Schema({}),
)
async def async_create_fix_flow(
hass: HomeAssistant,
issue_id: str,
data: dict[str, Any] | None,
) -> RepairsFlow:
"""Create a flow to fix a specific issue."""
if issue_id == "auth_failed":
entry_id = data.get("entry_id") if data else None
return AuthRepairFlow(entry_id)
raise ValueError(f"Unknown issue {issue_id}")
+15 -32
View File
@@ -12,6 +12,7 @@ from homeassistant.components.sensor import SensorEntity, SensorEntityDescriptio
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_RESOURCES
from homeassistant.core import HomeAssistant
from homeassistant.helpers.network import NoURLAvailableError, get_url
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import MailAndPackagesConfigEntry
@@ -118,6 +119,8 @@ class PackagesSensor(CoordinatorEntity, SensorEntity):
@property
def native_value(self) -> Any:
"""Return the state of the sensor."""
if self.coordinator.data is None:
return None
value = self.coordinator.data.get(self.type)
if self.type == "mail_updated":
@@ -146,6 +149,8 @@ class PackagesSensor(CoordinatorEntity, SensorEntity):
"""Return device specific state attributes."""
attr = {}
data = self.coordinator.data
if data is None:
return attr
if any(
sensor in self.type
@@ -154,10 +159,6 @@ class PackagesSensor(CoordinatorEntity, SensorEntity):
if tracking := data.get(self._tracking_key):
attr[ATTR_TRACKING_NUM] = tracking
# Catch no data entries
if self.data is None:
return attr
if "Amazon" in self._name:
self._add_amazon_attributes(attr, data)
elif self._name == "Mail USPS Mail":
@@ -225,6 +226,9 @@ class ImagePathSensors(CoordinatorEntity, SensorEntity):
@property
def native_value(self) -> str | None:
"""Return the state of the sensor."""
if self.coordinator.data is None:
return None
image = ""
the_path = None
@@ -239,10 +243,10 @@ class ImagePathSensors(CoordinatorEntity, SensorEntity):
if self.type == "usps_mail_image_system_path" and image:
_LOGGER.debug("Updating system image path to: %s", path)
the_path = f"{self.hass.config.path()}/{path}{image}"
the_path = self.hass.config.path(path, image)
elif self.type == "usps_mail_grid_image_path" and grid_image:
_LOGGER.debug("Updating grid image path to: %s", path)
the_path = f"{self.hass.config.path()}/{path}{grid_image}"
the_path = self.hass.config.path(path, grid_image)
elif self.type == "usps_mail_image_url" and image:
url = self._get_base_url()
if url:
@@ -250,33 +254,12 @@ class ImagePathSensors(CoordinatorEntity, SensorEntity):
return the_path
def _get_base_url(self) -> str | None:
"""Return the best available base URL for building image links.
Priority: explicit external URL HA Cloud remote URL internal URL.
"""
if self.hass.config.external_url:
return self.hass.config.external_url
# Try Home Assistant Cloud (Nabu Casa) — its remote URL is not exposed via
# hass.config.external_url when "Use Home Assistant Cloud" is selected.
"""Return the best available base URL for building image links."""
try:
from homeassistant.components.cloud import ( # noqa: PLC0415
CloudNotAvailable,
async_remote_ui_url,
)
except ImportError:
pass
else:
try:
return async_remote_ui_url(self.hass)
except (CloudNotAvailable, KeyError):
_LOGGER.debug("HA Cloud remote URL not available.")
if self.hass.config.internal_url:
_LOGGER.debug("Falling back to internal URL for image link.")
return self.hass.config.internal_url
return None
return get_url(self.hass, prefer_external=True)
except NoURLAvailableError:
_LOGGER.debug("No URL available for image link.")
return None
@property
def should_poll(self) -> bool:
@@ -104,12 +104,23 @@ class AmazonShipper(Shipper):
days = self.config.get(CONF_AMAZON_DAYS, DEFAULT_AMAZON_DAYS)
domain = self.config.get(CONF_AMAZON_DOMAIN)
if sensor_type in [AMAZON_PACKAGES, AMAZON_ORDER]:
param = "count" if sensor_type == AMAZON_PACKAGES else "order"
result = await self._parse_amazon_emails(
account, param, fwds, days, domain, cache, forwarding_header
if sensor_type == AMAZON_PACKAGES:
count = await self._parse_amazon_emails(
account, "count", fwds, days, domain, cache, forwarding_header
)
return {sensor_type: result}
orders = await self._parse_amazon_emails(
account, "order", fwds, days, domain, cache, forwarding_header
)
return {
AMAZON_PACKAGES: count,
AMAZON_ORDER: orders,
}
if sensor_type == AMAZON_ORDER:
result = await self._parse_amazon_emails(
account, "order", fwds, days, domain, cache, forwarding_header
)
return {AMAZON_ORDER: result}
if sensor_type == AMAZON_HUB:
return await self._amazon_hub(account, fwds, cache, forwarding_header)
@@ -199,7 +210,17 @@ class AmazonShipper(Shipper):
if param == "count":
return final_count
return list(context["all_shipped_orders"])
return [
order_id
for order_id in context["all_shipped_orders"]
if context["packages_arriving_today"].get(order_id, 0)
> context["delivered_packages"].get(order_id, 0)
or (
context["packages_arriving_today"].get(order_id, 0) == 0
and context["delivered_packages"].get(order_id, 0) == 0
)
]
async def _process_amazon_email(
self,
@@ -297,7 +297,14 @@ class GenericShipper(Shipper):
tracking = set(sensor_res.get(ATTR_TRACKING, []))
if sensor.endswith("_delivered"):
shippers[prefix]["delivered"].update(tracking)
# ATTR_TRACKING on _delivered sensors holds only TODAY's
# deliveries (so the sensor resets at midnight); dedup must
# use the extended-window list or packages delivered on a
# previous day are never subtracted from _delivering.
extended = sensor_res.get("pre_filtered_tracking")
shippers[prefix]["delivered"].update(
tracking if extended is None else set(extended)
)
elif sensor.endswith(("_delivering", "_exception")):
shippers[prefix]["delivering"].update(tracking)
shippers[prefix]["update_targets"].append((sensor, sensor_res))
@@ -662,7 +669,11 @@ class GenericShipper(Shipper):
msg_parts = (await email_fetch(account, eid, "(RFC822)"))[1]
for response_part in msg_parts:
if isinstance(response_part, (bytes, bytearray)):
if generic_delivery_image_extraction(
# The extraction does blocking file I/O (the image
# write) and CPU-heavy email parsing — run the whole
# sync function off the event loop.
if await self.hass.async_add_executor_job(
generic_delivery_image_extraction,
response_part,
s_config["image_path"],
s_config["image_name"],
@@ -27,6 +27,7 @@ from custom_components.mail_and_packages.const import (
CONF_FORWARDING_HEADER,
CONF_GENERATE_GRID,
CONF_GENERATE_MP4,
CONF_USPS_PLACEHOLDER,
DEFAULT_CUSTOM_IMG_FILE,
SENSOR_DATA,
)
@@ -103,32 +104,17 @@ class USPSShipper(Shipper):
images = await self._process_usps_images(all_msg_content, images)
image_count = len(images)
if image_count > 0:
await self._generate_mail_image(
images,
config["image_output_path"],
config["image_name"],
config["gif_duration"],
images_delete,
)
elif image_count == 0:
await self._copy_nomail_image(
config["image_output_path"],
config["image_name"],
config["custom_img"],
# Generate filtered list for GIF/MP4/Grid
gif_images = images.copy()
if not config.get("usps_placeholder", True):
placeholder_str = str(
Path(__file__).parent.parent / "image-no-mailpieces700.jpg"
)
if placeholder_str in gif_images:
gif_images.remove(placeholder_str)
if config["gen_mp4"]:
await self._generate_mp4_video(
config["image_output_path"],
config["image_name"],
)
if config["gen_grid"]:
await self._generate_grid_image(
config["image_output_path"],
config["image_name"],
image_count,
)
# Generate camera media
await self._create_camera_media(gif_images, config, images_delete)
return {
ATTR_COUNT: image_count,
@@ -159,6 +145,40 @@ class USPSShipper(Shipper):
return res
async def _create_camera_media(
self,
gif_images: list,
config: dict,
images_delete: list,
):
"""Create camera media (GIF, MP4, and Grid)."""
if len(gif_images) > 0:
await self._generate_mail_image(
gif_images,
config["image_output_path"],
config["image_name"],
config["gif_duration"],
images_delete,
)
else:
await self._copy_nomail_image(
config["image_output_path"],
config["image_name"],
config["custom_img"],
)
if config["gen_mp4"]:
await self._generate_mp4_video(
config["image_output_path"],
config["image_name"],
)
if config["gen_grid"]:
await self._generate_grid_image(
config["image_output_path"],
config["image_name"],
len(gif_images),
)
async def _generate_mp4_video(self, path: str, name: str):
"""Generate MP4 video from images."""
await self.hass.async_add_executor_job(_generate_mp4, path, name)
@@ -260,6 +280,7 @@ class USPSShipper(Shipper):
"custom_img": self.config.get(CONF_CUSTOM_IMG_FILE)
or DEFAULT_CUSTOM_IMG_FILE,
"gen_grid": self.config.get(CONF_GENERATE_GRID),
"usps_placeholder": self.config.get(CONF_USPS_PLACEHOLDER, True),
}
async def _search_informed_delivery(self, account: IMAP4_SSL) -> tuple:
@@ -48,6 +48,7 @@
"generate_grid": "Create image grid for LLM vision models",
"generate_mp4": "Create mp4 from images",
"allow_external": "Create image for notification apps",
"usps_placeholder": "Include USPS no-image placeholder in GIF?",
"custom_img": "Use custom USPS 'no mail' image?",
"amazon_custom_img": "Use custom 'no Amazon delivery' image?",
"ups_custom_img": "Use custom 'no UPS delivery' image?",
@@ -121,6 +122,7 @@
"resources": "Sensors List",
"imap_timeout": "Mailbox scan time limit (seconds, minimum 10)",
"allow_external": "Create image for notification apps",
"usps_placeholder": "Include USPS no-image placeholder in GIF?",
"custom_img": "Use custom USPS 'no mail' image?",
"amazon_custom_img": "Use custom 'no Amazon delivery' image?",
"ups_custom_img": "Use custom 'no UPS delivery' image?",
@@ -195,5 +197,18 @@
"oauth2_google": "OAuth2 - Google (Gmail)"
}
}
},
"issues": {
"auth_failed": {
"title": "Mail and Packages authentication failed",
"fix_flow": {
"step": {
"confirm": {
"title": "Re-authenticate Mail Server",
"description": "Authentication to your IMAP mail server has failed. Click submit to start the re-authentication flow."
}
}
}
}
}
}
@@ -36,7 +36,8 @@
"generic_custom_img": "Utilitza la imatge personalitzada genèrica 'no hi ha lliurament'?",
"generate_grid": "Crea una quadrícula d'imatges per a models de visió LLM",
"allow_forwarded_emails": "Permet els correus electrònics reenviats a més del valor predeterminat d'un servei (p. ex., no-reply@usps.com)",
"custom_days": "Days back to check for package emails (minimum 1, default 3)"
"custom_days": "Days back to check for package emails (minimum 1, default 3)",
"usps_placeholder": "Incloure la imatge de substitució de l'USPS sense imatge al GIF?"
},
"description": "Finalitzeu la configuració personalitzant la següent en funció de la vostra instal·lació de correu electrònic i la instal·lació d'assistència a casa. \n\n Per obtenir més informació sobre les opcions [Integració de paquets i correus] (https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration), reviseu les opcions [configuració, plantilles , secció i automatitzacions] (https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) a GitHub.",
"title": "Correu i paquets (pas 2 de 2)"
@@ -80,7 +81,8 @@
"generic_custom_img": "Utilitza la imatge personalitzada genèrica 'no hi ha lliurament'?",
"generate_grid": "Crea una quadrícula d'imatges per a models de visió LLM",
"allow_forwarded_emails": "Permet els correus electrònics reenviats a més del valor predeterminat d'un servei (p. ex., no-reply@usps.com)",
"custom_days": "Days back to check for package emails (minimum 1, default 3)"
"custom_days": "Days back to check for package emails (minimum 1, default 3)",
"usps_placeholder": "Incloure la imatge de substitució de l'USPS sense imatge al GIF?"
},
"description": "Acabeu la configuració personalitzant el següent en funció de l'estructura del vostre correu electrònic i de la instal·lació de Home Assistant.\n\nPer obtenir més detalls sobre les opcions d'[integració de correu i paquets](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration), consulteu la [secció de configuració, plantilles i automatitzacions](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) a GitHub.\n\nSi utilitzeu correus electrònics reenviats d'Amazon, separeu cada adreça amb una coma o introduïu (none) per esborrar aquesta configuració.",
"title": "Correu i paquets (pas 2 de 2)"
@@ -199,5 +201,18 @@
"oauth2_google": "OAuth2 - Google (Gmail)"
}
}
},
"issues": {
"auth_failed": {
"title": "L'autenticació de Mail and Packages ha fallat",
"fix_flow": {
"step": {
"confirm": {
"title": "Tornar a autenticar el servidor de correu",
"description": "L'autenticació amb el vostre servidor de correu IMAP ha fallat. Feu clic a envia per iniciar el flux de reautenticació."
}
}
}
}
}
}
@@ -40,7 +40,8 @@
"generic_custom_img": "Použít vlastní obrázek obecný 'bez dodání'?",
"allow_forwarded_emails": "Povolit přeposílané e-maily kromě výchozí hodnoty služby (např. no-reply@usps.com)",
"generate_grid": "Create image grid for LLM vision models",
"custom_days": "Days back to check for package emails (minimum 1, default 3)"
"custom_days": "Days back to check for package emails (minimum 1, default 3)",
"usps_placeholder": "Zahrnout zástupný symbol USPS bez obrázku do GIF?"
},
"description": "Dokončete konfiguraci přizpůsobením následujících položek na základě struktury e-mailu a instalace Home Assistant.\n\nPodrobnosti o [Mail and Packages integration](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) můžete zkontrolovat na [configurace, styly a automatizace](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) na GitHubu.",
"title": "Mail and Packages (Krok 2 ze 3)"
@@ -152,7 +153,8 @@
"walmart_custom_img": "Use custom 'no Walmart delivery' image?",
"fedex_custom_img": "Use custom 'no FedEx delivery' image?",
"allow_forwarded_emails": "Allow forwarded emails in addition to a service's default (e.g. no-reply@usps.com)",
"custom_days": "Days back to check for package emails (minimum 1, default 3)"
"custom_days": "Days back to check for package emails (minimum 1, default 3)",
"usps_placeholder": "Zahrnout zástupný symbol USPS bez obrázku do GIF?"
},
"description": "Finish the configuration by customizing the following based on your email structure and Home Assistant installation.\n\nIf using Amazon forwarded emails please separate each address with a comma or enter (none) to clear this setting.",
"title": "Mail and Packages (Step 2 of 2)"
@@ -265,5 +267,18 @@
"oauth2_google": "OAuth2 - Google (Gmail)"
}
}
},
"issues": {
"auth_failed": {
"title": "Ověření Mail and Packages selhalo",
"fix_flow": {
"step": {
"confirm": {
"title": "Znovu ověřit poštovní server",
"description": "Ověření k vašemu poštovnímu serveru IMAP selhalo. Kliknutím na odeslat spustíte proces opětovného ověření."
}
}
}
}
}
}
@@ -37,7 +37,8 @@
"post_de_custom_img": "Benutzerdefiniertes Post DE 'keine Post' Bild verwenden?",
"generate_grid": "Erstellen Sie ein Bildraster für LLM-Vision-Modelle",
"allow_forwarded_emails": "Weitergeleitete E-Mails zusätzlich zu den Standardwerten eines Dienstes zulassen (z. B. no-reply@usps.com)",
"custom_days": "Days back to check for package emails (minimum 1, default 3)"
"custom_days": "Days back to check for package emails (minimum 1, default 3)",
"usps_placeholder": "USPS-Platzhalter ohne Bild im GIF einschließen?"
},
"description": "Beenden Sie die Konfiguration, indem Sie Folgendes basierend auf Ihrer E-Mail-Struktur und der Installation von Home Assistant anpassen. \n\n Weitere Informationen zu den Optionen [Mail- und Paketintegration] (https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) finden Sie in den [Konfiguration, Vorlagen und Abschnitt Automatisierungen] (https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) auf GitHub.",
"title": "Briefe und Pakete (Schritt 2 von 2)"
@@ -83,7 +84,8 @@
"post_de_custom_img": "Benutzerdefiniertes Post DE 'keine Post' Bild verwenden?",
"generate_grid": "Erstellen Sie ein Bildraster für LLM-Vision-Modelle",
"allow_forwarded_emails": "Weitergeleitete E-Mails zusätzlich zu den Standardwerten eines Dienstes zulassen (z. B. no-reply@usps.com)",
"custom_days": "Days back to check for package emails (minimum 1, default 3)"
"custom_days": "Days back to check for package emails (minimum 1, default 3)",
"usps_placeholder": "USPS-Platzhalter ohne Bild im GIF einschließen?"
},
"description": "Schließen Sie die Konfiguration ab, indem Sie das Folgende an Ihre E-Mail-Struktur und Home Assistant-Installation anpassen.\n\nWeitere Informationen zu den [Mail und Packages Integration](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) Optionen finden Sie im [Konfigurations-, Vorlagen- und Automatisierungsabschnitt](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) auf GitHub.\n\nWenn Sie weitergeleitete E-Mails von Amazon verwenden, trennen Sie bitte jede Adresse durch ein Komma oder geben Sie (keine) ein, um diese Einstellung zu löschen.",
"title": "Briefe und Pakete (Schritt 2 von 2)"
@@ -203,5 +205,18 @@
"oauth2_google": "OAuth2 - Google (Gmail)"
}
}
},
"issues": {
"auth_failed": {
"title": "Mail and Packages Authentifizierung fehlgeschlagen",
"fix_flow": {
"step": {
"confirm": {
"title": "Mailserver neu authentifizieren",
"description": "Die Authentifizierung bei Ihrem IMAP-Mailserver ist fehlgeschlagen. Klicken Sie auf Senden, um den Reauthentifizierungs-Flow zu starten."
}
}
}
}
}
}
@@ -48,6 +48,7 @@
"imap_timeout": "Mailbox scan time limit (seconds, minimum 10)",
"generate_mp4": "Create mp4 from images",
"allow_external": "Create image for notification apps",
"usps_placeholder": "Include USPS no-image placeholder in GIF?",
"custom_img": "Use custom USPS 'no mail' image?",
"amazon_custom_img": "Use custom 'no Amazon delivery' image?",
"ups_custom_img": "Use custom 'no UPS delivery' image?",
@@ -116,6 +117,7 @@
"resources": "Sensors List",
"imap_timeout": "Mailbox scan time limit (seconds, minimum 10)",
"allow_external": "Create image for notification apps",
"usps_placeholder": "Include USPS no-image placeholder in GIF?",
"custom_img": "Use custom USPS 'no mail' image?",
"amazon_custom_img": "Use custom 'no Amazon delivery' image?",
"ups_custom_img": "Use custom 'no UPS delivery' image?",
@@ -209,5 +211,18 @@
"oauth2_google": "OAuth2 - Google (Gmail)"
}
}
},
"issues": {
"auth_failed": {
"title": "Mail and Packages authentication failed",
"fix_flow": {
"step": {
"confirm": {
"title": "Re-authenticate Mail Server",
"description": "Authentication to your IMAP mail server has failed. Click submit to start the re-authentication flow."
}
}
}
}
}
}
@@ -36,7 +36,8 @@
"generic_custom_img": "¿Usar imagen personalizada genérica 'sin entrega'?",
"generate_grid": "Crear cuadrícula de imágenes para modelos de visión LLM",
"allow_forwarded_emails": "Permitir correos electrónicos reenviados además del valor predeterminado de un servicio (p. ej., no-reply@usps.com)",
"custom_days": "Days back to check for package emails (minimum 1, default 3)"
"custom_days": "Days back to check for package emails (minimum 1, default 3)",
"usps_placeholder": "¿Incluir marcador de posición sin imagen de USPS en el GIF?"
},
"description": "Termine la configuración personalizando lo siguiente según su estructura de correo electrónico y la instalación de Home Assistant. \n\n Para obtener detalles sobre las opciones [Integración de correo y paquetes] (https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) revise las [configuración, plantillas , y la sección de automatizaciones] (https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) en GitHub.",
"title": "Correo y paquetes (Paso 2 de 2)"
@@ -80,7 +81,8 @@
"generic_custom_img": "¿Usar imagen personalizada genérica 'sin entrega'?",
"generate_grid": "Crear cuadrícula de imágenes para modelos de visión LLM",
"allow_forwarded_emails": "Permitir correos electrónicos reenviados además del valor predeterminado de un servicio (p. ej., no-reply@usps.com)",
"custom_days": "Days back to check for package emails (minimum 1, default 3)"
"custom_days": "Days back to check for package emails (minimum 1, default 3)",
"usps_placeholder": "¿Incluir marcador de posición sin imagen de USPS en el GIF?"
},
"description": "Finalice la configuración personalizando lo siguiente en función de la estructura de su correo electrónico y la instalación de Home Assistant.\n\nPara obtener detalles sobre las opciones de [integración de correo y paquetes](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration), revise la [sección de configuración, plantillas y automatizaciones](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) en GitHub.\n\nSi utiliza correos electrónicos reenviados de Amazon, separe cada dirección con una coma o ingrese (ninguno) para borrar esta configuración.",
"title": "Correo y paquetes (Paso 2 de 2)"
@@ -199,5 +201,18 @@
"oauth2_google": "OAuth2 - Google (Gmail)"
}
}
},
"issues": {
"auth_failed": {
"title": "Error de autenticación de Mail and Packages",
"fix_flow": {
"step": {
"confirm": {
"title": "Volver a autenticar el servidor de correo",
"description": "Error al autenticar en su servidor de correo IMAP. Haga clic en enviar para iniciar el flujo de reautenticación."
}
}
}
}
}
}
@@ -36,7 +36,8 @@
"generic_custom_img": "¿Usar imagen personalizada genérica 'sin entrega'?",
"generate_grid": "Crear cuadrícula de imágenes para modelos de visión LLM",
"allow_forwarded_emails": "Permitir correos electrónicos reenviados además del valor predeterminado de un servicio (p. ej., no-reply@usps.com)",
"custom_days": "Days back to check for package emails (minimum 1, default 3)"
"custom_days": "Days back to check for package emails (minimum 1, default 3)",
"usps_placeholder": "¿Incluir marcador de posición sin imagen de USPS en el GIF?"
},
"description": "Termine la configuración personalizando lo siguiente según su estructura de correo electrónico y la instalación de Home Assistant. \n\n Para obtener detalles sobre las opciones [Integración de correo y paquetes] (https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) revise las [configuración, plantillas , y la sección de automatizaciones] (https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) en GitHub.",
"title": "Correo y paquetes (Paso 2 de 2)"
@@ -80,7 +81,8 @@
"generic_custom_img": "¿Usar imagen personalizada genérica 'sin entrega'?",
"generate_grid": "Crear cuadrícula de imágenes para modelos de visión LLM",
"allow_forwarded_emails": "Permitir correos electrónicos reenviados además del valor predeterminado de un servicio (p. ej., no-reply@usps.com)",
"custom_days": "Days back to check for package emails (minimum 1, default 3)"
"custom_days": "Days back to check for package emails (minimum 1, default 3)",
"usps_placeholder": "¿Incluir marcador de posición sin imagen de USPS en el GIF?"
},
"description": "Finalice la configuración personalizando lo siguiente en función de la estructura de su correo electrónico y la instalación de Home Assistant.\n\nPara obtener detalles sobre las opciones de [integración de correo y paquetes](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration), revise la [sección de configuración, plantillas y automatizaciones](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) en GitHub.\n\nSi utiliza correos electrónicos reenviados de Amazon, separe cada dirección con una coma o ingrese (ninguno) para borrar esta configuración.",
"title": "Correo y paquetes (Paso 2 de 2)"
@@ -199,5 +201,18 @@
"oauth2_google": "OAuth2 - Google (Gmail)"
}
}
},
"issues": {
"auth_failed": {
"title": "Error de autenticación de Mail and Packages",
"fix_flow": {
"step": {
"confirm": {
"title": "Volver a autenticar el servidor de correo",
"description": "Error al autenticar en su servidor de correo IMAP. Haga clic en enviar para iniciar el flujo de reautenticación."
}
}
}
}
}
}
@@ -36,7 +36,8 @@
"generic_custom_img": "Käytä mukautettua yleistä 'ei toimituksia' kuvaa?",
"generate_grid": "Luo kuvaverkko LLM-näkömalleille",
"allow_forwarded_emails": "Salli edelleenlähetetyt sähköpostit palvelun oletusarvon lisäksi (esim. no-reply@usps.com)",
"custom_days": "Days back to check for package emails (minimum 1, default 3)"
"custom_days": "Days back to check for package emails (minimum 1, default 3)",
"usps_placeholder": "Sisällytä USPS:n ei-kuvaa -paikkamerkki GIF-tiedostoon?"
},
"description": "Viimeistele kokoonpano mukauttamalla seuraava sähköpostirakenteen ja Home Assistant -asennuksen perusteella. \n\n Lisätietoja [Posti ja paketit-integroinnista] (https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) -asetuksista on [kokoonpano, mallit , ja automaatiot-osio] (https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) GitHubissa.",
"title": "Posti ja paketit (vaihe 2/2)"
@@ -80,7 +81,8 @@
"generic_custom_img": "Käytä mukautettua yleistä 'ei toimituksia' kuvaa?",
"generate_grid": "Luo kuvaverkko LLM-näkömalleille",
"allow_forwarded_emails": "Salli edelleenlähetetyt sähköpostit palvelun oletusarvon lisäksi (esim. no-reply@usps.com)",
"custom_days": "Days back to check for package emails (minimum 1, default 3)"
"custom_days": "Days back to check for package emails (minimum 1, default 3)",
"usps_placeholder": "Sisällytä USPS:n ei-kuvaa -paikkamerkki GIF-tiedostoon?"
},
"description": "Suorita määritys loppuun mukauttamalla seuraavat sähköpostirakenteeseesi ja Home Assistant -asennukseesi perustuen.\n\nLisätietoja [Mail and Packages -integraation](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) vaihtoehdoista löydät GitHubista [määritys-, mallit- ja automaatiot-osiossa](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration).\n\nJos käytät Amazonin välittämiä sähköposteja, erottele jokainen osoite pilkulla tai syötä (none) tyhjentääksesi tämän asetuksen.",
"title": "Posti ja paketit (vaihe 2/2)"
@@ -199,5 +201,18 @@
"oauth2_google": "OAuth2 - Google (Gmail)"
}
}
},
"issues": {
"auth_failed": {
"title": "Mail and Packages -autentikointi epäonnistui",
"fix_flow": {
"step": {
"confirm": {
"title": "Tunnistaudu uudelleen sähköpostipalvelimeen",
"description": "Autentikointi IMAP-sähköpostipalvelimeesi epäonnistui. Napsauta Lähetä aloittaaksesi uudelleentunnistautumisen."
}
}
}
}
}
}
@@ -36,7 +36,8 @@
"generic_custom_img": "Utiliser une image personnalisée générique 'pas de livraison' ?",
"generate_grid": "Créer une grille d'images pour les modèles de vision LLM",
"allow_forwarded_emails": "Autoriser les e-mails transférés en plus de la valeur par défaut d'un service (par ex. no-reply@usps.com)",
"custom_days": "Days back to check for package emails (minimum 1, default 3)"
"custom_days": "Days back to check for package emails (minimum 1, default 3)",
"usps_placeholder": "Inclure l'image d'emplacement sans image USPS dans le GIF ?"
},
"description": "Terminez la configuration en personnalisant les éléments suivants en fonction de votre structure de messagerie et de l'installation de Home Assistant. \n\n Pour plus de détails sur les [Intégration de messagerie et de packages] (https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration), passez en revue les [configuration, modèles et section automatisations] (https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) sur GitHub.",
"title": "Courrier et colis (étape 2 sur 2)"
@@ -80,7 +81,8 @@
"generic_custom_img": "Utiliser une image personnalisée générique 'pas de livraison' ?",
"generate_grid": "Créer une grille d'images pour les modèles de vision LLM",
"allow_forwarded_emails": "Autoriser les e-mails transférés en plus de la valeur par défaut d'un service (par ex. no-reply@usps.com)",
"custom_days": "Days back to check for package emails (minimum 1, default 3)"
"custom_days": "Days back to check for package emails (minimum 1, default 3)",
"usps_placeholder": "Inclure l'image d'emplacement sans image USPS dans le GIF ?"
},
"description": "Terminez la configuration en personnalisant ce qui suit en fonction de la structure de votre courrier électronique et de l'installation de Home Assistant.\n\nPour plus de détails sur les options d'[intégration de courrier et de colis](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration), consultez la [section configuration, modèles et automatisations](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) sur GitHub.\n\nSi vous utilisez des courriels transférés par Amazon, veuillez séparer chaque adresse par une virgule ou entrer (aucun) pour effacer ce paramètre.",
"title": "Courrier et colis (étape 2 sur 2)"
@@ -199,5 +201,18 @@
"oauth2_google": "OAuth2 - Google (Gmail)"
}
}
},
"issues": {
"auth_failed": {
"title": "Échec de l'authentification de Mail and Packages",
"fix_flow": {
"step": {
"confirm": {
"title": "Ré-authentifier le serveur de messagerie",
"description": "L'authentification sur votre serveur de messagerie IMAP a échoué. Cliquez sur soumettre pour démarrer le flux de ré-authentification."
}
}
}
}
}
}
@@ -36,7 +36,8 @@
"generic_custom_img": "Használjon egyéni általános 'nincs szállítás' képet?",
"generate_grid": "Kép rács létrehozása LLM látásmodellekhez",
"allow_forwarded_emails": "Engedélyezze a továbbított e-maileket a szolgáltatás alapértelmezett értékén kívül (pl. no-reply@usps.com)",
"custom_days": "Days back to check for package emails (minimum 1, default 3)"
"custom_days": "Days back to check for package emails (minimum 1, default 3)",
"usps_placeholder": "Tartalmazza az USPS kép nélküli helyőrzőjét a GIF-ben?"
},
"description": "Végezze el a konfigurációt az alábbiak testreszabásával az e-mail struktúrája és a Home Assistant telepítése alapján. \n\n A [Levelek és csomagok integrációja] (https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) opciókkal kapcsolatban tekintse meg a [konfiguráció, sablonok , és az automatizálás szakasz] (https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) a GitHubon.",
"title": "Levél és csomagok (2. lépés a 2-ből)"
@@ -80,7 +81,8 @@
"generic_custom_img": "Használjon egyéni általános 'nincs szállítás' képet?",
"generate_grid": "Kép rács létrehozása LLM látásmodellekhez",
"allow_forwarded_emails": "Engedélyezze a továbbított e-maileket a szolgáltatás alapértelmezett értékén kívül (pl. no-reply@usps.com)",
"custom_days": "Days back to check for package emails (minimum 1, default 3)"
"custom_days": "Days back to check for package emails (minimum 1, default 3)",
"usps_placeholder": "Tartalmazza az USPS kép nélküli helyőrzőjét a GIF-ben?"
},
"description": "Fejezze be a konfigurációt a következők testreszabásával az e-mail struktúrája és a Home Assistant telepítése alapján.\n\nA [Mail and Packages integráció](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) lehetőségeinek részleteiről a GitHub-on található [konfiguráció, sablonok és automatizálások szekcióban](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) található információkat.\n\nHa Amazon továbbított e-maileket használ, kérjük, válassza el minden címet vesszővel, vagy írja be a (none) opciót, hogy törölje ezt a beállítást.",
"title": "Levél és csomagok (2. lépés a 2-ből)"
@@ -199,5 +201,18 @@
"oauth2_google": "OAuth2 - Google (Gmail)"
}
}
},
"issues": {
"auth_failed": {
"title": "Mail and Packages hitelesítés sikertelen",
"fix_flow": {
"step": {
"confirm": {
"title": "Levelezőszerver újrahitelesítése",
"description": "Nem sikerült a hitelesítés az IMAP levelezőszerveren. Kattintson a küldésre az újrahitelesítési folyamat elindításához."
}
}
}
}
}
}
@@ -36,7 +36,8 @@
"generic_custom_img": "Usare l'immagine personalizzata generica 'nessuna consegna'?",
"generate_grid": "Crea una griglia di immagini per i modelli di visione LLM",
"allow_forwarded_emails": "Consenti email inoltrate oltre al valore predefinito di un servizio (ad es. no-reply@usps.com)",
"custom_days": "Days back to check for package emails (minimum 1, default 3)"
"custom_days": "Days back to check for package emails (minimum 1, default 3)",
"usps_placeholder": "Includere il segnaposto USPS senza immagine nella GIF?"
},
"description": "Termina la configurazione personalizzando quanto segue in base alla struttura della tua e-mail e all'installazione di Home Assistant. \n\n Per i dettagli sulle opzioni [Integrazione posta e pacchetti] (https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) rivedere le [configurazione, modelli e sezione automazioni] (https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) su GitHub.",
"title": "Posta e pacchi (passaggio 2 di 2)"
@@ -80,7 +81,8 @@
"generic_custom_img": "Usare l'immagine personalizzata generica 'nessuna consegna'?",
"generate_grid": "Crea una griglia di immagini per i modelli di visione LLM",
"allow_forwarded_emails": "Consenti email inoltrate oltre al valore predefinito di un servizio (ad es. no-reply@usps.com)",
"custom_days": "Days back to check for package emails (minimum 1, default 3)"
"custom_days": "Days back to check for package emails (minimum 1, default 3)",
"usps_placeholder": "Includere il segnaposto USPS senza immagine nella GIF?"
},
"description": "Termina la configurazione personalizzando quanto segue in base alla struttura della tua email e all'installazione di Home Assistant.\n\nPer i dettagli sulle opzioni di [integrazione Mail e Pacchetti](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) consulta la [sezione configurazione, modelli e automazioni](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) su GitHub.\n\nSe utilizzi email inoltrate da Amazon, separa ogni indirizzo con una virgola o inserisci (nessuno) per cancellare questa impostazione.",
"title": "Posta e pacchi (passaggio 2 di 2)"
@@ -199,5 +201,18 @@
"oauth2_google": "OAuth2 - Google (Gmail)"
}
}
},
"issues": {
"auth_failed": {
"title": "Autenticazione Mail and Packages fallita",
"fix_flow": {
"step": {
"confirm": {
"title": "Riautentica il server di posta",
"description": "L'autenticazione al server di posta IMAP è fallita. Clicca su invia per avviare la procedura di riautenticazione."
}
}
}
}
}
}
@@ -36,7 +36,8 @@
"generic_custom_img": "사용자 정의 일반 '배송 없음' 이미지를 사용하시겠습니까?",
"generate_grid": "LLM 비전 모델을 위한 이미지 그리드 생성",
"allow_forwarded_emails": "서비스 기본값 외에 전달된 이메일 허용 (예: no-reply@usps.com)",
"custom_days": "Days back to check for package emails (minimum 1, default 3)"
"custom_days": "Days back to check for package emails (minimum 1, default 3)",
"usps_placeholder": "GIF에 USPS 이미지 없음 자리 표시자를 포함하시겠습니까?"
},
"description": "이메일 구조 및 Home Assistant 설치에 따라 다음을 사용자 정의하여 구성을 완료하십시오. \n\n [메일 및 패키지 통합] (https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) 옵션에 대한 자세한 내용은 [구성, 템플릿 및 자동화 섹션] (https://github.com/moralmunky/Home-Assistant-Mail-and-Packages/wiki/Configuration-and-Email-Settings#configuration)",
"title": "메일 및 패키지 (2/2 단계)"
@@ -80,7 +81,8 @@
"generic_custom_img": "사용자 정의 일반 '배송 없음' 이미지를 사용하시겠습니까?",
"generate_grid": "LLM 비전 모델을 위한 이미지 그리드 생성",
"allow_forwarded_emails": "서비스 기본값 외에 전달된 이메일 허용 (예: no-reply@usps.com)",
"custom_days": "Days back to check for package emails (minimum 1, default 3)"
"custom_days": "Days back to check for package emails (minimum 1, default 3)",
"usps_placeholder": "GIF에 USPS 이미지 없음 자리 표시자를 포함하시겠습니까?"
},
"description": "이메일 구조와 Home Assistant 설치에 따라 다음을 사용자 정의하여 구성을 완료하십시오.\n\n[Mail and Packages 통합](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) 옵션에 대한 자세한 내용은 GitHub의 [구성, 템플릿, 자동화 섹션](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration)을 참조하십시오.\n\nAmazon에서 전달된 이메일을 사용하는 경우 각 주소를 쉼표로 구분하거나 이 설정을 지우려면 (none)을 입력하십시오.",
"title": "메일 및 패키지 (2/2 단계)"
@@ -199,5 +201,18 @@
"oauth2_google": "OAuth2 - Google (Gmail)"
}
}
},
"issues": {
"auth_failed": {
"title": "Mail and Packages 인증 실패",
"fix_flow": {
"step": {
"confirm": {
"title": "메일 서버 재인증",
"description": "IMAP 메일 서버 인증에 실패했습니다. 제출을 클릭하여 재인증 흐름을 시작하십시오."
}
}
}
}
}
}
@@ -36,7 +36,8 @@
"generic_custom_img": "Gebruik aangepaste generieke 'geen levering' afbeelding?",
"generate_grid": "Maak een afbeeldingsraster voor LLM-visiemodellen",
"allow_forwarded_emails": "Doorgestuurde e-mails toestaan naast de standaardwaarde van een service (bijv. no-reply@usps.com)",
"custom_days": "Days back to check for package emails (minimum 1, default 3)"
"custom_days": "Days back to check for package emails (minimum 1, default 3)",
"usps_placeholder": "USPS tijdelijke aanduiding zonder afbeelding opnemen in GIF?"
},
"description": "Voltooi de configuratie door het volgende aan te passen op basis van uw e-mailstructuur en Home Assistant-installatie. \n\n Voor meer informatie over de [E-mail en pakketten integratie] (https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) opties bekijk de [configuratie, sjablonen , en automatisering sectie] (https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) op GitHub.",
"title": "Mail en pakketten (stap 2 van 2)"
@@ -80,7 +81,8 @@
"generic_custom_img": "Gebruik aangepaste generieke 'geen levering' afbeelding?",
"generate_grid": "Maak een afbeeldingsraster voor LLM-visiemodellen",
"allow_forwarded_emails": "Doorgestuurde e-mails toestaan naast de standaardwaarde van een service (bijv. no-reply@usps.com)",
"custom_days": "Days back to check for package emails (minimum 1, default 3)"
"custom_days": "Days back to check for package emails (minimum 1, default 3)",
"usps_placeholder": "USPS tijdelijke aanduiding zonder afbeelding opnemen in GIF?"
},
"description": "Rond de configuratie af door het volgende aan te passen op basis van uw e-mailstructuur en Home Assistant-installatie.\n\nVoor details over de [Mail en Packages integratie](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) opties, bekijk de [configuratie, sjablonen en automatiseringen sectie](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) op GitHub.\n\nAls u doorgestuurde e-mails van Amazon gebruikt, scheid dan elk adres met een komma of voer (geen) in om deze instelling te wissen.",
"title": "Mail en pakketten (stap 2 van 2)"
@@ -199,5 +201,18 @@
"oauth2_google": "OAuth2 - Google (Gmail)"
}
}
},
"issues": {
"auth_failed": {
"title": "Mail and Packages authenticatie mislukt",
"fix_flow": {
"step": {
"confirm": {
"title": "E-mailserver opnieuw verifiëren",
"description": "Authenticatie bij uw IMAP-mailserver is mislukt. Klik op verzenden om de herauthenticatiestroom te starten."
}
}
}
}
}
}
@@ -36,7 +36,8 @@
"generic_custom_img": "Bruk tilpasset generisk 'ingen levering' bilde?",
"generate_grid": "Opprett bildegitter for LLM-visjonsmodeller",
"allow_forwarded_emails": "Tillat videresendte e-poster i tillegg til en tjenestes standardverdi (f.eks. no-reply@usps.com)",
"custom_days": "Days back to check for package emails (minimum 1, default 3)"
"custom_days": "Days back to check for package emails (minimum 1, default 3)",
"usps_placeholder": "Inkluder USPS plassholder uten bilde i GIF?"
},
"description": "Fullfør konfigurasjonen ved å tilpasse følgende basert på e-poststrukturen og Home Assistant-installasjonen. \n\n For detaljer om alternativene [Mail and Packages] (https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration), les gjennom [konfigurasjon, maler , og automatiseringsdel] (https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) på GitHub.",
"title": "E-post og pakker (trinn 2 av 2)"
@@ -80,7 +81,8 @@
"generic_custom_img": "Bruk tilpasset generisk 'ingen levering' bilde?",
"generate_grid": "Opprett bildegitter for LLM-visjonsmodeller",
"allow_forwarded_emails": "Tillat videresendte e-poster i tillegg til en tjenestes standardverdi (f.eks. no-reply@usps.com)",
"custom_days": "Days back to check for package emails (minimum 1, default 3)"
"custom_days": "Days back to check for package emails (minimum 1, default 3)",
"usps_placeholder": "Inkluder USPS plassholder uten bilde i GIF?"
},
"description": "Fullfør konfigurasjonen ved å tilpasse følgende basert på din e-poststruktur og Home Assistant-installasjon.\n\nFor detaljer om [Mail and Packages-integrasjonen](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) alternativene, se gjennom [konfigurasjon, maler og automatiseringsseksjonen](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) på GitHub.\n\nHvis du bruker Amazon videresendte e-poster, vennligst skill hver adresse med et komma eller skriv inn (ingen) for å tømme denne innstillingen.",
"title": "E-post og pakker (trinn 2 av 2)"
@@ -199,5 +201,18 @@
"oauth2_google": "OAuth2 - Google (Gmail)"
}
}
},
"issues": {
"auth_failed": {
"title": "Autentisering for Mail and Packages mislyktes",
"fix_flow": {
"step": {
"confirm": {
"title": "Autentiser e-postserver på nytt",
"description": "Autentisering mot din IMAP-e-postserver mislyktes. Klikk på send for å starte reautentiseringsflyten."
}
}
}
}
}
}
@@ -36,7 +36,8 @@
"generic_custom_img": "Użyć niestandardowego obrazu ogólnego 'brak dostawy'?",
"generate_grid": "Utwórz siatkę obrazów dla modeli wizji LLM",
"allow_forwarded_emails": "Zezwalaj na przekierowane e-maile oprócz wartości domyślnej usługi (np. no-reply@usps.com)",
"custom_days": "Days back to check for package emails (minimum 1, default 3)"
"custom_days": "Days back to check for package emails (minimum 1, default 3)",
"usps_placeholder": "Dołączyć zarezerwowane miejsce dla USPS bez obrazu do pliku GIF?"
},
"description": "Zakończ konfigurację, dostosowując następujące elementy w oparciu o strukturę poczty e-mail i instalację Home Assistant. \n\n Aby uzyskać szczegółowe informacje na temat opcji [Integracja poczty i pakietów] (https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) sprawdź opcje [konfiguracja, szablony i sekcja automatyzacji] (https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) na GitHub.",
"title": "Poczta i paczki (krok 2 z 2)"
@@ -80,7 +81,8 @@
"generic_custom_img": "Użyć niestandardowego obrazu ogólnego 'brak dostawy'?",
"generate_grid": "Utwórz siatkę obrazów dla modeli wizji LLM",
"allow_forwarded_emails": "Zezwalaj na przekierowane e-maile oprócz wartości domyślnej usługi (np. no-reply@usps.com)",
"custom_days": "Days back to check for package emails (minimum 1, default 3)"
"custom_days": "Days back to check for package emails (minimum 1, default 3)",
"usps_placeholder": "Dołączyć zarezerwowane miejsce dla USPS bez obrazu do pliku GIF?"
},
"description": "Zakończ konfigurację, dostosowując następujące elementy do struktury swojego e-maila i instalacji Home Assistant.\n\nAby uzyskać szczegółowe informacje na temat opcji [integracji Mail and Packages](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration), zapoznaj się z [sekcją konfiguracji, szablonów i automatyzacji](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) na GitHubie.\n\nJeśli korzystasz z przekierowanych e-maili Amazon, oddziel każdy adres przecinkiem lub wprowadź (brak), aby wyczyścić to ustawienie.",
"title": "Poczta i paczki (krok 2 z 2)"
@@ -199,5 +201,18 @@
"oauth2_google": "OAuth2 - Google (Gmail)"
}
}
},
"issues": {
"auth_failed": {
"title": "Błąd uwierzytelniania Mail and Packages",
"fix_flow": {
"step": {
"confirm": {
"title": "Ponownie uwierzytelnij serwer pocztowy",
"description": "Uwierzytelnienie na serwerze pocztowym IMAP nie powiodło się. Kliknij prześlij, aby rozpocząć proces ponownego uwierzytelniania."
}
}
}
}
}
}
@@ -36,7 +36,8 @@
"generic_custom_img": "Usar imagem personalizada genérica 'sem entrega'?",
"generate_grid": "Criar grade de imagens para modelos de visão LLM",
"allow_forwarded_emails": "Permitir emails encaminhados além do padrão de um serviço (ex: no-reply@usps.com)",
"custom_days": "Days back to check for package emails (minimum 1, default 3)"
"custom_days": "Days back to check for package emails (minimum 1, default 3)",
"usps_placeholder": "Incluir marcador de posição sem imagem USPS no GIF?"
},
"description": "Conclua a configuração, personalizando o seguinte com base na sua estrutura de email e instalação do Home Assistant. \n\n Para obter detalhes sobre as opções [integração de Mail e Pacotes] (https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration), revise as opções de [configuração, modelos e seção de automações] (https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) no GitHub.",
"title": "Correio e pacotes (Etapa 2 de 2)"
@@ -80,7 +81,8 @@
"generic_custom_img": "Usar imagem personalizada genérica 'sem entrega'?",
"generate_grid": "Criar grade de imagens para modelos de visão LLM",
"allow_forwarded_emails": "Permitir emails encaminhados além do padrão de um serviço (ex: no-reply@usps.com)",
"custom_days": "Days back to check for package emails (minimum 1, default 3)"
"custom_days": "Days back to check for package emails (minimum 1, default 3)",
"usps_placeholder": "Incluir marcador de posição sem imagem USPS no GIF?"
},
"description": "Conclua a configuração personalizando o seguinte com base na estrutura do seu email e na instalação do Home Assistant.\n\nPara detalhes sobre as opções de [integração de Correio e Pacotes](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration), consulte a [seção de configuração, modelos e automações](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) no GitHub.\n\nSe estiver usando emails encaminhados da Amazon, separe cada endereço com uma vírgula ou insira (nenhum) para limpar essa configuração.",
"title": "Correio e pacotes (Etapa 2 de 2)"
@@ -199,5 +201,18 @@
"oauth2_google": "OAuth2 - Google (Gmail)"
}
}
},
"issues": {
"auth_failed": {
"title": "Falha na autenticação de Mail and Packages",
"fix_flow": {
"step": {
"confirm": {
"title": "Reautenticar o Servidor de E-mail",
"description": "A autenticação no seu servidor de e-mail IMAP falhou. Clique em enviar para iniciar o fluxo de reautenticação."
}
}
}
}
}
}
@@ -36,7 +36,8 @@
"generic_custom_img": "Usar imagem personalizada genérica 'sem entrega'?",
"generate_grid": "Criar grade de imagens para modelos de visão LLM",
"allow_forwarded_emails": "Permitir e-mails encaminhados além do padrão de um serviço (ex: no-reply@usps.com)",
"custom_days": "Days back to check for package emails (minimum 1, default 3)"
"custom_days": "Days back to check for package emails (minimum 1, default 3)",
"usps_placeholder": "Incluir marcador de posição sem imagem USPS no GIF?"
},
"description": "Conclua a configuração, personalizando o seguinte com base na sua estrutura de email e instalação do Home Assistant. \n\n Para obter detalhes sobre as opções [integração de Mail e Pacotes] (https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration), revise as opções de [configuração, modelos e seção de automações] (https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) no GitHub.",
"title": "Correio e pacotes (Etapa 2 de 2)"
@@ -80,7 +81,8 @@
"generic_custom_img": "Usar imagem personalizada genérica 'sem entrega'?",
"generate_grid": "Criar grade de imagens para modelos de visão LLM",
"allow_forwarded_emails": "Permitir e-mails encaminhados além do padrão de um serviço (ex: no-reply@usps.com)",
"custom_days": "Days back to check for package emails (minimum 1, default 3)"
"custom_days": "Days back to check for package emails (minimum 1, default 3)",
"usps_placeholder": "Incluir marcador de posição sem imagem USPS no GIF?"
},
"description": "Conclua a configuração personalizando o seguinte com base na estrutura do seu email e na instalação do Home Assistant.\n\nPara detalhes sobre as opções de [integração de Mail e Pacotes](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration), consulte a [seção de configuração, modelos e automações](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) no GitHub.\n\nSe estiver usando emails encaminhados da Amazon, separe cada endereço com uma vírgula ou insira (nenhum) para limpar essa configuração.",
"title": "Correio e pacotes (Etapa 2 de 2)"
@@ -199,5 +201,18 @@
"oauth2_google": "OAuth2 - Google (Gmail)"
}
}
},
"issues": {
"auth_failed": {
"title": "Falha na autenticação de Mail and Packages",
"fix_flow": {
"step": {
"confirm": {
"title": "Reautenticar o Servidor de E-mail",
"description": "A autenticação no seu servidor de e-mail IMAP falhou. Clique em enviar para iniciar o fluxo de reautenticação."
}
}
}
}
}
}
@@ -36,7 +36,8 @@
"generic_custom_img": "Использовать пользовательское изображение общее 'нет доставки'?",
"generate_grid": "Создать сетку изображений для моделей зрения LLM",
"allow_forwarded_emails": "Разрешить переадресованные электронные письма в дополнение к значениям по умолчанию службы (например, no-reply@usps.com)",
"custom_days": "Days back to check for package emails (minimum 1, default 3)"
"custom_days": "Days back to check for package emails (minimum 1, default 3)",
"usps_placeholder": "Включить заполнитель без изображения USPS в GIF?"
},
"description": "Завершите настройку, настроив следующие параметры в зависимости от структуры электронной почты и установки Home Assistant. \n\n Подробнее о параметрах [Интеграция с почтой и пакетами] (https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) см. В разделе [конфигурация, шаблоны и раздел автоматизации] (https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) на GitHub.",
"title": "Почта и пакеты (шаг 2 из 2)"
@@ -80,7 +81,8 @@
"generic_custom_img": "Использовать пользовательское изображение общее 'нет доставки'?",
"generate_grid": "Создать сетку изображений для моделей зрения LLM",
"allow_forwarded_emails": "Разрешить переадресованные электронные письма в дополнение к значениям по умолчанию службы (например, no-reply@usps.com)",
"custom_days": "Days back to check for package emails (minimum 1, default 3)"
"custom_days": "Days back to check for package emails (minimum 1, default 3)",
"usps_placeholder": "Включить заполнитель без изображения USPS в GIF?"
},
"description": "Завершите настройку, настроив следующее в соответствии со структурой вашей электронной почты и установкой Home Assistant.\n\nДля получения подробной информации о вариантах [интеграции Почта и Пакеты](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) ознакомьтесь с [разделом конфигурации, шаблонов и автоматизации](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) на GitHub.\n\nЕсли вы используете переадресованные электронные письма Amazon, разделите каждый адрес запятой или введите (none), чтобы очистить эту настройку.",
"title": "Почта и пакеты (шаг 2 из 2)"
@@ -199,5 +201,18 @@
"oauth2_google": "OAuth2 - Google (Gmail)"
}
}
},
"issues": {
"auth_failed": {
"title": "Ошибка авторизации Mail and Packages",
"fix_flow": {
"step": {
"confirm": {
"title": "Повторная авторизация почтового сервера",
"description": "Не удалось авторизоваться на вашем почтовом сервере IMAP. Нажмите «Отправить», чтобы начать процесс повторной авторизации."
}
}
}
}
}
}
@@ -36,7 +36,8 @@
"generic_custom_img": "Použiť vlastný obrázok všeobecný 'bez dodávky'?",
"generate_grid": "Vytvoriť mriežku obrázkov pre modely videnia LLM",
"allow_forwarded_emails": "Povoliť preposielané e-maily okrem predvolenej hodnoty služby (napr. no-reply@usps.com)",
"custom_days": "Days back to check for package emails (minimum 1, default 3)"
"custom_days": "Days back to check for package emails (minimum 1, default 3)",
"usps_placeholder": "Zahrnúť zástupný symbol USPS bez obrázka do GIF?"
},
"description": "Dokončite konfiguráciu prispôsobením nasledujúcich položiek na základe štruktúry e-mailu a inštalácie Home Assistant.\n\nPodrobnosti nájdete na [Pošta a balíky integrácia](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) možnosti si pozrite v časti [konfigurácia, šablóny a automatizácie](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) na GitHube.",
"title": "Pošta a balíky (krok 2 z 2)"
@@ -80,7 +81,8 @@
"generic_custom_img": "Použiť vlastný obrázok všeobecný 'bez dodávky'?",
"generate_grid": "Vytvoriť mriežku obrázkov pre modely videnia LLM",
"allow_forwarded_emails": "Povoliť preposielané e-maily okrem predvolenej hodnoty služby (napr. no-reply@usps.com)",
"custom_days": "Days back to check for package emails (minimum 1, default 3)"
"custom_days": "Days back to check for package emails (minimum 1, default 3)",
"usps_placeholder": "Zahrnúť zástupný symbol USPS bez obrázka do GIF?"
},
"description": "Dokončite konfiguráciu prispôsobením nasledujúceho na základe štruktúry vášho e-mailu a inštalácie Home Assistant.\n\nPre podrobnosti o možnostiach [integrácie Mail a Packages](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) si prečítajte [sekciu o konfigurácii, šablónach a automatizáciách](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) na GitHub.\n\nAk používate preposlané e-maily od Amazonu, oddelte každú adresu čiarkou alebo zadajte (none) na vymazanie tohto nastavenia.",
"title": "Pošta a balíky (krok 2 z 2)"
@@ -199,5 +201,18 @@
"oauth2_google": "OAuth2 - Google (Gmail)"
}
}
},
"issues": {
"auth_failed": {
"title": "Overenie Mail and Packages zlyhalo",
"fix_flow": {
"step": {
"confirm": {
"title": "Znovu overiť poštový server",
"description": "Overenie k vášmu poštovému serveru IMAP zlyhalo. Kliknutím na odoslať spustíte proces opätovného overenia."
}
}
}
}
}
}
@@ -36,7 +36,8 @@
"generic_custom_img": "Uporabite prilagojeno splošno sliko 'brez dostave'?",
"generate_grid": "Ustvari mrežo slik za vizualne modele LLM",
"allow_forwarded_emails": "Dovoli posredovane e-poštne sporočila poleg privzete vrednosti storitve (npr. no-reply@usps.com)",
"custom_days": "Days back to check for package emails (minimum 1, default 3)"
"custom_days": "Days back to check for package emails (minimum 1, default 3)",
"usps_placeholder": "Ali naj se v GIF vključi nadomestni znak USPS brez slike?"
},
"description": "Končajte konfiguracijo s prilagoditvijo naslednjih na podlagi strukture e-pošte in namestitve Home Assistant. \n\n Za podrobnosti o možnostih [Integracija pošte in paketov] (https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) preglejte [konfiguracijo, predloge in oddelku za avtomatizacije] (https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) na GitHubu.",
"title": "Pošta in paketi (2. korak od 2)"
@@ -80,7 +81,8 @@
"generic_custom_img": "Uporabite prilagojeno splošno sliko 'brez dostave'?",
"generate_grid": "Ustvari mrežo slik za vizualne modele LLM",
"allow_forwarded_emails": "Dovoli posredovane e-poštne sporočila poleg privzete vrednosti storitve (npr. no-reply@usps.com)",
"custom_days": "Days back to check for package emails (minimum 1, default 3)"
"custom_days": "Days back to check for package emails (minimum 1, default 3)",
"usps_placeholder": "Ali naj se v GIF vključi nadomestni znak USPS brez slike?"
},
"description": "Konfiguracijo dokončajte z prilagajanjem naslednjega glede na strukturo vašega e-poštnega sporočila in namestitev Home Assistant.\n\nZa podrobnosti o možnostih [integracije Mail and Packages](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) si oglejte [razdelek o konfiguraciji, predlogah in avtomatizacijah](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) na GitHubu.\n\nČe uporabljate preusmerjena e-poštna sporočila Amazon, ločite vsak naslov z vejico ali vnesite (noben) za izbris te nastavitve.",
"title": "Pošta in paketi (2. korak od 2)"
@@ -199,5 +201,18 @@
"oauth2_google": "OAuth2 - Google (Gmail)"
}
}
},
"issues": {
"auth_failed": {
"title": "Preverjanje pristnosti za Mail and Packages ni uspelo",
"fix_flow": {
"step": {
"confirm": {
"title": "Ponovno preveri poštni strežnik",
"description": "Preverjanje pristnosti z vašim poštnim strežnikom IMAP ni uspelo. Kliknite Pošlji, če želite začeti potek ponovnega preverjanja pristnosti."
}
}
}
}
}
}
@@ -36,7 +36,8 @@
"generic_custom_img": "Använd anpassad generisk 'ingen leverans' bild?",
"generate_grid": "Skapa bildrutnät för LLM-visionsmodeller",
"allow_forwarded_emails": "Tillåt vidarebefordrade e-postmeddelanden utöver en tjänsts standardvärde (t.ex. no-reply@usps.com)",
"custom_days": "Days back to check for package emails (minimum 1, default 3)"
"custom_days": "Days back to check for package emails (minimum 1, default 3)",
"usps_placeholder": "Inkludera USPS platshållare utan bild i GIF?"
},
"description": "Avsluta konfigurationen genom att anpassa följande baserat på din e-poststruktur och installation av hemassistent. \n\n Mer information om alternativen [Mail and Packages] (https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) läser [konfiguration, mallar , och automatiseringsavsnitt] (https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) på GitHub.",
"title": "Mail och paket (steg 2 av 2)"
@@ -80,7 +81,8 @@
"generic_custom_img": "Använd anpassad generisk 'ingen leverans' bild?",
"generate_grid": "Skapa bildrutnät för LLM-visionsmodeller",
"allow_forwarded_emails": "Tillåt vidarebefordrade e-postmeddelanden utöver en tjänsts standardvärde (t.ex. no-reply@usps.com)",
"custom_days": "Days back to check for package emails (minimum 1, default 3)"
"custom_days": "Days back to check for package emails (minimum 1, default 3)",
"usps_placeholder": "Inkludera USPS platshållare utan bild i GIF?"
},
"description": "Slutför konfigurationen genom att anpassa följande baserat på din e-poststruktur och Home Assistant-installation.\n\nFör detaljer om [Mail and Packages integration](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) alternativen granska [konfiguration, mallar och automatiseringssektionen](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) på GitHub.\n\nOm du använder vidarebefordrade e-postmeddelanden från Amazon, separera varje adress med ett kommatecken eller ange (ingen) för att rensa denna inställning.",
"title": "Mail och paket (steg 2 av 2)"
@@ -199,5 +201,18 @@
"oauth2_google": "OAuth2 - Google (Gmail)"
}
}
},
"issues": {
"auth_failed": {
"title": "Mail and Packages-autentisering misslyckades",
"fix_flow": {
"step": {
"confirm": {
"title": "Återautentisera e-postserver",
"description": "Autentisering mot din IMAP-e-postserver misslyckades. Klicka på skicka för att starta återautentiseringsflödet."
}
}
}
}
}
}
@@ -36,7 +36,8 @@
"generic_custom_img": "使用自訂的通用「無送貨」圖像?",
"generate_grid": "為LLM視覺模型創建圖像網格",
"allow_forwarded_emails": "允許轉發的電郵,除了服務的預設值(例如 no-reply@usps.com",
"custom_days": "Days back to check for package emails (minimum 1, default 3)"
"custom_days": "Days back to check for package emails (minimum 1, default 3)",
"usps_placeholder": "在 GIF 中包含 USPS 無圖像預留位置?"
},
"description": "通過根據您的電子郵件結構和Home Assistant安裝自定義以下內容來完成配置。 \n\n有關[郵件和軟件包集成]https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration)選項的詳細信息,請查看[配置,模板和自動化部分](GitHub上的https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration)。",
"title": "郵件和包裹(第2步,共2步)"
@@ -80,7 +81,8 @@
"generic_custom_img": "使用自訂的通用「無送貨」圖像?",
"generate_grid": "為LLM視覺模型創建圖像網格",
"allow_forwarded_emails": "允許轉發的電郵,除了服務的預設值(例如 no-reply@usps.com",
"custom_days": "Days back to check for package emails (minimum 1, default 3)"
"custom_days": "Days back to check for package emails (minimum 1, default 3)",
"usps_placeholder": "在 GIF 中包含 USPS 無圖像預留位置?"
},
"description": "根據您的電郵結構和Home Assistant安裝來完成配置。\n\n有關[郵件和包裹整合](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration)選項的詳情,請查閱GitHub上的[配置、模板和自動化部分](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration)。\n\n如果使用Amazon轉發的電郵,請用逗號分隔每個地址,或輸入(none)以清除此設定。",
"title": "郵件和包裹(第2步,共2步)"
@@ -199,5 +201,18 @@
"oauth2_google": "OAuth2 - Google (Gmail)"
}
}
},
"issues": {
"auth_failed": {
"title": "Mail and Packages 認證失敗",
"fix_flow": {
"step": {
"confirm": {
"title": "重新認證郵件伺服器",
"description": "認證 IMAP 郵件伺服器失敗。請按提交以啟動重新認證流程。"
}
}
}
}
}
}
@@ -43,7 +43,12 @@ _MAX_IMAGE_SIZE = 10 * 1024 * 1024 # 10 MB
DOMAIN_LANG_MAP = {
"amazon.de": ["versandbestaetigung", "Geliefert:", "Zugestellt:"],
"amazon.it": ["conferma-spedizione", "Consegna effettuata:", "Arriverà"],
"amazon.it": [
"conferma-spedizione",
"Consegna effettuata:",
"Arriverà",
"Spedito:",
],
"amazon.nl": [
"update-bestelling",
"verzending-volgen",
@@ -374,7 +374,7 @@ def _get_courier_info(
),
]
base_path = f"{hass.config.path()}/{default_image_path(hass, config)}"
base_path = hass.config.path(default_image_path(hass, config))
for (
active,
@@ -5,6 +5,7 @@ import binascii
import logging
import re
import unicodedata
from urllib.parse import quote, unquote
import aioimaplib
from aioimaplib import (
@@ -115,6 +116,30 @@ def quote_folder(folder: str) -> str:
return folder if _is_imap_atom(folder) else f'"{folder}"'
def encode_folder_ref(folder: str) -> str:
"""Percent-encode a folder name for use in a composite ``folder/uid`` ID.
Multi-folder searches tag each UID with its source folder as
``folder/uid``. Those composite IDs are space-joined and re-split on
whitespace at several call sites, and split on ``/`` to recover the
folder so the folder component must contain neither whitespace nor
``/``. A folder named ``# - Projects`` would otherwise shatter into
``#``, ``-``, ``Projects/55`` when the joined ID list is ``.split()``.
``quote(..., safe="")`` escapes both (and ``%`` itself, keeping the
round-trip lossless for any folder name).
"""
return quote(folder, safe="")
def decode_folder_ref(folder: str) -> str:
"""Decode the percent-encoded folder component of a composite ID.
Takes the already-split folder component (everything before the final
``/`` of a ``folder/uid`` ID), not the full composite ID.
"""
return unquote(folder)
class InvalidAuth(HomeAssistantError):
"""Raise exception for invalid credentials."""
@@ -382,7 +407,7 @@ def _parse_esearch_line(line_bytes: bytes) -> list[bytes]:
pass
else:
uids.append(part)
return [f"{mailbox}/{uid}".encode() for uid in uids]
return [f"{encode_folder_ref(mailbox)}/{uid}".encode() for uid in uids]
async def _execute_single_search(account: IMAP4_SSL, search_query: str) -> list[bytes]: # noqa: C901
@@ -446,7 +471,8 @@ async def _execute_single_search(account: IMAP4_SSL, search_query: str) -> list[
if res.result == "OK" and res.lines:
parsed = parse_search_response(res.lines)
all_uids.extend(
f"{folder}/{uid.decode()}".encode() for uid in parsed
f"{encode_folder_ref(folder)}/{uid.decode()}".encode()
for uid in parsed
)
except TimeoutError:
raise
@@ -570,7 +596,7 @@ async def email_fetch(account: IMAP4_SSL, num, parts: str = "(RFC822)") -> tuple
num_str = num.decode() if isinstance(num, bytes) else str(num)
if "/" in num_str:
folder, num_str = num_str.rsplit("/", 1)
await selectfolder(account, folder)
await selectfolder(account, decode_folder_ref(folder))
try:
res = await account.uid("FETCH", num_str, parts)
except TimeoutError:
@@ -597,7 +623,7 @@ async def email_fetch_headers(account: IMAP4_SSL, num) -> tuple:
num_str = num.decode() if isinstance(num, bytes) else str(num)
if "/" in num_str:
folder, num_str = num_str.rsplit("/", 1)
await selectfolder(account, folder)
await selectfolder(account, decode_folder_ref(folder))
try:
res = await account.uid("FETCH", num_str, "(BODY[HEADER.FIELDS (SUBJECT)])")
except TimeoutError:
@@ -627,7 +653,7 @@ async def email_fetch_text(account: IMAP4_SSL, num, parts: str = "(BODY[1])") ->
num_str = num.decode() if isinstance(num, bytes) else str(num)
if "/" in num_str:
folder, num_str = num_str.rsplit("/", 1)
await selectfolder(account, folder)
await selectfolder(account, decode_folder_ref(folder))
try:
res = await account.uid("FETCH", num_str, parts)
except TimeoutError:
@@ -688,6 +714,7 @@ async def email_fetch_batch( # noqa: C901
num_str = num.decode() if isinstance(num, bytes) else str(num)
if "/" in num_str:
folder, actual_num = num_str.rsplit("/", 1)
folder = decode_folder_ref(folder)
else:
folder, actual_num = None, num_str
folder_to_nums.setdefault(folder, []).append(actual_num)
@@ -114,8 +114,21 @@ def _find_tracking_in_body(
return None
def save_image_data_to_disk(shipper_name: str, path: str, image_data: bytes) -> bool:
def save_image_data_to_disk(
shipper_name: str, path: str, image_data: bytes | None
) -> bool:
"""Write image bytes to disk and verify."""
if not image_data:
# Extraction can hand us zero bytes (e.g. a bare/empty base64 data URI
# in the email HTML). Writing that produces a 0-byte "photo" the
# camera then serves as a broken image — report failure instead so
# the caller falls through to the next extraction pass.
_LOGGER.debug(
"%s - No image data extracted; not writing %s",
shipper_name,
path,
)
return False
try:
# Ensure directory exists
directory = Path(path).parent
@@ -240,8 +253,11 @@ def _extract_from_html(
else str(payload)
)
# Base64 check
if matches := re.findall(base64_pattern, content):
# Base64 check. Skip empty matches: a bare "data:image/...;base64,"
# URI (seen in real FedEx delivered emails) matches zero characters,
# and taking it would discard a real photo later in the same part.
matches = [m for m in re.findall(base64_pattern, content) if m]
if matches:
try:
base64_data = matches[0].replace(" ", "").replace("=3D", "=")
return save_image_data_to_disk(
@@ -54,7 +54,10 @@ from .const import (
DEFAULT_PANEL_ENABLED,
DEFAULT_WARNING_DAYS,
DOMAIN,
EVENT_UNSUBS_KEY,
GLOBAL_UNIQUE_ID,
MAX_COST,
MAX_DURATION_MINUTES,
PLATFORMS,
SERVICE_ADD_OBJECT,
SERVICE_ADD_TASK,
@@ -67,6 +70,13 @@ from .const import (
SERVICE_UPDATE_TASK,
SIGNAL_NEW_OBJECT_ENTRY,
SIGNAL_OBJECT_ENTRY_REMOVED,
STORES_CACHE_KEY,
)
from .const import (
DOCUMENT_STORE_KEY as _DS_KEY,
)
from .const import (
NOTIFICATION_MANAGER_KEY as _NM_KEY,
)
from .coordinator import MaintenanceCoordinator
from .entity.summary_coordinator import MaintenanceSummaryCoordinator
@@ -82,8 +92,9 @@ from .websocket import async_register_commands
_LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
NOTIFICATION_MANAGER_KEY = "_notification_manager"
DOCUMENT_STORE_KEY = "_document_store"
# Re-exported from const for the existing deferred `from . import ...` users.
NOTIFICATION_MANAGER_KEY = _NM_KEY
DOCUMENT_STORE_KEY = _DS_KEY
@dataclass
@@ -105,8 +116,8 @@ SERVICE_COMPLETE_SCHEMA = vol.Schema(
{
vol.Required(ATTR_ENTITY_ID): cv.entity_id,
vol.Optional("notes"): vol.All(cv.string, vol.Length(max=2000)),
vol.Optional("cost"): vol.All(vol.Coerce(float), vol.Range(min=0, max=1_000_000)),
vol.Optional("duration"): vol.All(vol.Coerce(int), vol.Range(min=0, max=525_600)),
vol.Optional("cost"): vol.All(vol.Coerce(float), vol.Range(min=0, max=MAX_COST)),
vol.Optional("duration"): vol.All(vol.Coerce(int), vol.Range(min=0, max=MAX_DURATION_MINUTES)),
# Meter readings (v2.20, #83): recorded value for `reading` tasks.
vol.Optional("reading_value"): vol.All(vol.Coerce(float), vol.Range(min=-1e12, max=1e12)),
}
@@ -884,7 +895,7 @@ async def _async_setup_shared(hass: HomeAssistant) -> bool:
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, lambda _e: unsub_digest())
# Store unsub callbacks so they can be cleaned up when domain is unloaded
hass.data[DOMAIN]["_event_unsubs"] = [
hass.data[DOMAIN][EVENT_UNSUBS_KEY] = [
unsub_notification,
unsub_tag,
unsub_action,
@@ -1069,16 +1080,40 @@ async def async_setup_entry(hass: HomeAssistant, entry: MaintenanceSupporterConf
_LOGGER.debug("Global config entry set up: %s", entry.entry_id)
else:
# Maintenance object entry: create Store + coordinator
store = MaintenanceStore(hass, entry.entry_id)
# Maintenance object entry: fetch-or-create the Store. Reused across
# entry RELOADS (cache in a top-level hass.data key): a second Store
# instance for the same file would race the first one's pending
# debounced save — see STORES_CACHE_KEY in const.py.
stores: dict[str, MaintenanceStore] = hass.data.setdefault(STORES_CACHE_KEY, {})
cached_store = stores.get(entry.entry_id)
store = cached_store if cached_store is not None else MaintenanceStore(hass, entry.entry_id)
stores[entry.entry_id] = store
# Migrate dynamic state from ConfigEntry.data → Store (one-time)
cleaned_data = await async_migrate_to_store(hass, entry.entry_id, entry.data, store)
if cleaned_data is not entry.data:
hass.config_entries.async_update_entry(entry, data=dict(cleaned_data))
if cached_store is None:
# First setup this run: load from disk + one-time migration.
# Compare against the CAPTURED snapshot, not the live entry.data:
# the migration awaits store I/O, and a concurrent WS write during
# that window replaces entry.data — an identity check against the
# live attribute then fails spuriously and this write clobbers the
# concurrent update with the pre-await snapshot (lost update, seen
# live when rapid part creates raced a reload's setup).
data_before_migration = entry.data
cleaned_data = await async_migrate_to_store(hass, entry.entry_id, data_before_migration, store)
if cleaned_data is not data_before_migration:
hass.config_entries.async_update_entry(entry, data=dict(cleaned_data))
# A cached store's memory is authoritative — re-loading from disk here
# would drop any change still sitting in its debounce window.
# Reconcile the entry.data <-> Store split (journey I1): drop store
# state orphaned by a crash between the two writes of a deletion.
# Same reconciliation for spare-part stock state (journey S6): a crash
# between the ConfigEntry write and the Store save on a part deletion
# leaves its stock orphaned forever otherwise.
pruned_parts = store.prune_part_orphans(set(entry.data.get("parts") or {}))
if pruned_parts:
_LOGGER.info("Pruned %d orphaned part stock state(s) for %s", pruned_parts, entry.title)
await store.async_save()
pruned = store.prune_orphans(set(entry.data.get(CONF_TASKS, {})))
if pruned:
_LOGGER.info(
@@ -1105,6 +1140,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: MaintenanceSupporterConf
async_dispatcher_send(hass, SIGNAL_NEW_OBJECT_ENTRY, entry.entry_id)
# Buy-task catch-up (spare parts): converge the shopping reminders with
# the current stock/pause/archive state — covers import/restore, a
# resume/unarchive, and any change made while this entry was unloaded.
# Declarative + idempotent, reloads only when something changed.
if entry.data.get("parts"):
from .parts_runtime import schedule_buy_task_reconcile
schedule_buy_task_reconcile(hass, entry)
_LOGGER.debug(
"Maintenance object entry set up: %s (%s)",
entry.title,
@@ -1373,6 +1417,13 @@ async def async_unload_entry(hass: HomeAssistant, entry: MaintenanceSupporterCon
# Unregister panel when global entry is unloaded
await async_unregister_panel(hass)
# Flush a pending debounced store save BEFORE tearing down — belt and
# suspenders next to the store cache: disk is current the moment the entry
# goes away, whether or not it ever comes back this run.
store = hass.data.get(STORES_CACHE_KEY, {}).get(entry.entry_id)
if store is not None:
await store.async_save()
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
# Clean up domain data if no entries left
@@ -1381,7 +1432,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: MaintenanceSupporterCon
nm = hass.data.get(DOMAIN, {}).get(NOTIFICATION_MANAGER_KEY)
if nm is not None:
await nm.async_unload()
for unsub in hass.data.get(DOMAIN, {}).get("_event_unsubs", []):
for unsub in hass.data.get(DOMAIN, {}).get(EVENT_UNSUBS_KEY, []):
unsub()
hass.data.pop(DOMAIN, None)
@@ -1396,8 +1447,15 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
# as a fixable repair issue so the user can restore it (#86).
_sync_missing_global_entry_issue(hass)
return
store = MaintenanceStore(hass, entry.entry_id)
store = hass.data.get(STORES_CACHE_KEY, {}).pop(entry.entry_id, None)
if store is None:
store = MaintenanceStore(hass, entry.entry_id)
await store.async_remove()
# Drop the per-entry reconcile lock — the module dict would otherwise grow
# by one lock per object ever created in this HA run.
from .parts_runtime import discard_reconcile_lock
discard_reconcile_lock(entry.entry_id)
# v1.5.4: also called from ws_delete_object — but if the user removes the
# config entry from HA's "Configure" UI, that path doesn't run, leaving
# phantom task_refs in groups. Belt-and-suspenders.

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