11 files
This commit is contained in:
@@ -39,8 +39,9 @@ _LOGGER = logging.getLogger(__name__)
|
||||
# JPEG; RAW/NEF/etc. aren't supported as camera frames anyway.
|
||||
_MAX_DOWNLOAD_BYTES = 64 * 1024 * 1024
|
||||
|
||||
# Only these content types are accepted as image bodies. If a server returns
|
||||
# HTML (captive portal, 404 page rendered as 200, etc.) we reject it early.
|
||||
# Image content types are accepted, with a narrow binary-image exception for
|
||||
# Apple's CDN below. HTML (captive portal, 404 page rendered as 200, etc.) is
|
||||
# still rejected early.
|
||||
_ACCEPTED_IMAGE_PREFIX = ("image/",)
|
||||
|
||||
# Max candidates we'll scan when searching for a mismatched-orientation
|
||||
@@ -1262,7 +1263,20 @@ class AlbumSlideshowCamera(Camera):
|
||||
|
||||
content_type = resp.headers.get("Content-Type", "")
|
||||
primary = content_type.split(";", 1)[0].strip().lower()
|
||||
if primary and not primary.startswith(_ACCEPTED_IMAGE_PREFIX):
|
||||
icloud_binary_image = False
|
||||
if primary == "application/octet-stream":
|
||||
# Check the final response host after redirects, never
|
||||
# a substring of the original URL's path or query.
|
||||
host = (resp.url.host or "").lower()
|
||||
icloud_binary_image = (
|
||||
host == "icloud-content.com"
|
||||
or host.endswith(".icloud-content.com")
|
||||
)
|
||||
if (
|
||||
primary
|
||||
and not primary.startswith(_ACCEPTED_IMAGE_PREFIX)
|
||||
and not icloud_binary_image
|
||||
):
|
||||
_LOGGER.debug(
|
||||
"Album Slideshow: rejecting %s, content-type %r is not an image",
|
||||
url, primary,
|
||||
|
||||
@@ -141,6 +141,14 @@ def _describe_error(err: BaseException) -> str:
|
||||
return f"HTTP {status}: {detail}" if status else f"{type(err).__name__}: {detail}"
|
||||
|
||||
|
||||
def _redact_token(token: str | None) -> str:
|
||||
"""Show enough of a share token to debug it without publishing the album."""
|
||||
if not token:
|
||||
return "<empty>"
|
||||
shape = "".join("-" if c in "-_" else "x" for c in token)
|
||||
return f"{token[:3]}...({len(token)} chars, shape {shape})"
|
||||
|
||||
|
||||
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
VERSION = 1
|
||||
|
||||
@@ -549,7 +557,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
await client.async_validate()
|
||||
albums = await client.async_list_albums()
|
||||
people = await client.async_list_people()
|
||||
except Exception: # noqa: BLE001 - any failure means bad URL/creds
|
||||
except Exception as err: # noqa: BLE001 - any failure means bad URL/creds
|
||||
_LOGGER.warning(
|
||||
"PhotoPrism validation failed for %s: %s",
|
||||
client.base_url,
|
||||
_describe_error(err),
|
||||
)
|
||||
errors["base"] = "photoprism_cannot_connect"
|
||||
else:
|
||||
self._pp_url = client.base_url
|
||||
@@ -729,7 +742,13 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
client = icloud_api.IcloudClient(self.hass, token)
|
||||
try:
|
||||
await client.async_validate()
|
||||
except Exception: # noqa: BLE001 - any failure means bad/expired link
|
||||
except Exception as err: # noqa: BLE001 - any failure means bad/expired link
|
||||
_LOGGER.warning(
|
||||
"iCloud validation failed for token %s (%s backend): %s",
|
||||
_redact_token(token),
|
||||
backend,
|
||||
_describe_error(err),
|
||||
)
|
||||
errors["base"] = "icloud_cannot_connect"
|
||||
else:
|
||||
await self.async_set_unique_id(
|
||||
@@ -791,7 +810,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
await client.async_login(otp_code=otp or None)
|
||||
except syn_api.SynologyOtpRequired:
|
||||
errors["otp_code"] = "synology_otp_required"
|
||||
except Exception: # noqa: BLE001 - any failure means bad URL/creds
|
||||
except Exception as err: # noqa: BLE001 - any failure means bad URL/creds
|
||||
_LOGGER.warning(
|
||||
"Synology login failed for %s: %s", url, _describe_error(err)
|
||||
)
|
||||
errors["base"] = "synology_cannot_connect"
|
||||
|
||||
if not errors:
|
||||
@@ -811,7 +833,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
places = await client.async_list_places()
|
||||
tags = await client.async_list_tags()
|
||||
subjects = await client.async_list_subjects()
|
||||
except Exception: # noqa: BLE001
|
||||
except Exception as err: # noqa: BLE001
|
||||
_LOGGER.warning(
|
||||
"Synology listing failed for space %s: %s",
|
||||
space,
|
||||
_describe_error(err),
|
||||
)
|
||||
if space == SYNOLOGY_SPACE_SHARED:
|
||||
errors["base"] = "synology_shared_unavailable"
|
||||
else:
|
||||
@@ -1039,7 +1066,13 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
)
|
||||
try:
|
||||
await client.async_validate()
|
||||
except Exception: # noqa: BLE001 - any failure means bad URL/creds/folder
|
||||
except Exception as err: # noqa: BLE001 - any failure means bad URL/creds/folder
|
||||
_LOGGER.warning(
|
||||
"Nextcloud validation failed for %s (folder %r): %s",
|
||||
client.base_url,
|
||||
client.folder,
|
||||
_describe_error(err),
|
||||
)
|
||||
errors["base"] = "nextcloud_cannot_connect"
|
||||
else:
|
||||
await self.async_set_unique_id(
|
||||
@@ -1111,7 +1144,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
client = nc_api.NextcloudPublicClient(self.hass, base_url, token)
|
||||
try:
|
||||
await client.async_validate()
|
||||
except Exception: # noqa: BLE001 - any failure means bad/expired link
|
||||
except Exception as err: # noqa: BLE001 - any failure means bad/expired link
|
||||
_LOGGER.warning(
|
||||
"Nextcloud public album validation failed for %s: %s",
|
||||
base_url,
|
||||
_describe_error(err),
|
||||
)
|
||||
errors["base"] = "nextcloud_public_cannot_connect"
|
||||
else:
|
||||
await self.async_set_unique_id(
|
||||
|
||||
@@ -61,6 +61,17 @@ PROVIDER_SYNOLOGY = "synology"
|
||||
PROVIDER_NEXTCLOUD = "nextcloud"
|
||||
PROVIDER_ENTE = "ente"
|
||||
|
||||
# Providers whose coordinator runs a background enrichment pass: per-photo
|
||||
# metadata reads, reverse-geocoding, or both. These are the entries that get
|
||||
# the Enrichment progress diagnostic sensor. Keep this as the single source of
|
||||
# truth so the sensor can't drift away from what the coordinator actually does.
|
||||
ENRICHING_PROVIDERS = (
|
||||
PROVIDER_LOCAL_FOLDER,
|
||||
PROVIDER_IMMICH,
|
||||
PROVIDER_NEXTCLOUD,
|
||||
PROVIDER_ENTE,
|
||||
)
|
||||
|
||||
# Nextcloud provider - two auth modes against the same PROVIDER_NEXTCLOUD id:
|
||||
#
|
||||
# - "folder": authenticated WebDAV folder. Points at any folder in a user's
|
||||
|
||||
@@ -81,6 +81,7 @@ from .const import (
|
||||
ENTE_IMAGE_PREVIEW,
|
||||
DEFAULT_REVERSE_GEOCODE,
|
||||
DOMAIN,
|
||||
ENRICHING_PROVIDERS,
|
||||
PROVIDER_GOOGLE_SHARED,
|
||||
PROVIDER_LOCAL_FOLDER,
|
||||
PROVIDER_MEDIA_SOURCE,
|
||||
@@ -356,7 +357,9 @@ def _find_largest_item_list(obj: Any) -> list[dict[str, Any]]:
|
||||
|
||||
|
||||
def _looks_like_video(raw: dict[str, Any]) -> bool:
|
||||
mime = raw.get("mimeType")
|
||||
# publicalbum.org sends lowercase ``mimetype``; Google's own shapes use
|
||||
# ``mimeType``.
|
||||
mime = raw.get("mimeType") or raw.get("mimetype")
|
||||
if isinstance(mime, str) and mime.startswith("video/"):
|
||||
return True
|
||||
|
||||
@@ -1090,12 +1093,7 @@ class AlbumCoordinator(DataUpdateCoordinator):
|
||||
raise
|
||||
|
||||
items = data.get("items") or []
|
||||
if self.provider in (
|
||||
PROVIDER_LOCAL_FOLDER,
|
||||
PROVIDER_IMMICH,
|
||||
PROVIDER_NEXTCLOUD,
|
||||
PROVIDER_ENTE,
|
||||
) and items:
|
||||
if self.provider in ENRICHING_PROVIDERS and items:
|
||||
# Carry forward EXIF/geocode metadata for items we've already
|
||||
# scanned this session; new items get filled in by the
|
||||
# background worker below.
|
||||
@@ -2505,10 +2503,13 @@ class AlbumCoordinator(DataUpdateCoordinator):
|
||||
# to bypass publicalbum.org's ~300 cap.
|
||||
scraped_items: list[MediaItem] = []
|
||||
scraped_title: str | None = None
|
||||
# Media keys the scraper identified as video. publicalbum.org returns
|
||||
# no media type at all, so this is what lets us drop them there too.
|
||||
video_keys: set[str] = set()
|
||||
try:
|
||||
from . import google_scraper
|
||||
|
||||
scraped_title, scraped_items = await google_scraper.fetch_album(
|
||||
scraped_title, scraped_items, video_keys = await google_scraper.fetch_album(
|
||||
session, self.album_url
|
||||
)
|
||||
except Exception as err: # never let scrape failure break the integration
|
||||
@@ -2550,12 +2551,19 @@ class AlbumCoordinator(DataUpdateCoordinator):
|
||||
|
||||
page_items = _find_largest_item_list(result) or _find_largest_item_list(data)
|
||||
api_items: list[MediaItem] = []
|
||||
dropped_videos = 0
|
||||
if page_items:
|
||||
seen_urls: set[str] = set()
|
||||
for raw in page_items:
|
||||
if _looks_like_video(raw):
|
||||
continue
|
||||
|
||||
# publicalbum.org gives no media type, so fall back to the
|
||||
# scraper's verdict for the same photo (matched on media key).
|
||||
if raw.get("id") in video_keys:
|
||||
dropped_videos += 1
|
||||
continue
|
||||
|
||||
url = _pick_url(raw)
|
||||
if not url:
|
||||
continue
|
||||
@@ -2625,8 +2633,9 @@ class AlbumCoordinator(DataUpdateCoordinator):
|
||||
raise UpdateFailed("No photos returned from Google shared album")
|
||||
|
||||
_LOGGER.info(
|
||||
"Album scraper: source=publicalbum items=%d (batchexecute=%d)",
|
||||
len(api_items), len(scraped_items),
|
||||
"Album scraper: source=publicalbum items=%d (batchexecute=%d, "
|
||||
"videos dropped via scraper=%d)",
|
||||
len(api_items), len(scraped_items), dropped_videos,
|
||||
)
|
||||
return {
|
||||
"title": result.get("title") or self.entry.title,
|
||||
|
||||
@@ -71,16 +71,23 @@ class _AlbumKeys:
|
||||
self.auth_key = auth_key
|
||||
|
||||
|
||||
async def fetch_album(session, share_url: str, *, timeout: float = 30.0) -> tuple[str | None, list[MediaItem]]:
|
||||
"""Fetch a shared album in full. Returns (title, items).
|
||||
async def fetch_album(
|
||||
session, share_url: str, *, timeout: float = 30.0
|
||||
) -> tuple[str | None, list[MediaItem], set[str]]:
|
||||
"""Fetch a shared album in full. Returns (title, items, video_keys).
|
||||
|
||||
The HTML page is fetched once - just to recover the album/auth keys and
|
||||
title. All actual photo enumeration goes through Google's ``batchexecute``
|
||||
endpoint, which is the only way to reach photos beyond the first ~300.
|
||||
|
||||
``video_keys`` holds the media keys of items skipped as video. The
|
||||
publicalbum.org source returns no media type at all, so it relies on these
|
||||
to drop the same videos (see #26).
|
||||
"""
|
||||
video_keys: set[str] = set()
|
||||
keys, title = await _fetch_album_keys(session, share_url, timeout=timeout)
|
||||
if keys is None:
|
||||
return None, []
|
||||
return None, [], video_keys
|
||||
|
||||
items: list[MediaItem] = []
|
||||
seen_urls: set[str] = set()
|
||||
@@ -90,7 +97,7 @@ async def fetch_album(session, share_url: str, *, timeout: float = 30.0) -> tupl
|
||||
page_no += 1
|
||||
try:
|
||||
page_items, page_id = await _fetch_album_page(
|
||||
session, keys, page_id, timeout=timeout
|
||||
session, keys, page_id, timeout=timeout, video_keys=video_keys
|
||||
)
|
||||
except Exception as err:
|
||||
_LOGGER.warning(
|
||||
@@ -119,7 +126,7 @@ async def fetch_album(session, share_url: str, *, timeout: float = 30.0) -> tupl
|
||||
"Album scraper: batchexecute fetched %d photos in %d page(s)",
|
||||
len(items), page_no,
|
||||
)
|
||||
return title, items
|
||||
return title, items, video_keys
|
||||
|
||||
|
||||
# -- Internals ---------------------------------------------------------------
|
||||
@@ -173,11 +180,14 @@ def _extract_title(html: str) -> str | None:
|
||||
return title or None
|
||||
|
||||
|
||||
def _extract_first_page_items(html: str) -> list[MediaItem]:
|
||||
def _extract_first_page_items(
|
||||
html: str, video_keys: set[str] | None = None
|
||||
) -> list[MediaItem]:
|
||||
"""Pull the first 300 items out of the AF_initDataCallback blocks.
|
||||
|
||||
Returns an empty list if the embedded data can't be parsed - the caller
|
||||
will still get the rest via batchexecute pagination.
|
||||
will still get the rest via batchexecute pagination. Media keys of items
|
||||
skipped as video are added to ``video_keys`` when one is supplied.
|
||||
"""
|
||||
candidates: list[list[Any]] = []
|
||||
for blob in _iter_af_data_blobs(html):
|
||||
@@ -195,6 +205,7 @@ def _extract_first_page_items(html: str) -> list[MediaItem]:
|
||||
seen: set[str] = set()
|
||||
for raw in best:
|
||||
if _is_video_item(raw):
|
||||
_record_video_key(raw, video_keys)
|
||||
continue
|
||||
item = _parse_album_item(raw)
|
||||
if item is None or item.url in seen:
|
||||
@@ -226,6 +237,7 @@ async def _fetch_album_page(
|
||||
page_id: str | None,
|
||||
*,
|
||||
timeout: float,
|
||||
video_keys: set[str] | None = None,
|
||||
) -> tuple[list[MediaItem], str | None]:
|
||||
"""Call snAcKc once. ``page_id=""`` is treated as ``None`` (initial fetch)."""
|
||||
pid = page_id or None
|
||||
@@ -247,10 +259,12 @@ async def _fetch_album_page(
|
||||
async with session.post(url, data=form, headers=headers, timeout=timeout) as resp:
|
||||
resp.raise_for_status()
|
||||
body = await resp.text()
|
||||
return _parse_batchexecute_album_page(body)
|
||||
return _parse_batchexecute_album_page(body, video_keys)
|
||||
|
||||
|
||||
def _parse_batchexecute_album_page(body: str) -> tuple[list[MediaItem], str | None]:
|
||||
def _parse_batchexecute_album_page(
|
||||
body: str, video_keys: set[str] | None = None
|
||||
) -> tuple[list[MediaItem], str | None]:
|
||||
"""Parse a batchexecute response for one snAcKc call.
|
||||
|
||||
Format (per line, after the XSSI prefix):
|
||||
@@ -293,6 +307,7 @@ def _parse_batchexecute_album_page(body: str) -> tuple[list[MediaItem], str | No
|
||||
items: list[MediaItem] = []
|
||||
for raw in raw_items:
|
||||
if _is_video_item(raw):
|
||||
_record_video_key(raw, video_keys)
|
||||
continue
|
||||
item = _parse_album_item(raw)
|
||||
if item is not None:
|
||||
@@ -300,6 +315,22 @@ def _parse_batchexecute_album_page(body: str) -> tuple[list[MediaItem], str | No
|
||||
return items, next_page
|
||||
|
||||
|
||||
def _record_video_key(raw: Any, sink: set[str] | None) -> None:
|
||||
"""Remember a rejected video so other sources can drop it by id too."""
|
||||
if sink is None:
|
||||
return
|
||||
key = _media_key(raw)
|
||||
if key:
|
||||
sink.add(key)
|
||||
|
||||
|
||||
def _media_key(raw: Any) -> str | None:
|
||||
"""Google's stable per-photo id, which publicalbum.org echoes as item ``id``."""
|
||||
if isinstance(raw, list) and raw and isinstance(raw[0], str):
|
||||
return raw[0]
|
||||
return None
|
||||
|
||||
|
||||
def _is_video_item(raw: Any) -> bool:
|
||||
"""Return ``True`` when an album item carries a video duration.
|
||||
|
||||
|
||||
@@ -50,6 +50,10 @@ _BASE62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
# also contain ``-`` and ``_`` (e.g. ``045YeI20-8u3X31bBPD5z9B_A``).
|
||||
_TOKEN_CHARS = frozenset(_BASE62 + "-_")
|
||||
|
||||
# Used when a token's leading characters aren't base62 and the partition can't
|
||||
# be derived. Apple answers with an X-Apple-MMe-Host redirect to the right one.
|
||||
_DEFAULT_PARTITION_HOST = "p01-sharedstreams.icloud.com"
|
||||
|
||||
# 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``.
|
||||
@@ -95,10 +99,18 @@ _CK_BROWSER_SAFE_ORIGINAL = {"public.jpeg", "public.png"}
|
||||
_CK_VIDEO_HINTS = ("movie", "video", "mpeg-4", "quicktime")
|
||||
|
||||
|
||||
def _base62_to_int(value: str) -> int:
|
||||
def _base62_to_int(value: str) -> int | None:
|
||||
"""Decode base62, or ``None`` if the text isn't purely base62.
|
||||
|
||||
Share tokens may contain ``-``/``_`` (CloudKit uses a URL-safe alphabet), so
|
||||
this has to answer "not base62" rather than raise.
|
||||
"""
|
||||
result = 0
|
||||
for ch in value:
|
||||
result = result * 62 + _BASE62.index(ch)
|
||||
index = _BASE62.find(ch)
|
||||
if index < 0:
|
||||
return None
|
||||
result = result * 62 + index
|
||||
return result
|
||||
|
||||
|
||||
@@ -155,7 +167,9 @@ 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.
|
||||
one char after a leading ``A``, otherwise the first two chars. When those
|
||||
characters aren't base62 the partition can't be computed, so fall back to
|
||||
a valid host and let Apple redirect via ``X-Apple-MMe-Host``.
|
||||
"""
|
||||
if not token:
|
||||
return ""
|
||||
@@ -163,6 +177,8 @@ def partition_host(token: str) -> str:
|
||||
partition = _base62_to_int(token[1:2])
|
||||
else:
|
||||
partition = _base62_to_int(token[1:3])
|
||||
if partition is None:
|
||||
return _DEFAULT_PARTITION_HOST
|
||||
return f"p{partition:02d}-sharedstreams.icloud.com"
|
||||
|
||||
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"issue_tracker": "https://github.com/eyalgal/album_slideshow/issues",
|
||||
"requirements": ["Pillow", "PyNaCl>=1.5.0"],
|
||||
"version": "1.9.0"
|
||||
"version": "1.9.1"
|
||||
}
|
||||
|
||||
@@ -6,8 +6,9 @@ HTTP lives in ``PhotoprismClient``; the parsing/URL helpers are pure functions
|
||||
so they can be unit-tested without a live server or aiohttp.
|
||||
|
||||
API shape (PhotoPrism ``/api/v1``, Bearer auth):
|
||||
- ``POST /api/v1/session`` ``{username, password}`` -> ``{access_token, ...}``
|
||||
(only needed for the username + password auth method).
|
||||
- ``POST /api/v1/session`` ``{username, password}`` -> ``{access_token,
|
||||
config: {previewToken, ...}, ...}`` (only needed for the username +
|
||||
password auth method).
|
||||
- ``GET /api/v1/photos`` ``?count&offset&order=newest&primary=true`` plus a
|
||||
filter (``s=<album_uid>``, ``q=subject:<uid>``, ``q=favorite:true``, or a
|
||||
custom ``q=``) -> a JSON list of photos. Metadata is inline (``TakenAt``,
|
||||
@@ -23,6 +24,7 @@ API shape (PhotoPrism ``/api/v1``, Bearer auth):
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
@@ -224,8 +226,8 @@ class PhotoprismClient:
|
||||
# Bearer token used for API calls: the app password directly, or the
|
||||
# session access token obtained from username + password.
|
||||
self._bearer: str | None = token if auth_method == "app_password" else None
|
||||
# Preview token captured from the most recent search response, used to
|
||||
# build thumbnail URLs.
|
||||
# Login config supplies a preview token even when search headers omit
|
||||
# it. Later search responses can refresh it for thumbnail URLs.
|
||||
self.preview_token: str | None = None
|
||||
|
||||
@property
|
||||
@@ -251,6 +253,12 @@ class PhotoprismClient:
|
||||
if not token:
|
||||
raise PhotoprismAuthError("session login returned no access_token")
|
||||
self._bearer = token
|
||||
config = data.get("config")
|
||||
preview_token = config.get("previewToken") if isinstance(config, dict) else None
|
||||
# A new session replaces the old preview token, including on re-login.
|
||||
self.preview_token = (
|
||||
preview_token.strip() or None if isinstance(preview_token, str) else None
|
||||
)
|
||||
|
||||
async def async_authenticate(self) -> None:
|
||||
"""Ensure a usable Bearer token is available."""
|
||||
@@ -259,8 +267,10 @@ class PhotoprismClient:
|
||||
elif not self._bearer:
|
||||
raise PhotoprismAuthError("no app password configured")
|
||||
|
||||
async def _get(self, path: str, params: dict[str, str] | None = None) -> tuple[Any, dict[str, str]]:
|
||||
"""GET returning ``(json, headers)``, re-authenticating once on 401."""
|
||||
async def _get(
|
||||
self, path: str, params: dict[str, str] | None = None
|
||||
) -> tuple[Any, Mapping[str, str]]:
|
||||
"""GET JSON and case-insensitive headers, re-authenticating once on 401."""
|
||||
if self._bearer is None:
|
||||
await self.async_authenticate()
|
||||
session = async_get_clientsession(self.hass)
|
||||
@@ -274,7 +284,7 @@ class PhotoprismClient:
|
||||
await self._login()
|
||||
continue
|
||||
resp.raise_for_status()
|
||||
return await resp.json(), dict(resp.headers)
|
||||
return await resp.json(), resp.headers
|
||||
raise PhotoprismAuthError("unauthorized after re-authentication")
|
||||
|
||||
async def async_validate(self) -> None:
|
||||
|
||||
@@ -6,7 +6,11 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity import EntityCategory
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .const import DOMAIN, PROVIDER_GOOGLE_SHARED, PROVIDER_LOCAL_FOLDER
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
ENRICHING_PROVIDERS,
|
||||
PROVIDER_GOOGLE_SHARED,
|
||||
)
|
||||
from .coordinator import AlbumCoordinator
|
||||
|
||||
|
||||
@@ -17,11 +21,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e
|
||||
AlbumTitleSensor(entry, coordinator),
|
||||
CacheUsageSensor(entry, coordinator),
|
||||
]
|
||||
if coordinator.provider == PROVIDER_LOCAL_FOLDER:
|
||||
# Diagnostic surface for the local-folder background enrichment
|
||||
# (EXIF reads + reverse-geocode). For Google albums there's no
|
||||
# enrichment work, so this sensor is omitted to keep the device
|
||||
# screen tidy.
|
||||
if coordinator.provider in ENRICHING_PROVIDERS:
|
||||
# Diagnostic surface for the background enrichment pass (per-photo
|
||||
# metadata reads and/or reverse-geocode). Providers with nothing to
|
||||
# enrich omit it to keep the device screen tidy.
|
||||
entities.append(EnrichmentProgressSensor(entry, coordinator))
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
* tap_action: none # none | more-info
|
||||
*/
|
||||
|
||||
const VERSION = "1.9.0";
|
||||
const VERSION = "1.9.1";
|
||||
|
||||
const ANIMATED_TRANSITIONS = [
|
||||
"fade",
|
||||
|
||||
+24
-24
@@ -4,10 +4,10 @@
|
||||
"state": "ON",
|
||||
"led_brightness": 100,
|
||||
"countdown_to_turn_off": 0,
|
||||
"voltage": 119.1,
|
||||
"voltage": 118.5,
|
||||
"countdown_to_turn_on": 0,
|
||||
"ac_frequency": 60,
|
||||
"power_factor": 0.15,
|
||||
"power_factor": 0.11,
|
||||
"update": {
|
||||
"state": "idle",
|
||||
"installed_version": 268513381,
|
||||
@@ -22,15 +22,15 @@
|
||||
},
|
||||
"0xffffb40e0607af27": {
|
||||
"state": "ON",
|
||||
"voltage": 118.4,
|
||||
"voltage": 117.8,
|
||||
"ac_frequency": 60,
|
||||
"led_brightness": 100,
|
||||
"countdown_to_turn_off": 0,
|
||||
"countdown_to_turn_on": 0,
|
||||
"power": 244.7,
|
||||
"current": 2.29,
|
||||
"energy": 40.38,
|
||||
"power_factor": 0.9,
|
||||
"power": 236.2,
|
||||
"current": 2.04,
|
||||
"energy": 40.43,
|
||||
"power_factor": 0.98,
|
||||
"update": {
|
||||
"state": "idle",
|
||||
"installed_version": 268513381,
|
||||
@@ -45,10 +45,10 @@
|
||||
"state": "ON",
|
||||
"led_brightness": 100,
|
||||
"countdown_to_turn_off": 0,
|
||||
"voltage": 118.6,
|
||||
"voltage": 117.6,
|
||||
"countdown_to_turn_on": 0,
|
||||
"energy": 79.66,
|
||||
"power_factor": 0.31,
|
||||
"power_factor": 0.91,
|
||||
"ac_frequency": 60,
|
||||
"update": {
|
||||
"state": "idle",
|
||||
@@ -58,8 +58,8 @@
|
||||
"latest_release_notes": null
|
||||
},
|
||||
"linkquality": 80,
|
||||
"power": 0.2,
|
||||
"current": 0.01,
|
||||
"power": 84.1,
|
||||
"current": 0.83,
|
||||
"power_on_behavior": "on"
|
||||
},
|
||||
"0xb40e060fffe031e3": {
|
||||
@@ -74,13 +74,13 @@
|
||||
"led_brightness": 100,
|
||||
"countdown_to_turn_off": 0,
|
||||
"countdown_to_turn_on": 0,
|
||||
"voltage": 118.4,
|
||||
"voltage": 116.6,
|
||||
"state": "ON",
|
||||
"ac_frequency": 60,
|
||||
"energy": 162.1,
|
||||
"power": 0.8,
|
||||
"current": 0.05,
|
||||
"power_factor": 0.39,
|
||||
"energy": 162.12,
|
||||
"power": 129.7,
|
||||
"current": 1.27,
|
||||
"power_factor": 0.94,
|
||||
"update": {
|
||||
"state": "idle",
|
||||
"installed_version": 268513381,
|
||||
@@ -95,13 +95,13 @@
|
||||
"led_brightness": 100,
|
||||
"countdown_to_turn_off": 0,
|
||||
"countdown_to_turn_on": 0,
|
||||
"voltage": 118.3,
|
||||
"energy": 78.31,
|
||||
"voltage": 117.6,
|
||||
"energy": 78.32,
|
||||
"state": "ON",
|
||||
"power": 24.2,
|
||||
"current": 0.35,
|
||||
"power": 30.8,
|
||||
"current": 0.43,
|
||||
"ac_frequency": 60,
|
||||
"power_factor": 0.63,
|
||||
"power_factor": 0.62,
|
||||
"update": {
|
||||
"state": "idle",
|
||||
"installed_version": 268513381,
|
||||
@@ -114,12 +114,12 @@
|
||||
},
|
||||
"0xffffb40e060895b3": {
|
||||
"state": "ON",
|
||||
"voltage": 118.5,
|
||||
"voltage": 118.3,
|
||||
"ac_frequency": 60,
|
||||
"energy": 11.09,
|
||||
"current": 0.01,
|
||||
"power": 0.1,
|
||||
"power_factor": 0.11,
|
||||
"power_factor": 0.16,
|
||||
"linkquality": 105,
|
||||
"update": {
|
||||
"state": "idle",
|
||||
@@ -136,7 +136,7 @@
|
||||
"0xffffb40e0608864e": {
|
||||
"led_brightness": 100,
|
||||
"countdown_to_turn_off": 0,
|
||||
"voltage": 119.5,
|
||||
"voltage": 118.5,
|
||||
"energy": 24.52,
|
||||
"countdown_to_turn_on": 0,
|
||||
"state": "ON",
|
||||
|
||||
Reference in New Issue
Block a user