187 files

This commit is contained in:
Home Assistant Version Control
2026-10-09 11:57:31 +00:00
parent 28fa34d78a
commit 1e1b12d52b
187 changed files with 40899 additions and 32654 deletions
+23 -2
View File
@@ -11,6 +11,7 @@ from pathlib import Path
from typing import Any
import async_timeout
from aiohttp import ClientResponseError
from PIL import Image
from homeassistant.components.camera import Camera
@@ -1581,12 +1582,26 @@ class AlbumSlideshowCamera(Camera):
return dict(headers)
return None
def _image_request_params(self, url: str) -> dict[str, str] | None:
"""Session query params added at fetch time only (UGREEN ``ugk``)."""
params = getattr(self.coordinator, "image_request_params", None)
if params and isinstance(url, str) and url.startswith("http"):
return dict(params)
return None
async def _http_get(self, url: str) -> bytes | None:
"""Fetch one remote image with validation and a hard timeout."""
session = async_get_clientsession(self.hass)
session = async_get_clientsession(
self.hass,
verify_ssl=getattr(self.coordinator, "image_request_verify_ssl", True),
)
try:
async with async_timeout.timeout(30):
async with session.get(url, headers=self._image_request_headers(url)) as resp:
async with session.get(
url,
headers=self._image_request_headers(url),
params=self._image_request_params(url),
) as resp:
resp.raise_for_status()
content_type = resp.headers.get("Content-Type", "")
@@ -1636,6 +1651,12 @@ class AlbumSlideshowCamera(Camera):
return None
chunks.append(chunk)
return b"".join(chunks)
except ClientResponseError as err:
# str(err) includes the full request URL, fetch-time params and all.
_LOGGER.warning(
"Album Slideshow: failed to fetch image %s: HTTP %s", url, err.status
)
return None
except Exception as err:
_LOGGER.warning("Album Slideshow: failed to fetch image: %s", err)
return None
@@ -99,6 +99,13 @@ from .const import (
DEFAULT_ENTE_IMAGE_SIZE,
ENTE_IMAGE_FULL,
ENTE_IMAGE_PREVIEW,
CONF_UGREEN_URL,
CONF_UGREEN_USERNAME,
CONF_UGREEN_PASSWORD,
CONF_UGREEN_ALBUM_UUID,
CONF_UGREEN_ALBUM_TYPE,
CONF_UGREEN_ALBUM_NAME,
CONF_UGREEN_VERIFY_SSL,
DEFAULT_REVERSE_GEOCODE,
PROVIDER_GOOGLE_SHARED,
PROVIDER_LOCAL_FOLDER,
@@ -109,6 +116,7 @@ from .const import (
PROVIDER_SYNOLOGY,
PROVIDER_NEXTCLOUD,
PROVIDER_ENTE,
PROVIDER_UGREEN,
DEFAULT_RECURSIVE,
)
@@ -374,6 +382,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
self._syn_places: dict[str, str] = {}
self._syn_tags: dict[str, str] = {}
self._syn_subjects: dict[str, str] = {}
# UGREEN flow state carried between steps.
self._ugr_url: str | None = None
self._ugr_username: str | None = None
self._ugr_password: str | None = None
self._ugr_verify_ssl: bool = True
self._ugr_albums: list[dict[str, Any]] = []
@staticmethod
@callback
@@ -382,8 +396,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
) -> config_entries.OptionsFlow:
"""Return the options flow handler.
Local-folder, Nextcloud and Ente entries expose the reverse-geocode
toggle; Immich entries reopen the albums/people/favorites picker.
Local-folder, Nextcloud, Ente and UGREEN entries expose the
reverse-geocode toggle; Immich entries reopen the
albums/people/favorites picker.
Other providers get a no-op handler so that the "Configure" button
doesn't appear empty in the UI.
@@ -397,6 +412,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
PROVIDER_LOCAL_FOLDER,
PROVIDER_NEXTCLOUD,
PROVIDER_ENTE,
PROVIDER_UGREEN,
):
return LocalFolderOptionsFlow()
if config_entry.data.get(CONF_PROVIDER) == PROVIDER_IMMICH:
@@ -424,6 +440,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
return await self.async_step_nextcloud()
if self._provider == PROVIDER_ENTE:
return await self.async_step_ente()
if self._provider == PROVIDER_UGREEN:
return await self.async_step_ugreen()
return await self.async_step_google_shared()
schema = vol.Schema(
@@ -437,6 +455,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
PROVIDER_SYNOLOGY: "Synology Photos (direct API, full metadata)",
PROVIDER_NEXTCLOUD: "Nextcloud (WebDAV folder or public album link)",
PROVIDER_ENTE: "Ente Photos (public album link)",
PROVIDER_UGREEN: "UGREEN NAS (UGOS Photos, experimental)",
PROVIDER_MEDIA_SOURCE: "Media Source (any source, no metadata)",
})
}
@@ -1116,6 +1135,123 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
step_id="synology_select", data_schema=vol.Schema(fields), errors=errors
)
async def async_step_ugreen(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Collect the UGREEN NAS URL + account and log in.
The album is picked from a live dropdown in the next step rather
than typed in, so logging in here (and listing albums) doubles as
credential validation.
"""
errors: dict[str, str] = {}
if user_input is not None:
url = user_input[CONF_UGREEN_URL].strip()
username = user_input[CONF_UGREEN_USERNAME].strip()
password = user_input.get(CONF_UGREEN_PASSWORD) or ""
verify_ssl = bool(user_input.get(CONF_UGREEN_VERIFY_SSL, True))
from . import ugreen as ugr_api
client = ugr_api.UGreenClient(
self.hass, url, username, password, verify_ssl=verify_ssl
)
albums: list[dict[str, Any]] = []
try:
await client.async_login()
albums = await client.async_list_albums()
except ugr_api.UGreenAuthError as err:
_LOGGER.warning(
"UGREEN login failed for %s: %s", url, _describe_error(err)
)
errors["base"] = "ugreen_cannot_connect"
except ugr_api.UGreenApiError as err:
_LOGGER.warning(
"UGREEN album listing failed for %s: %s", url, _describe_error(err)
)
errors["base"] = "ugreen_cannot_connect"
named_albums = [
a for a in albums if isinstance(a, dict) and a.get("album_name")
]
if not errors and not named_albums:
errors["base"] = "ugreen_no_albums"
if not errors:
self._ugr_url = client.base_url
self._ugr_username = username
self._ugr_password = password
self._ugr_verify_ssl = verify_ssl
self._ugr_albums = named_albums
return await self.async_step_ugreen_select()
schema = vol.Schema(
{
vol.Required(CONF_UGREEN_URL): str,
vol.Required(CONF_UGREEN_USERNAME): str,
vol.Required(CONF_UGREEN_PASSWORD): selector.TextSelector(
selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD)
),
vol.Optional(CONF_UGREEN_VERIFY_SSL, default=True): bool,
}
)
return self.async_show_form(step_id="ugreen", data_schema=schema, errors=errors)
async def async_step_ugreen_select(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Pick the UGOS Photos album from a live, searchable dropdown."""
from . import ugreen as ugr_api
# Keyed by uuid since album names aren't guaranteed unique.
albums_by_uuid = {a["album_uuid"]: a for a in self._ugr_albums}
if user_input is not None:
album_uuid = user_input[CONF_UGREEN_ALBUM_UUID]
album = albums_by_uuid[album_uuid]
unique = (
f"{DOMAIN}:{PROVIDER_UGREEN}:{self._ugr_url}:"
f"{self._ugr_username}:{album_uuid}"
)
await self.async_set_unique_id(unique)
self._abort_if_unique_id_configured()
data = {
CONF_PROVIDER: PROVIDER_UGREEN,
CONF_UGREEN_URL: self._ugr_url,
CONF_UGREEN_USERNAME: self._ugr_username,
CONF_UGREEN_PASSWORD: self._ugr_password,
CONF_UGREEN_VERIFY_SSL: self._ugr_verify_ssl,
CONF_UGREEN_ALBUM_UUID: album_uuid,
CONF_UGREEN_ALBUM_TYPE: album.get(
"album_type", ugr_api.ALBUM_TYPE_REGULAR
),
CONF_UGREEN_ALBUM_NAME: album["album_name"],
}
return self.async_create_entry(title=album["album_name"], data=data)
# A searchable dropdown (rather than a plain vol.In) keeps this
# usable for accounts with a large number of albums.
options = [
selector.SelectOptionDict(
value=a["album_uuid"],
label=f"{a['album_name']} ({ugr_api.describe_album_type(a.get('album_type'))})",
)
for a in self._ugr_albums
]
schema = vol.Schema(
{
vol.Required(CONF_UGREEN_ALBUM_UUID): selector.SelectSelector(
selector.SelectSelectorConfig(
options=options,
mode=selector.SelectSelectorMode.DROPDOWN,
custom_value=False,
)
)
}
)
return self.async_show_form(step_id="ugreen_select", data_schema=schema)
async def async_step_nextcloud(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
@@ -75,6 +75,7 @@ PROVIDER_ICLOUD = "icloud"
PROVIDER_SYNOLOGY = "synology"
PROVIDER_NEXTCLOUD = "nextcloud"
PROVIDER_ENTE = "ente"
PROVIDER_UGREEN = "ugreen"
# Providers whose coordinator runs a background enrichment pass: per-photo
# metadata reads, reverse-geocoding, or both. These are the entries that get
@@ -85,6 +86,7 @@ ENRICHING_PROVIDERS = (
PROVIDER_IMMICH,
PROVIDER_NEXTCLOUD,
PROVIDER_ENTE,
PROVIDER_UGREEN,
)
# Nextcloud provider - two auth modes against the same PROVIDER_NEXTCLOUD id:
@@ -239,6 +241,23 @@ DEFAULT_PHOTOPRISM_IMAGE_SIZE = PHOTOPRISM_IMAGE_PREVIEW
PHOTOPRISM_SELECTION_COMPOSITE = "composite"
# UGREEN NAS (UGOS Photos) provider. Talks to the undocumented UGOS Photos
# web API (see ``ugreen.py``); login is username + password, with the
# password RSA-encrypted before it ever leaves HA, same as the UGOS web app.
# TLS certificates are verified unless the user turns that off for a NAS with
# a self-signed certificate.
CONF_UGREEN_URL = "ugreen_url"
CONF_UGREEN_USERNAME = "ugreen_username"
CONF_UGREEN_PASSWORD = "ugreen_password"
CONF_UGREEN_VERIFY_SSL = "ugreen_verify_ssl"
# The album is stored by ``album_uuid`` + ``album_type`` (what the API needs),
# so renaming it in UGOS Photos does not break the slideshow. The name is kept
# for display only.
CONF_UGREEN_ALBUM_UUID = "ugreen_album_uuid"
CONF_UGREEN_ALBUM_TYPE = "ugreen_album_type"
CONF_UGREEN_ALBUM_NAME = "ugreen_album_name"
FILL_COVER = "cover"
FILL_CONTAIN = "contain"
FILL_BLUR = "blur"
@@ -89,6 +89,12 @@ from .const import (
CONF_ENTE_IMAGE_SIZE,
DEFAULT_ENTE_IMAGE_SIZE,
ENTE_IMAGE_PREVIEW,
CONF_UGREEN_URL,
CONF_UGREEN_USERNAME,
CONF_UGREEN_PASSWORD,
CONF_UGREEN_ALBUM_UUID,
CONF_UGREEN_ALBUM_TYPE,
CONF_UGREEN_VERIFY_SSL,
DEFAULT_REVERSE_GEOCODE,
DOMAIN,
ENRICHING_PROVIDERS,
@@ -101,6 +107,7 @@ from .const import (
PROVIDER_SYNOLOGY,
PROVIDER_NEXTCLOUD,
PROVIDER_ENTE,
PROVIDER_UGREEN,
)
from .store import SlideshowStore
@@ -1094,9 +1101,20 @@ class AlbumCoordinator(DataUpdateCoordinator):
# Extra headers the camera must send when fetching image bytes
# (Immich API key). Empty for providers that need no auth.
self.image_request_headers: dict[str, str] = {}
# Query params added only at fetch time (UGREEN ``ugk``), so stored
# URLs, attributes and logs never carry them.
self.image_request_params: dict[str, str] = {}
# False when the entry opted out of TLS certificate checks (UGREEN NAS
# with a self-signed certificate).
self.image_request_verify_ssl: bool = True
# Ente only: file id -> {key, header, thumbnail}, the material needed
# to decrypt each image. Rebuilt on every album refresh.
self._ente_fetch_meta: dict[str, dict[str, Any]] = {}
# UGREEN only: logged-in client + selected album, reused by the
# background enrichment pass instead of logging in again per photo.
self._ugreen_client: Any = None
self._ugreen_album_uuid: str | None = None
self._ugreen_album_type: int = 1
# Persist the most recent successful album fetch so that a transient
# network/Google failure doesn't blank the slideshow on restart.
@@ -1172,6 +1190,8 @@ class AlbumCoordinator(DataUpdateCoordinator):
data = await self._update_nextcloud()
elif self.provider == PROVIDER_ENTE:
data = await self._update_ente()
elif self.provider == PROVIDER_UGREEN:
data = await self._update_ugreen()
else:
raise UpdateFailed(f"Unsupported provider: {self.provider}")
except UpdateFailed:
@@ -1993,6 +2013,102 @@ class AlbumCoordinator(DataUpdateCoordinator):
"items": items,
}
async def _update_ugreen(self) -> dict[str, Any]:
"""Fetch photos from a UGREEN NAS UGOS Photos album.
Logs in fresh on every refresh, like the Synology provider. The
album is addressed by its stored uuid, so renaming it in UGOS Photos
needs no reconfiguration.
"""
from . import ugreen as ugr_api
url = self.entry.data.get(CONF_UGREEN_URL)
username = self.entry.data.get(CONF_UGREEN_USERNAME)
password = self.entry.data.get(CONF_UGREEN_PASSWORD)
album_uuid = self.entry.data.get(CONF_UGREEN_ALBUM_UUID)
album_type = self.entry.data.get(
CONF_UGREEN_ALBUM_TYPE, ugr_api.ALBUM_TYPE_REGULAR
)
verify_ssl = bool(self.entry.data.get(CONF_UGREEN_VERIFY_SSL, True))
if not url or not username or not password or not album_uuid:
raise UpdateFailed("UGREEN provider is missing URL, credentials or album")
client = ugr_api.UGreenClient(
self.hass, url, username, password, verify_ssl=verify_ssl
)
try:
await client.async_login()
photos = await client.async_list_album_pictures(album_uuid, album_type)
except ugr_api.UGreenAuthError as err:
raise UpdateFailed(f"UGREEN login failed: {err}") from err
except ugr_api.UGreenApiError as err:
raise UpdateFailed(f"Error listing UGREEN album photos: {err}") from err
if not photos:
raise UpdateFailed(f"No images found in UGREEN album '{self.entry.title}'")
# Stored so the camera can fetch image bytes server-side (session
# cookie plus ``ugk``, added per request so stored URLs never carry it)
# with the same TLS setting as the login.
self.image_request_headers = dict(client.image_headers)
self.image_request_params = dict(client.image_params)
self.image_request_verify_ssl = verify_ssl
# Kept for the background enrichment pass (GPS/address lookups),
# which needs a logged-in client and the album context per photo.
self._ugreen_client = client
self._ugreen_album_uuid = album_uuid
self._ugreen_album_type = album_type
items: list[MediaItem] = []
for p in photos:
picture_id = p.get("picture_id")
if picture_id is None:
continue
# Backstop for type_option; a video would only show its poster frame.
ext = p.get("real_ext_name") or Path(str(p.get("file_name") or "")).suffix
if f".{str(ext).lstrip('.').lower()}" in _VIDEO_EXTS:
continue
meta = ugr_api.parse_photo_meta(p)
items.append(
MediaItem(
url=ugr_api.build_image_url(
client.base_url,
picture_id,
album_uuid,
source_album_type=album_type,
upload_time=p.get("upload_time", 0),
),
width=meta.get("width"),
height=meta.get("height"),
mime_type=None,
filename=p.get("file_name"),
captured_at=meta.get("captured_at"),
byte_size=meta.get("byte_size"),
latitude=None,
longitude=None,
location=None,
description=None,
source_id=str(picture_id),
# GPS/address isn't in this listing - the background
# enrichment pass fills it in via picture/info.
exif_scanned=False,
)
)
if not items:
raise UpdateFailed("Could not resolve any UGREEN images")
try:
album_name = await client.async_get_album_name(album_uuid)
except Exception as err: # noqa: BLE001 - the title is cosmetic
_LOGGER.debug("UGREEN: could not read the album name: %s", err)
album_name = None
return {
"title": album_name or self.entry.title,
"items": items,
}
async def _update_nextcloud(self) -> dict[str, Any]:
"""List photos from Nextcloud, dispatching on the configured auth mode."""
mode = self.entry.data.get(
@@ -2502,6 +2618,40 @@ class AlbumCoordinator(DataUpdateCoordinator):
if completed:
self.async_set_updated_data(data)
async def _enrich_ugreen_item(self, item: MediaItem) -> None:
"""Fetch GPS coordinates and a location label for one UGREEN photo.
Reuses the client stashed by ``_update_ugreen`` instead of logging in
again per photo. Image URLs are stable across refreshes, so
``_merge_prior_enrichment`` carries results forward and only new
photos are looked up.
"""
from . import ugreen as ugr_api
client = self._ugreen_client
if client is None or not item.source_id or not self._ugreen_album_uuid:
item.exif_scanned = True
return
try:
info = await client.async_get_picture_info(
item.source_id, self._ugreen_album_uuid, self._ugreen_album_type
)
except asyncio.CancelledError:
raise
except ugr_api.UGreenApiError as err:
_LOGGER.debug(
"UGREEN: picture/info failed for %s: %s", item.source_id, err
)
item.exif_scanned = True
return
meta = ugr_api.parse_picture_location(info)
if "latitude" in meta and "longitude" in meta:
item.latitude = meta["latitude"]
item.longitude = meta["longitude"]
if "location" in meta:
item.location = meta["location"]
item.exif_scanned = True
async def _enrich_items_background(self, data: dict[str, Any]) -> None:
"""Read EXIF for unscanned local files, then reverse-geocode.
@@ -2564,6 +2714,24 @@ class AlbumCoordinator(DataUpdateCoordinator):
self.async_set_updated_data(data)
continue
if self.provider == PROVIDER_UGREEN:
try:
await self._enrich_ugreen_item(item)
except asyncio.CancelledError:
raise
except Exception as err: # noqa: BLE001
_LOGGER.debug("UGREEN enrich error: %s", err)
item.exif_scanned = True
scanned_since_save += 1
self._enrich_progress["exif_done"] = (
self._enrich_progress.get("exif_done", 0) + 1
)
if scanned_since_save >= _EXIF_BATCH_SAVE:
scanned_since_save = 0
await self._save_cached_items(data)
self.async_set_updated_data(data)
continue
url = item.url
if not url.startswith("file://"):
item.exif_scanned = True
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"issue_tracker": "https://github.com/eyalgal/album_slideshow/issues",
"requirements": ["PyNaCl>=1.5.0"],
"version": "1.13.0"
"version": "1.14.0"
}
+11 -2
View File
@@ -41,7 +41,13 @@ class _BaseAlbumSensor(SensorEntity):
def __init__(self, entry: ConfigEntry, coordinator: AlbumCoordinator) -> None:
self.entry = entry
self.coordinator = coordinator
coordinator.async_add_listener(self.async_write_ha_state)
async def async_added_to_hass(self) -> None:
await super().async_added_to_hass()
# Disabled entities are constructed but never added, so subscribe here.
self.async_on_remove(
self.coordinator.async_add_listener(self.async_write_ha_state)
)
@property
def device_info(self):
@@ -80,7 +86,10 @@ class HiddenPhotoCountSensor(_BaseAlbumSensor):
super().__init__(entry, coordinator)
self._attr_unique_id = f"{entry.entry_id}_hidden_photo_count"
self._attr_name = "Hidden photos"
coordinator.store.add_listener(self.async_write_ha_state)
async def async_added_to_hass(self) -> None:
await super().async_added_to_hass()
self.coordinator.store.add_listener(self.async_write_ha_state)
@property
def native_value(self):
+20 -1
View File
@@ -110,6 +110,23 @@
"synology_image_size": "Image quality"
}
},
"ugreen": {
"title": "UGREEN NAS (UGOS Photos)",
"description": "Experimental: connect to the Photos app on a UGREEN NAS (UGOS Pro). Enter the NAS address including port (e.g. https://192.168.1.10:9443) and an account. This provider talks to an undocumented, reverse-engineered API and may break with future UGOS updates.",
"data": {
"ugreen_url": "NAS URL",
"ugreen_username": "Username",
"ugreen_password": "Password",
"ugreen_verify_ssl": "Verify SSL certificate"
}
},
"ugreen_select": {
"title": "UGREEN album",
"description": "Pick the UGOS Photos album to show.",
"data": {
"ugreen_album_uuid": "Album"
}
},
"nextcloud": {
"title": "Nextcloud",
"description": "Choose how to connect: an authenticated WebDAV folder in your Nextcloud files, or a public Nextcloud Photos album share link.",
@@ -173,7 +190,9 @@
"invalid_nextcloud_url": "That does not look like a Nextcloud Photos public album link.",
"nextcloud_public_cannot_connect": "Could not connect to that Nextcloud album. Check the link is still valid.",
"invalid_ente_url": "That does not look like an Ente public album link. It should look like {example_url}, including the part after the #.",
"ente_cannot_connect": "Could not open that Ente album. The link may be expired, revoked, or password-protected."
"ente_cannot_connect": "Could not open that Ente album. The link may be expired, revoked, or password-protected.",
"ugreen_cannot_connect": "Could not connect to the UGREEN NAS. Check the URL, username and password. If the NAS uses a self-signed certificate, turn off Verify SSL certificate.",
"ugreen_no_albums": "No albums were found on this UGREEN NAS."
}
},
"options": {
@@ -29,11 +29,6 @@ class PairDividerColorText(TextEntity, RestoreEntity):
self._attr_unique_id = f"{entry.entry_id}_pair_divider_color"
self._attr_name = "Pair divider color"
def _on_store_change() -> None:
self.async_write_ha_state()
store.add_listener(_on_store_change)
@property
def native_value(self) -> str:
return self.store.pair_divider_color
@@ -110,6 +110,23 @@
"synology_image_size": "Image quality"
}
},
"ugreen": {
"title": "UGREEN NAS (UGOS Photos)",
"description": "Experimental: connect to the Photos app on a UGREEN NAS (UGOS Pro). Enter the NAS address including port (e.g. https://192.168.1.10:9443) and an account. This provider talks to an undocumented, reverse-engineered API and may break with future UGOS updates.",
"data": {
"ugreen_url": "NAS URL",
"ugreen_username": "Username",
"ugreen_password": "Password",
"ugreen_verify_ssl": "Verify SSL certificate"
}
},
"ugreen_select": {
"title": "UGREEN album",
"description": "Pick the UGOS Photos album to show.",
"data": {
"ugreen_album_uuid": "Album"
}
},
"nextcloud": {
"title": "Nextcloud",
"description": "Choose how to connect: an authenticated WebDAV folder in your Nextcloud files, or a public Nextcloud Photos album share link.",
@@ -173,7 +190,9 @@
"invalid_nextcloud_url": "That does not look like a Nextcloud Photos public album link.",
"nextcloud_public_cannot_connect": "Could not connect to that Nextcloud album. Check the link is still valid.",
"invalid_ente_url": "That does not look like an Ente public album link. It should look like {example_url}, including the part after the #.",
"ente_cannot_connect": "Could not open that Ente album. The link may be expired, revoked, or password-protected."
"ente_cannot_connect": "Could not open that Ente album. The link may be expired, revoked, or password-protected.",
"ugreen_cannot_connect": "Could not connect to the UGREEN NAS. Check the URL, username and password. If the NAS uses a self-signed certificate, turn off Verify SSL certificate.",
"ugreen_no_albums": "No albums were found on this UGREEN NAS."
}
},
"options": {
@@ -26,7 +26,7 @@
* tap_action: none # none | more-info
*/
const VERSION = "1.13.0";
const VERSION = "1.14.0";
const ANIMATED_TRANSITIONS = [
"fade",