App Updates
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.
@@ -48,12 +48,19 @@ from .const import (
|
||||
PHOTOPRISM_AUTH_APP_PASSWORD,
|
||||
PHOTOPRISM_AUTH_USER_PASSWORD,
|
||||
PHOTOPRISM_SELECTION_COMPOSITE,
|
||||
CONF_ICLOUD_URL,
|
||||
CONF_ICLOUD_TOKEN,
|
||||
CONF_ICLOUD_IMAGE_SIZE,
|
||||
DEFAULT_ICLOUD_IMAGE_SIZE,
|
||||
ICLOUD_IMAGE_FULL,
|
||||
ICLOUD_IMAGE_PREVIEW,
|
||||
DEFAULT_REVERSE_GEOCODE,
|
||||
PROVIDER_GOOGLE_SHARED,
|
||||
PROVIDER_LOCAL_FOLDER,
|
||||
PROVIDER_MEDIA_SOURCE,
|
||||
PROVIDER_IMMICH,
|
||||
PROVIDER_PHOTOPRISM,
|
||||
PROVIDER_ICLOUD,
|
||||
DEFAULT_RECURSIVE,
|
||||
)
|
||||
|
||||
@@ -134,6 +141,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
return await self.async_step_immich()
|
||||
if self._provider == PROVIDER_PHOTOPRISM:
|
||||
return await self.async_step_photoprism()
|
||||
if self._provider == PROVIDER_ICLOUD:
|
||||
return await self.async_step_icloud()
|
||||
return await self.async_step_google_shared()
|
||||
|
||||
schema = vol.Schema(
|
||||
@@ -143,6 +152,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
PROVIDER_LOCAL_FOLDER: "Local Folder",
|
||||
PROVIDER_IMMICH: "Immich (direct API, full metadata)",
|
||||
PROVIDER_PHOTOPRISM: "PhotoPrism (direct API, full metadata)",
|
||||
PROVIDER_ICLOUD: "iCloud Shared Album",
|
||||
PROVIDER_MEDIA_SOURCE: "Media Source (any source, no metadata)",
|
||||
})
|
||||
}
|
||||
@@ -586,6 +596,61 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
step_id="photoprism_select", data_schema=schema, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_icloud(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Collect and validate an iCloud Shared Album link."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
name = user_input[CONF_ALBUM_NAME].strip()
|
||||
raw_url = user_input[CONF_ICLOUD_URL].strip()
|
||||
size = user_input.get(CONF_ICLOUD_IMAGE_SIZE, DEFAULT_ICLOUD_IMAGE_SIZE)
|
||||
|
||||
from . import icloud as icloud_api
|
||||
|
||||
token = icloud_api.parse_share_link(raw_url)
|
||||
if not token:
|
||||
errors[CONF_ICLOUD_URL] = "invalid_icloud_url"
|
||||
else:
|
||||
client = icloud_api.IcloudClient(self.hass, token)
|
||||
try:
|
||||
await client.async_validate()
|
||||
except Exception: # noqa: BLE001 - any failure means bad/expired link
|
||||
errors["base"] = "icloud_cannot_connect"
|
||||
else:
|
||||
await self.async_set_unique_id(
|
||||
f"{DOMAIN}:{PROVIDER_ICLOUD}:{token}"
|
||||
)
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
title=name,
|
||||
data={
|
||||
CONF_PROVIDER: PROVIDER_ICLOUD,
|
||||
CONF_ICLOUD_TOKEN: token,
|
||||
CONF_ICLOUD_IMAGE_SIZE: size,
|
||||
CONF_ALBUM_NAME: name,
|
||||
},
|
||||
)
|
||||
|
||||
schema = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ALBUM_NAME): str,
|
||||
vol.Required(CONF_ICLOUD_URL): str,
|
||||
vol.Optional(
|
||||
CONF_ICLOUD_IMAGE_SIZE, default=DEFAULT_ICLOUD_IMAGE_SIZE
|
||||
): vol.In(
|
||||
{
|
||||
ICLOUD_IMAGE_FULL: "Full size (best for slideshow)",
|
||||
ICLOUD_IMAGE_PREVIEW: "Preview (thumbnail, fastest)",
|
||||
}
|
||||
),
|
||||
}
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id="icloud", data_schema=schema, errors=errors
|
||||
)
|
||||
|
||||
|
||||
class LocalFolderOptionsFlow(config_entries.OptionsFlow):
|
||||
"""Options for local-folder entries.
|
||||
|
||||
@@ -56,6 +56,21 @@ PROVIDER_LOCAL_FOLDER = "local_folder"
|
||||
PROVIDER_MEDIA_SOURCE = "media_source"
|
||||
PROVIDER_IMMICH = "immich"
|
||||
PROVIDER_PHOTOPRISM = "photoprism"
|
||||
PROVIDER_ICLOUD = "icloud"
|
||||
|
||||
# iCloud Shared Album provider. The share token in the pasted link is the only
|
||||
# credential; no account or password is involved.
|
||||
CONF_ICLOUD_URL = "icloud_url"
|
||||
CONF_ICLOUD_TOKEN = "icloud_token"
|
||||
CONF_ICLOUD_IMAGE_SIZE = "icloud_image_size"
|
||||
|
||||
# ``full`` picks the largest derivative Apple generated (best for a slideshow,
|
||||
# usually ~2048px); ``preview`` picks the smallest (a thumbnail; fastest).
|
||||
ICLOUD_IMAGE_FULL = "full"
|
||||
ICLOUD_IMAGE_PREVIEW = "preview"
|
||||
ICLOUD_IMAGE_SIZE_OPTIONS = [ICLOUD_IMAGE_FULL, ICLOUD_IMAGE_PREVIEW]
|
||||
DEFAULT_ICLOUD_IMAGE_SIZE = ICLOUD_IMAGE_FULL
|
||||
|
||||
|
||||
# PhotoPrism (direct API) provider.
|
||||
CONF_PHOTOPRISM_URL = "photoprism_url"
|
||||
|
||||
@@ -41,6 +41,9 @@ from .const import (
|
||||
CONF_PHOTOPRISM_IMAGE_SIZE,
|
||||
CONF_PHOTOPRISM_FILTER,
|
||||
DEFAULT_PHOTOPRISM_IMAGE_SIZE,
|
||||
CONF_ICLOUD_TOKEN,
|
||||
CONF_ICLOUD_IMAGE_SIZE,
|
||||
DEFAULT_ICLOUD_IMAGE_SIZE,
|
||||
DEFAULT_REVERSE_GEOCODE,
|
||||
DOMAIN,
|
||||
PROVIDER_GOOGLE_SHARED,
|
||||
@@ -48,6 +51,7 @@ from .const import (
|
||||
PROVIDER_MEDIA_SOURCE,
|
||||
PROVIDER_IMMICH,
|
||||
PROVIDER_PHOTOPRISM,
|
||||
PROVIDER_ICLOUD,
|
||||
)
|
||||
from .store import SlideshowStore
|
||||
|
||||
@@ -933,6 +937,8 @@ class AlbumCoordinator(DataUpdateCoordinator):
|
||||
data = await self._update_immich()
|
||||
elif self.provider == PROVIDER_PHOTOPRISM:
|
||||
data = await self._update_photoprism()
|
||||
elif self.provider == PROVIDER_ICLOUD:
|
||||
data = await self._update_icloud()
|
||||
else:
|
||||
raise UpdateFailed(f"Unsupported provider: {self.provider}")
|
||||
except UpdateFailed:
|
||||
@@ -1445,6 +1451,67 @@ class AlbumCoordinator(DataUpdateCoordinator):
|
||||
"items": items,
|
||||
}
|
||||
|
||||
async def _update_icloud(self) -> dict[str, Any]:
|
||||
"""Fetch photos from a public iCloud Shared Album.
|
||||
|
||||
The webstream response carries capture date and caption inline, so
|
||||
there is no enrichment pass. Signed image URLs are resolved up front
|
||||
and expire after roughly a day, so they are refreshed on every album
|
||||
refresh (like Google Photos).
|
||||
"""
|
||||
from . import icloud as icloud_api
|
||||
|
||||
token = self.entry.data.get(CONF_ICLOUD_TOKEN)
|
||||
size = self.entry.data.get(CONF_ICLOUD_IMAGE_SIZE, DEFAULT_ICLOUD_IMAGE_SIZE)
|
||||
if not token:
|
||||
raise UpdateFailed("iCloud provider is missing the album token")
|
||||
|
||||
client = icloud_api.IcloudClient(self.hass, token)
|
||||
try:
|
||||
photos = await client.async_get_photos()
|
||||
asset_urls = await client.async_get_asset_urls(
|
||||
[p["photoGuid"] for p in photos if p.get("photoGuid")]
|
||||
)
|
||||
except Exception as err:
|
||||
raise UpdateFailed(f"Error querying iCloud album: {err}") from err
|
||||
|
||||
if not photos:
|
||||
raise UpdateFailed("No images found in the iCloud album")
|
||||
|
||||
items: list[MediaItem] = []
|
||||
for p in photos:
|
||||
guid = p.get("photoGuid")
|
||||
checksum = icloud_api.pick_checksum(p, size)
|
||||
if not guid or not checksum:
|
||||
continue
|
||||
url = icloud_api.build_image_url(asset_urls.get(checksum))
|
||||
if not url:
|
||||
continue
|
||||
meta = icloud_api.parse_photo_meta(p)
|
||||
w = p.get("width")
|
||||
h = p.get("height")
|
||||
items.append(
|
||||
MediaItem(
|
||||
url=url,
|
||||
width=int(w) if str(w).isdigit() else None,
|
||||
height=int(h) if str(h).isdigit() else None,
|
||||
mime_type=None,
|
||||
filename=None,
|
||||
captured_at=meta.get("captured_at"),
|
||||
description=meta.get("description"),
|
||||
source_id=guid,
|
||||
exif_scanned=True,
|
||||
)
|
||||
)
|
||||
|
||||
if not items:
|
||||
raise UpdateFailed("Could not resolve any iCloud image URLs")
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
"""iCloud Shared Album client and pure parsing helpers.
|
||||
|
||||
Talks to Apple's public "shared streams" web API for a shared photo album -
|
||||
the same undocumented JSON endpoints the iCloud web album viewer uses. No
|
||||
account or password is involved; the album's share token (the part after
|
||||
``#`` in the share link) is the only credential.
|
||||
|
||||
API shape (POST, ``Content-Type: text/plain``, ``Origin: https://www.icloud.com``):
|
||||
- ``POST {base}/webstream`` ``{"streamCtag": null}`` -> ``{streamName, photos:
|
||||
[{photoGuid, derivatives:{<height>:{checksum,width,height,fileSize}},
|
||||
dateCreated, caption, width, height}]}``. May first answer with a
|
||||
``330`` redirect carrying an ``X-Apple-MMe-Host`` header pointing at the
|
||||
correct partition host; retry there.
|
||||
- ``POST {base}/webasseturls`` ``{"photoGuids": [...]}`` -> ``{items:
|
||||
{<checksum>: {url_location, url_path, url_expiry}}}``. Build the image URL
|
||||
as ``https://{url_location}{url_path}``; it is a signed CDN link that
|
||||
expires after roughly a day, so it is refreshed on every album refresh.
|
||||
|
||||
Metadata: capture date (``dateCreated``) and caption are inline. Apple strips
|
||||
GPS from shared-album web data, so there is no location.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import async_timeout
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
_TIMEOUT = 30
|
||||
_MAX_ASSETS = 20_000
|
||||
# webasseturls request batch size. A single call handled 40+ guids fine in
|
||||
# testing; chunking keeps request bodies bounded for very large albums.
|
||||
_URL_BATCH = 25
|
||||
|
||||
_BASE62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
|
||||
# Headers Apple's web endpoints expect for the shared-streams API.
|
||||
_API_HEADERS = {
|
||||
"Content-Type": "text/plain",
|
||||
"Origin": "https://www.icloud.com",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
|
||||
def _base62_to_int(value: str) -> int:
|
||||
result = 0
|
||||
for ch in value:
|
||||
result = result * 62 + _BASE62.index(ch)
|
||||
return result
|
||||
|
||||
|
||||
def parse_share_link(url: str) -> str | None:
|
||||
"""Extract the album share token from a pasted iCloud link.
|
||||
|
||||
Accepts a full ``https://www.icloud.com/sharedalbum/#TOKEN`` link or a
|
||||
bare token. Returns ``None`` if nothing token-like is found.
|
||||
"""
|
||||
if not url:
|
||||
return None
|
||||
text = url.strip()
|
||||
if "#" in text:
|
||||
text = text.rsplit("#", 1)[1]
|
||||
elif "/" in text:
|
||||
text = text.rstrip("/").rsplit("/", 1)[1]
|
||||
# Tokens are base62 and start with an uppercase letter (A, B, ...).
|
||||
token = text.strip()
|
||||
if token and all(ch in _BASE62 for ch in token):
|
||||
return token
|
||||
return None
|
||||
|
||||
|
||||
def partition_host(token: str) -> str:
|
||||
"""Derive the shared-streams partition host for a token.
|
||||
|
||||
Apple encodes the server partition in the first characters of the token:
|
||||
one char after a leading ``A``, otherwise the first two chars.
|
||||
"""
|
||||
if not token:
|
||||
return ""
|
||||
if token[0] == "A":
|
||||
partition = _base62_to_int(token[1:2])
|
||||
else:
|
||||
partition = _base62_to_int(token[1:3])
|
||||
return f"p{partition:02d}-sharedstreams.icloud.com"
|
||||
|
||||
|
||||
def base_url(token: str, host: str | None = None) -> str:
|
||||
"""Return the ``/sharedstreams/`` base URL for a token (and optional host)."""
|
||||
host = host or partition_host(token)
|
||||
return f"https://{host}/{token}/sharedstreams"
|
||||
|
||||
|
||||
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 parse_webstream(payload: Any) -> list[dict[str, Any]]:
|
||||
"""Return the list of photo dicts from a ``webstream`` response."""
|
||||
if not isinstance(payload, dict):
|
||||
return []
|
||||
photos = payload.get("photos")
|
||||
if not isinstance(photos, list):
|
||||
return []
|
||||
out: list[dict[str, Any]] = []
|
||||
for p in photos:
|
||||
if isinstance(p, dict) and p.get("photoGuid") and isinstance(
|
||||
p.get("derivatives"), dict
|
||||
) and p["derivatives"]:
|
||||
out.append(p)
|
||||
return out
|
||||
|
||||
|
||||
def pick_checksum(photo: dict[str, Any], size: str) -> str | None:
|
||||
"""Pick the derivative checksum for the requested display ``size``.
|
||||
|
||||
Derivatives are keyed by their long edge (e.g. ``"342"``, ``"2049"``).
|
||||
``full`` selects the largest available (best for a slideshow); ``preview``
|
||||
selects the smallest (fastest / least bandwidth).
|
||||
"""
|
||||
derivatives = photo.get("derivatives")
|
||||
if not isinstance(derivatives, dict) or not derivatives:
|
||||
return None
|
||||
|
||||
def edge(item: tuple[str, Any]) -> int:
|
||||
key, val = item
|
||||
try:
|
||||
return int(key)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if isinstance(val, dict):
|
||||
try:
|
||||
return int(val.get("fileSize") or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
return 0
|
||||
|
||||
entries = list(derivatives.items())
|
||||
chosen = min(entries, key=edge) if size == "preview" else max(entries, key=edge)
|
||||
val = chosen[1]
|
||||
return val.get("checksum") if isinstance(val, dict) else None
|
||||
|
||||
|
||||
def build_image_url(item: dict[str, Any]) -> str | None:
|
||||
"""Build a fetchable image URL from a ``webasseturls`` item entry."""
|
||||
if not isinstance(item, dict):
|
||||
return None
|
||||
location = item.get("url_location")
|
||||
path = item.get("url_path")
|
||||
if not location or not path:
|
||||
return None
|
||||
scheme = item.get("scheme") or "https"
|
||||
return f"{scheme}://{location}{path}"
|
||||
|
||||
|
||||
def parse_photo_meta(photo: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Extract the metadata we surface from a webstream photo item."""
|
||||
out: dict[str, Any] = {}
|
||||
captured = _to_epoch_ms(photo.get("dateCreated")) or _to_epoch_ms(
|
||||
photo.get("batchDateCreated")
|
||||
)
|
||||
if captured is not None:
|
||||
out["captured_at"] = captured
|
||||
caption = photo.get("caption")
|
||||
if isinstance(caption, str) and caption.strip():
|
||||
out["description"] = caption.strip()
|
||||
return out
|
||||
|
||||
|
||||
class IcloudClient:
|
||||
"""Thin async wrapper over the iCloud shared-streams web API."""
|
||||
|
||||
def __init__(self, hass, token: str) -> None:
|
||||
self.hass = hass
|
||||
self.token = token
|
||||
# Resolved after the first webstream call (Apple may redirect us to a
|
||||
# different partition host via X-Apple-MMe-Host).
|
||||
self._host: str | None = None
|
||||
|
||||
@property
|
||||
def base_url(self) -> str:
|
||||
return base_url(self.token, self._host)
|
||||
|
||||
async def _post(self, path: str, body: dict[str, Any]) -> tuple[int, dict[str, str], bytes]:
|
||||
session = async_get_clientsession(self.hass)
|
||||
async with async_timeout.timeout(_TIMEOUT):
|
||||
async with session.post(
|
||||
self.base_url + path, json=body, headers=_API_HEADERS
|
||||
) as resp:
|
||||
return resp.status, dict(resp.headers), await resp.read()
|
||||
|
||||
async def async_get_photos(self) -> list[dict[str, Any]]:
|
||||
"""Fetch the album's photo list, following a partition redirect once."""
|
||||
import json as _json
|
||||
|
||||
status, headers, raw = await self._post("/webstream", {"streamCtag": None})
|
||||
redirect_host = headers.get("X-Apple-MMe-Host")
|
||||
if redirect_host and redirect_host != self._host:
|
||||
# Our partition guess was wrong; Apple told us the right host.
|
||||
self._host = redirect_host
|
||||
status, headers, raw = await self._post("/webstream", {"streamCtag": None})
|
||||
if status != 200:
|
||||
raise RuntimeError(f"iCloud webstream failed: HTTP {status}")
|
||||
payload = _json.loads(raw)
|
||||
return parse_webstream(payload)[:_MAX_ASSETS]
|
||||
|
||||
async def async_get_asset_urls(
|
||||
self, guids: list[str]
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""Resolve signed asset URLs for photo guids, keyed by checksum."""
|
||||
import json as _json
|
||||
|
||||
out: dict[str, dict[str, Any]] = {}
|
||||
for start in range(0, len(guids), _URL_BATCH):
|
||||
chunk = guids[start : start + _URL_BATCH]
|
||||
status, _headers, raw = await self._post(
|
||||
"/webasseturls", {"photoGuids": chunk}
|
||||
)
|
||||
if status != 200:
|
||||
raise RuntimeError(f"iCloud webasseturls failed: HTTP {status}")
|
||||
payload = _json.loads(raw)
|
||||
items = payload.get("items") if isinstance(payload, dict) else None
|
||||
if isinstance(items, dict):
|
||||
out.update(items)
|
||||
return out
|
||||
|
||||
async def async_validate(self) -> str | None:
|
||||
"""Return the album name if the token works, else raise."""
|
||||
import json as _json
|
||||
|
||||
status, headers, raw = await self._post("/webstream", {"streamCtag": None})
|
||||
redirect_host = headers.get("X-Apple-MMe-Host")
|
||||
if redirect_host and redirect_host != self._host:
|
||||
self._host = redirect_host
|
||||
status, headers, raw = await self._post("/webstream", {"streamCtag": None})
|
||||
if status != 200:
|
||||
raise RuntimeError(f"iCloud webstream failed: HTTP {status}")
|
||||
payload = _json.loads(raw)
|
||||
return payload.get("streamName") if isinstance(payload, dict) else None
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"issue_tracker": "https://github.com/eyalgal/album_slideshow/issues",
|
||||
"requirements": ["Pillow"],
|
||||
"version": "1.3.0"
|
||||
"version": "1.4.0"
|
||||
}
|
||||
|
||||
@@ -75,6 +75,15 @@
|
||||
"photoprism_filter": "Extra search query (optional)",
|
||||
"photoprism_image_size": "Image quality"
|
||||
}
|
||||
},
|
||||
"icloud": {
|
||||
"title": "iCloud Shared Album",
|
||||
"description": "Paste the public link to an iCloud Shared Album (in Photos, open the album, tap the share icon, and copy the Public Website link). No Apple ID or password is needed - the link itself is the credential. Capture date and captions come through; Apple does not include location in shared albums.",
|
||||
"data": {
|
||||
"album_name": "Album name",
|
||||
"icloud_url": "Shared album link",
|
||||
"icloud_image_size": "Image quality"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -89,7 +98,9 @@
|
||||
"immich_albums_required": "Pick at least one album for the Albums source.",
|
||||
"photoprism_cannot_connect": "Could not connect to PhotoPrism. Check the URL and credentials.",
|
||||
"photoprism_token_required": "Enter an app password, or switch to Username + password.",
|
||||
"photoprism_user_required": "Enter both a username and password, or switch to App password."
|
||||
"photoprism_user_required": "Enter both a username and password, or switch to App password.",
|
||||
"invalid_icloud_url": "That does not look like an iCloud Shared Album link.",
|
||||
"icloud_cannot_connect": "Could not reach that album. Check the link is a current public share."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
|
||||
@@ -75,6 +75,15 @@
|
||||
"photoprism_filter": "Extra search query (optional)",
|
||||
"photoprism_image_size": "Image quality"
|
||||
}
|
||||
},
|
||||
"icloud": {
|
||||
"title": "iCloud Shared Album",
|
||||
"description": "Paste the public link to an iCloud Shared Album (in Photos, open the album, tap the share icon, and copy the Public Website link). No Apple ID or password is needed - the link itself is the credential. Capture date and captions come through; Apple does not include location in shared albums.",
|
||||
"data": {
|
||||
"album_name": "Album name",
|
||||
"icloud_url": "Shared album link",
|
||||
"icloud_image_size": "Image quality"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -89,7 +98,9 @@
|
||||
"immich_albums_required": "Pick at least one album for the Albums source.",
|
||||
"photoprism_cannot_connect": "Could not connect to PhotoPrism. Check the URL and credentials.",
|
||||
"photoprism_token_required": "Enter an app password, or switch to Username + password.",
|
||||
"photoprism_user_required": "Enter both a username and password, or switch to App password."
|
||||
"photoprism_user_required": "Enter both a username and password, or switch to App password.",
|
||||
"invalid_icloud_url": "That does not look like an iCloud Shared Album link.",
|
||||
"icloud_cannot_connect": "Could not reach that album. Check the link is a current public share."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
* tap_action: none # none | more-info
|
||||
*/
|
||||
|
||||
const VERSION = "1.3.0";
|
||||
const VERSION = "1.4.0";
|
||||
|
||||
const ANIMATED_TRANSITIONS = [
|
||||
"fade",
|
||||
|
||||
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.
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.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+52
@@ -0,0 +1,52 @@
|
||||
/** #106 follow-up: reproduce with the EXACT _build_task_summary shape. */
|
||||
import { expect, fixture, html } from "@open-wc/testing";
|
||||
import "../components/task-dialog.js";
|
||||
import type { MaintenanceTaskDialog } from "../components/task-dialog";
|
||||
import { createMockHass } from "./_test-utils.js";
|
||||
|
||||
it("fleet-shaped task (nested manual schedule + plural-only threshold) keeps its trigger", async () => {
|
||||
const { hass, sent } = createMockHass({
|
||||
states: { "sensor.maintenance_supporter_batteries_to_replace": { state: "8" } },
|
||||
handlers: { "maintenance_supporter/task/update": () => ({ success: true }) },
|
||||
});
|
||||
const el = await fixture<MaintenanceTaskDialog>(html`
|
||||
<maintenance-task-dialog .hass=${hass}></maintenance-task-dialog>
|
||||
`);
|
||||
await el.updateComplete;
|
||||
await el.openEdit("entry_x", {
|
||||
id: "t1",
|
||||
name: "Replace low batteries",
|
||||
type: "inspection",
|
||||
enabled: true,
|
||||
schedule_type: "sensor_based",
|
||||
interval_days: null,
|
||||
interval_unit: "days",
|
||||
due_date: null,
|
||||
interval_anchor: "completion",
|
||||
schedule: { kind: "manual" },
|
||||
warning_days: 7,
|
||||
battery_fleet_task: true,
|
||||
trigger_config: {
|
||||
type: "threshold",
|
||||
entity_ids: ["sensor.maintenance_supporter_batteries_to_replace"],
|
||||
trigger_above: 0,
|
||||
entity_logic: "any",
|
||||
auto_complete_on_recovery: true,
|
||||
},
|
||||
trigger_entity_info: {
|
||||
entity_id: "sensor.maintenance_supporter_batteries_to_replace",
|
||||
friendly_name: "Batteries to replace",
|
||||
unit_of_measurement: null,
|
||||
},
|
||||
} as any);
|
||||
await el.updateComplete;
|
||||
// the user's edit: rename + warning days
|
||||
(el as any)._name = "Remplacer les piles";
|
||||
(el as any)._warningDays = "1";
|
||||
await (el as any)._save();
|
||||
const update = sent.find((m: any) => m.type === "maintenance_supporter/task/update") as any;
|
||||
expect(update, "update sent").to.exist;
|
||||
expect(update.trigger_config, "trigger_config must survive").to.exist;
|
||||
expect(update.trigger_config.type).to.equal("threshold");
|
||||
expect(update.trigger_config.trigger_above).to.equal(0);
|
||||
});
|
||||
+1
@@ -84,6 +84,7 @@ export class MaintenanceBatteryFleetSection extends LitElement {
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/battery_fleet/setup",
|
||||
language: this._lang,
|
||||
});
|
||||
await this._load();
|
||||
} catch (e) {
|
||||
|
||||
@@ -1045,6 +1045,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
try {
|
||||
const res = await this.hass.connection.sendMessagePromise<{ entry_id: string; task_id?: string }>({
|
||||
type: "maintenance_supporter/battery_fleet/setup",
|
||||
language: this.hass.language || "en",
|
||||
});
|
||||
this._batteryFleetSetupAvailable = false;
|
||||
await this._loadData();
|
||||
|
||||
@@ -1235,6 +1235,10 @@ export const panelStyles = css`
|
||||
:host([narrow]) .task-header-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
/* Longer labels (de "Überspringen", fr "Archiver", …) overflow a phone
|
||||
viewport in a nowrap row — the ⋮ menu then needs a horizontal scroll
|
||||
to reach. Wrap instead; language-independent. */
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
:host([narrow]) .filter-bar {
|
||||
@@ -1433,7 +1437,7 @@ export const panelStyles = css`
|
||||
.two-column-layout { grid-template-columns: 1fr; }
|
||||
.tab { padding: 12px 16px; font-size: 14px; }
|
||||
.task-header { flex-direction: column; align-items: flex-start; }
|
||||
.task-header-actions { width: 100%; justify-content: flex-start; }
|
||||
.task-header-actions { width: 100%; justify-content: flex-start; flex-wrap: wrap; }
|
||||
.filter-bar { flex-wrap: wrap; }
|
||||
.filter-bar select { flex: 1; min-width: 0; }
|
||||
/* Mirror the :host([narrow]) grid layout for narrow desktop windows */
|
||||
|
||||
Binary file not shown.
@@ -2528,6 +2528,10 @@ ${H(n.notes)}</div>`:""}
|
||||
:host([narrow]) .task-header-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
/* Longer labels (de "Überspringen", fr "Archiver", …) overflow a phone
|
||||
viewport in a nowrap row — the ⋮ menu then needs a horizontal scroll
|
||||
to reach. Wrap instead; language-independent. */
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
:host([narrow]) .filter-bar {
|
||||
@@ -2726,7 +2730,7 @@ ${H(n.notes)}</div>`:""}
|
||||
.two-column-layout { grid-template-columns: 1fr; }
|
||||
.tab { padding: 12px 16px; font-size: 14px; }
|
||||
.task-header { flex-direction: column; align-items: flex-start; }
|
||||
.task-header-actions { width: 100%; justify-content: flex-start; }
|
||||
.task-header-actions { width: 100%; justify-content: flex-start; flex-wrap: wrap; }
|
||||
.filter-bar { flex-wrap: wrap; }
|
||||
.filter-bar select { flex: 1; min-width: 0; }
|
||||
/* Mirror the :host([narrow]) grid layout for narrow desktop windows */
|
||||
@@ -5354,7 +5358,7 @@ ${p?`<div class="sub">${p}</div>`:""}
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
.actions { display: flex; justify-content: flex-end; gap: 8px; padding-top: 8px; }
|
||||
`,d([y({attribute:!1})],X.prototype,"hass",2),d([_()],X.prototype,"_open",2),d([_()],X.prototype,"_loading",2),d([_()],X.prototype,"_adopting",2),d([_()],X.prototype,"_error",2),d([_()],X.prototype,"_setups",2),d([_()],X.prototype,"_selected",2),d([_()],X.prototype,"_baselines",2),d([_()],X.prototype,"_targets",2),d([_()],X.prototype,"_objects",2);customElements.get("maintenance-suggested-setups-dialog")||customElements.define("maintenance-suggested-setups-dialog",X);var ve=class extends S{constructor(){super(...arguments);this._ov=null;this._loading=!1;this._marking=!1;this._error="";this._localeReady=!1;this._markAll=async()=>{await this._mark(void 0)};this._repair=async()=>{if(!this._marking){this._marking=!0,this._error="";try{await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/battery_fleet/setup"}),await this._load()}catch(e){this._error=j(e,this._lang)}finally{this._marking=!1}}}}get _lang(){return this.hass?.language||"en"}connectedCallback(){super.connectedCallback(),this.hass&&this._load()}updated(e){e.has("hass")&&this.hass&&!this._localeReady&&(this._localeReady=!0,V(this._lang).then(()=>this.requestUpdate()),this._ov===null&&!this._loading&&this._load())}async _load(){this._loading=!0,this._error="";try{this._ov=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/battery_fleet/overview"})}catch(e){this._error=j(e,this._lang)}finally{this._loading=!1}}async _mark(e){if(!this._marking){this._marking=!0,this._error="";try{await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/battery_fleet/mark_replaced",...e?{entity_ids:e}:{}}),await this._load()}catch(t){this._error=j(t,this._lang)}finally{this._marking=!1}}}_shoppingLine(e){return Object.entries(e).map(([t,i])=>`${i}\xD7 ${t}`).join(" \xB7 ")}render(){let e=this._lang;if(this._loading&&this._ov===null)return o`<div class="bf-card"><div class="bf-loading">…</div></div>`;let t=this._ov;if(!t)return this._error?o`<div class="bf-card"><div class="bf-error">${this._error}</div></div>`:h;let i=t.low.length;return o`
|
||||
`,d([y({attribute:!1})],X.prototype,"hass",2),d([_()],X.prototype,"_open",2),d([_()],X.prototype,"_loading",2),d([_()],X.prototype,"_adopting",2),d([_()],X.prototype,"_error",2),d([_()],X.prototype,"_setups",2),d([_()],X.prototype,"_selected",2),d([_()],X.prototype,"_baselines",2),d([_()],X.prototype,"_targets",2),d([_()],X.prototype,"_objects",2);customElements.get("maintenance-suggested-setups-dialog")||customElements.define("maintenance-suggested-setups-dialog",X);var ve=class extends S{constructor(){super(...arguments);this._ov=null;this._loading=!1;this._marking=!1;this._error="";this._localeReady=!1;this._markAll=async()=>{await this._mark(void 0)};this._repair=async()=>{if(!this._marking){this._marking=!0,this._error="";try{await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/battery_fleet/setup",language:this._lang}),await this._load()}catch(e){this._error=j(e,this._lang)}finally{this._marking=!1}}}}get _lang(){return this.hass?.language||"en"}connectedCallback(){super.connectedCallback(),this.hass&&this._load()}updated(e){e.has("hass")&&this.hass&&!this._localeReady&&(this._localeReady=!0,V(this._lang).then(()=>this.requestUpdate()),this._ov===null&&!this._loading&&this._load())}async _load(){this._loading=!0,this._error="";try{this._ov=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/battery_fleet/overview"})}catch(e){this._error=j(e,this._lang)}finally{this._loading=!1}}async _mark(e){if(!this._marking){this._marking=!0,this._error="";try{await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/battery_fleet/mark_replaced",...e?{entity_ids:e}:{}}),await this._load()}catch(t){this._error=j(t,this._lang)}finally{this._marking=!1}}}_shoppingLine(e){return Object.entries(e).map(([t,i])=>`${i}\xD7 ${t}`).join(" \xB7 ")}render(){let e=this._lang;if(this._loading&&this._ov===null)return o`<div class="bf-card"><div class="bf-loading">…</div></div>`;let t=this._ov;if(!t)return this._error?o`<div class="bf-card"><div class="bf-error">${this._error}</div></div>`:h;let i=t.low.length;return o`
|
||||
<div class="bf-card">
|
||||
<div class="bf-head">
|
||||
<ha-icon icon="mdi:battery-alert"></ha-icon>
|
||||
@@ -8389,7 +8393,7 @@ ${p?`<div class="sub">${p}</div>`:""}
|
||||
<div class="palette-hint">${s("palette_hint",e)}</div>
|
||||
</div>
|
||||
</div>
|
||||
`}_openAdoptProblemSensors(){this.shadowRoot.querySelector("maintenance-adopt-problem-sensors-dialog")?.open()}async _onProblemSensorsAdopted(e){let t=e.detail?.tasks_created??0,i=e.detail?.created??[];await this._loadData();let a=s("adopt_problem_done",this._lang).replace("{tasks}",String(t));i.length>0?this._showActionToast(a,s("adopt_problem_configure",this._lang),()=>{let l=i[0],c=this._objects.find(u=>u.entry_id===l.entry_id),p=c?.tasks.find(u=>u.id===l.task_id);c&&p&&this.shadowRoot.querySelector("maintenance-task-dialog")?.openEdit(l.entry_id,p)}):this._showToast(a)}async _setupBatteryFleet(){try{let e=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/battery_fleet/setup"});this._batteryFleetSetupAvailable=!1,await this._loadData();let t=this._objects.find(a=>a.entry_id===e.entry_id),i=t?.tasks.find(a=>a.id===e.task_id)||t?.tasks[0];t&&i&&this._showTask(t.entry_id,i.id),this._showToast(s("battery_fleet_setup_done",this._lang))}catch(e){this._showToast(j(e,this._lang))}}_openSuggestedSetups(){this.shadowRoot.querySelector("maintenance-suggested-setups-dialog")?.open()}_onSetupsAdopted(e){let t=e.detail?.tasks_created??0;this._showToast(s("setups_done",this._lang).replace("{tasks}",String(t))),this._loadData()}async _openTemplateGallery(){if(this._templateGalleryOpen=!0,!(this._templates.length>0))try{let e=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/templates",language:this._lang});this._templateCategories=e.categories||{},this._templates=(e.templates||[]).filter(t=>!t.disabled)}catch{this._showToast(s("action_error",this._lang))}}async _createFromTemplate(e){this._templateBusy=!0;try{let t=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/object/from_template",language:this._lang,template_id:e});this._templateGalleryOpen=!1,await this._loadData(),this._showToast(s("template_created",this._lang)),t?.entry_id&&this._showObject(t.entry_id)}catch{this._showToast(s("action_error",this._lang))}finally{this._templateBusy=!1}}_categoryName(e){let t=this._templateCategories[e];return t&&(t[`name_${this._lang}`]||t.name_en)||e}_renderTemplateGallery(){if(!this._templateGalleryOpen)return h;let e=this._lang,t=new Map;for(let i of this._templates)t.has(i.category)||t.set(i.category,[]),t.get(i.category).push(i);return o`
|
||||
`}_openAdoptProblemSensors(){this.shadowRoot.querySelector("maintenance-adopt-problem-sensors-dialog")?.open()}async _onProblemSensorsAdopted(e){let t=e.detail?.tasks_created??0,i=e.detail?.created??[];await this._loadData();let a=s("adopt_problem_done",this._lang).replace("{tasks}",String(t));i.length>0?this._showActionToast(a,s("adopt_problem_configure",this._lang),()=>{let l=i[0],c=this._objects.find(u=>u.entry_id===l.entry_id),p=c?.tasks.find(u=>u.id===l.task_id);c&&p&&this.shadowRoot.querySelector("maintenance-task-dialog")?.openEdit(l.entry_id,p)}):this._showToast(a)}async _setupBatteryFleet(){try{let e=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/battery_fleet/setup",language:this.hass.language||"en"});this._batteryFleetSetupAvailable=!1,await this._loadData();let t=this._objects.find(a=>a.entry_id===e.entry_id),i=t?.tasks.find(a=>a.id===e.task_id)||t?.tasks[0];t&&i&&this._showTask(t.entry_id,i.id),this._showToast(s("battery_fleet_setup_done",this._lang))}catch(e){this._showToast(j(e,this._lang))}}_openSuggestedSetups(){this.shadowRoot.querySelector("maintenance-suggested-setups-dialog")?.open()}_onSetupsAdopted(e){let t=e.detail?.tasks_created??0;this._showToast(s("setups_done",this._lang).replace("{tasks}",String(t))),this._loadData()}async _openTemplateGallery(){if(this._templateGalleryOpen=!0,!(this._templates.length>0))try{let e=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/templates",language:this._lang});this._templateCategories=e.categories||{},this._templates=(e.templates||[]).filter(t=>!t.disabled)}catch{this._showToast(s("action_error",this._lang))}}async _createFromTemplate(e){this._templateBusy=!0;try{let t=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/object/from_template",language:this._lang,template_id:e});this._templateGalleryOpen=!1,await this._loadData(),this._showToast(s("template_created",this._lang)),t?.entry_id&&this._showObject(t.entry_id)}catch{this._showToast(s("action_error",this._lang))}finally{this._templateBusy=!1}}_categoryName(e){let t=this._templateCategories[e];return t&&(t[`name_${this._lang}`]||t.name_en)||e}_renderTemplateGallery(){if(!this._templateGalleryOpen)return h;let e=this._lang,t=new Map;for(let i of this._templates)t.has(i.category)||t.set(i.category,[]),t.get(i.category).push(i);return o`
|
||||
<div class="palette-backdrop" @click=${()=>{this._templateGalleryOpen=!1}}>
|
||||
<div class="template-gallery" @click=${i=>i.stopPropagation()}>
|
||||
<div class="template-gallery-head">
|
||||
|
||||
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
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.
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.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -38,22 +38,27 @@ def find_fleet_entry(hass: HomeAssistant) -> ConfigEntry | None:
|
||||
return None
|
||||
|
||||
|
||||
async def async_setup_battery_fleet(hass: HomeAssistant) -> dict[str, Any]:
|
||||
async def async_setup_battery_fleet(hass: HomeAssistant, language: str | None = None) -> dict[str, Any]:
|
||||
"""Create (or return) the Battery Fleet object with type-parts + the task.
|
||||
|
||||
Idempotent: a second call reconciles the type-parts against the current
|
||||
fleet (adds parts for newly-seen types) and returns the existing entry.
|
||||
``language`` is the caller's UI language (same contract as the template
|
||||
WS): the created object/task/part names and notes are localized through
|
||||
the ``_T`` table, falling back to the server language, then English.
|
||||
"""
|
||||
from ..websocket.objects import async_create_object
|
||||
from ..websocket.tasks_persist import async_persist_task
|
||||
from .i18n import normalize_language
|
||||
from .parts import normalize_part
|
||||
|
||||
lang = (language or normalize_language(hass))[:2].lower()
|
||||
types = discover_battery_types(hass) # {TYPE: total_qty}
|
||||
|
||||
existing = find_fleet_entry(hass)
|
||||
if existing is not None:
|
||||
added = _reconcile_type_parts(hass, existing, types)
|
||||
repaired = await _reconcile_fleet_task(hass, existing)
|
||||
added = _reconcile_type_parts(hass, existing, types, lang)
|
||||
repaired = await _reconcile_fleet_task(hass, existing, lang)
|
||||
return {
|
||||
"entry_id": existing.entry_id,
|
||||
"created": False,
|
||||
@@ -62,7 +67,9 @@ async def async_setup_battery_fleet(hass: HomeAssistant) -> dict[str, Any]:
|
||||
"task_repaired": repaired,
|
||||
}
|
||||
|
||||
entry_id = await async_create_object(hass, name="Battery Fleet")
|
||||
from ..templates import localize_template_text
|
||||
|
||||
entry_id = await async_create_object(hass, name=localize_template_text("Battery Fleet", lang) or "Battery Fleet")
|
||||
entry = hass.config_entries.async_get_entry(entry_id)
|
||||
if entry is None: # pragma: no cover — just created above
|
||||
raise HomeAssistantError("Battery Fleet object entry vanished after creation")
|
||||
@@ -74,7 +81,7 @@ async def async_setup_battery_fleet(hass: HomeAssistant) -> dict[str, Any]:
|
||||
new_data[CONF_OBJECT] = obj
|
||||
parts: dict[str, dict[str, Any]] = {}
|
||||
for btype, total_qty in types.items():
|
||||
part = normalize_part(_type_part(btype, total_qty))
|
||||
part = normalize_part(_type_part(btype, total_qty, lang))
|
||||
parts[part["id"]] = part
|
||||
new_data[CONF_PARTS] = parts
|
||||
hass.config_entries.async_update_entry(entry, data=new_data)
|
||||
@@ -88,19 +95,7 @@ async def async_setup_battery_fleet(hass: HomeAssistant) -> dict[str, Any]:
|
||||
await store.async_save()
|
||||
|
||||
# The single aggregate task, triggered by the global low-count sensor.
|
||||
obj_id = obj.get("id", "")
|
||||
task = {
|
||||
"id": uuid4().hex,
|
||||
"object_id": obj_id,
|
||||
"name": "Replace low batteries",
|
||||
"type": "inspection",
|
||||
"enabled": True,
|
||||
TASK_FLAG: True,
|
||||
"schedule": {"kind": "manual"},
|
||||
"trigger_config": _fleet_trigger_config(),
|
||||
"created_at": dt_util.now().date().isoformat(),
|
||||
"notes": ("Aggregate battery check. The detail view lists which devices are low and which battery types to buy."),
|
||||
}
|
||||
task = _fleet_task(obj.get("id", ""), lang)
|
||||
await async_persist_task(hass, entry, task)
|
||||
|
||||
return {
|
||||
@@ -130,23 +125,48 @@ def _fleet_trigger_config() -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _type_part(btype: str, total_qty: int) -> dict[str, Any]:
|
||||
"""A spare-part definition for one battery type.
|
||||
def _fleet_task(obj_id: str, lang: str) -> dict[str, Any]:
|
||||
"""The single aggregate fleet task, with localized name + notes."""
|
||||
from ..templates import localize_template_text
|
||||
|
||||
return {
|
||||
"id": uuid4().hex,
|
||||
"object_id": obj_id,
|
||||
"name": localize_template_text("Replace low batteries", lang) or "Replace low batteries",
|
||||
"type": "inspection",
|
||||
"enabled": True,
|
||||
TASK_FLAG: True,
|
||||
"schedule": {"kind": "manual"},
|
||||
"trigger_config": _fleet_trigger_config(),
|
||||
"created_at": dt_util.now().date().isoformat(),
|
||||
"notes": localize_template_text(
|
||||
"Aggregate battery check. The detail view lists which devices are low and which battery types to buy.",
|
||||
lang,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _type_part(btype: str, total_qty: int, lang: str) -> dict[str, Any]:
|
||||
"""A spare-part definition for one battery type (localized name/notes).
|
||||
|
||||
reorder_threshold defaults to keeping a spare set roughly the size of the
|
||||
fleet's need for that type (min 2); restock is double that. auto_buy_task
|
||||
stays off so setup never spawns extra buy-tasks — the fleet task's detail
|
||||
is the shopping surface; the user can enable auto-buy per type later.
|
||||
"""
|
||||
from ..templates import localize_template_text
|
||||
|
||||
threshold = max(2, total_qty)
|
||||
name_tpl = localize_template_text("{type} battery", lang) or "{type} battery"
|
||||
notes_tpl = localize_template_text("Typical service life ~{months} months.", lang) or "Typical service life ~{months} months."
|
||||
return {
|
||||
"id": f"batt_{btype.lower()}",
|
||||
"name": f"{btype} battery",
|
||||
"name": name_tpl.format(type=btype),
|
||||
"unit": "pcs",
|
||||
"reorder_threshold": threshold,
|
||||
"restock_quantity": threshold * 2,
|
||||
"auto_buy_task": False,
|
||||
"notes": f"Typical service life ~{lifetime_months(btype)} months (editorial).",
|
||||
"notes": notes_tpl.format(months=lifetime_months(btype)),
|
||||
}
|
||||
|
||||
|
||||
@@ -223,12 +243,12 @@ def fleet_task_trigger_ok(entry: ConfigEntry) -> bool:
|
||||
return tc.get("type") == "threshold" and LOW_COUNT_ENTITY_ID in eids
|
||||
|
||||
|
||||
async def _reconcile_fleet_task(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def _reconcile_fleet_task(hass: HomeAssistant, entry: ConfigEntry, lang: str) -> bool:
|
||||
"""Repair the fleet task if broken. Returns True when something was fixed.
|
||||
|
||||
* Trigger lost (issue #106) → restore the canonical threshold trigger,
|
||||
keeping the user's name/type/translations untouched.
|
||||
* Task deleted entirely → recreate it fresh.
|
||||
* Task deleted entirely → recreate it fresh (localized).
|
||||
"""
|
||||
from ..websocket.tasks_persist import async_persist_task
|
||||
|
||||
@@ -249,23 +269,11 @@ async def _reconcile_fleet_task(hass: HomeAssistant, entry: ConfigEntry) -> bool
|
||||
return True
|
||||
|
||||
obj = entry.data.get(CONF_OBJECT, {})
|
||||
task = {
|
||||
"id": uuid4().hex,
|
||||
"object_id": obj.get("id", ""),
|
||||
"name": "Replace low batteries",
|
||||
"type": "inspection",
|
||||
"enabled": True,
|
||||
TASK_FLAG: True,
|
||||
"schedule": {"kind": "manual"},
|
||||
"trigger_config": _fleet_trigger_config(),
|
||||
"created_at": dt_util.now().date().isoformat(),
|
||||
"notes": ("Aggregate battery check. The detail view lists which devices are low and which battery types to buy."),
|
||||
}
|
||||
await async_persist_task(hass, entry, task)
|
||||
await async_persist_task(hass, entry, _fleet_task(obj.get("id", ""), lang))
|
||||
return True
|
||||
|
||||
|
||||
def _reconcile_type_parts(hass: HomeAssistant, entry: ConfigEntry, types: dict[str, int]) -> int:
|
||||
def _reconcile_type_parts(hass: HomeAssistant, entry: ConfigEntry, types: dict[str, int], lang: str) -> int:
|
||||
"""Add parts for battery types newly seen since setup. Returns count added."""
|
||||
from .parts import normalize_part
|
||||
|
||||
@@ -275,7 +283,7 @@ def _reconcile_type_parts(hass: HomeAssistant, entry: ConfigEntry, types: dict[s
|
||||
for btype, total_qty in types.items():
|
||||
pid = f"batt_{btype.lower()}"
|
||||
if pid not in existing_ids:
|
||||
parts[pid] = normalize_part(_type_part(btype, total_qty))
|
||||
parts[pid] = normalize_part(_type_part(btype, total_qty, lang))
|
||||
added += 1
|
||||
if added:
|
||||
new_data = dict(entry.data)
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
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