This commit is contained in:
Home Assistant Version Control
2026-07-31 15:08:30 +00:00
parent 878b92f499
commit 765e5dd1b5
11 changed files with 4167 additions and 135 deletions
@@ -52,6 +52,7 @@ from .const import (
CONF_ICLOUD_TOKEN,
CONF_ICLOUD_IMAGE_SIZE,
DEFAULT_ICLOUD_IMAGE_SIZE,
CONF_ICLOUD_BACKEND,
ICLOUD_IMAGE_FULL,
ICLOUD_IMAGE_PREVIEW,
CONF_SYNOLOGY_URL,
@@ -219,7 +220,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
if not ALBUM_URL_RE.match(url):
errors[CONF_ALBUM_URL] = "invalid_album_url"
else:
await self.async_set_unique_id(f"{DOMAIN}:{PROVIDER_GOOGLE_SHARED}:{url}")
await self.async_set_unique_id(f"{DOMAIN}:{PROVIDER_GOOGLE_SHARED}:{url}:{name}")
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=name,
@@ -249,7 +250,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
if not path:
errors[CONF_LOCAL_PATH] = "invalid_path"
else:
await self.async_set_unique_id(f"{DOMAIN}:{PROVIDER_LOCAL_FOLDER}:{path}")
await self.async_set_unique_id(f"{DOMAIN}:{PROVIDER_LOCAL_FOLDER}:{path}:{name}")
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=name,
@@ -283,7 +284,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
errors[CONF_MEDIA_CONTENT_ID] = "invalid_media_source"
else:
await self.async_set_unique_id(
f"{DOMAIN}:{PROVIDER_MEDIA_SOURCE}:{content_id}"
f"{DOMAIN}:{PROVIDER_MEDIA_SOURCE}:{content_id}:{name}"
)
self._abort_if_unique_id_configured()
return self.async_create_entry(
@@ -395,7 +396,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
sel_id = json.dumps(selection, sort_keys=True)
unique = (
f"{DOMAIN}:{PROVIDER_IMMICH}:{self._immich_url}:"
f"composite:{sel_id}:{raw_filter}"
f"composite:{sel_id}:{raw_filter}:{name}"
)
await self.async_set_unique_id(unique)
self._abort_if_unique_id_configured()
@@ -574,7 +575,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
sel_id = json.dumps(selection, sort_keys=True)
unique = (
f"{DOMAIN}:{PROVIDER_PHOTOPRISM}:{self._pp_url}:"
f"composite:{sel_id}:{raw_filter}"
f"composite:{sel_id}:{raw_filter}:{name}"
)
await self.async_set_unique_id(unique)
self._abort_if_unique_id_configured()
@@ -659,18 +660,22 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
from . import icloud as icloud_api
token = icloud_api.parse_share_link(raw_url)
if not token:
parsed = icloud_api.parse_share(raw_url)
if not parsed:
errors[CONF_ICLOUD_URL] = "invalid_icloud_url"
else:
client = icloud_api.IcloudClient(self.hass, token)
token, backend = parsed
if backend == icloud_api.BACKEND_CLOUDKIT:
client = icloud_api.IcloudCloudKitClient(self.hass, token)
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}"
f"{DOMAIN}:{PROVIDER_ICLOUD}:{token}:{name}"
)
self._abort_if_unique_id_configured()
return self.async_create_entry(
@@ -678,6 +683,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
data={
CONF_PROVIDER: PROVIDER_ICLOUD,
CONF_ICLOUD_TOKEN: token,
CONF_ICLOUD_BACKEND: backend,
CONF_ICLOUD_IMAGE_SIZE: size,
CONF_ALBUM_NAME: name,
},
@@ -866,7 +872,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
sel_id = json.dumps(selection, sort_keys=True)
unique = (
f"{DOMAIN}:{PROVIDER_SYNOLOGY}:{self._syn_url}:"
f"{self._syn_space}:{sel_id}"
f"{self._syn_space}:{sel_id}:{name}"
)
await self.async_set_unique_id(unique)
self._abort_if_unique_id_configured()
@@ -957,7 +963,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
else:
await self.async_set_unique_id(
f"{DOMAIN}:{PROVIDER_NEXTCLOUD}:{client.base_url}:"
f"{username}:{client.folder}"
f"{username}:{client.folder}:{name}"
)
self._abort_if_unique_id_configured()
return self.async_create_entry(
@@ -129,6 +129,17 @@ CONF_ICLOUD_URL = "icloud_url"
CONF_ICLOUD_TOKEN = "icloud_token"
CONF_ICLOUD_IMAGE_SIZE = "icloud_image_size"
# Which iCloud backend serves the album. Older share links
# (``www.icloud.com/sharedalbum/#TOKEN``) use the legacy "shared streams" web
# API; iOS 26/macOS 26 and newer links
# (``photos.icloud.com/shared/album/TOKEN``) use CloudKit Web Services. Both are
# public and need no account. Entries created before this option existed default
# to the legacy backend.
CONF_ICLOUD_BACKEND = "icloud_backend"
ICLOUD_BACKEND_SHAREDSTREAMS = "sharedstreams"
ICLOUD_BACKEND_CLOUDKIT = "cloudkit"
DEFAULT_ICLOUD_BACKEND = ICLOUD_BACKEND_SHAREDSTREAMS
# ``full`` picks the largest derivative Apple generated (best for a slideshow,
# usually ~2048px); ``preview`` picks the smallest (a thumbnail; fastest).
ICLOUD_IMAGE_FULL = "full"
@@ -44,6 +44,9 @@ from .const import (
CONF_ICLOUD_TOKEN,
CONF_ICLOUD_IMAGE_SIZE,
DEFAULT_ICLOUD_IMAGE_SIZE,
CONF_ICLOUD_BACKEND,
DEFAULT_ICLOUD_BACKEND,
ICLOUD_BACKEND_CLOUDKIT,
CONF_SYNOLOGY_URL,
CONF_SYNOLOGY_USERNAME,
CONF_SYNOLOGY_PASSWORD,
@@ -1523,18 +1526,23 @@ class AlbumCoordinator(DataUpdateCoordinator):
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).
Two public backends are supported: the legacy "shared streams" API and
the newer CloudKit backend used by iOS 26/macOS 26 share links. Both
carry capture date and caption inline, so there is no enrichment pass.
Signed image URLs are resolved up front and expire after a while, 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)
backend = self.entry.data.get(CONF_ICLOUD_BACKEND, DEFAULT_ICLOUD_BACKEND)
if not token:
raise UpdateFailed("iCloud provider is missing the album token")
if backend == ICLOUD_BACKEND_CLOUDKIT:
return await self._update_icloud_cloudkit(icloud_api, token, size)
client = icloud_api.IcloudClient(self.hass, token)
try:
photos = await client.async_get_photos()
@@ -1581,6 +1589,43 @@ class AlbumCoordinator(DataUpdateCoordinator):
"items": items,
}
async def _update_icloud_cloudkit(
self, icloud_api, token: str, size: str
) -> dict[str, Any]:
"""Fetch photos from a CloudKit-backed iCloud shared album."""
client = icloud_api.IcloudCloudKitClient(self.hass, token)
try:
photos = await client.async_get_items(size)
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] = [
MediaItem(
url=p["url"],
width=p.get("width"),
height=p.get("height"),
mime_type=None,
filename=None,
captured_at=p.get("captured_at"),
description=p.get("description"),
source_id=p.get("source_id"),
exif_scanned=True,
)
for p in photos
if p.get("url")
]
if not items:
raise UpdateFailed("Could not resolve any iCloud image URLs")
return {
"title": self.entry.title,
"items": items,
}
async def _update_synology(self) -> dict[str, Any]:
"""Fetch photos from a Synology Photos library via its web API.
+331 -10
View File
@@ -1,11 +1,11 @@
"""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.
Reads a public iCloud shared photo album. No account or password is involved;
the album's share token (from the share link) is the only credential. Apple
serves shared albums through two different public backends depending on when
the album was created, and this module speaks both:
API shape (POST, ``Content-Type: text/plain``, ``Origin: https://www.icloud.com``):
Legacy "shared streams" backend (``www.icloud.com/sharedalbum/#TOKEN``):
- ``POST {base}/webstream`` ``{"streamCtag": null}`` -> ``{streamName, photos:
[{photoGuid, derivatives:{<height>:{checksum,width,height,fileSize}},
dateCreated, caption, width, height}]}``. May first answer with a
@@ -16,13 +16,24 @@ API shape (POST, ``Content-Type: text/plain``, ``Origin: https://www.icloud.com`
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.
CloudKit backend (``photos.icloud.com/shared/album/TOKEN``, iOS 26/macOS 26+):
- ``POST ckdatabasews.icloud.com/.../public/records/resolve?shortGUID=TOKEN``
resolves the short link to a shared CloudKit zone and hands back an
anonymous ``publicAccessAuthToken`` plus the partition host to talk to.
- ``POST {partition}/.../shared/records/query`` with the anonymous token and
``sharing_url_key`` returns ``CPLMaster``/``CPLAsset`` records; each
``CPLMaster`` carries signed ``downloadURL`` derivatives directly.
Both backends are POST with ``Content-Type: text/plain`` and
``Origin: https://www.icloud.com``. Metadata (capture date, caption) is inline;
Apple strips GPS from shared-album web data, so there is no location.
"""
from __future__ import annotations
import base64
from datetime import datetime, timezone
from typing import Any
from urllib.parse import quote, urlencode
import async_timeout
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -35,6 +46,12 @@ _URL_BATCH = 25
_BASE62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
# Which iCloud backend serves an album. These string values match the
# ``ICLOUD_BACKEND_*`` constants in ``const.py``; they are duplicated here to
# keep this module free of a hard dependency on ``const``.
BACKEND_SHAREDSTREAMS = "sharedstreams"
BACKEND_CLOUDKIT = "cloudkit"
# Headers Apple's web endpoints expect for the shared-streams API.
_API_HEADERS = {
"Content-Type": "text/plain",
@@ -42,6 +59,37 @@ _API_HEADERS = {
"Accept": "application/json",
}
# --- CloudKit backend constants -------------------------------------------
_CK_RESOLVE_HOST = "https://ckdatabasews.icloud.com"
_CK_CONTAINER = "com.apple.photos.cloud"
_CK_BUILD = "2626"
# Sorted, non-hidden, non-deleted assets. Returns both CPLMaster (image
# resources) and CPLAsset (metadata) records in one query.
_CK_RECORD_TYPE = "CPLAssetAndMasterByAssetDateWithoutHiddenOrDeleted"
_CK_PAGE = 200
_CK_HEADERS = {
"Content-Type": "text/plain",
"Origin": "https://www.icloud.com",
"Referer": "https://www.icloud.com/",
"Accept": "application/json",
}
# Image resource fields on a CPLMaster, in ascending size order, paired with
# their width/height sibling fields.
_CK_IMAGE_RES = (
("resJPEGThumbRes", "resJPEGThumbWidth", "resJPEGThumbHeight"),
("resJPEGMedRes", "resJPEGMedWidth", "resJPEGMedHeight"),
("resJPEGLargeRes", "resJPEGLargeWidth", "resJPEGLargeHeight"),
("resOriginalRes", "resOriginalWidth", "resOriginalHeight"),
)
# Original item types a browser can display directly. The JPEG derivatives are
# always browser-safe; the original is only used as a fallback when it is one
# of these.
_CK_BROWSER_SAFE_ORIGINAL = {"public.jpeg", "public.png"}
# itemType substrings that mark a master as a video (skipped in a slideshow).
_CK_VIDEO_HINTS = ("movie", "video", "mpeg-4", "quicktime")
def _base62_to_int(value: str) -> int:
result = 0
@@ -53,23 +101,51 @@ def _base62_to_int(value: str) -> int:
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.
Accepts a legacy ``https://www.icloud.com/sharedalbum/#TOKEN`` link, a new
``https://photos.icloud.com/shared/album/TOKEN`` link, or a bare token.
Returns ``None`` if nothing token-like is found.
"""
if not url:
return None
text = url.strip()
# Legacy links carry the token in the fragment; new links carry it as the
# last path segment.
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, ...).
# Drop any leftover query string.
text = text.split("?", 1)[0]
token = text.strip()
# Tokens are base62. Legacy tokens start with an uppercase letter; the new
# CloudKit short GUIDs may start with a digit, so no leading-char check.
if token and all(ch in _BASE62 for ch in token):
return token
return None
def detect_backend(url: str) -> str:
"""Return which iCloud backend a pasted share link belongs to.
New ``photos.icloud.com/shared/album/...`` links use the CloudKit backend;
everything else (including bare tokens) defaults to the legacy shared
streams backend, preserving behaviour for links created before CloudKit.
"""
text = (url or "").lower()
if "/shared/album/" in text or "photos.icloud.com" in text:
return BACKEND_CLOUDKIT
return BACKEND_SHAREDSTREAMS
def parse_share(url: str) -> tuple[str, str] | None:
"""Parse a share link into ``(token, backend)`` or ``None`` if invalid."""
token = parse_share_link(url)
if not token:
return None
return token, detect_backend(url)
def partition_host(token: str) -> str:
"""Derive the shared-streams partition host for a token.
@@ -250,3 +326,248 @@ class IcloudClient:
raise RuntimeError(f"iCloud webstream failed: HTTP {status}")
payload = _json.loads(raw)
return payload.get("streamName") if isinstance(payload, dict) else None
# --- CloudKit backend helpers ---------------------------------------------
def _ck_value(fields: dict[str, Any], name: str) -> Any:
"""Return the inner ``value`` of a CloudKit field, or ``None``."""
field = fields.get(name) if isinstance(fields, dict) else None
return field.get("value") if isinstance(field, dict) else None
def _ck_decode_filename(fields: dict[str, Any]) -> str | None:
"""Decode the (base64) ``filenameEnc`` field of a CPLMaster, if present."""
raw = _ck_value(fields, "filenameEnc")
if isinstance(raw, str) and raw:
try:
return base64.b64decode(raw).decode("utf-8", "replace")
except (ValueError, TypeError):
return None
return None
def _ck_is_video(master_fields: dict[str, Any]) -> bool:
"""Return ``True`` when a CPLMaster describes a video (not a still image).
Only the ``itemType`` is used: Live Photos keep an image ``itemType`` (and
carry a non-zero asset duration), so they are treated as stills and shown.
"""
item_type = _ck_value(master_fields, "itemType")
if isinstance(item_type, str):
lowered = item_type.lower()
return any(hint in lowered for hint in _CK_VIDEO_HINTS)
return False
def pick_ck_resource(
master_fields: dict[str, Any], size: str
) -> tuple[str, Any, Any] | None:
"""Pick a downloadable image derivative for the requested display ``size``.
Returns ``(download_url, width, height)``. ``full`` selects the largest
available derivative (best for a slideshow); ``preview`` selects the
smallest. JPEG derivatives are always browser-safe; the original is only
considered when it is itself a browser-displayable format.
"""
if not isinstance(master_fields, dict):
return None
item_type = _ck_value(master_fields, "itemType")
item_type = item_type.lower() if isinstance(item_type, str) else ""
original_safe = item_type in _CK_BROWSER_SAFE_ORIGINAL
candidates: list[tuple[int, str, Any, Any]] = []
for res_key, w_key, h_key in _CK_IMAGE_RES:
res = master_fields.get(res_key)
if not isinstance(res, dict):
continue
val = res.get("value")
if not isinstance(val, dict):
continue
download_url = val.get("downloadURL")
if not download_url:
continue
if res_key == "resOriginalRes" and not original_safe:
continue
width = _ck_value(master_fields, w_key)
height = _ck_value(master_fields, h_key)
try:
rank = int(width)
except (TypeError, ValueError):
rank = 0
candidates.append((rank, download_url, width, height))
if not candidates:
return None
candidates.sort(key=lambda c: c[0])
_rank, download_url, width, height = candidates[0 if size == "preview" else -1]
return download_url, width, height
def build_ck_image_url(download_url: str | None, filename: str | None = None) -> str | None:
"""Fill the ``${f}`` filename placeholder in a CloudKit download URL."""
if not download_url:
return None
return download_url.replace("${f}", quote(filename or "image", safe=""))
def _ck_share_title(resolve_result: dict[str, Any]) -> str | None:
"""Return the album title from a resolve result's share record, if any."""
share = resolve_result.get("share") if isinstance(resolve_result, dict) else None
fields = share.get("fields") if isinstance(share, dict) else None
title = _ck_value(fields, "cloudkit.title") if isinstance(fields, dict) else None
if isinstance(title, str) and title.strip():
return title.strip()
return None
def _to_int(value: Any) -> int | None:
return int(value) if isinstance(value, (int, float)) else None
def parse_ck_records(records: list[Any], size: str) -> list[dict[str, Any]]:
"""Join CPLMaster + CPLAsset records into normalized photo dicts.
Each returned dict has ``source_id``, ``url``, ``width``, ``height``,
``captured_at`` (epoch ms) and ``description``.
"""
masters: dict[str, dict[str, Any]] = {}
assets: list[dict[str, Any]] = []
for rec in records:
if not isinstance(rec, dict):
continue
if rec.get("recordType") == "CPLMaster":
masters[rec.get("recordName")] = rec.get("fields") or {}
elif rec.get("recordType") == "CPLAsset":
assets.append(rec)
out: list[dict[str, Any]] = []
seen: set[str] = set()
for asset in assets:
af = asset.get("fields") or {}
if _ck_value(af, "isHidden") or _ck_value(af, "trashReason"):
continue
ref = af.get("masterRef")
ref_val = ref.get("value") if isinstance(ref, dict) else None
master_name = ref_val.get("recordName") if isinstance(ref_val, dict) else None
master_fields = masters.get(master_name)
if not master_fields or _ck_is_video(master_fields):
continue
picked = pick_ck_resource(master_fields, size)
if not picked:
continue
download_url, width, height = picked
url = build_ck_image_url(download_url, _ck_decode_filename(master_fields))
if not url:
continue
source_id = master_name or asset.get("recordName")
if not source_id or source_id in seen:
continue
seen.add(source_id)
out.append(
{
"source_id": source_id,
"url": url,
"width": _to_int(width),
"height": _to_int(height),
"captured_at": _to_int(_ck_value(af, "assetDate")),
"description": None,
}
)
return out
class IcloudCloudKitClient:
"""Async client for the CloudKit-backed iCloud shared album backend.
Everything is anonymous: a ``resolve`` call turns the short share token
into a shared CloudKit zone plus a short-lived anonymous access token, and
a ``records/query`` call returns the album's photos with signed image URLs
already embedded.
"""
def __init__(self, hass, token: str) -> None:
self.hass = hass
self.token = token
# Cached (zone, base_url, access_token, title) from the resolve call.
self._resolved: tuple[dict[str, Any], str, str, str | None] | None = None
async def _post(self, url: str, body: dict[str, Any]) -> tuple[int, bytes]:
session = async_get_clientsession(self.hass)
async with async_timeout.timeout(_TIMEOUT):
async with session.post(url, json=body, headers=_CK_HEADERS) as resp:
return resp.status, await resp.read()
async def _resolve(self) -> tuple[dict[str, Any], str, str, str | None]:
if self._resolved is not None:
return self._resolved
import json as _json
url = (
f"{_CK_RESOLVE_HOST}/database/1/{_CK_CONTAINER}/production"
f"/public/records/resolve?ckjsBuildVersion={_CK_BUILD}"
f"&shortGUID={self.token}"
)
status, raw = await self._post(url, {"shortGUIDs": [{"value": self.token}]})
if status != 200:
raise RuntimeError(f"iCloud resolve failed: HTTP {status}")
payload = _json.loads(raw)
results = payload.get("results") if isinstance(payload, dict) else None
if not results:
raise RuntimeError("iCloud share link did not resolve to an album")
result = results[0]
zone = result.get("zoneID")
access = result.get("anonymousPublicAccess") or {}
access_token = access.get("token")
partition = access.get("databasePartition")
if not zone or not access_token or not partition:
raise RuntimeError("iCloud shared album is not publicly accessible")
base = f"{partition}/database/1/{_CK_CONTAINER}/production"
self._resolved = (zone, base, access_token, _ck_share_title(result))
return self._resolved
def _query_url(self, base: str, access_token: str) -> str:
query = urlencode(
{
"remapEnums": "true",
"getCurrentSyncToken": "true",
"sharing_url_key": self.token,
"publicAccessAuthToken": access_token,
}
)
return f"{base}/shared/records/query?{query}"
async def async_validate(self) -> str | None:
"""Return the album title if the share link resolves, else raise."""
_zone, _base, _token, title = await self._resolve()
return title
async def async_get_items(self, size: str) -> list[dict[str, Any]]:
"""Return normalized photo dicts for the album (see ``parse_ck_records``)."""
import json as _json
zone, base, access_token, _title = await self._resolve()
url = self._query_url(base, access_token)
records: list[Any] = []
continuation: str | None = None
while len(records) < _MAX_ASSETS:
body: dict[str, Any] = {
"zoneID": zone,
"query": {"recordType": _CK_RECORD_TYPE},
"resultsLimit": _CK_PAGE,
}
if continuation:
body["continuationMarker"] = continuation
status, raw = await self._post(url, body)
if status != 200:
raise RuntimeError(f"iCloud records query failed: HTTP {status}")
payload = _json.loads(raw)
batch = payload.get("records") if isinstance(payload, dict) else None
if not batch:
break
records.extend(batch)
continuation = payload.get("continuationMarker") if isinstance(payload, dict) else None
if not continuation:
break
return parse_ck_records(records, size)
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"issue_tracker": "https://github.com/eyalgal/album_slideshow/issues",
"requirements": ["Pillow"],
"version": "1.6.2"
"version": "1.7.0"
}
@@ -78,7 +78,7 @@
},
"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.",
"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). Both the older icloud.com/sharedalbum links and the newer photos.icloud.com/shared/album links work. No Apple ID or password is needed - the link itself is the credential. Capture date comes through; Apple does not include location in shared albums.",
"data": {
"album_name": "Album name",
"icloud_url": "Shared album link",
@@ -78,7 +78,7 @@
},
"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.",
"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). Both the older icloud.com/sharedalbum links and the newer photos.icloud.com/shared/album links work. No Apple ID or password is needed - the link itself is the credential. Capture date comes through; Apple does not include location in shared albums.",
"data": {
"album_name": "Album name",
"icloud_url": "Shared album link",
@@ -26,7 +26,7 @@
* tap_action: none # none | more-info
*/
const VERSION = "1.6.2";
const VERSION = "1.7.0";
const ANIMATED_TRANSITIONS = [
"fade",
File diff suppressed because one or more lines are too long
Binary file not shown.
+28 -28
View File
@@ -4,7 +4,7 @@
"state": "ON",
"led_brightness": 100,
"countdown_to_turn_off": 0,
"voltage": 121.5,
"voltage": 120.9,
"countdown_to_turn_on": 0,
"ac_frequency": 60,
"power_factor": 0.11,
@@ -22,15 +22,15 @@
},
"0xffffb40e0607af27": {
"state": "ON",
"voltage": 121.7,
"voltage": 121.3,
"ac_frequency": 60,
"led_brightness": 100,
"countdown_to_turn_off": 0,
"countdown_to_turn_on": 0,
"power": 2.1,
"current": 0.12,
"energy": 24.05,
"power_factor": 0.14,
"power": 4,
"current": 0.13,
"energy": 24.1,
"power_factor": 0.25,
"update": {
"state": "idle",
"installed_version": 268513381,
@@ -47,7 +47,7 @@
"countdown_to_turn_off": 0,
"voltage": 120.7,
"countdown_to_turn_on": 0,
"energy": 44.18,
"energy": 44.22,
"power_factor": 0.2,
"ac_frequency": 60,
"update": {
@@ -58,8 +58,8 @@
"latest_release_notes": null
},
"linkquality": 83,
"power": 83.7,
"current": 0.82,
"power": 0.2,
"current": 0.02,
"power_on_behavior": "on"
},
"0xb40e060fffe031e3": {
@@ -74,13 +74,13 @@
"led_brightness": 100,
"countdown_to_turn_off": 0,
"countdown_to_turn_on": 0,
"voltage": 120.3,
"voltage": 120,
"state": "ON",
"ac_frequency": 60,
"energy": 89.25,
"power": 0.8,
"energy": 89.31,
"power": 3.7,
"current": 0.05,
"power_factor": 0.38,
"power_factor": 0.41,
"update": {
"state": "idle",
"installed_version": 268513381,
@@ -95,13 +95,13 @@
"led_brightness": 100,
"countdown_to_turn_off": 0,
"countdown_to_turn_on": 0,
"voltage": 121.4,
"energy": 36.66,
"voltage": 120.7,
"energy": 36.73,
"state": "ON",
"power": 64.6,
"current": 0.63,
"power": 77.4,
"current": 0.84,
"ac_frequency": 60,
"power_factor": 0.85,
"power_factor": 0.8,
"update": {
"state": "idle",
"installed_version": 268513381,
@@ -114,12 +114,12 @@
},
"0xffffb40e060895b3": {
"state": "ON",
"voltage": 121,
"voltage": 120.8,
"ac_frequency": 60,
"energy": 5.74,
"current": 0.02,
"current": 0.01,
"power": 0.1,
"power_factor": 0.22,
"power_factor": 0.11,
"linkquality": 109,
"update": {
"state": "idle",
@@ -136,14 +136,14 @@
"0xffffb40e0608864e": {
"led_brightness": 100,
"countdown_to_turn_off": 0,
"voltage": 121.9,
"voltage": 121.4,
"energy": 16.08,
"countdown_to_turn_on": 0,
"state": "ON",
"current": 0.02,
"ac_frequency": 60,
"power": 0.4,
"power_factor": 0.14,
"power_factor": 0.2,
"update": {
"state": "idle",
"installed_version": 268513381,
@@ -172,7 +172,7 @@
"0xffffb40e060893d8": {
"state": "ON",
"led_brightness": 100,
"voltage": 122.1,
"voltage": 121.7,
"countdown_to_turn_off": 0,
"countdown_to_turn_on": 0,
"energy": 2.98,
@@ -187,12 +187,12 @@
"latest_source": "https://raw.githubusercontent.com/Koenkk/zigbee-OTA/master/images/ThirdReality/SmartPlug_Zigbee_PROD_OTA_V101_1.01.01.ota",
"latest_release_notes": null
},
"power_factor": 0.06,
"power": 0.1
"power_factor": 0,
"power": 0.3
},
"0xa4c1380d0679ffff": {
"battery": 100,
"temperature": 26.8,
"temperature": 26.9,
"temperature_units": "celsius",
"temperature_calibration": 0,
"update": {
@@ -245,7 +245,7 @@
},
"0xb40e060fffe717c5": {
"battery": 100,
"occupancy": true,
"occupancy": false,
"tamper": false,
"battery_low": false,
"linkquality": 65,