App Updates
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.7 KiB |
+1
-1
@@ -1 +1 @@
|
|||||||
{"pid": 72, "version": 1, "ha_version": "2026.7.2", "start_ts": 1784592292.815142}
|
{"pid": 71, "version": 1, "ha_version": "2026.7.2", "start_ts": 1784644362.8406641}
|
||||||
+21
-27
@@ -739,14 +739,14 @@
|
|||||||
actions:
|
actions:
|
||||||
- data:
|
- data:
|
||||||
title: Pool Sensor Battery Low
|
title: Pool Sensor Battery Low
|
||||||
message: The pool temperature sensor battery is currently at {{ states('sensor.pool_temperature_battery')
|
message: The pool temperature sensor battery is currently at {{ states('sensor.pool_battery')
|
||||||
}}%. Please change it soon.
|
}}%. Please change it soon.
|
||||||
action: notify.persistent_notification
|
action: notify.persistent_notification
|
||||||
- action: notify.notify
|
- action: notify.notify
|
||||||
metadata: {}
|
metadata: {}
|
||||||
data:
|
data:
|
||||||
title: Pool Sensor Battery Low
|
title: Pool Sensor Battery Low
|
||||||
message: The pool temperature sensor battery is currently at {{ states('sensor.pool_temperature_battery')
|
message: The pool temperature sensor battery is currently at {{ states('sensor.pool_battery')
|
||||||
}}%. Please change it soon.
|
}}%. Please change it soon.
|
||||||
mode: single
|
mode: single
|
||||||
- &id001
|
- &id001
|
||||||
@@ -978,28 +978,16 @@
|
|||||||
notification_id: '{{ notification_id }}'
|
notification_id: '{{ notification_id }}'
|
||||||
mode: queued
|
mode: queued
|
||||||
max: 10
|
max: 10
|
||||||
- id: '1783293479398'
|
|
||||||
alias: Blink Camera Fast Update Cycle
|
|
||||||
description: Polls the Blink cloud every 30 seconds to bypass the default 5-minute
|
|
||||||
delay and force instant motion detection state updates.
|
|
||||||
triggers:
|
|
||||||
- trigger: time_pattern
|
|
||||||
seconds: /30
|
|
||||||
actions:
|
|
||||||
- action: homeassistant.update_entity
|
|
||||||
target:
|
|
||||||
entity_id:
|
|
||||||
- binary_sensor.backyard_shed_motion
|
|
||||||
- binary_sensor.front_door_motion
|
|
||||||
mode: single
|
|
||||||
- id: '1783452320698'
|
- id: '1783452320698'
|
||||||
alias: Nest Test Motion
|
alias: Nest Test Motion
|
||||||
description: Takes a snapshot when the Blink camera detects motion, analyzes it
|
description: Takes a snapshot when the Blink camera detects motion, analyzes it
|
||||||
with AI, and sends a mobile and persistent notification.
|
with AI, and sends a mobile and persistent notification.
|
||||||
triggers:
|
triggers:
|
||||||
- trigger: state
|
- trigger: state
|
||||||
entity_id: binary_sensor.backyard_tree_motion
|
entity_id:
|
||||||
to: 'on'
|
- binary_sensor.backyard
|
||||||
|
to:
|
||||||
|
- 'on'
|
||||||
conditions: []
|
conditions: []
|
||||||
actions:
|
actions:
|
||||||
- delay: 00:00:02
|
- delay: 00:00:02
|
||||||
@@ -1618,7 +1606,7 @@
|
|||||||
}}'
|
}}'
|
||||||
sequence:
|
sequence:
|
||||||
- target:
|
- target:
|
||||||
entity_id: light.thirdreality_night_light
|
entity_id: light.night_light
|
||||||
data:
|
data:
|
||||||
brightness_pct: 100
|
brightness_pct: 100
|
||||||
rgb_color:
|
rgb_color:
|
||||||
@@ -1632,7 +1620,7 @@
|
|||||||
}}'
|
}}'
|
||||||
sequence:
|
sequence:
|
||||||
- target:
|
- target:
|
||||||
entity_id: light.thirdreality_night_light
|
entity_id: light.night_light
|
||||||
data:
|
data:
|
||||||
brightness_pct: 20
|
brightness_pct: 20
|
||||||
rgb_color:
|
rgb_color:
|
||||||
@@ -2030,6 +2018,7 @@
|
|||||||
entity_id: light.bedroom_lights
|
entity_id: light.bedroom_lights
|
||||||
data:
|
data:
|
||||||
brightness_pct: 25
|
brightness_pct: 25
|
||||||
|
dim_turns_lights_on: true
|
||||||
- id: '1783962246983'
|
- id: '1783962246983'
|
||||||
alias: 'OFFICE: IKEA BILRESA E2489 Dual Button (Matter) - In Meeting and Away Mode'
|
alias: 'OFFICE: IKEA BILRESA E2489 Dual Button (Matter) - In Meeting and Away Mode'
|
||||||
description: Ikea BILRESA E2489 Dual Button controls in Meeting toggle, and enable
|
description: Ikea BILRESA E2489 Dual Button controls in Meeting toggle, and enable
|
||||||
@@ -2185,18 +2174,21 @@
|
|||||||
entity_id: light.playroom_light
|
entity_id: light.playroom_light
|
||||||
data:
|
data:
|
||||||
brightness_pct: 30
|
brightness_pct: 30
|
||||||
|
dim_turns_lights_on: true
|
||||||
- id: '1784073759556'
|
- id: '1784073759556'
|
||||||
alias: 'Office: Desk Lamp Meeting Alert'
|
alias: 'Office: Desk Lamp Meeting Alert'
|
||||||
description: Flashes the desk lamp color temperature for a few seconds when a meeting
|
description: Flashes the desk lamp color temperature for a few seconds when a meeting
|
||||||
status changes (on or off) to confirm the button press registered.
|
status changes (on or off) to confirm the button press registered.
|
||||||
triggers:
|
triggers:
|
||||||
- id: meeting_changed
|
- trigger: state
|
||||||
entity_id: input_boolean.in_a_meeting
|
entity_id: input_boolean.in_a_meeting
|
||||||
trigger: state
|
|
||||||
conditions:
|
conditions:
|
||||||
- condition: state
|
- condition: state
|
||||||
entity_id: input_boolean.meeting_lamp_override
|
entity_id: input_boolean.meeting_lamp_override
|
||||||
state: 'off'
|
state: 'off'
|
||||||
|
- condition: state
|
||||||
|
entity_id: input_boolean.away_mode
|
||||||
|
state: 'off'
|
||||||
actions:
|
actions:
|
||||||
- action: scene.create
|
- action: scene.create
|
||||||
data:
|
data:
|
||||||
@@ -2209,6 +2201,7 @@
|
|||||||
data:
|
data:
|
||||||
brightness_pct: 100
|
brightness_pct: 100
|
||||||
color_temp_kelvin: 2261
|
color_temp_kelvin: 2261
|
||||||
|
transition: 1
|
||||||
- delay:
|
- delay:
|
||||||
hours: 0
|
hours: 0
|
||||||
minutes: 0
|
minutes: 0
|
||||||
@@ -2217,7 +2210,8 @@
|
|||||||
- action: scene.turn_on
|
- action: scene.turn_on
|
||||||
target:
|
target:
|
||||||
entity_id: scene.desk_lamp_before_alert
|
entity_id: scene.desk_lamp_before_alert
|
||||||
data: {}
|
data:
|
||||||
|
transition: 1
|
||||||
mode: queued
|
mode: queued
|
||||||
max: 2
|
max: 2
|
||||||
- id: '1784153893584'
|
- id: '1784153893584'
|
||||||
@@ -2473,7 +2467,7 @@
|
|||||||
- delay:
|
- delay:
|
||||||
hours: 0
|
hours: 0
|
||||||
minutes: 0
|
minutes: 0
|
||||||
seconds: 15
|
seconds: 20
|
||||||
milliseconds: 0
|
milliseconds: 0
|
||||||
- action: homeassistant.update_entity
|
- action: homeassistant.update_entity
|
||||||
target:
|
target:
|
||||||
@@ -2608,7 +2602,7 @@
|
|||||||
- delay:
|
- delay:
|
||||||
hours: 0
|
hours: 0
|
||||||
minutes: 0
|
minutes: 0
|
||||||
seconds: 15
|
seconds: 20
|
||||||
milliseconds: 0
|
milliseconds: 0
|
||||||
- action: homeassistant.update_entity
|
- action: homeassistant.update_entity
|
||||||
target:
|
target:
|
||||||
@@ -2752,7 +2746,7 @@
|
|||||||
- delay:
|
- delay:
|
||||||
hours: 0
|
hours: 0
|
||||||
minutes: 0
|
minutes: 0
|
||||||
seconds: 15
|
seconds: 20
|
||||||
milliseconds: 0
|
milliseconds: 0
|
||||||
- action: homeassistant.update_entity
|
- action: homeassistant.update_entity
|
||||||
target:
|
target:
|
||||||
@@ -2892,7 +2886,7 @@
|
|||||||
- delay:
|
- delay:
|
||||||
hours: 0
|
hours: 0
|
||||||
minutes: 0
|
minutes: 0
|
||||||
seconds: 15
|
seconds: 20
|
||||||
milliseconds: 0
|
milliseconds: 0
|
||||||
- action: homeassistant.update_entity
|
- action: homeassistant.update_entity
|
||||||
target:
|
target:
|
||||||
|
|||||||
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_APP_PASSWORD,
|
||||||
PHOTOPRISM_AUTH_USER_PASSWORD,
|
PHOTOPRISM_AUTH_USER_PASSWORD,
|
||||||
PHOTOPRISM_SELECTION_COMPOSITE,
|
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,
|
DEFAULT_REVERSE_GEOCODE,
|
||||||
PROVIDER_GOOGLE_SHARED,
|
PROVIDER_GOOGLE_SHARED,
|
||||||
PROVIDER_LOCAL_FOLDER,
|
PROVIDER_LOCAL_FOLDER,
|
||||||
PROVIDER_MEDIA_SOURCE,
|
PROVIDER_MEDIA_SOURCE,
|
||||||
PROVIDER_IMMICH,
|
PROVIDER_IMMICH,
|
||||||
PROVIDER_PHOTOPRISM,
|
PROVIDER_PHOTOPRISM,
|
||||||
|
PROVIDER_ICLOUD,
|
||||||
DEFAULT_RECURSIVE,
|
DEFAULT_RECURSIVE,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -134,6 +141,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
return await self.async_step_immich()
|
return await self.async_step_immich()
|
||||||
if self._provider == PROVIDER_PHOTOPRISM:
|
if self._provider == PROVIDER_PHOTOPRISM:
|
||||||
return await self.async_step_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()
|
return await self.async_step_google_shared()
|
||||||
|
|
||||||
schema = vol.Schema(
|
schema = vol.Schema(
|
||||||
@@ -143,6 +152,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
PROVIDER_LOCAL_FOLDER: "Local Folder",
|
PROVIDER_LOCAL_FOLDER: "Local Folder",
|
||||||
PROVIDER_IMMICH: "Immich (direct API, full metadata)",
|
PROVIDER_IMMICH: "Immich (direct API, full metadata)",
|
||||||
PROVIDER_PHOTOPRISM: "PhotoPrism (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)",
|
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
|
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):
|
class LocalFolderOptionsFlow(config_entries.OptionsFlow):
|
||||||
"""Options for local-folder entries.
|
"""Options for local-folder entries.
|
||||||
|
|||||||
@@ -56,6 +56,21 @@ PROVIDER_LOCAL_FOLDER = "local_folder"
|
|||||||
PROVIDER_MEDIA_SOURCE = "media_source"
|
PROVIDER_MEDIA_SOURCE = "media_source"
|
||||||
PROVIDER_IMMICH = "immich"
|
PROVIDER_IMMICH = "immich"
|
||||||
PROVIDER_PHOTOPRISM = "photoprism"
|
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.
|
# PhotoPrism (direct API) provider.
|
||||||
CONF_PHOTOPRISM_URL = "photoprism_url"
|
CONF_PHOTOPRISM_URL = "photoprism_url"
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ from .const import (
|
|||||||
CONF_PHOTOPRISM_IMAGE_SIZE,
|
CONF_PHOTOPRISM_IMAGE_SIZE,
|
||||||
CONF_PHOTOPRISM_FILTER,
|
CONF_PHOTOPRISM_FILTER,
|
||||||
DEFAULT_PHOTOPRISM_IMAGE_SIZE,
|
DEFAULT_PHOTOPRISM_IMAGE_SIZE,
|
||||||
|
CONF_ICLOUD_TOKEN,
|
||||||
|
CONF_ICLOUD_IMAGE_SIZE,
|
||||||
|
DEFAULT_ICLOUD_IMAGE_SIZE,
|
||||||
DEFAULT_REVERSE_GEOCODE,
|
DEFAULT_REVERSE_GEOCODE,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
PROVIDER_GOOGLE_SHARED,
|
PROVIDER_GOOGLE_SHARED,
|
||||||
@@ -48,6 +51,7 @@ from .const import (
|
|||||||
PROVIDER_MEDIA_SOURCE,
|
PROVIDER_MEDIA_SOURCE,
|
||||||
PROVIDER_IMMICH,
|
PROVIDER_IMMICH,
|
||||||
PROVIDER_PHOTOPRISM,
|
PROVIDER_PHOTOPRISM,
|
||||||
|
PROVIDER_ICLOUD,
|
||||||
)
|
)
|
||||||
from .store import SlideshowStore
|
from .store import SlideshowStore
|
||||||
|
|
||||||
@@ -933,6 +937,8 @@ class AlbumCoordinator(DataUpdateCoordinator):
|
|||||||
data = await self._update_immich()
|
data = await self._update_immich()
|
||||||
elif self.provider == PROVIDER_PHOTOPRISM:
|
elif self.provider == PROVIDER_PHOTOPRISM:
|
||||||
data = await self._update_photoprism()
|
data = await self._update_photoprism()
|
||||||
|
elif self.provider == PROVIDER_ICLOUD:
|
||||||
|
data = await self._update_icloud()
|
||||||
else:
|
else:
|
||||||
raise UpdateFailed(f"Unsupported provider: {self.provider}")
|
raise UpdateFailed(f"Unsupported provider: {self.provider}")
|
||||||
except UpdateFailed:
|
except UpdateFailed:
|
||||||
@@ -1445,6 +1451,67 @@ class AlbumCoordinator(DataUpdateCoordinator):
|
|||||||
"items": items,
|
"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:
|
async def _enrich_immich_item(self, item: MediaItem) -> None:
|
||||||
"""Fetch one Immich asset's detail and fill location/description."""
|
"""Fetch one Immich asset's detail and fill location/description."""
|
||||||
from . import immich as immich_api
|
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",
|
"iot_class": "cloud_polling",
|
||||||
"issue_tracker": "https://github.com/eyalgal/album_slideshow/issues",
|
"issue_tracker": "https://github.com/eyalgal/album_slideshow/issues",
|
||||||
"requirements": ["Pillow"],
|
"requirements": ["Pillow"],
|
||||||
"version": "1.3.0"
|
"version": "1.4.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,15 @@
|
|||||||
"photoprism_filter": "Extra search query (optional)",
|
"photoprism_filter": "Extra search query (optional)",
|
||||||
"photoprism_image_size": "Image quality"
|
"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": {
|
"error": {
|
||||||
@@ -89,7 +98,9 @@
|
|||||||
"immich_albums_required": "Pick at least one album for the Albums source.",
|
"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_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_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": {
|
"options": {
|
||||||
|
|||||||
@@ -75,6 +75,15 @@
|
|||||||
"photoprism_filter": "Extra search query (optional)",
|
"photoprism_filter": "Extra search query (optional)",
|
||||||
"photoprism_image_size": "Image quality"
|
"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": {
|
"error": {
|
||||||
@@ -89,7 +98,9 @@
|
|||||||
"immich_albums_required": "Pick at least one album for the Albums source.",
|
"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_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_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": {
|
"options": {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
* tap_action: none # none | more-info
|
* tap_action: none # none | more-info
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const VERSION = "1.3.0";
|
const VERSION = "1.4.0";
|
||||||
|
|
||||||
const ANIMATED_TRANSITIONS = [
|
const ANIMATED_TRANSITIONS = [
|
||||||
"fade",
|
"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 {
|
try {
|
||||||
await this.hass.connection.sendMessagePromise({
|
await this.hass.connection.sendMessagePromise({
|
||||||
type: "maintenance_supporter/battery_fleet/setup",
|
type: "maintenance_supporter/battery_fleet/setup",
|
||||||
|
language: this._lang,
|
||||||
});
|
});
|
||||||
await this._load();
|
await this._load();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -1045,6 +1045,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
|||||||
try {
|
try {
|
||||||
const res = await this.hass.connection.sendMessagePromise<{ entry_id: string; task_id?: string }>({
|
const res = await this.hass.connection.sendMessagePromise<{ entry_id: string; task_id?: string }>({
|
||||||
type: "maintenance_supporter/battery_fleet/setup",
|
type: "maintenance_supporter/battery_fleet/setup",
|
||||||
|
language: this.hass.language || "en",
|
||||||
});
|
});
|
||||||
this._batteryFleetSetupAvailable = false;
|
this._batteryFleetSetupAvailable = false;
|
||||||
await this._loadData();
|
await this._loadData();
|
||||||
|
|||||||
@@ -1235,6 +1235,10 @@ export const panelStyles = css`
|
|||||||
:host([narrow]) .task-header-actions {
|
:host([narrow]) .task-header-actions {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
justify-content: flex-start;
|
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 {
|
:host([narrow]) .filter-bar {
|
||||||
@@ -1433,7 +1437,7 @@ export const panelStyles = css`
|
|||||||
.two-column-layout { grid-template-columns: 1fr; }
|
.two-column-layout { grid-template-columns: 1fr; }
|
||||||
.tab { padding: 12px 16px; font-size: 14px; }
|
.tab { padding: 12px 16px; font-size: 14px; }
|
||||||
.task-header { flex-direction: column; align-items: flex-start; }
|
.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 { flex-wrap: wrap; }
|
||||||
.filter-bar select { flex: 1; min-width: 0; }
|
.filter-bar select { flex: 1; min-width: 0; }
|
||||||
/* Mirror the :host([narrow]) grid layout for narrow desktop windows */
|
/* 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 {
|
:host([narrow]) .task-header-actions {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
justify-content: flex-start;
|
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 {
|
:host([narrow]) .filter-bar {
|
||||||
@@ -2726,7 +2730,7 @@ ${H(n.notes)}</div>`:""}
|
|||||||
.two-column-layout { grid-template-columns: 1fr; }
|
.two-column-layout { grid-template-columns: 1fr; }
|
||||||
.tab { padding: 12px 16px; font-size: 14px; }
|
.tab { padding: 12px 16px; font-size: 14px; }
|
||||||
.task-header { flex-direction: column; align-items: flex-start; }
|
.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 { flex-wrap: wrap; }
|
||||||
.filter-bar select { flex: 1; min-width: 0; }
|
.filter-bar select { flex: 1; min-width: 0; }
|
||||||
/* Mirror the :host([narrow]) grid layout for narrow desktop windows */
|
/* 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);
|
color: var(--primary-text-color);
|
||||||
}
|
}
|
||||||
.actions { display: flex; justify-content: flex-end; gap: 8px; padding-top: 8px; }
|
.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-card">
|
||||||
<div class="bf-head">
|
<div class="bf-head">
|
||||||
<ha-icon icon="mdi:battery-alert"></ha-icon>
|
<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 class="palette-hint">${s("palette_hint",e)}</div>
|
||||||
</div>
|
</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="palette-backdrop" @click=${()=>{this._templateGalleryOpen=!1}}>
|
||||||
<div class="template-gallery" @click=${i=>i.stopPropagation()}>
|
<div class="template-gallery" @click=${i=>i.stopPropagation()}>
|
||||||
<div class="template-gallery-head">
|
<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
|
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.
|
"""Create (or return) the Battery Fleet object with type-parts + the task.
|
||||||
|
|
||||||
Idempotent: a second call reconciles the type-parts against the current
|
Idempotent: a second call reconciles the type-parts against the current
|
||||||
fleet (adds parts for newly-seen types) and returns the existing entry.
|
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.objects import async_create_object
|
||||||
from ..websocket.tasks_persist import async_persist_task
|
from ..websocket.tasks_persist import async_persist_task
|
||||||
|
from .i18n import normalize_language
|
||||||
from .parts import normalize_part
|
from .parts import normalize_part
|
||||||
|
|
||||||
|
lang = (language or normalize_language(hass))[:2].lower()
|
||||||
types = discover_battery_types(hass) # {TYPE: total_qty}
|
types = discover_battery_types(hass) # {TYPE: total_qty}
|
||||||
|
|
||||||
existing = find_fleet_entry(hass)
|
existing = find_fleet_entry(hass)
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
added = _reconcile_type_parts(hass, existing, types)
|
added = _reconcile_type_parts(hass, existing, types, lang)
|
||||||
repaired = await _reconcile_fleet_task(hass, existing)
|
repaired = await _reconcile_fleet_task(hass, existing, lang)
|
||||||
return {
|
return {
|
||||||
"entry_id": existing.entry_id,
|
"entry_id": existing.entry_id,
|
||||||
"created": False,
|
"created": False,
|
||||||
@@ -62,7 +67,9 @@ async def async_setup_battery_fleet(hass: HomeAssistant) -> dict[str, Any]:
|
|||||||
"task_repaired": repaired,
|
"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)
|
entry = hass.config_entries.async_get_entry(entry_id)
|
||||||
if entry is None: # pragma: no cover — just created above
|
if entry is None: # pragma: no cover — just created above
|
||||||
raise HomeAssistantError("Battery Fleet object entry vanished after creation")
|
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
|
new_data[CONF_OBJECT] = obj
|
||||||
parts: dict[str, dict[str, Any]] = {}
|
parts: dict[str, dict[str, Any]] = {}
|
||||||
for btype, total_qty in types.items():
|
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
|
parts[part["id"]] = part
|
||||||
new_data[CONF_PARTS] = parts
|
new_data[CONF_PARTS] = parts
|
||||||
hass.config_entries.async_update_entry(entry, data=new_data)
|
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()
|
await store.async_save()
|
||||||
|
|
||||||
# The single aggregate task, triggered by the global low-count sensor.
|
# The single aggregate task, triggered by the global low-count sensor.
|
||||||
obj_id = obj.get("id", "")
|
task = _fleet_task(obj.get("id", ""), lang)
|
||||||
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."),
|
|
||||||
}
|
|
||||||
await async_persist_task(hass, entry, task)
|
await async_persist_task(hass, entry, task)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -130,23 +125,48 @@ def _fleet_trigger_config() -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _type_part(btype: str, total_qty: int) -> dict[str, Any]:
|
def _fleet_task(obj_id: str, lang: str) -> dict[str, Any]:
|
||||||
"""A spare-part definition for one battery type.
|
"""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
|
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
|
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
|
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.
|
is the shopping surface; the user can enable auto-buy per type later.
|
||||||
"""
|
"""
|
||||||
|
from ..templates import localize_template_text
|
||||||
|
|
||||||
threshold = max(2, total_qty)
|
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 {
|
return {
|
||||||
"id": f"batt_{btype.lower()}",
|
"id": f"batt_{btype.lower()}",
|
||||||
"name": f"{btype} battery",
|
"name": name_tpl.format(type=btype),
|
||||||
"unit": "pcs",
|
"unit": "pcs",
|
||||||
"reorder_threshold": threshold,
|
"reorder_threshold": threshold,
|
||||||
"restock_quantity": threshold * 2,
|
"restock_quantity": threshold * 2,
|
||||||
"auto_buy_task": False,
|
"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
|
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.
|
"""Repair the fleet task if broken. Returns True when something was fixed.
|
||||||
|
|
||||||
* Trigger lost (issue #106) → restore the canonical threshold trigger,
|
* Trigger lost (issue #106) → restore the canonical threshold trigger,
|
||||||
keeping the user's name/type/translations untouched.
|
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
|
from ..websocket.tasks_persist import async_persist_task
|
||||||
|
|
||||||
@@ -249,23 +269,11 @@ async def _reconcile_fleet_task(hass: HomeAssistant, entry: ConfigEntry) -> bool
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
obj = entry.data.get(CONF_OBJECT, {})
|
obj = entry.data.get(CONF_OBJECT, {})
|
||||||
task = {
|
await async_persist_task(hass, entry, _fleet_task(obj.get("id", ""), lang))
|
||||||
"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)
|
|
||||||
return True
|
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."""
|
"""Add parts for battery types newly seen since setup. Returns count added."""
|
||||||
from .parts import normalize_part
|
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():
|
for btype, total_qty in types.items():
|
||||||
pid = f"batt_{btype.lower()}"
|
pid = f"batt_{btype.lower()}"
|
||||||
if pid not in existing_ids:
|
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
|
added += 1
|
||||||
if added:
|
if added:
|
||||||
new_data = dict(entry.data)
|
new_data = dict(entry.data)
|
||||||
|
|||||||
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