App Updates

This commit is contained in:
2026-07-21 11:19:17 -04:00
parent 87ffe648a5
commit 0ed1687503
180 changed files with 943 additions and 118 deletions
@@ -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
+252
View File
@@ -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"
}
+12 -1
View File
@@ -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",
@@ -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);
});
@@ -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 */
@@ -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">
@@ -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)

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