updated apps
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}")
|
||||
@@ -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:
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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 郵件伺服器失敗。請按提交以啟動重新認證流程。"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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.
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user