diff --git a/custom_components/album_slideshow/camera.py b/custom_components/album_slideshow/camera.py index ae06c94b..8dc12ac6 100644 --- a/custom_components/album_slideshow/camera.py +++ b/custom_components/album_slideshow/camera.py @@ -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, diff --git a/custom_components/album_slideshow/config_flow.py b/custom_components/album_slideshow/config_flow.py index ffd62ddf..b3b0ef9a 100644 --- a/custom_components/album_slideshow/config_flow.py +++ b/custom_components/album_slideshow/config_flow.py @@ -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 "" + 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( diff --git a/custom_components/album_slideshow/const.py b/custom_components/album_slideshow/const.py index fcef926f..469eab1b 100644 --- a/custom_components/album_slideshow/const.py +++ b/custom_components/album_slideshow/const.py @@ -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 diff --git a/custom_components/album_slideshow/coordinator.py b/custom_components/album_slideshow/coordinator.py index 1238d612..5abea846 100644 --- a/custom_components/album_slideshow/coordinator.py +++ b/custom_components/album_slideshow/coordinator.py @@ -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, diff --git a/custom_components/album_slideshow/google_scraper.py b/custom_components/album_slideshow/google_scraper.py index 37a077b7..93b3d203 100644 --- a/custom_components/album_slideshow/google_scraper.py +++ b/custom_components/album_slideshow/google_scraper.py @@ -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. diff --git a/custom_components/album_slideshow/icloud.py b/custom_components/album_slideshow/icloud.py index 3c9ae3d9..c94ff06b 100644 --- a/custom_components/album_slideshow/icloud.py +++ b/custom_components/album_slideshow/icloud.py @@ -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" diff --git a/custom_components/album_slideshow/manifest.json b/custom_components/album_slideshow/manifest.json index e9441570..52e452b0 100644 --- a/custom_components/album_slideshow/manifest.json +++ b/custom_components/album_slideshow/manifest.json @@ -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" } diff --git a/custom_components/album_slideshow/photoprism.py b/custom_components/album_slideshow/photoprism.py index d26dced5..c4f7d6cd 100644 --- a/custom_components/album_slideshow/photoprism.py +++ b/custom_components/album_slideshow/photoprism.py @@ -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=``, ``q=subject:``, ``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: diff --git a/custom_components/album_slideshow/sensor.py b/custom_components/album_slideshow/sensor.py index 85714447..5ffb6b65 100644 --- a/custom_components/album_slideshow/sensor.py +++ b/custom_components/album_slideshow/sensor.py @@ -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) diff --git a/custom_components/album_slideshow/www/album-slideshow-card.js b/custom_components/album_slideshow/www/album-slideshow-card.js index 598198ce..71f48519 100644 --- a/custom_components/album_slideshow/www/album-slideshow-card.js +++ b/custom_components/album_slideshow/www/album-slideshow-card.js @@ -26,7 +26,7 @@ * tap_action: none # none | more-info */ -const VERSION = "1.9.0"; +const VERSION = "1.9.1"; const ANIMATED_TRANSITIONS = [ "fade", diff --git a/zigbee2mqtt/state.json b/zigbee2mqtt/state.json index 81c688ea..e47bb242 100644 --- a/zigbee2mqtt/state.json +++ b/zigbee2mqtt/state.json @@ -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",