278 files

This commit is contained in:
Home Assistant Version Control committed 2026-09-13 20:38:58 +00:00
1 parent d7a0a08372
commit 1b4f5f68c6
278 files changed
+36892 -7150

No files matched your search

+214 -31
View File
@@ -6,9 +6,11 @@ from datetime import timedelta
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import slugify
from .api import SeaTemperatureAPI
from .api import SeaTemperatureAPI, SeaTemperatureError
from .const import (
CONF_AREA,
CONF_CONTINENT,
@@ -16,13 +18,54 @@ from .const import (
CONF_PATH,
CONF_PLACE,
CONF_PLACE_ID,
CONF_SCAN_INTERVAL_HOURS,
DEFAULT_SCAN_INTERVAL_HOURS,
DOMAIN,
MAX_SCAN_INTERVAL_HOURS,
MIN_SCAN_INTERVAL_HOURS,
)
PLATFORMS = [Platform.SENSOR]
SCAN_INTERVAL = timedelta(hours=2) # Sea temperatures don't change frequently
_LOGGER = logging.getLogger(__name__)
CARD_FILENAME = "sea-temperatures-card.js"
CARD_URL_PREFIX = "/seatemperatures_frontend/"
async def _async_reconcile_card_resource(resources, new_url: str) -> None:
"""Leave exactly one Lovelace resource pointing at the bundled card.
The resource store is loaded lazily: until something awaits it, async_items()
returns an empty list. Registering off that empty list appended a second
resource on every restart, and the browser then loaded the bundle twice.
"""
# Default to False, not True: assuming a collection we cannot recognise is
# already loaded would let us register against an empty item list and save
# a store that has lost every other card's resource. Missing async_load
# raises instead, and the caller skips registration.
if not getattr(resources, "loaded", False):
await resources.async_load()
resources.loaded = True
own = [
item
for item in resources.async_items()
if item.get("url", "").startswith(CARD_URL_PREFIX)
]
if not own:
_LOGGER.info("Registering lovelace resource: %s", new_url)
await resources.async_create_item({"res_type": "module", "url": new_url})
return
for duplicate in own[1:]:
_LOGGER.info("Removing duplicate lovelace resource %s", duplicate.get("url"))
await resources.async_delete_item(duplicate.get("id"))
if own[0].get("url") != new_url:
_LOGGER.debug("Updating lovelace resource URL to %s", new_url)
await resources.async_update_item(own[0].get("id"), {"url": new_url})
async def async_setup(hass: HomeAssistant, config: dict) -> bool:
"""Set up the Sea Temperatures component."""
@@ -37,15 +80,13 @@ async def async_setup(hass: HomeAssistant, config: dict) -> bool:
await hass.http.async_register_static_paths(
[
StaticPathConfig(
url_path="/seatemperatures_frontend/sea-temperatures-card.js",
path=hass.config.path(
"custom_components/seatemperatures/sea-temperatures-card.js"
),
url_path=f"{CARD_URL_PREFIX}{CARD_FILENAME}",
path=hass.config.path(f"custom_components/{DOMAIN}/{CARD_FILENAME}"),
cache_headers=True,
)
]
)
new_url = f"/seatemperatures_frontend/sea-temperatures-card.js?v={version}"
new_url = f"{CARD_URL_PREFIX}{CARD_FILENAME}?v={version}"
async def _async_register_lovelace_resource(event=None):
_LOGGER.debug("Attempting to register lovelace resource")
@@ -68,18 +109,8 @@ async def async_setup(hass: HomeAssistant, config: dict) -> bool:
)
return
# Check if resource is already registered
for item in resources.async_items():
if item.get("url", "").startswith("/seatemperatures_frontend/"):
if item.get("url") != new_url:
_LOGGER.debug("Updating lovelace resource URL to %s", new_url)
await resources.async_update_item(item.get("id"), {"url": new_url})
return
# Not registered, add it
try:
_LOGGER.info("Registering lovelace resource: %s", new_url)
await resources.async_create_item({"res_type": "module", "url": new_url})
await _async_reconcile_card_resource(resources, new_url)
except Exception as e: # noqa: BLE001 - resource registration must never break setup
_LOGGER.warning("Failed to register lovelace resource: %s", e)
@@ -96,9 +127,122 @@ async def async_setup(hass: HomeAssistant, config: dict) -> bool:
return True
def async_scan_interval(entry: ConfigEntry) -> timedelta:
"""Return the poll interval for an entry, honouring the options flow."""
hours = entry.options.get(CONF_SCAN_INTERVAL_HOURS, DEFAULT_SCAN_INTERVAL_HOURS)
try:
hours = int(hours)
except (TypeError, ValueError):
hours = DEFAULT_SCAN_INTERVAL_HOURS
if not MIN_SCAN_INTERVAL_HOURS <= hours <= MAX_SCAN_INTERVAL_HOURS:
# The options flow bounds the form, but a hand-edited or older stored
# value reaches this reader directly - clamp both ends, not just the
# one that would hammer the site.
hours = DEFAULT_SCAN_INTERVAL_HOURS
return timedelta(hours=hours)
def entry_location_key(entry: ConfigEntry) -> str:
"""Return the key an entry's entity unique_ids are built from.
The migration and the sensor both call this, so the id they arrive at is
the same one by construction rather than by two matching expressions.
"""
return str(
entry.data.get(CONF_PLACE_ID)
or entry.data.get(CONF_PATH)
or entry.data.get(CONF_PLACE)
or entry.entry_id
)
def slug_location_key(location_key: object) -> str:
"""Slugify a location key without folding "/" and "-" together.
``slugify`` maps both to "_", so ``/europe/greece/nea-plagia/`` and
``/europe/greece/nea/plagia/`` - two real, distinct locations - used to
produce the very same id. Each path segment is therefore slugified with "-"
as its separator and the segments are joined with "_": a "-" can then only
come from inside a segment and a "_" only from a segment boundary, which
makes the encoding injective instead of merely unlikely to clash. A legacy
numeric place_id has no separators at all and slugifies to itself.
"""
segments = [segment for segment in str(location_key).split("/") if segment]
return "_".join(slugify(segment, separator="-") for segment in segments)
def build_unique_id(location_key: object, sensor_key: str) -> str:
"""Return the slugified unique_id used from entry version 4 on."""
return f"{DOMAIN}_{slug_location_key(location_key)}_{sensor_key}"
async def _async_migrate_unique_ids(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Bring the registry unique_ids of an entry's entities up to date.
Path-based entries produced ids like
``seatemperatures_/europe/germany/island-of-sylt/_today``, and entry
version 3 slugified those with "_" for every separator - which folded two
distinct paths onto one id. The target id is derived from the *entry*, not
parsed back out of the old one, so it is the same id the sensor will claim.
Rewriting the registry entry rather than just the sensor keeps the
entity_id - and therefore the recorded history - exactly where it was. A
legacy numeric key slugifies to itself, so those entries are left
untouched.
"""
registry = er.async_get(hass)
location_key = entry_location_key(entry)
for registry_entry in er.async_entries_for_config_entry(registry, entry.entry_id):
old_unique_id = registry_entry.unique_id
if not old_unique_id.startswith(f"{DOMAIN}_"):
continue
middle, _, sensor_key = old_unique_id[len(DOMAIN) + 1 :].rpartition("_")
if not middle or not sensor_key:
continue
new_unique_id = build_unique_id(location_key, sensor_key)
if new_unique_id == old_unique_id:
continue
holder = registry.async_get_entity_id(
registry_entry.domain, registry_entry.platform, new_unique_id
)
if holder is not None and holder != registry_entry.entity_id:
# The sensor claims the new id unconditionally at setup, so leaving
# the old id in place would cost the user this entity: the platform
# would reject the duplicate and the stale row would be orphaned.
# Ids are collision-free by construction now, so a holder here is a
# leftover row - drop it and let the live entity keep its history.
conflicting = registry.async_get(holder)
owner = getattr(conflicting, "config_entry_id", None)
if owner not in (None, entry.entry_id):
_LOGGER.error(
"Cannot migrate unique_id %s to %s: %s of another config entry "
"holds it. Both entities keep the id they have.",
old_unique_id,
new_unique_id,
holder,
)
continue
_LOGGER.warning(
"Removing the stale registry entry %s so %s can take the id %s",
holder,
registry_entry.entity_id,
new_unique_id,
)
registry.async_remove(holder)
_LOGGER.debug("Migrating unique_id %s to %s", old_unique_id, new_unique_id)
registry.async_update_entity(
registry_entry.entity_id, new_unique_id=new_unique_id
)
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Migrate legacy config entries from place IDs to path-based locations."""
if entry.version > 2:
if entry.version > 4:
_LOGGER.error("Unsupported SeaTemperatures config entry version: %s", entry.version)
return False
@@ -137,38 +281,77 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
version=2,
)
if entry.version < 4:
# Version 3 slugified these ids already, but with a scheme that could
# fold two paths onto one id; re-deriving them from the entry fixes a
# v3 install as well as a v2 one.
await _async_migrate_unique_ids(hass, entry)
hass.config_entries.async_update_entry(entry, version=4)
return True
async def _async_fetch(
api: SeaTemperatureAPI, location_path: str | None, place_name: str
) -> dict:
"""Fetch one refresh, turning an API failure into a single logged one.
The coordinator logs an UpdateFailed once and stays quiet while the failure
persists, so the API layer deliberately reports nothing of its own.
"""
if not location_path:
raise UpdateFailed(
"No location path configured. Remove and re-add the integration."
)
try:
data = await api.get_temperatures(location_path)
except SeaTemperatureError as err:
raise UpdateFailed(str(err)) from err
if not data:
raise UpdateFailed(f"Failed to fetch data for place {place_name}")
return data
async def _async_options_updated(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Reload the entry after the options flow saved a new poll interval.
The coordinator is built with ``update_interval`` once, at setup, so a saved
interval is inert until the entry is set up again. Core offers
``OptionsFlowWithReload`` for this, but only from 2025.8 on; an explicit
listener works on every version the integration supports and keeps the
minimum where hacs.json puts it.
"""
await hass.config_entries.async_reload(entry.entry_id)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Sea Temperature from a config entry."""
hass.data.setdefault(DOMAIN, {})
# Registered before the first refresh so an options save during a slow or
# failing setup is not silently dropped; async_on_unload drops the
# subscription with the entry rather than stacking one per reload.
entry.async_on_unload(entry.add_update_listener(_async_options_updated))
place_name = entry.data.get(CONF_PLACE, "Unknown")
location_path = entry.data.get(CONF_PATH)
location_key = entry.data.get(CONF_PLACE_ID) or location_path or entry.entry_id
location_key = entry_location_key(entry)
api = SeaTemperatureAPI(hass)
async def async_update_data():
"""Fetch data from API."""
if not location_path:
raise UpdateFailed(
"No location path configured. Remove and re-add the integration."
)
data = await api.get_temperatures(location_path)
if not data:
raise UpdateFailed(f"Failed to fetch data for place {place_name}")
return data
return await _async_fetch(api, location_path, place_name)
coordinator = DataUpdateCoordinator(
hass,
_LOGGER,
name=f"seatemperatures_{location_key}",
update_method=async_update_data,
update_interval=SCAN_INTERVAL,
update_interval=async_scan_interval(entry),
)
await coordinator.async_config_entry_first_refresh()
+48 -73
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import logging
import time
from typing import Any
import aiohttp
@@ -9,7 +10,6 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import (
API_URL_MAP_LOCATIONS,
API_URL_SEARCH,
BASE_URL,
DEFAULT_USER_AGENT,
DOMAIN,
@@ -19,45 +19,23 @@ from .parser import parse_location_page, validate_location_path
_LOGGER = logging.getLogger(__name__)
_MAP_LOCATIONS_CACHE = "map_locations_cache"
# A host that accepts the connection but never answers would otherwise hold a
# refresh for the aiohttp default (no total timeout at all), and the coordinator
# would sit on that one request instead of failing and retrying two hours later.
REQUEST_TIMEOUT = aiohttp.ClientTimeout(total=30, connect=10)
def parse_search_results(data: Any) -> list[dict[str, str]]:
"""Parse search endpoint results into normalized location dictionaries."""
if not isinstance(data, dict):
return []
# The map-locations payload is ~19k rows and was previously cached for the whole
# process lifetime, so a beach the site added only showed up after a restart.
MAP_LOCATIONS_TTL = 3600.0
results: list[dict[str, str]] = []
for item in data.get("results", []):
if not isinstance(item, dict):
continue
path = item.get("path")
name = item.get("name")
if not isinstance(path, str) or not isinstance(name, str):
continue
class SeaTemperatureError(Exception):
"""Raised when a location could not be fetched.
try:
normalized_path = validate_location_path(path)
except ValueError:
_LOGGER.debug("Ignoring search result with invalid path: %s", path)
continue
results.append(
{
"name": name,
"country": item.get("country", "")
if isinstance(item.get("country"), str)
else "",
"region": item.get("region", "")
if isinstance(item.get("region"), str)
else "",
"area": item.get("area", "")
if isinstance(item.get("area"), str)
else "",
"path": normalized_path,
}
)
return results
The message is meant to be handed to UpdateFailed: the coordinator already
logs a failed refresh, so logging it here as well printed every outage
twice.
"""
def parse_map_locations(data: Any) -> dict[str, dict[str, str]]:
@@ -105,30 +83,12 @@ class SeaTemperatureAPI:
self.hass = hass
self._headers = {"User-Agent": DEFAULT_USER_AGENT}
async def search_locations(self, query: str) -> list[dict[str, str]] | None:
"""Search SeaTemperatures locations by query string."""
if not query.strip():
return []
session = async_get_clientsession(self.hass)
try:
async with session.get(
API_URL_SEARCH,
params={"q": query.strip()},
headers=self._headers,
) as response:
response.raise_for_status()
return parse_search_results(await response.json())
except (aiohttp.ClientError, TimeoutError) as err:
_LOGGER.error("Error searching SeaTemperatures locations for %s: %s", query, err)
return None
async def get_location_by_place_id(self, place_id: str) -> dict[str, str] | None:
"""Resolve a legacy place ID to a current path-based location."""
if not place_id:
return None
cache = await self._get_map_locations()
cache = await self.get_map_locations()
if cache is None:
return None
@@ -137,44 +97,59 @@ class SeaTemperatureAPI:
location = cache.get(f"sea-{place_id}")
return location
async def get_temperatures(self, location_path: str) -> dict[str, Any] | None:
"""Fetch temperature data for a specific location path."""
async def get_temperatures(self, location_path: str) -> dict[str, Any]:
"""Fetch temperature data for a specific location path.
Raises SeaTemperatureError so the coordinator owns the one log line.
"""
try:
normalized_path = validate_location_path(location_path)
except ValueError as err:
_LOGGER.error("Invalid SeaTemperatures path %s: %s", location_path, err)
return None
raise SeaTemperatureError(
f"Invalid SeaTemperatures path {location_path}: {err}"
) from err
url = f"{BASE_URL}{normalized_path}"
session = async_get_clientsession(self.hass)
try:
async with session.get(url, headers=self._headers) as response:
async with session.get(
url, headers=self._headers, timeout=REQUEST_TIMEOUT
) as response:
response.raise_for_status()
html = await response.text()
except (aiohttp.ClientError, TimeoutError) as err:
_LOGGER.error(
"Error fetching temperature data for path %s: %s",
normalized_path,
err,
)
return None
raise SeaTemperatureError(
f"Error fetching temperature data for path {normalized_path}: {err}"
) from err
return parse_location_page(html).as_legacy_payload()
async def _get_map_locations(self) -> dict[str, dict[str, str]] | None:
"""Fetch and cache the map-locations payload for legacy migration."""
async def get_map_locations(self) -> dict[str, dict[str, str]] | None:
"""Fetch the map-locations payload, cached for MAP_LOCATIONS_TTL seconds.
The config flow and the legacy place_id migration both read this list,
and both run several times in a row - but a cache that never expires
means a newly published location stays invisible until Home Assistant
restarts, so the entry carries a monotonic timestamp.
"""
domain_data = self.hass.data.setdefault(DOMAIN, {})
if _MAP_LOCATIONS_CACHE in domain_data:
return domain_data[_MAP_LOCATIONS_CACHE]
cached = domain_data.get(_MAP_LOCATIONS_CACHE)
if cached is not None:
cached_at, mapping = cached
if time.monotonic() - cached_at < MAP_LOCATIONS_TTL:
return mapping
session = async_get_clientsession(self.hass)
try:
async with session.get(API_URL_MAP_LOCATIONS, headers=self._headers) as response:
async with session.get(
API_URL_MAP_LOCATIONS, headers=self._headers, timeout=REQUEST_TIMEOUT
) as response:
response.raise_for_status()
mapping = parse_map_locations(await response.json())
except (aiohttp.ClientError, TimeoutError) as err:
_LOGGER.error("Error fetching SeaTemperatures map locations: %s", err)
# Debug only: the config flow and the migration both report this.
_LOGGER.debug("Error fetching SeaTemperatures map locations: %s", err)
return None
domain_data[_MAP_LOCATIONS_CACHE] = mapping
domain_data[_MAP_LOCATIONS_CACHE] = (time.monotonic(), mapping)
return mapping
+151 -29
View File
@@ -5,7 +5,16 @@ from typing import Any
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.selector import (
NumberSelector,
NumberSelectorConfig,
NumberSelectorMode,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)
from .api import SeaTemperatureAPI
from .const import (
@@ -14,16 +23,63 @@ from .const import (
CONF_COUNTRY,
CONF_PATH,
CONF_PLACE,
CONF_SCAN_INTERVAL_HOURS,
DEFAULT_SCAN_INTERVAL_HOURS,
DOMAIN,
MAX_SCAN_INTERVAL_HOURS,
MIN_SCAN_INTERVAL_HOURS,
)
_LOGGER = logging.getLogger(__name__)
_CONTINENT_MINOR_WORDS = frozenset({"and", "of", "the"})
def _searchable(options: list[str]) -> SelectSelector:
"""Build a dropdown that filters as the user types.
vol.In renders a plain list, and the United States alone contributes 4,477
places to it - unusable without a search field. Filtering happens in the
frontend against the already fetched list, so it costs no extra request.
"""
return SelectSelector(
SelectSelectorConfig(
options=options,
mode=SelectSelectorMode.DROPDOWN,
sort=True,
custom_value=False,
)
)
# The first path segment of every location, as published by
# /api/map-locations.json. Slugs missing here fall back to a title-cased name,
# so a stale entry is worse than none: it produces a second spelling of a
# continent that no location actually resolves to.
CONTINENT_NAMES = {
"africa": "Africa",
"antarctica": "Antarctica",
"asia": "Asia",
"australia-and-oceania": "Australia and Oceania",
"central-america-and-the-caribbean": "Central America and the Caribbean",
"europe": "Europe",
"middle-east": "Middle East",
"north-america": "North America",
"south-america": "South America",
}
class SeaTemperatureConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Sea Temperature."""
VERSION = 2
VERSION = 4
@staticmethod
@callback
def async_get_options_flow(
config_entry: config_entries.ConfigEntry,
) -> SeaTemperatureOptionsFlow:
"""Return the options flow handler."""
return SeaTemperatureOptionsFlow()
def __init__(self) -> None:
"""Initialize the config flow."""
@@ -37,29 +93,43 @@ class SeaTemperatureConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the initial step (fetch map locations and select continent)."""
api = SeaTemperatureAPI(self.hass)
errors: dict[str, str] = {}
if self._locations_data is None:
self._locations_data = await api._get_map_locations()
if not self._locations_data:
return self.async_abort(reason="cannot_connect")
api = SeaTemperatureAPI(self.hass)
locations = await api.get_map_locations()
if not locations:
# Re-show the form rather than aborting: "cannot_connect" only
# exists under config.error in the translations, and an upstream
# hiccup should be retryable without restarting the whole flow.
errors["base"] = "cannot_connect"
else:
self._locations_data = locations
# Extract unique continents from path segments
continents_set = set()
for loc in self._locations_data.values():
path = loc.get("path", "")
parts = [p for p in path.split("/") if p]
if parts:
continents_set.add(self._get_continent_name(parts[0]))
self._continents = sorted(continents_set)
# Extract unique continents from path segments
continents_set = set()
for loc in self._locations_data.values():
path = loc.get("path", "")
parts = [p for p in path.split("/") if p]
if parts:
continents_set.add(self._get_continent_name(parts[0]))
self._continents = sorted(continents_set)
if user_input is not None:
# The retry form carries no continent field, so its submission has to fall
# through to another fetch instead of being read as a selection.
if user_input is not None and CONF_CONTINENT in user_input:
self._data[CONF_CONTINENT] = user_input[CONF_CONTINENT]
return await self.async_step_country()
if errors:
# An empty schema still renders a submit button, which is the retry.
return self.async_show_form(
step_id="user", data_schema=vol.Schema({}), errors=errors
)
data_schema = vol.Schema(
{
vol.Required(CONF_CONTINENT): vol.In(self._continents),
vol.Required(CONF_CONTINENT): _searchable(self._continents),
}
)
@@ -93,7 +163,7 @@ class SeaTemperatureConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
data_schema = vol.Schema(
{
vol.Required(CONF_COUNTRY): vol.In(self._countries),
vol.Required(CONF_COUNTRY): _searchable(self._countries),
}
)
@@ -145,7 +215,7 @@ class SeaTemperatureConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
data_schema = vol.Schema(
{
vol.Required(CONF_PLACE): vol.In(
vol.Required(CONF_PLACE): _searchable(
sorted(self._places.keys()) if self._places else []
),
}
@@ -159,15 +229,67 @@ class SeaTemperatureConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
def _get_continent_name(self, slug: str) -> str:
"""Map continent slug to friendly name."""
continent_map = {
"africa": "Africa",
"asia": "Asia",
"caribbean-sea": "Caribbean Sea",
"central-america": "Central America",
"europe": "Europe",
"middle-east": "Middle East",
"north-america": "North America",
"oceania": "Oceania",
"south-america": "South America",
}
return continent_map.get(slug, slug.replace("-", " ").title())
if slug in CONTINENT_NAMES:
return CONTINENT_NAMES[slug]
# A slug the site adds later still has to read as a name rather than as
# "Central America And The Caribbean", so keep the joining words small.
words = slug.split("-")
return " ".join(
word if index and word in _CONTINENT_MINOR_WORDS else word.title()
for index, word in enumerate(words)
)
class SeaTemperatureOptionsFlow(config_entries.OptionsFlow):
"""Let an existing entry be re-tuned without being deleted.
Only the poll interval is offered. Repointing an entry at a different beach
is deliberately *not* an option: the entry's unique_id is the location path,
and keeping the entity while swapping the place would splice two locations'
readings into one long-term statistic - the history this flow exists to
protect. A different place is a different entry.
The reload that makes a new interval take effect comes from the update
listener ``async_setup_entry`` registers, not from a base class:
``OptionsFlowWithReload`` only exists from core 2025.8 on, and buying that
convenience with a narrower supported range is a bad trade. Note the two are
mutually exclusive - core rejects an entry that has both.
"""
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the options step."""
if user_input is not None:
return self.async_create_entry(
data={
CONF_SCAN_INTERVAL_HOURS: int(
user_input[CONF_SCAN_INTERVAL_HOURS]
)
}
)
current = self.config_entry.options.get(
CONF_SCAN_INTERVAL_HOURS, DEFAULT_SCAN_INTERVAL_HOURS
)
data_schema = vol.Schema(
{
vol.Required(
CONF_SCAN_INTERVAL_HOURS, default=current
): NumberSelector(
NumberSelectorConfig(
min=MIN_SCAN_INTERVAL_HOURS,
max=MAX_SCAN_INTERVAL_HOURS,
step=1,
mode=NumberSelectorMode.BOX,
unit_of_measurement="h",
)
),
}
)
return self.async_show_form(
step_id="init", data_schema=data_schema, last_step=True
)
+8 -19
View File
@@ -8,27 +8,16 @@ CONF_AREA = "area"
CONF_PLACE = "place"
CONF_PLACE_ID = "place_id"
CONF_PATH = "path"
CONF_SCAN_INTERVAL_HOURS = "scan_interval_hours"
# Sea temperatures move by tenths of a degree a day, so two hours is generous
# already. The bounds exist to keep an options-flow typo from either hammering
# the site or letting a sensor go stale for days.
DEFAULT_SCAN_INTERVAL_HOURS = 2
MIN_SCAN_INTERVAL_HOURS = 1
MAX_SCAN_INTERVAL_HOURS = 24
BASE_URL = "https://seatemperatures.net"
API_URL_MAP_LOCATIONS = f"{BASE_URL}/api/map-locations.json"
API_URL_SEARCH = f"{BASE_URL}/api/search/"
DEFAULT_USER_AGENT = "HomeAssistant seatemperatures integration"
# Sensors
SENSOR_TODAY = "today"
SENSOR_YESTERDAY = "yesterday"
SENSOR_LAST_YEAR = "last_year"
SENSOR_LAST_WEEK = "last_week"
SENSOR_AVERAGE_MIN = "average_min"
SENSOR_AVERAGE_MAX = "average_max"
SENSOR_AVERAGE_AVG = "average_avg"
SENSOR_TYPES = (
SENSOR_TODAY,
SENSOR_YESTERDAY,
SENSOR_LAST_YEAR,
SENSOR_LAST_WEEK,
SENSOR_AVERAGE_MIN,
SENSOR_AVERAGE_MAX,
SENSOR_AVERAGE_AVG,
)
@@ -12,5 +12,5 @@
"iot_class": "cloud_polling",
"issue_tracker": "https://github.com/timmaurice/sea-temperatures/issues",
"requirements": [],
"version": "3.2.0"
"version": "3.3.0"
}
@@ -1,37 +1,32 @@
function t(t,e,n,r){var i,a=arguments.length,s=a<3?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var o=t.length-1;o>=0;o--)(i=t[o])&&(s=(a<3?i(s):a>3?i(e,n,s):i(e,n))||s);return a>3&&s&&Object.defineProperty(e,n,s),s}console.groupCollapsed("%c🌊 SEA TEMPERATURES CARD%cv3.2.0","color: orange; font-weight: bold; background: black; padding: 2px 4px; border-radius: 2px 0 0 2px;","color: white; font-weight: bold; background: dimgray; padding: 2px 4px; border-radius: 0 2px 2px 0;"),console.info("A Lovelace card to display sea temperatures."),console.info("Github: https://github.com/timmaurice/sea-temperatures.git"),console.info("Sponsor: https://buymeacoffee.com/timmaurice"),console.groupEnd(),"function"==typeof SuppressedError&&SuppressedError;
function t(t,e,n,r){var i,a=arguments.length,s=a<3?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var o=t.length-1;o>=0;o--)(i=t[o])&&(s=(a<3?i(s):a>3?i(e,n,s):i(e,n))||s);return a>3&&s&&Object.defineProperty(e,n,s),s}console.groupCollapsed("%c🌊 SEA TEMPERATURES CARD%cv3.3.0","color: orange; font-weight: bold; background: black; padding: 2px 4px; border-radius: 2px 0 0 2px;","color: white; font-weight: bold; background: dimgray; padding: 2px 4px; border-radius: 0 2px 2px 0;"),console.info("A Lovelace card to display sea temperatures."),console.info("Github: https://github.com/timmaurice/sea-temperatures.git"),console.info("Sponsor: https://buymeacoffee.com/timmaurice"),console.groupEnd(),"function"==typeof SuppressedError&&SuppressedError;
/**
* @license
* Copyright 2019 Google LLC
* SPDX-License-Identifier: BSD-3-Clause
*/
const e=globalThis,n=e.ShadowRoot&&(void 0===e.ShadyCSS||e.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,r=Symbol(),i=new WeakMap;let a=class{constructor(t,e,n){if(this._$cssResult$=!0,n!==r)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=t,this.t=e}get styleSheet(){let t=this.o;const e=this.t;if(n&&void 0===t){const n=void 0!==e&&1===e.length;n&&(t=i.get(e)),void 0===t&&((this.o=t=new CSSStyleSheet).replaceSync(this.cssText),n&&i.set(e,t))}return t}toString(){return this.cssText}};const s=t=>new a("string"==typeof t?t:t+"",void 0,r),o=(t,...e)=>{const n=1===t.length?t[0]:e.reduce((e,n,r)=>e+(t=>{if(!0===t._$cssResult$)return t.cssText;if("number"==typeof t)return t;throw Error("Value passed to 'css' function must be a 'css' function result: "+t+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(n)+t[r+1],t[0]);return new a(n,t,r)},c=n?t=>t:t=>t instanceof CSSStyleSheet?(t=>{let e="";for(const n of t.cssRules)e+=n.cssText;return s(e)})(t):t,{is:l,defineProperty:h,getOwnPropertyDescriptor:u,getOwnPropertyNames:d,getOwnPropertySymbols:p,getPrototypeOf:f}=Object,g=globalThis,m=g.trustedTypes,_=m?m.emptyScript:"",y=g.reactiveElementPolyfillSupport,v=(t,e)=>t,b={toAttribute(t,e){switch(e){case Boolean:t=t?_:null;break;case Object:case Array:t=null==t?t:JSON.stringify(t)}return t},fromAttribute(t,e){let n=t;switch(e){case Boolean:n=null!==t;break;case Number:n=null===t?null:Number(t);break;case Object:case Array:try{n=JSON.parse(t)}catch(t){n=null}}return n}},x=(t,e)=>!l(t,e),$={attribute:!0,type:String,converter:b,reflect:!1,useDefault:!1,hasChanged:x};
const e=globalThis,n=e.ShadowRoot&&(void 0===e.ShadyCSS||e.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,r=Symbol(),i=new WeakMap;let a=class{constructor(t,e,n){if(this._$cssResult$=!0,n!==r)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=t,this.t=e}get styleSheet(){let t=this.o;const e=this.t;if(n&&void 0===t){const n=void 0!==e&&1===e.length;n&&(t=i.get(e)),void 0===t&&((this.o=t=new CSSStyleSheet).replaceSync(this.cssText),n&&i.set(e,t))}return t}toString(){return this.cssText}};const s=t=>new a("string"==typeof t?t:t+"",void 0,r),o=(t,...e)=>{const n=1===t.length?t[0]:e.reduce((e,n,r)=>e+(t=>{if(!0===t._$cssResult$)return t.cssText;if("number"==typeof t)return t;throw Error("Value passed to 'css' function must be a 'css' function result: "+t+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(n)+t[r+1],t[0]);return new a(n,t,r)},c=n?t=>t:t=>t instanceof CSSStyleSheet?(t=>{let e="";for(const n of t.cssRules)e+=n.cssText;return s(e)})(t):t,{is:l,defineProperty:u,getOwnPropertyDescriptor:h,getOwnPropertyNames:d,getOwnPropertySymbols:p,getPrototypeOf:f}=Object,g=globalThis,m=g.trustedTypes,_=m?m.emptyScript:"",y=g.reactiveElementPolyfillSupport,v=(t,e)=>t,b={toAttribute(t,e){switch(e){case Boolean:t=t?_:null;break;case Object:case Array:t=null==t?t:JSON.stringify(t)}return t},fromAttribute(t,e){let n=t;switch(e){case Boolean:n=null!==t;break;case Number:n=null===t?null:Number(t);break;case Object:case Array:try{n=JSON.parse(t)}catch(t){n=null}}return n}},x=(t,e)=>!l(t,e),$={attribute:!0,type:String,converter:b,reflect:!1,useDefault:!1,hasChanged:x};
/**
* @license
* Copyright 2017 Google LLC
* SPDX-License-Identifier: BSD-3-Clause
*/Symbol.metadata??=Symbol("metadata"),g.litPropertyMetadata??=new WeakMap;let w=class extends HTMLElement{static addInitializer(t){this._$Ei(),(this.l??=[]).push(t)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(t,e=$){if(e.state&&(e.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(t)&&((e=Object.create(e)).wrapped=!0),this.elementProperties.set(t,e),!e.noAccessor){const n=Symbol(),r=this.getPropertyDescriptor(t,n,e);void 0!==r&&h(this.prototype,t,r)}}static getPropertyDescriptor(t,e,n){const{get:r,set:i}=u(this.prototype,t)??{get(){return this[e]},set(t){this[e]=t}};return{get:r,set(e){const a=r?.call(this);i?.call(this,e),this.requestUpdate(t,a,n)},configurable:!0,enumerable:!0}}static getPropertyOptions(t){return this.elementProperties.get(t)??$}static _$Ei(){if(this.hasOwnProperty(v("elementProperties")))return;const t=f(this);t.finalize(),void 0!==t.l&&(this.l=[...t.l]),this.elementProperties=new Map(t.elementProperties)}static finalize(){if(this.hasOwnProperty(v("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(v("properties"))){const t=this.properties,e=[...d(t),...p(t)];for(const n of e)this.createProperty(n,t[n])}const t=this[Symbol.metadata];if(null!==t){const e=litPropertyMetadata.get(t);if(void 0!==e)for(const[t,n]of e)this.elementProperties.set(t,n)}this._$Eh=new Map;for(const[t,e]of this.elementProperties){const n=this._$Eu(t,e);void 0!==n&&this._$Eh.set(n,t)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(t){const e=[];if(Array.isArray(t)){const n=new Set(t.flat(1/0).reverse());for(const t of n)e.unshift(c(t))}else void 0!==t&&e.push(c(t));return e}static _$Eu(t,e){const n=e.attribute;return!1===n?void 0:"string"==typeof n?n:"string"==typeof t?t.toLowerCase():void 0}constructor(){super(),this._$Ep=void 0,this.isUpdatePending=!1,this.hasUpdated=!1,this._$Em=null,this._$Ev()}_$Ev(){this._$ES=new Promise(t=>this.enableUpdating=t),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach(t=>t(this))}addController(t){(this._$EO??=new Set).add(t),void 0!==this.renderRoot&&this.isConnected&&t.hostConnected?.()}removeController(t){this._$EO?.delete(t)}_$E_(){const t=new Map,e=this.constructor.elementProperties;for(const n of e.keys())this.hasOwnProperty(n)&&(t.set(n,this[n]),delete this[n]);t.size>0&&(this._$Ep=t)}createRenderRoot(){const t=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return((t,r)=>{if(n)t.adoptedStyleSheets=r.map(t=>t instanceof CSSStyleSheet?t:t.styleSheet);else for(const n of r){const r=document.createElement("style"),i=e.litNonce;void 0!==i&&r.setAttribute("nonce",i),r.textContent=n.cssText,t.appendChild(r)}})(t,this.constructor.elementStyles),t}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach(t=>t.hostConnected?.())}enableUpdating(t){}disconnectedCallback(){this._$EO?.forEach(t=>t.hostDisconnected?.())}attributeChangedCallback(t,e,n){this._$AK(t,n)}_$ET(t,e){const n=this.constructor.elementProperties.get(t),r=this.constructor._$Eu(t,n);if(void 0!==r&&!0===n.reflect){const i=(void 0!==n.converter?.toAttribute?n.converter:b).toAttribute(e,n.type);this._$Em=t,null==i?this.removeAttribute(r):this.setAttribute(r,i),this._$Em=null}}_$AK(t,e){const n=this.constructor,r=n._$Eh.get(t);if(void 0!==r&&this._$Em!==r){const t=n.getPropertyOptions(r),i="function"==typeof t.converter?{fromAttribute:t.converter}:void 0!==t.converter?.fromAttribute?t.converter:b;this._$Em=r;const a=i.fromAttribute(e,t.type);this[r]=a??this._$Ej?.get(r)??a,this._$Em=null}}requestUpdate(t,e,n,r=!1,i){if(void 0!==t){const a=this.constructor;if(!1===r&&(i=this[t]),n??=a.getPropertyOptions(t),!((n.hasChanged??x)(i,e)||n.useDefault&&n.reflect&&i===this._$Ej?.get(t)&&!this.hasAttribute(a._$Eu(t,n))))return;this.C(t,e,n)}!1===this.isUpdatePending&&(this._$ES=this._$EP())}C(t,e,{useDefault:n,reflect:r,wrapped:i},a){n&&!(this._$Ej??=new Map).has(t)&&(this._$Ej.set(t,a??e??this[t]),!0!==i||void 0!==a)||(this._$AL.has(t)||(this.hasUpdated||n||(e=void 0),this._$AL.set(t,e)),!0===r&&this._$Em!==t&&(this._$Eq??=new Set).add(t))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(t){Promise.reject(t)}const t=this.scheduleUpdate();return null!=t&&await t,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(const[t,e]of this._$Ep)this[t]=e;this._$Ep=void 0}const t=this.constructor.elementProperties;if(t.size>0)for(const[e,n]of t){const{wrapped:t}=n,r=this[e];!0!==t||this._$AL.has(e)||void 0===r||this.C(e,void 0,n,r)}}let t=!1;const e=this._$AL;try{t=this.shouldUpdate(e),t?(this.willUpdate(e),this._$EO?.forEach(t=>t.hostUpdate?.()),this.update(e)):this._$EM()}catch(e){throw t=!1,this._$EM(),e}t&&this._$AE(e)}willUpdate(t){}_$AE(t){this._$EO?.forEach(t=>t.hosLine truncated
*/Symbol.metadata??=Symbol("metadata"),g.litPropertyMetadata??=new WeakMap;let w=class extends HTMLElement{static addInitializer(t){this._$Ei(),(this.l??=[]).push(t)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(t,e=$){if(e.state&&(e.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(t)&&((e=Object.create(e)).wrapped=!0),this.elementProperties.set(t,e),!e.noAccessor){const n=Symbol(),r=this.getPropertyDescriptor(t,n,e);void 0!==r&&u(this.prototype,t,r)}}static getPropertyDescriptor(t,e,n){const{get:r,set:i}=h(this.prototype,t)??{get(){return this[e]},set(t){this[e]=t}};return{get:r,set(e){const a=r?.call(this);i?.call(this,e),this.requestUpdate(t,a,n)},configurable:!0,enumerable:!0}}static getPropertyOptions(t){return this.elementProperties.get(t)??$}static _$Ei(){if(this.hasOwnProperty(v("elementProperties")))return;const t=f(this);t.finalize(),void 0!==t.l&&(this.l=[...t.l]),this.elementProperties=new Map(t.elementProperties)}static finalize(){if(this.hasOwnProperty(v("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(v("properties"))){const t=this.properties,e=[...d(t),...p(t)];for(const n of e)this.createProperty(n,t[n])}const t=this[Symbol.metadata];if(null!==t){const e=litPropertyMetadata.get(t);if(void 0!==e)for(const[t,n]of e)this.elementProperties.set(t,n)}this._$Eh=new Map;for(const[t,e]of this.elementProperties){const n=this._$Eu(t,e);void 0!==n&&this._$Eh.set(n,t)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(t){const e=[];if(Array.isArray(t)){const n=new Set(t.flat(1/0).reverse());for(const t of n)e.unshift(c(t))}else void 0!==t&&e.push(c(t));return e}static _$Eu(t,e){const n=e.attribute;return!1===n?void 0:"string"==typeof n?n:"string"==typeof t?t.toLowerCase():void 0}constructor(){super(),this._$Ep=void 0,this.isUpdatePending=!1,this.hasUpdated=!1,this._$Em=null,this._$Ev()}_$Ev(){this._$ES=new Promise(t=>this.enableUpdating=t),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach(t=>t(this))}addController(t){(this._$EO??=new Set).add(t),void 0!==this.renderRoot&&this.isConnected&&t.hostConnected?.()}removeController(t){this._$EO?.delete(t)}_$E_(){const t=new Map,e=this.constructor.elementProperties;for(const n of e.keys())this.hasOwnProperty(n)&&(t.set(n,this[n]),delete this[n]);t.size>0&&(this._$Ep=t)}createRenderRoot(){const t=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return((t,r)=>{if(n)t.adoptedStyleSheets=r.map(t=>t instanceof CSSStyleSheet?t:t.styleSheet);else for(const n of r){const r=document.createElement("style"),i=e.litNonce;void 0!==i&&r.setAttribute("nonce",i),r.textContent=n.cssText,t.appendChild(r)}})(t,this.constructor.elementStyles),t}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach(t=>t.hostConnected?.())}enableUpdating(t){}disconnectedCallback(){this._$EO?.forEach(t=>t.hostDisconnected?.())}attributeChangedCallback(t,e,n){this._$AK(t,n)}_$ET(t,e){const n=this.constructor.elementProperties.get(t),r=this.constructor._$Eu(t,n);if(void 0!==r&&!0===n.reflect){const i=(void 0!==n.converter?.toAttribute?n.converter:b).toAttribute(e,n.type);this._$Em=t,null==i?this.removeAttribute(r):this.setAttribute(r,i),this._$Em=null}}_$AK(t,e){const n=this.constructor,r=n._$Eh.get(t);if(void 0!==r&&this._$Em!==r){const t=n.getPropertyOptions(r),i="function"==typeof t.converter?{fromAttribute:t.converter}:void 0!==t.converter?.fromAttribute?t.converter:b;this._$Em=r;const a=i.fromAttribute(e,t.type);this[r]=a??this._$Ej?.get(r)??a,this._$Em=null}}requestUpdate(t,e,n,r=!1,i){if(void 0!==t){const a=this.constructor;if(!1===r&&(i=this[t]),n??=a.getPropertyOptions(t),!((n.hasChanged??x)(i,e)||n.useDefault&&n.reflect&&i===this._$Ej?.get(t)&&!this.hasAttribute(a._$Eu(t,n))))return;this.C(t,e,n)}!1===this.isUpdatePending&&(this._$ES=this._$EP())}C(t,e,{useDefault:n,reflect:r,wrapped:i},a){n&&!(this._$Ej??=new Map).has(t)&&(this._$Ej.set(t,a??e??this[t]),!0!==i||void 0!==a)||(this._$AL.has(t)||(this.hasUpdated||n||(e=void 0),this._$AL.set(t,e)),!0===r&&this._$Em!==t&&(this._$Eq??=new Set).add(t))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(t){Promise.reject(t)}const t=this.scheduleUpdate();return null!=t&&await t,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(const[t,e]of this._$Ep)this[t]=e;this._$Ep=void 0}const t=this.constructor.elementProperties;if(t.size>0)for(const[e,n]of t){const{wrapped:t}=n,r=this[e];!0!==t||this._$AL.has(e)||void 0===r||this.C(e,void 0,n,r)}}let t=!1;const e=this._$AL;try{t=this.shouldUpdate(e),t?(this.willUpdate(e),this._$EO?.forEach(t=>t.hostUpdate?.()),this.update(e)):this._$EM()}catch(e){throw t=!1,this._$EM(),e}t&&this._$AE(e)}willUpdate(t){}_$AE(t){this._$EO?.forEach(t=>t.hosLine truncated
/**
* @license
* Copyright 2017 Google LLC
* SPDX-License-Identifier: BSD-3-Clause
*/
const M=globalThis,A=t=>t,S=M.trustedTypes,T=S?S.createPolicy("lit-html",{createHTML:t=>t}):void 0,C="$lit$",N=`lit$${Math.random().toFixed(9).slice(2)}$`,E="?"+N,k=`<${E}>`,U=document,D=()=>U.createComment(""),P=t=>null===t||"object"!=typeof t&&"function"!=typeof t,O=Array.isArray,F="[ \t\n\f\r]",H=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,L=/-->/g,z=/>/g,j=RegExp(`>|${F}(?:([^\\s"'>=/]+)(${F}*=${F}*(?:[^ \t\n\f\r"'\`<>=]|("|')|))|$)`,"g"),R=/'/g,Y=/"/g,q=/^(?:script|style|textarea|title)$/i,I=t=>(e,...n)=>({_$litType$:t,strings:e,values:n}),W=I(1),Z=I(2),V=Symbol.for("lit-noChange"),B=Symbol.for("lit-nothing"),G=new WeakMap,X=U.createTreeWalker(U,129);function J(t,e){if(!O(t)||!t.hasOwnProperty("raw"))throw Error("invalid template strings array");return void 0!==T?T.createHTML(e):e}const Q=(t,e)=>{const n=t.length-1,r=[];let i,a=2===e?"<svg>":3===e?"<math>":"",s=H;for(let e=0;e<n;e++){const n=t[e];let o,c,l=-1,h=0;for(;h<n.length&&(s.lastIndex=h,c=s.exec(n),null!==c);)h=s.lastIndex,s===H?"!--"===c[1]?s=L:void 0!==c[1]?s=z:void 0!==c[2]?(q.test(c[2])&&(i=RegExp("</"+c[2],"g")),s=j):void 0!==c[3]&&(s=j):s===j?">"===c[0]?(s=i??H,l=-1):void 0===c[1]?l=-2:(l=s.lastIndex-c[2].length,o=c[1],s=void 0===c[3]?j:'"'===c[3]?Y:R):s===Y||s===R?s=j:s===L||s===z?s=H:(s=j,i=void 0);const u=s===j&&t[e+1].startsWith("/>")?" ":"";a+=s===H?n+k:l>=0?(r.push(o),n.slice(0,l)+C+n.slice(l)+N+u):n+N+(-2===l?e:u)}return[J(t,a+(t[n]||"<?>")+(2===e?"</svg>":3===e?"</math>":"")),r]};class K{constructor({strings:t,_$litType$:e},n){let r;this.parts=[];let i=0,a=0;const s=t.length-1,o=this.parts,[c,l]=Q(t,e);if(this.el=K.createElement(c,n),X.currentNode=this.el.content,2===e||3===e){const t=this.el.content.firstChild;t.replaceWith(...t.childNodes)}for(;null!==(r=X.nextNode())&&o.length<s;){if(1===r.nodeType){if(r.hasAttributes())for(const t of r.getAttributeNames())if(t.endsWith(C)){const e=l[a++],n=r.getAttribute(t).split(N),s=/([.?@])?(.*)/.exec(e);o.push({type:1,index:i,name:s[2],strings:n,ctor:"."===s[1]?it:"?"===s[1]?at:"@"===s[1]?st:rt}),r.removeAttribute(t)}else t.startsWith(N)&&(o.push({type:6,index:i}),r.removeAttribute(t));if(q.test(r.tagName)){const t=r.textContent.split(N),e=t.length-1;if(e>0){r.textContent=S?S.emptyScript:"";for(let n=0;n<e;n++)r.append(t[n],D()),X.nextNode(),o.push({type:2,index:++i});r.append(t[e],D())}}}else if(8===r.nodeType)if(r.data===E)o.push({type:2,index:i});else{let t=-1;for(;-1!==(t=r.data.indexOf(N,t+1));)o.push({type:7,index:i}),t+=N.length-1}i++}}static createElement(t,e){const n=U.createElement("template");return n.innerHTML=t,n}}function tt(t,e,n=t,r){if(e===V)return e;let i=void 0!==r?n._$Co?.[r]:n._$Cl;const a=P(e)?void 0:e._$litDirective$;return i?.constructor!==a&&(i?._$AO?.(!1),void 0===a?i=void 0:(i=new a(t),i._$AT(t,n,r)),void 0!==r?(n._$Co??=[])[r]=i:n._$Cl=i),void 0!==i&&(e=tt(t,i._$AS(t,e.values),i,r)),e}class et{constructor(t,e){this._$AV=[],this._$AN=void 0,this._$AD=t,this._$AM=e}get parentNode(){return this._$AM.parentNode}get _$AU(){return this._$AM._$AU}u(t){const{el:{content:e},parts:n}=this._$AD,r=(t?.creationScope??U).importNode(e,!0);X.currentNode=r;let i=X.nextNode(),a=0,s=0,o=n[0];for(;void 0!==o;){if(a===o.index){let e;2===o.type?e=new nt(i,i.nextSibling,this,t):1===o.type?e=new o.ctor(i,o.name,o.strings,this,t):6===o.type&&(e=new ot(i,this,t)),this._$AV.push(e),o=n[++s]}a!==o?.index&&(i=X.nextNode(),a++)}return X.currentNode=U,r}p(t){let e=0;for(const n of this._$AV)void 0!==n&&(void 0!==n.strings?(n._$AI(t,n,e),e+=n.strings.length-2):n._$AI(t[e])),e++}}class nt{get _$AU(){return this._$AM?._$AU??this._$Cv}constructor(t,e,n,r){this.type=2,this._$AH=B,this._$AN=void 0,this._$AA=t,this._$AB=e,this._$AM=n,this.options=r,this._$Cv=r?.isConnected??!0}get parentNode(){let t=this._$AA.parentNode;const e=this._$AM;return void 0!==e&&11===t?.nodeType&&(t=e.parentNode),t}get startNode(){return this._$AA}get endNode(){return this._$AB}_$AI(t,e=this){t=tt(this,t,e),P(t)?t===B||null==t||""===t?(this._$AH!==B&&this._$AR(),this._$AH=B):t!==this._$AH&&t!==V&&this._(t):void 0!==t._$litType$?this.$(t):void 0!==t.nodeType?this.T(t):(t=>O(t)||"function"==typeof t?.[Symbol.iterator])(t)?this.k(t):this._(t)}O(t){return this._$AA.parentNode.insertBefore(t,this._$AB)}T(t){this._$AH!==t&&(this._$AR(),this._$AH=this.O(t))}_(t){this._$AH!==B&&P(this._$AH)?this._$AA.nextSibling.data=t:this.T(U.createTextNode(t)),this._$AH=t}$(t){const{values:e,_$litType$:n}=t,r="number"==typeof n?this._$AC(t):(void 0===n.el&&(n.el=K.createElement(J(n.h,n.h[0]),this.options)),n);if(this._$AH?._$AD===r)this._$AH.p(e);else{const t=new et(r,this),n=t.u(this.options);t.p(e),this.T(n),this._$AH=t}}_$AC(t){let e=G.get(t.strings);return void 0===e&&G.set(t.strings,e=new K(t)),e}k(t){O(this._$AH)||(this._$AH=[],this._$AR());const e=this._$AH;let n,r=0;for(const i of t)r===e.length?e.push(n=new nt(this.O(D()),this.O(D()),this,this.options)):n=e[r],n._$AI(i),r++;r<e.length&&(this._$AR(n&&n._$AB.nextSibling,r),e.Line truncated
const M=globalThis,A=t=>t,S=M.trustedTypes,T=S?S.createPolicy("lit-html",{createHTML:t=>t}):void 0,C="$lit$",N=`lit$${Math.random().toFixed(9).slice(2)}$`,k="?"+N,E=`<${k}>`,D=document,U=()=>D.createComment(""),P=t=>null===t||"object"!=typeof t&&"function"!=typeof t,O=Array.isArray,H="[ \t\n\f\r]",F=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,L=/-->/g,z=/>/g,j=RegExp(`>|${H}(?:([^\\s"'>=/]+)(${H}*=${H}*(?:[^ \t\n\f\r"'\`<>=]|("|')|))|$)`,"g"),R=/'/g,Y=/"/g,q=/^(?:script|style|textarea|title)$/i,I=t=>(e,...n)=>({_$litType$:t,strings:e,values:n}),W=I(1),Z=I(2),V=Symbol.for("lit-noChange"),B=Symbol.for("lit-nothing"),G=new WeakMap,X=D.createTreeWalker(D,129);function J(t,e){if(!O(t)||!t.hasOwnProperty("raw"))throw Error("invalid template strings array");return void 0!==T?T.createHTML(e):e}const Q=(t,e)=>{const n=t.length-1,r=[];let i,a=2===e?"<svg>":3===e?"<math>":"",s=F;for(let e=0;e<n;e++){const n=t[e];let o,c,l=-1,u=0;for(;u<n.length&&(s.lastIndex=u,c=s.exec(n),null!==c);)u=s.lastIndex,s===F?"!--"===c[1]?s=L:void 0!==c[1]?s=z:void 0!==c[2]?(q.test(c[2])&&(i=RegExp("</"+c[2],"g")),s=j):void 0!==c[3]&&(s=j):s===j?">"===c[0]?(s=i??F,l=-1):void 0===c[1]?l=-2:(l=s.lastIndex-c[2].length,o=c[1],s=void 0===c[3]?j:'"'===c[3]?Y:R):s===Y||s===R?s=j:s===L||s===z?s=F:(s=j,i=void 0);const h=s===j&&t[e+1].startsWith("/>")?" ":"";a+=s===F?n+E:l>=0?(r.push(o),n.slice(0,l)+C+n.slice(l)+N+h):n+N+(-2===l?e:h)}return[J(t,a+(t[n]||"<?>")+(2===e?"</svg>":3===e?"</math>":"")),r]};class K{constructor({strings:t,_$litType$:e},n){let r;this.parts=[];let i=0,a=0;const s=t.length-1,o=this.parts,[c,l]=Q(t,e);if(this.el=K.createElement(c,n),X.currentNode=this.el.content,2===e||3===e){const t=this.el.content.firstChild;t.replaceWith(...t.childNodes)}for(;null!==(r=X.nextNode())&&o.length<s;){if(1===r.nodeType){if(r.hasAttributes())for(const t of r.getAttributeNames())if(t.endsWith(C)){const e=l[a++],n=r.getAttribute(t).split(N),s=/([.?@])?(.*)/.exec(e);o.push({type:1,index:i,name:s[2],strings:n,ctor:"."===s[1]?it:"?"===s[1]?at:"@"===s[1]?st:rt}),r.removeAttribute(t)}else t.startsWith(N)&&(o.push({type:6,index:i}),r.removeAttribute(t));if(q.test(r.tagName)){const t=r.textContent.split(N),e=t.length-1;if(e>0){r.textContent=S?S.emptyScript:"";for(let n=0;n<e;n++)r.append(t[n],U()),X.nextNode(),o.push({type:2,index:++i});r.append(t[e],U())}}}else if(8===r.nodeType)if(r.data===k)o.push({type:2,index:i});else{let t=-1;for(;-1!==(t=r.data.indexOf(N,t+1));)o.push({type:7,index:i}),t+=N.length-1}i++}}static createElement(t,e){const n=D.createElement("template");return n.innerHTML=t,n}}function tt(t,e,n=t,r){if(e===V)return e;let i=void 0!==r?n._$Co?.[r]:n._$Cl;const a=P(e)?void 0:e._$litDirective$;return i?.constructor!==a&&(i?._$AO?.(!1),void 0===a?i=void 0:(i=new a(t),i._$AT(t,n,r)),void 0!==r?(n._$Co??=[])[r]=i:n._$Cl=i),void 0!==i&&(e=tt(t,i._$AS(t,e.values),i,r)),e}class et{constructor(t,e){this._$AV=[],this._$AN=void 0,this._$AD=t,this._$AM=e}get parentNode(){return this._$AM.parentNode}get _$AU(){return this._$AM._$AU}u(t){const{el:{content:e},parts:n}=this._$AD,r=(t?.creationScope??D).importNode(e,!0);X.currentNode=r;let i=X.nextNode(),a=0,s=0,o=n[0];for(;void 0!==o;){if(a===o.index){let e;2===o.type?e=new nt(i,i.nextSibling,this,t):1===o.type?e=new o.ctor(i,o.name,o.strings,this,t):6===o.type&&(e=new ot(i,this,t)),this._$AV.push(e),o=n[++s]}a!==o?.index&&(i=X.nextNode(),a++)}return X.currentNode=D,r}p(t){let e=0;for(const n of this._$AV)void 0!==n&&(void 0!==n.strings?(n._$AI(t,n,e),e+=n.strings.length-2):n._$AI(t[e])),e++}}class nt{get _$AU(){return this._$AM?._$AU??this._$Cv}constructor(t,e,n,r){this.type=2,this._$AH=B,this._$AN=void 0,this._$AA=t,this._$AB=e,this._$AM=n,this.options=r,this._$Cv=r?.isConnected??!0}get parentNode(){let t=this._$AA.parentNode;const e=this._$AM;return void 0!==e&&11===t?.nodeType&&(t=e.parentNode),t}get startNode(){return this._$AA}get endNode(){return this._$AB}_$AI(t,e=this){t=tt(this,t,e),P(t)?t===B||null==t||""===t?(this._$AH!==B&&this._$AR(),this._$AH=B):t!==this._$AH&&t!==V&&this._(t):void 0!==t._$litType$?this.$(t):void 0!==t.nodeType?this.T(t):(t=>O(t)||"function"==typeof t?.[Symbol.iterator])(t)?this.k(t):this._(t)}O(t){return this._$AA.parentNode.insertBefore(t,this._$AB)}T(t){this._$AH!==t&&(this._$AR(),this._$AH=this.O(t))}_(t){this._$AH!==B&&P(this._$AH)?this._$AA.nextSibling.data=t:this.T(D.createTextNode(t)),this._$AH=t}$(t){const{values:e,_$litType$:n}=t,r="number"==typeof n?this._$AC(t):(void 0===n.el&&(n.el=K.createElement(J(n.h,n.h[0]),this.options)),n);if(this._$AH?._$AD===r)this._$AH.p(e);else{const t=new et(r,this),n=t.u(this.options);t.p(e),this.T(n),this._$AH=t}}_$AC(t){let e=G.get(t.strings);return void 0===e&&G.set(t.strings,e=new K(t)),e}k(t){O(this._$AH)||(this._$AH=[],this._$AR());const e=this._$AH;let n,r=0;for(const i of t)r===e.length?e.push(n=new nt(this.O(U()),this.O(U()),this,this.options)):n=e[r],n._$AI(i),r++;r<e.length&&(this._$AR(n&&n._$AB.nextSibling,r),e.Line truncated
/**
* @license
* Copyright 2017 Google LLC
* SPDX-License-Identifier: BSD-3-Clause
*/class ht extends w{constructor(){super(...arguments),this.renderOptions={host:this},this._$Do=void 0}createRenderRoot(){const t=super.createRenderRoot();return this.renderOptions.renderBefore??=t.firstChild,t}update(t){const e=this.render();this.hasUpdated||(this.renderOptions.isConnected=this.isConnected),super.update(t),this._$Do=((t,e,n)=>{const r=n?.renderBefore??e;let i=r._$litPart$;if(void 0===i){const t=n?.renderBefore??null;r._$litPart$=i=new nt(e.insertBefore(D(),t),t,void 0,n??{})}return i._$AI(t),i})(e,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return V}}ht._$litElement$=!0,ht.finalized=!0,lt.litElementHydrateSupport?.({LitElement:ht});const ut=lt.litElementPolyfillSupport;ut?.({LitElement:ht}),(lt.litElementVersions??=[]).push("4.2.2");
*/class ut extends w{constructor(){super(...arguments),this.renderOptions={host:this},this._$Do=void 0}createRenderRoot(){const t=super.createRenderRoot();return this.renderOptions.renderBefore??=t.firstChild,t}update(t){const e=this.render();this.hasUpdated||(this.renderOptions.isConnected=this.isConnected),super.update(t),this._$Do=((t,e,n)=>{const r=n?.renderBefore??e;let i=r._$litPart$;if(void 0===i){const t=n?.renderBefore??null;r._$litPart$=i=new nt(e.insertBefore(U(),t),t,void 0,n??{})}return i._$AI(t),i})(e,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return V}}ut._$litElement$=!0,ut.finalized=!0,lt.litElementHydrateSupport?.({LitElement:ut});const ht=lt.litElementPolyfillSupport;ht?.({LitElement:ut}),(lt.litElementVersions??=[]).push("4.2.2");
/**
* @license
* Copyright 2017 Google LLC
* SPDX-License-Identifier: BSD-3-Clause
*/
const dt={attribute:!0,type:String,converter:b,reflect:!1,hasChanged:x},pt=(t=dt,e,n)=>{const{kind:r,metadata:i}=n;let a=globalThis.litPropertyMetadata.get(i);if(void 0===a&&globalThis.litPropertyMetadata.set(i,a=new Map),"setter"===r&&((t=Object.create(t)).wrapped=!0),a.set(n.name,t),"accessor"===r){const{name:r}=n;return{set(n){const i=e.get.call(this);e.set.call(this,n),this.requestUpdate(r,i,t,!0,n)},init(e){return void 0!==e&&this.C(r,void 0,t,e),e}}}if("setter"===r){const{name:r}=n;return function(n){const i=this[r];e.call(this,n),this.requestUpdate(r,i,t,!0,n)}}throw Error("Unsupported decorator location: "+r)};
/**
* @license
* Copyright 2017 Google LLC
* SPDX-License-Identifier: BSD-3-Clause
*/function ft(t){return(e,n)=>"object"==typeof n?pt(t,e,n):((t,e,n)=>{const r=e.hasOwnProperty(n);return e.constructor.createProperty(n,t),r?Object.getOwnPropertyDescriptor(e,n):void 0})(t,e,n)}
const dt={attribute:!0,type:String,converter:b,reflect:!1,hasChanged:x},pt=(t=dt,e,n)=>{const{kind:r,metadata:i}=n;let a=globalThis.litPropertyMetadata.get(i);if(void 0===a&&globalThis.litPropertyMetadata.set(i,a=new Map),"setter"===r&&((t=Object.create(t)).wrapped=!0),a.set(n.name,t),"accessor"===r){const{name:r}=n;return{set(n){const i=e.get.call(this);e.set.call(this,n),this.requestUpdate(r,i,t,!0,n)},init(e){return void 0!==e&&this.C(r,void 0,t,e),e}}}if("setter"===r){const{name:r}=n;return function(n){const i=this[r];e.call(this,n),this.requestUpdate(r,i,t,!0,n)}}throw Error("Unsupported decorator location: "+r)};function ft(t){return(e,n)=>"object"==typeof n?pt(t,e,n):((t,e,n)=>{const r=e.hasOwnProperty(n);return e.constructor.createProperty(n,t),r?Object.getOwnPropertyDescriptor(e,n):void 0})(t,e,n)}
/**
* @license
* Copyright 2017 Google LLC
@@ -41,55 +36,64 @@ const dt={attribute:!0,type:String,converter:b,reflect:!1,hasChanged:x},pt=(t=dt
* @license
* Copyright 2017 Google LLC
* SPDX-License-Identifier: BSD-3-Clause
*/const mt={en:{editor:{groups:{display:"Display"},title:"Title (Optional)",places:"Places",show_trend:"Show trend indicators (vs Yesterday)",show_last_updated:"Show last updated",show_stats:"Show statistics (Yesterday, Last Week, 10-Year Avg)",show_chart:"Show historical chart (30d)",show_country:"Append country to place name",chart_smoothing:"Chart smoothing",chart_smoothing_options:{smooth:"Smooth",linear:"Linear",step:"Step"},sort_by:"Sort places by",sort_by_options:{default:"Default (Manual)",name:"Name (A-Z)",temp_asc:"Temperature (Coldest First)",temp_desc:"Temperature (Warmest First)"},add_place:"Add Place"},card:{yesterday:"Yesterday",last_week:"Last Week",average_avg:"10-Year Avg",averages:"Averages",min:"Min",max:"Max",avg:"Avg",chart_description:"30-day trend"},common:{errors:{no_places:"You need to define at least one place.",duplicate_place:"This place is already in your list."}}},de:{editor:{groups:{display:"Anzeige"},title:"Titel (Optional)",places:"Orte",show_trend:"Zeige Trend-Indikatoren (vs Gestern)",show_last_updated:"Zuletzt aktualisiert anzeigen",show_stats:"Statistiken anzeigen (Gestern, Letzte Woche, 10-Jahres-Ø)",show_chart:"Historisches Diagramm anzeigen (30t)",show_country:"Land an den Ortsnamen anhängen",chart_smoothing:"Kurvenglättung",chart_smoothing_options:{smooth:"Weich",linear:"Linear",step:"Stufen"},sort_by:"Orte sortieren nach",sort_by_options:{default:"Standard (Manuell)",name:"Name (A-Z)",temp_asc:"Temperatur (Kälteste zuerst)",temp_desc:"Temperatur (Wärmste zuerst)"},add_place:"Ort hinzufügen"},card:{yesterday:"Gestern",last_week:"Letzte Woche",average_avg:"10-Jahres-Ø",averages:"Durchschnitte",min:"Min",max:"Max",avg:"Ø",chart_description:"30-Tage-Trend"},common:{errors:{no_places:"Du musst mindestens einen Ort definieren.",duplicate_place:"Dieser Ort ist bereits in deiner Liste."}}},fr:{editor:{groups:{display:"Affichage"},title:"Titre (Optionnel)",places:"Lieux",show_trend:"Afficher les indicateurs de tendance (vs Hier)",show_last_updated:"Afficher la dernière mise à jour",show_stats:"Afficher les statistiques (Hier, La semaine dernière, Moy. 10 ans)",show_chart:"Afficher le graphique historique (30j)",show_country:"Ajouter le pays au nom du lieu",chart_smoothing:"Lissage du graphique",chart_smoothing_options:{smooth:"Lisse",linear:"Linéaire",step:"En escalier"},sort_by:"Trier les lieux par",sort_by_options:{default:"Défaut (Manuel)",name:"Nom (A-Z)",temp_asc:"Température (Le plus froid d'abord)",temp_desc:"Température (Le plus chaud d'abord)"},add_place:"Ajouter un lieu"},card:{yesterday:"Hier",last_week:"La semaine dernière",average_avg:"Moy. 10 ans",averages:"Moyennes",min:"Min",max:"Max",avg:"Moy",chart_description:"Tendance sur 30 jours"},common:{errors:{no_places:"Vous devez définir au moins un lieu.",duplicate_place:"Ce lieu est déjà dans votre liste."}}},es:{editor:{groups:{display:"Pantalla"},title:"Título (Opcional)",places:"Lugares",show_trend:"Mostrar indicadores de tendencia (vs Ayer)",show_last_updated:"Mostrar última actualización",show_stats:"Mostrar estadísticas (Ayer, Semana pasada, Media 10 años)",show_chart:"Mostrar gráfico histórico (30d)",show_country:"Añadir el país al nombre del lugar",chart_smoothing:"Suavizado del gráfico",chart_smoothing_options:{smooth:"Suave",linear:"Lineal",step:"Escalonado"},sort_by:"Ordenar lugares por",sort_by_options:{default:"Por defecto (Manual)",name:"Nombre (A-Z)",temp_asc:"Temperatura (Más frío primero)",temp_desc:"Temperatura (Más cálido primero)"},add_place:"Añadir Lugar"},card:{yesterday:"Ayer",last_week:"Semana Pasada",average_avg:"Media 10 años",averages:"Medias",min:"Mín",max:"Máx",avg:"Med",chart_description:"Tendencia de 30 días"},common:{errors:{no_places:"Debes definir al menos un lugar.",duplicate_place:"Este lugar ya está en tu lista."}}},it:{editor:{groups:{display:"Visualizzazione"},title:"Titolo (Opzionale)",places:"Luoghi",show_trend:"Mostra indicatori di tendenza (vs Ieri)",show_last_updated:"Mostra ultimo aggiornamento",show_stats:"Mostra statistiche (Ieri, Settimana scorsa, Media 10 anni)",show_chart:"Mostra grafico storico (30g)",show_country:"Aggiungi il paese al nome del luogo",chart_smoothing:"Arrotondamento grafico",chart_smoothing_options:{smooth:"Morbido",linear:"Lineare",step:"A gradini"},sort_by:"Ordina luoghi per",sort_by_options:{default:"Predefinito (Manuale)",name:"Nome (A-Z)",temp_asc:"Temperatura (Più freddo prima)",temp_desc:"Temperatura (Più caldo prima)"},add_place:"Aggiungi Luogo"},card:{yesterday:"Ieri",last_week:"Settimana Scorsa",average_avg:"Media 10 anni",averages:"Medie",min:"Min",max:"Max",avg:"Med",chart_description:"Andamento a 30 giorni"},common:{errors:{no_places:"Devi definire almeno un luogo.",duplicate_place:"Questo luogo è già nella tua lista."}}}};function _t(t,e){let n=mt[t];for(const t of e){if("object"!=typeof n||null===n)return;n=n[t]}return"string"==typeof n?n:void 0}function yt(t,e,n={}){const r=t?.languageLine truncated
*/const mt={en:{editor:{groups:{display:"Display"},title:"Title (Optional)",places:"Places",show_trend:"Show trend indicators (vs Yesterday)",show_last_updated:"Show last updated",show_stats:"Show statistics (Yesterday, Last Week, 10-Year Avg)",show_chart:"Show historical chart (30d)",show_country:"Append country to place name",chart_smoothing:"Chart smoothing",chart_smoothing_options:{smooth:"Smooth",linear:"Linear",step:"Step"},sort_by:"Sort places by",sort_by_options:{default:"Default (Manual)",name:"Name (A-Z)",temp_asc:"Temperature (Coldest First)",temp_desc:"Temperature (Warmest First)"},add_place:"Add Place"},card:{yesterday:"Yesterday",last_week:"Last Week",average_avg:"10-Year Avg",averages:"Averages",min:"Min",max:"Max",avg:"Avg",chart_description:"30-day trend",entity_problem:{not_found:"Entity {entity} was not found.",wrong_domain:"{entity} is not a sensor.",not_numeric:"{entity} does not report a temperature.",unavailable:"{entity} is unavailable."}},common:{errors:{no_places:"You need to define at least one place.",duplicate_place:"This place is already in your list."}}},de:{editor:{groups:{display:"Anzeige"},title:"Titel (Optional)",places:"Orte",show_trend:"Zeige Trend-Indikatoren (vs Gestern)",show_last_updated:"Zuletzt aktualisiert anzeigen",show_stats:"Statistiken anzeigen (Gestern, Letzte Woche, 10-Jahres-Ø)",show_chart:"Historisches Diagramm anzeigen (30t)",show_country:"Land an den Ortsnamen anhängen",chart_smoothing:"Kurvenglättung",chart_smoothing_options:{smooth:"Weich",linear:"Linear",step:"Stufen"},sort_by:"Orte sortieren nach",sort_by_options:{default:"Standard (Manuell)",name:"Name (A-Z)",temp_asc:"Temperatur (Kälteste zuerst)",temp_desc:"Temperatur (Wärmste zuerst)"},add_place:"Ort hinzufügen"},card:{yesterday:"Gestern",last_week:"Letzte Woche",average_avg:"10-Jahres-Ø",averages:"Durchschnitte",min:"Min",max:"Max",avg:"Ø",chart_description:"30-Tage-Trend",entity_problem:{not_found:"Entität {entity} wurde nicht gefunden.",wrong_domain:"{entity} ist kein Sensor.",not_numeric:"{entity} liefert keine Temperatur.",unavailable:"{entity} ist nicht verfügbar."}},common:{errors:{no_places:"Du musst mindestens einen Ort definieren.",duplicate_place:"Dieser Ort ist bereits in deiner Liste."}}},fr:{editor:{groups:{display:"Affichage"},title:"Titre (Optionnel)",places:"Lieux",show_trend:"Afficher les indicateurs de tendance (vs Hier)",show_last_updated:"Afficher la dernière mise à jour",show_stats:"Afficher les statistiques (Hier, La semaine dernière, Moy. 10 ans)",show_chart:"Afficher le graphique historique (30j)",show_country:"Ajouter le pays au nom du lieu",chart_smoothing:"Lissage du graphique",chart_smoothing_options:{smooth:"Lisse",linear:"Linéaire",step:"En escalier"},sort_by:"Trier les lieux par",sort_by_options:{default:"Défaut (Manuel)",name:"Nom (A-Z)",temp_asc:"Température (Le plus froid d'abord)",temp_desc:"Température (Le plus chaud d'abord)"},add_place:"Ajouter un lieu"},card:{yesterday:"Hier",last_week:"La semaine dernière",average_avg:"Moy. 10 ans",averages:"Moyennes",min:"Min",max:"Max",avg:"Moy",chart_description:"Tendance sur 30 jours",entity_problem:{not_found:"L'entité {entity} est introuvable.",wrong_domain:"{entity} n'est pas un capteur.",not_numeric:"{entity} ne fournit pas de température.",unavailable:"{entity} est indisponible."}},common:{errors:{no_places:"Vous devez définir au moins un lieu.",duplicate_place:"Ce lieu est déjà dans votre liste."}}},es:{editor:{groups:{display:"Pantalla"},title:"Título (Opcional)",places:"Lugares",show_trend:"Mostrar indicadores de tendencia (vs Ayer)",show_last_updated:"Mostrar última actualización",show_stats:"Mostrar estadísticas (Ayer, Semana pasada, Media 10 años)",show_chart:"Mostrar gráfico histórico (30d)",show_country:"Añadir el país al nombre del lugar",chart_smoothing:"Suavizado del gráfico",chart_smoothing_options:{smooth:"Suave",linear:"Lineal",step:"Escalonado"},sort_by:"Ordenar lugares por",sort_by_options:{default:"Por defecto (Manual)",name:"Nombre (A-Z)",temp_asc:"Temperatura (Más frío primero)",temp_desc:"Temperatura (Más cálido primero)"},add_place:"Añadir Lugar"},card:{yesterday:"Ayer",last_week:"Semana Pasada",average_avg:"Media 10 años",averages:"Medias",min:"Mín",max:"Máx",avg:"Med",chart_description:"Tendencia de 30 días",entity_problem:{not_found:"No se encontró la entidad {entity}.",wrong_domain:"{entity} no es un sensor.",not_numeric:"{entity} no proporciona una temperatura.",unavailable:"{entity} no está disponible."}},common:{errors:{no_places:"Debes definir al menos un lugar.",duplicate_place:"Este lugar ya está en tu lista."}}},it:{editor:{groups:{display:"Visualizzazione"},title:"Titolo (Opzionale)",places:"Luoghi",show_trend:"Mostra indicatori di tendenza (vs Ieri)",show_last_updated:"Mostra ultimo aggiornamento",show_stats:"Mostra statistiche (Ieri, Settimana scorsa, Media 10 anni)",show_chart:"Mostra grafico storico (30g)",show_country:"Aggiungi ilLine truncated
${t?"↑":"↓"} ${t?"+":"-"}${e}${n}
</div>`}return W`<div class="stat-delta neu current-trend">→ 0.0${n}</div>`}render(){if(!this._config||!this.hass)return W``;const t=this._getPlacesData(this.hass,this._config),e=this._chartWidth<360;return W`
</div>`}const s=wt(0,this.hass,{minimumFractionDigits:1,maximumFractionDigits:1});return W`<div class="stat-delta neu current-trend">→ ${s}${n}</div>`}render(){if(!this._config||!this.hass)return W``;const t=this._getPlacesData(this.hass,this._config),e=this._chartWidth<360;return W`
<ha-card .header=${this._config.title} tabindex="0" class="${e?"narrow":""}">
<div class="card-content">
${t.map(t=>W`
<div class="place-row">
<div
class="place-header"
role="button"
tabindex="0"
aria-label=${this._placeAriaLabel(t)}
@click=${()=>this._handleMoreInfo(t.entity_id)}
@keydown=${e=>this._handleHeaderKeydown(e,t.entity_id)}
>
<div class="place-info">
<div class="place-name-container">
<span class="place-name">${t.name}</span>
${t.country?W`<span class="place-country">${t.country}</span>`:""}
</div>
${this._config.show_last_updated?W`<div class="last-updated">
${this.hass.states[t.entity_id]?new Date(this.hass.states[t.entity_id].last_updated).toLocaleString(this.hass.language||void 0,{month:"short",day:"numeric",hour:"2-digit",minute:"2-digit"}):""}
</div>`:""}
</div>
<div class="current-temp">
${t.unavailable?W`<span class="temp-value unavailable" title=${t.temperature}>&mdash;</span>`:W`<span class="temp-value"
>${isNaN(Number(t.temperature))?t.temperature:new Intl.NumberFormat(this.hass?.language).format(Number(t.temperature))}</span
>
<span class="temp-unit">${t.unit}</span>
${this._renderTrend(t.yesterday,t.temperature,t.unit)}`}
</div>
</div>
${!1!==this._config.show_stats?W`
<div class="stats-grid">
${this._renderStat(yt(this.hass,"card.yesterday"),t.yesterday,t.unit)}
${this._renderStat(yt(this.hass,"card.last_week"),t.last_week,t.unit)}
${this._renderStat(yt(this.hass,"card.average_avg"),t.average_avg,t.unit)}
${0===t.length?W`<div class="entity-warning" role="status">
<ha-icon icon="mdi:water-off-outline"></ha-icon>
<span>${yt(this.hass,"common.errors.no_places")}</span>
</div>`:""}
${t.map(t=>{return t.problem?W`<div class="place-row">${e=this.hass,n=t.problem,r=t.entity_id,W`
<div class="entity-warning" role="status">
<ha-icon icon="mdi:alert-outline"></ha-icon>
<span>${yt(e,`card.entity_problem.${n}`,{entity:r})}</span>
</div>
`}</div>`:W`
<div class="place-row">
<div
class="place-header"
role="button"
tabindex="0"
aria-label=${this._placeAriaLabel(t)}
@click=${()=>this._handleMoreInfo(t.entity_id)}
@keydown=${e=>this._handleHeaderKeydown(e,t.entity_id)}
>
<div class="place-info">
<div class="place-name-container">
<span class="place-name">${t.name}</span>
${t.country?W`<span class="place-country">${t.country}</span>`:""}
</div>
`:""}
${!1!==this._config.show_chart?this._renderChart(t):""}
</div>
`)}
${this._config.show_last_updated?W`<div class="last-updated">
${this.hass.states[t.entity_id]?function(t,e){const n=function(t){switch(t?.locale?.time_format){case"12":case"am_pm":return!0;case"24":return!1;default:return}}(e);return Mt(t,e,{month:"short",day:"numeric",hour:"2-digit",minute:"2-digit",...void 0===n?{}:{hour12:n}})}(new Date(this.hass.states[t.entity_id].last_updated),this.hass):""}
</div>`:""}
</div>
<div class="current-temp">
${t.unavailable?W`<span class="temp-value unavailable" title=${t.temperature}>&mdash;</span>`:W`<span class="temp-value"
>${St(t.temperature)?wt(Number(t.temperature),this.hass):t.temperature}</span
>
${St(t.temperature)?W`<span class="temp-unit">${t.unit}</span>`:""}
${this._renderTrend(t.yesterday,t.temperature,t.unit)}`}
</div>
</div>
${!1!==this._config.show_stats?W`
<div class="stats-grid">
${this._renderStat(yt(this.hass,"card.yesterday"),t.yesterday,t.unit)}
${this._renderStat(yt(this.hass,"card.last_week"),t.last_week,t.unit)}
${this._renderStat(yt(this.hass,"card.average_avg"),t.average_avg,t.unit)}
</div>
`:""}
${!1!==this._config.show_chart?this._renderChart(t):""}
</div>
`;var e,n,r})}
</div>
</ha-card>
`}_renderStat(t,e,n){if(!e||"unknown"===e||"unavailable"===e)return W``;const r=Number(e),i=isNaN(r)?e:new Intl.NumberFormat(this.hass?.language).format(r);return W`
`}_renderStat(t,e,n){if(!e||"unknown"===e||"unavailable"===e)return W``;const r=Number(e),i=!isNaN(r),a=i?wt(r,this.hass):e;return W`
<div class="stat-item">
<span class="stat-label">${t}</span>
<span class="stat-value">${i}${n}</span>
<span class="stat-value">${a}${i?n:""}</span>
</div>
`}_renderChart(t){const e=this._chartData[t.entity_id];return!e||e.length<2?W``:W`
<div class="chart-container">
@@ -103,7 +107,7 @@ const dt={attribute:!0,type:String,converter:b,reflect:!1,hasChanged:x},pt=(t=dt
${this._drawChart(t.entity_id,e,t)}
</svg>
</div>
`}_handlePointerMove(t,e,n,r,i,a){if("mouse"!==t.pointerType&&"pointermove"===t.type&&0===t.buttons)return;const s=t.currentTarget.ownerSVGElement;if(!s)return;const o=s.createSVGPoint();o.x=t.clientX,o.y=t.clientY;const c=s.getScreenCTM();if(!c)return;const l=o.matrixTransform(c.inverse()),h=r.invert(l.x),u=$t(t=>t.date).left,d=u(n,h,1),p=n[d-1],f=n[d];let g=p;if(p&&f?g=h.getTime()-p.date.getTime()>f.date.getTime()-h.getTime()?f:p:f&&(g=f),!g)return;const m=s.querySelector(".hover-group");if(!m)return;m.style.opacity="1";const _=r(g.date),y=i(g.value),v=m.querySelector(".hover-line");v.setAttribute("x1",String(_)),v.setAttribute("x2",String(_));const b=m.querySelector(".hover-point");b.setAttribute("cx",String(_)),b.setAttribute("cy",String(y));const x=m.querySelector(".hover-tooltip-bg"),$=m.querySelector(".hover-tooltip-text"),w=m.querySelector(".hover-tooltip-date"),M=this._chartWidth,A=Math.max(0,Math.min(_-35,M-70)),S=A+35,T="middle",C=Math.max(0,y-34),N=C+11,E=C+23;x.setAttribute("x",String(A)),x.setAttribute("y",String(C)),$.setAttribute("x",String(S)),$.setAttribute("y",String(N)),$.setAttribute("text-anchor",T);const k=new Intl.NumberFormat(this.hass?.language).format(g.value);$.textContent=`${k}${a}`,w.setAttribute("x",String(S)),w.setAttribute("y",String(E)),w.setAttribute("text-anchor",T),w.textContent=g.date.toLocaleDateString(this.hass?.language||void 0,{month:"short",day:"numeric"})}_handlePointerLeave(t){const e=t.currentTarget.ownerSVGElement;if(!e)return;const n=e.querySelector(".hover-group");n&&(n.style.opacity="0")}_drawChart(t,e,n){const r=this._chartWidth,i=120,a=r<360,s=15,o=a?48:65,c=25,l=10,h=pi().domain(At(e,t=>t.date)).range([l,r-o]),u=[...e.map(t=>t.value)];n.average_min&&u.push(parseFloat(n.average_min)),n.average_max&&u.push(parseFloat(n.average_max));const d=At(u),p=.1*(d[1]-d[0])||1,f=Ke().domain([d[0]-p,d[1]+p]).range([i-c,s]);let g=Pi;"linear"===this._config.chart_smoothing&&(g=Mi),"step"===this._config.chart_smoothing&&(g=Fi);const m=Ti().x(t=>h(t.date)).y(t=>f(t.value)).curve(g),_=Ci().x(t=>h(t.date)).y0(i-c).y1(t=>f(t.value)).curve(g),y=(t,e,i)=>{if(!t)return null;const s=parseFloat(t);if(isNaN(s))return null;const c=f(s),h=n.unit||"°C",u=i?a?`${s.toFixed(1)}${h}`:`${i} ${s.toFixed(1)}${h}`:"";return Z`
`}_handlePointerMove(t,e,n,r,i,a){if("mouse"!==t.pointerType&&"pointermove"===t.type&&0===t.buttons)return;const s=t.currentTarget.ownerSVGElement;if(!s)return;const o=s.createSVGPoint();o.x=t.clientX,o.y=t.clientY;const c=s.getScreenCTM();if(!c)return;const l=o.matrixTransform(c.inverse()),u=r.invert(l.x),h=Nt(t=>t.date).left,d=h(n,u,1),p=n[d-1],f=n[d];let g=p;if(p&&f?g=u.getTime()-p.date.getTime()>f.date.getTime()-u.getTime()?f:p:f&&(g=f),!g)return;const m=s.querySelector(".hover-group");if(!m)return;m.style.opacity="1";const _=r(g.date),y=i(g.value),v=m.querySelector(".hover-line");v.setAttribute("x1",String(_)),v.setAttribute("x2",String(_));const b=m.querySelector(".hover-point");b.setAttribute("cx",String(_)),b.setAttribute("cy",String(y));const x=m.querySelector(".hover-tooltip-bg"),$=m.querySelector(".hover-tooltip-text"),w=m.querySelector(".hover-tooltip-date"),M=this._chartWidth,A=Math.max(0,Math.min(_-35,M-70)),S=A+35,T="middle",C=Math.max(0,y-34),N=C+11,k=C+23;x.setAttribute("x",String(A)),x.setAttribute("y",String(C)),$.setAttribute("x",String(S)),$.setAttribute("y",String(N)),$.setAttribute("text-anchor",T);const E=wt(g.value,this.hass);$.textContent=`${E}${a}`,w.setAttribute("x",String(S)),w.setAttribute("y",String(k)),w.setAttribute("text-anchor",T),w.textContent=At(g.date,this.hass)}_handlePointerLeave(t){const e=t.currentTarget.ownerSVGElement;if(!e)return;const n=e.querySelector(".hover-group");n&&(n.style.opacity="0")}_drawChart(t,e,n){const r=this._chartWidth,i=120,a=r<360,s=15,o=a?48:65,c=25,l=10,u=bi().domain(Dt(e,t=>t.date)).range([l,r-o]),h=[...e.map(t=>t.value)];n.average_min&&h.push(parseFloat(n.average_min)),n.average_max&&h.push(parseFloat(n.average_max));const d=Dt(h),p=.1*(d[1]-d[0])||1,f=on().domain([d[0]-p,d[1]+p]).range([i-c,s]);let g=Ri;"linear"===this._config.chart_smoothing&&(g=Ei),"step"===this._config.chart_smoothing&&(g=qi);const m=Pi().x(t=>u(t.date)).y(t=>f(t.value)).curve(g),_=Oi().x(t=>u(t.date)).y0(i-c).y1(t=>f(t.value)).curve(g),y=(t,e,i)=>{if(!t)return null;const s=parseFloat(t);if(isNaN(s))return null;const c=f(s),u=n.unit||"°C",h=wt(s,this.hass,{minimumFractionDigits:1,maximumFractionDigits:1}),d=i?a?`${h}${u}`:`${i} ${h}${u}`:"";return Z`
<line
class="ref-line ${e}"
x1="${l}"
@@ -111,11 +115,11 @@ const dt={attribute:!0,type:String,converter:b,reflect:!1,hasChanged:x},pt=(t=dt
y1="${c}"
y2="${c}"
></line>
${u?Z`<text class="ref-label ${e}" x="${r-o+4}" y="${c}" text-anchor="start">${u}</text>`:""}
`},v=t=>t.toLocaleDateString(this.hass?.language||void 0,{month:"short",day:"numeric"}),b=h.domain()[0],x=h.domain()[1],$=e[e.length-1],w=$?Z`
${d?Z`<text class="ref-label ${e}" x="${r-o+4}" y="${c}" text-anchor="start">${d}</text>`:""}
`},v=t=>At(t,this.hass),b=u.domain()[0],x=u.domain()[1],$=e[e.length-1],w=$?Z`
<circle
class="now-dot"
cx="${h($.date)}"
cx="${u($.date)}"
cy="${f($.value)}"
r="3"
fill="var(--primary-color, #0077be)"
@@ -151,8 +155,8 @@ const dt={attribute:!0,type:String,converter:b,reflect:!1,hasChanged:x},pt=(t=dt
${y(n.average_max,"max",yt(this.hass,"card.max"))}
${y(n.average_avg,"avg",yt(this.hass,"card.avg"))}
${M?Z`<circle class="extrema-dot min" cx="${h(M.date)}" cy="${f(M.value)}" r="3"></circle>`:""}
${A?Z`<circle class="extrema-dot max" cx="${h(A.date)}" cy="${f(A.value)}" r="3"></circle>`:""}
${M?Z`<circle class="extrema-dot min" cx="${u(M.date)}" cy="${f(M.value)}" r="3"></circle>`:""}
${A?Z`<circle class="extrema-dot max" cx="${u(A.date)}" cy="${f(A.value)}" r="3"></circle>`:""}
${w}
${T}
@@ -164,19 +168,19 @@ const dt={attribute:!0,type:String,converter:b,reflect:!1,hasChanged:x},pt=(t=dt
width="${r-l-o}"
height="${i-s-c}"
fill="transparent"
@pointerdown="${n=>this._handlePointerMove(n,t,e,h,f,S)}"
@pointermove="${n=>this._handlePointerMove(n,t,e,h,f,S)}"
@pointerdown="${n=>this._handlePointerMove(n,t,e,u,f,S)}"
@pointermove="${n=>this._handlePointerMove(n,t,e,u,f,S)}"
@pointerleave="${t=>this._handlePointerLeave(t)}"
@pointercancel="${t=>this._handlePointerLeave(t)}"
@pointerup="${t=>this._handlePointerLeave(t)}"
></rect>
`}static get styles(){return[s(Hi)]}}t([ft({attribute:!1})],ji.prototype,"hass",void 0),t([
`}static get styles(){return[s(Ii)]}}t([ft({attribute:!1})],Vi.prototype,"hass",void 0),t([
/**
* @license
* Copyright 2017 Google LLC
* SPDX-License-Identifier: BSD-3-Clause
*/
function(t){return(e,n,r)=>((t,e,n)=>(n.configurable=!0,n.enumerable=!0,Reflect.decorate&&"object"!=typeof e&&Object.defineProperty(t,e,n),n))(e,n,{get(){return(e=>e.renderRoot?.querySelector(t)??null)(this)}})}("ha-card")],ji.prototype,"_card",void 0),t([gt()],ji.prototype,"_config",void 0),t([gt()],ji.prototype,"_chartData",void 0),t([gt()],ji.prototype,"_chartWidth",void 0),customElements.get(Li)||customElements.define(Li,ji),window.customCards=window.customCards||[],window.customCards.push({type:Li,name:"Sea Temperatures Card",description:"Display current and historical sea temperatures.",preview:!0,documentationURL:"https://github.com/timmaurice/sea-temperatures",getEntitySuggestion:(t,e)=>e.startsWith("sensor.seatemperatures_")&&t.states[e]?{config:{type:`custom:${Li}`,places:[e]}}:null});const Ri=o`.card-config{display:flex;flex-direction:column;gap:12px}.group{border:1px solid var(--divider-color);border-radius:var(--ha-card-border-radius, 12px);margin-top:0;padding:16px}.group-header{color:var(--primary-text-color);font-size:16px;font-weight:500;margin-bottom:12px}ha-form{display:block}.places-list{border:1px solid var(--divider-color);border-radius:var(--ha-card-border-radius, 12px);padding:16px;margin-bottom:12px}.places-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}.places-header h3{margin:0;font-size:16px;font-weight:500;color:var(--primary-text-color)}.places{display:flex;flex-direction:column;gap:8px}.place-item{display:flex;align-items:center;gap:8px}.place-item ha-selector{flex:1}.handle{cursor:grab;color:var(--secondary-text-color);display:flex;align-items:center;justify-content:center}.handle:active{cursor:grabbing}.add-place-container{display:flex;justify-content:flex-end;margin-top:8px}`;var Yi;const qi=[{name:"title",selector:{text:{}}}],Ii=[{name:"sort_by",selector:{select:{options:[{value:"default",label:"Default"},{value:"name",label:"Name"},{value:"temp_asc",label:"Temperature Asc"},{value:"temp_desc",label:"Temperature Desc"}]}}},{type:"expandable",title:"groups.display",schema:[{name:"show_last_updated",selector:{boolean:{}}},{name:"show_trend",selector:{boolean:{}}},{name:"show_stats",selector:{boolean:{}}},{name:"show_chart",selector:{boolean:{}}},{name:"show_country",selector:{boolean:{}}},{name:"chart_smoothing",selector:{select:{options:["smooth","linear","step"]}}}]}];let Wi=class extends ht{static{Yi=this}setConfig(t){this._config={sort_by:"default",show_trend:!0,show_last_updated:!0,show_stats:!0,show_chart:!0,show_country:!1,chart_smoothing:"smooth",...t}}_valueChanged(t){this.hass&&this._config&&vt(this,"config-changed",{config:{...this._config,...t.detail.value}})}_placeMoved(t){t.stopPropagation();const{oldIndex:e,newIndex:n}=t.detail;if(e===n)return;const r=[...this._config.places||[]],i=r.splice(e,1)[0];r.splice(n,0,i),this._config={...this._config,places:r},vt(this,"config-changed",{config:this._config})}static _targetOf(t){return t?"string"==typeof t?t:t.device||"":""}static _nameOf(t){return t&&"string"!=typeof t&&t.name||""}static _isEntityTarget(t){return t.includes(".")}static _buildPlace(t,e){return e?{device:t,name:e}:t}_commitPlace(t,e){const n=[...this._config.places||[]];n[t]=e,this._config={...this._config,places:n},vt(this,"config-changed",{config:this._config})}_placeChanged(t,e){const n=Yi._targetOf(e);if(!n)return void this._removePlace(t);const r=[...this._config.places||[]],i=r.some((e,r)=>r!==t&&e&&Yi._targetOf(e)===n);if(i)return vt(this,"hass-notification",{message:yt(this.hass,"common.errors.duplicate_place")}),void this.requestUpdate();const a=Yi._nameOf(r[t]);this._commitPlace(t,Yi._buildPlace(n,a))}_removePlace(t){const e=[...this._config.places||[]];e.splice(t,1),this._config={...this._config,places:e},vt(this,"config-changed",{config:this._config})}_addPlace(){const t=[...this._config.places||[],""];this._config={...this._config,places:t},vt(this,"config-changed",{config:this._config})}render(){if(!this.hass||!this._config)return W``;const t=t=>t.map(t=>{const e={...t};return"sort_by"===e.name&&(e.selector={select:{options:[{value:"default",label:yt(this.hass,"editor.sort_by_options.default")},{value:"name",label:yt(this.hass,"editor.sort_by_options.name")},{value:"temp_asc",label:yt(this.hass,"editor.sort_by_options.temp_asc")},{value:"temp_desc",label:yt(this.hass,"editor.sort_by_options.temp_desc")}]}}),"chart_smoothing"===e.name&&(e.selector={select:{options:[{value:"smooth",label:yt(this.hass,"editor.chart_smoothing_options.smooth")},{value:"linear",label:yt(this.hass,"editor.chart_smoothing_options.linear")},{value:"step",label:yt(this.hass,"editor.chart_smoothing_options.step")}]}}),"expandable"===e.type&&Array.isArray(e.schema)&&(e.title=yt(this.hass,`editor.${e.title}`),e.schema=e.schema.map(t=>({...t}))),e}),e=t(qi),n=t(Ii);return W`
function(t){return(e,n,r)=>((t,e,n)=>(n.configurable=!0,n.enumerable=!0,Reflect.decorate&&"object"!=typeof e&&Object.defineProperty(t,e,n),n))(e,n,{get(){return(e=>e.renderRoot?.querySelector(t)??null)(this)}})}("ha-card")],Vi.prototype,"_card",void 0),t([gt()],Vi.prototype,"_config",void 0),t([gt()],Vi.prototype,"_chartData",void 0),t([gt()],Vi.prototype,"_chartWidth",void 0),customElements.get(Wi)||customElements.define(Wi,Vi),window.customCards=window.customCards||[],window.customCards.some(t=>t.type===Wi)||window.customCards.push({type:Wi,name:"Sea Temperatures Card",description:"Display current and historical sea temperatures.",preview:!0,documentationURL:"https://github.com/timmaurice/sea-temperatures",getEntitySuggestion:(t,e)=>e.startsWith("sensor.seatemperatures_")&&t.states[e]?{config:{type:`custom:${Wi}`,places:[e]}}:null});const Bi=o`.card-config{display:flex;flex-direction:column;gap:12px}.group{border:1px solid var(--divider-color);border-radius:var(--ha-card-border-radius, 12px);margin-top:0;padding:16px}.group-header{color:var(--primary-text-color);font-size:16px;font-weight:500;margin-bottom:12px}ha-form{display:block}.places-list{border:1px solid var(--divider-color);border-radius:var(--ha-card-border-radius, 12px);padding:16px;margin-bottom:12px}.places-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}.places-header h3{margin:0;font-size:16px;font-weight:500;color:var(--primary-text-color)}.places{display:flex;flex-direction:column;gap:8px}.place-item{display:flex;align-items:center;gap:8px}.place-item ha-selector{flex:1}.handle{cursor:grab;color:var(--secondary-text-color);display:flex;align-items:center;justify-content:center}.handle:active{cursor:grabbing}.add-place-container{display:flex;justify-content:flex-end;margin-top:8px}`,Gi=[{name:"title",selector:{text:{}}}],Xi=[{name:"sort_by",selector:{select:{options:[{value:"default",label:"Default"},{value:"name",label:"Name"},{value:"temp_asc",label:"Temperature Asc"},{value:"temp_desc",label:"Temperature Desc"}]}}},{type:"expandable",title:"groups.display",schema:[{name:"show_last_updated",selector:{boolean:{}}},{name:"show_trend",selector:{boolean:{}}},{name:"show_stats",selector:{boolean:{}}},{name:"show_chart",selector:{boolean:{}}},{name:"show_country",selector:{boolean:{}}},{name:"chart_smoothing",selector:{select:{options:["smooth","linear","step"]}}}]}],Ji={sort_by:"default",show_trend:!0,show_last_updated:!0,show_stats:!0,show_chart:!0,show_country:!1,chart_smoothing:"smooth"};function Qi(t){const e={...t};for(const[t,n]of Object.entries(Ji))e[t]===n&&delete e[t];return""===e.title&&delete e.title,Array.isArray(e.places)&&(e.places=e.places.filter(t=>""!==Ki._targetOf(t))),e}class Ki extends ut{constructor(){super(...arguments),this._draftPlace=!1}setConfig(t){this._config={...Ji,...t}}_emit(t){vt(this,"config-changed",{config:Qi(t)})}_valueChanged(t){this.hass&&this._config&&this._emit({...this._config,...t.detail.value})}_placeMoved(t){t.stopPropagation();const{oldIndex:e,newIndex:n}=t.detail;if(e===n)return;const r=[...this._config.places||[]],i=r.splice(e,1)[0];r.splice(n,0,i),this._config={...this._config,places:r},this._emit(this._config)}static _targetOf(t){return t?"string"==typeof t?t:t.device||"":""}static _nameOf(t){return t&&"string"!=typeof t&&t.name||""}static _isEntityTarget(t){return t.includes(".")}static _buildPlace(t,e){return e?{device:t,name:e}:t}_commitPlace(t,e){const n=[...this._config.places||[]];n[t]=e,this._config={...this._config,places:n},this._emit(this._config)}_placeChanged(t,e){const n=Ki._targetOf(e);if(!n)return t>=(this._config.places?.length??0)?void(this._draftPlace=!1):void this._removePlace(t);const r=[...this._config.places||[]],i=r.some((e,r)=>r!==t&&e&&Ki._targetOf(e)===n);if(i)return vt(this,"hass-notification",{message:yt(this.hass,"common.errors.duplicate_place")}),void this.requestUpdate();const a=Ki._nameOf(r[t]);this._draftPlace=!1,this._commitPlace(t,Ki._buildPlace(n,a))}_removePlace(t){const e=[...this._config.places||[]];e.splice(t,1),this._config={...this._config,places:e},this._emit(this._config)}_addPlace(){this._draftPlace=!0}_placeRows(){const t=[...this._config.places??[]];return this._draftPlace&&t.push(""),t}render(){if(!this.hass||!this._config)return W``;const t=t=>t.map(t=>{const e={...t};return"sort_by"===e.name&&(e.selector={select:{options:[{value:"default",label:yt(this.hass,"editor.sort_by_options.default")},{value:"name",label:yt(this.hass,"editor.sort_by_options.name")},{value:"temp_asc",label:yt(this.hass,"editor.sort_by_options.temp_asc")},{value:"temp_desc",label:yt(this.hass,"editor.sort_by_options.temp_desc")}]}}),"chart_smoothing"===e.name&&(e.selector={select:{options:[{value:"smooth",label:yt(this.hass,"editor.chart_smoothing_options.smooth")},{value:"linear",label:yt(this.hass,"editor.chart_smoothing_options.linear")},{value:"step",label:yt(this.hass,"editor.chart_smoothing_options.step")}]}}),"expandable"===e.Line truncated
<ha-card>
<div class="card-content card-config">
<ha-form
@@ -192,7 +196,7 @@ function(t){return(e,n,r)=>((t,e,n)=>(n.configurable=!0,n.enumerable=!0,Reflect.
</div>
<ha-sortable handle-selector=".handle" @item-moved=${this._placeMoved}>
<div class="places">
${this._config.places?.map((t,e)=>{const n=Yi._targetOf(t),r=Yi._isEntityTarget(n)?{entity:{integration:"seatemperatures"}}:{device:{integration:"seatemperatures"}};return W`
${this._placeRows().map((t,e)=>{const n=Ki._targetOf(t),r=Ki._isEntityTarget(n)?{entity:{integration:"seatemperatures"}}:{device:{integration:"seatemperatures"}};return W`
<div class="place-item">
<div class="handle">
<ha-icon icon="mdi:drag"></ha-icon>
@@ -208,7 +212,7 @@ function(t){return(e,n,r)=>((t,e,n)=>(n.configurable=!0,n.enumerable=!0,Reflect.
</div>
</ha-sortable>
<div class="add-place-container">
<ha-button @click=${this._addPlace} variant="brand" appearance="accent" size="medium">
<ha-button @click=${this._addPlace} variant="brand" appearance="accent" size="m">
<ha-icon icon="mdi:plus"></ha-icon>
${yt(this.hass,"editor.add_place")}
</ha-button>
@@ -224,5 +228,5 @@ function(t){return(e,n,r)=>((t,e,n)=>(n.configurable=!0,n.enumerable=!0,Reflect.
</div>
</ha-card>
`}static{this.styles=o`
${s(Ri)}
`}};t([ft({attribute:!1})],Wi.prototype,"hass",void 0),t([gt()],Wi.prototype,"_config",void 0),Wi=Yi=t([(t=>(e,n)=>{void 0!==n?n.addInitializer(()=>{customElements.define(t,e)}):customElements.define(t,e)})("sea-temperatures-card-editor")],Wi);var Zi=Object.freeze({__proto__:null,get SeaTemperaturesCardEditor(){return Wi}});export{ji as SeaTemperaturesCard};
${s(Bi)}
`}}t([ft({attribute:!1})],Ki.prototype,"hass",void 0),t([gt()],Ki.prototype,"_config",void 0),t([gt()],Ki.prototype,"_draftPlace",void 0);const ta="sea-temperatures-card-editor";customElements.get(ta)||customElements.define(ta,Ki);var ea=Object.freeze({__proto__:null,SeaTemperaturesCardEditor:Ki});export{Vi as SeaTemperaturesCard};
+12 -10
View File
@@ -20,7 +20,8 @@ from homeassistant.helpers.update_coordinator import (
)
from homeassistant.util import slugify
from .const import BASE_URL, CONF_AREA, CONF_PATH, CONF_PLACE, CONF_PLACE_ID, DOMAIN
from . import build_unique_id, entry_location_key
from .const import BASE_URL, CONF_AREA, CONF_PATH, CONF_PLACE, DOMAIN
from .parser import validate_location_path
_LOGGER = logging.getLogger(__name__)
@@ -86,29 +87,30 @@ class SeaTemperatureSensor(CoordinatorEntity, SensorEntity):
self.entity_description = description
self._entry = entry
self._place_name = entry.data.get(CONF_PLACE, "Unknown")
self._location_key = entry.data.get(CONF_PLACE_ID) or entry.data.get(CONF_PATH)
if self._location_key is None:
self._location_key = self._place_name
# Shared with the unique_id migration, so both sides agree on the id.
self._location_key = entry_location_key(entry)
# Set friendly name
self._attr_name = "Temperature"
place_prefix = slugify(self._place_name)
self._attr_unique_id = f"{DOMAIN}_{self._location_key}_{description.key}"
# Slugified: the raw location path produced ids like
# "seatemperatures_/europe/germany/island-of-sylt/_today". Entry
# version 4 rewrites the registry, so existing entities keep their
# entity_id and their history.
self._attr_unique_id = build_unique_id(self._location_key, description.key)
self.entity_id = f"sensor.{DOMAIN}_{place_prefix}_{description.key}"
@property
def native_value(self) -> float | str | None:
"""Return the state of the sensor."""
# native_value is read on every state write, so nothing here logs the
# payload: it carries the 30-point chart series, and one place turned
# debug logging for this integration into an unreadable wall of numbers.
if not self.coordinator.data:
_LOGGER.debug("No coordinator data available for %s", self.entity_id)
return None
_LOGGER.debug(
"Coordinator data for %s: %s", self.entity_id, self.coordinator.data
)
# Try to get data from 'sst' nesting if it exists
data = self.coordinator.data.get("sst", self.coordinator.data)
@@ -1,18 +1,27 @@
from __future__ import annotations
import logging
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import aiohttp
import pytest
from homeassistant.helpers.selector import SelectSelector
from homeassistant.helpers.update_coordinator import UpdateFailed
from custom_components.seatemperatures import async_migrate_entry
from custom_components.seatemperatures import _async_fetch, async_migrate_entry
from custom_components.seatemperatures.api import (
MAP_LOCATIONS_TTL,
REQUEST_TIMEOUT,
SeaTemperatureAPI,
SeaTemperatureError,
parse_map_locations,
parse_search_results,
)
from custom_components.seatemperatures.config_flow import SeaTemperatureConfigFlow
from custom_components.seatemperatures.config_flow import (
CONTINENT_NAMES,
SeaTemperatureConfigFlow,
)
from custom_components.seatemperatures.const import (
CONF_AREA,
CONF_CONTINENT,
@@ -118,34 +127,6 @@ async def test_validate_location_path() -> None:
validate_location_path("/../../etc/passwd")
async def test_parse_search_results() -> None:
"""Search results should be normalized into path-based location records."""
data = {
"results": [
{
"name": "Copenhagen",
"country": "Denmark",
"region": "Europe",
"path": "/europe/denmark/copenhagen/",
"area": "Denmark, Europe",
},
{"name": "Broken", "path": "https://example.com/not-allowed"},
]
}
results = parse_search_results(data)
assert results == [
{
"name": "Copenhagen",
"country": "Denmark",
"region": "Europe",
"area": "Denmark, Europe",
"path": "/europe/denmark/copenhagen/",
}
]
async def test_parse_map_locations() -> None:
"""Legacy map-locations payload should support place_id migration."""
data = {
@@ -264,7 +245,7 @@ async def test_config_flow_stores_path_based_location(mock_hass) -> None:
}
}
with patch.object(SeaTemperatureAPI, "_get_map_locations", AsyncMock(return_value=locations_cache)):
with patch.object(SeaTemperatureAPI, "get_map_locations", AsyncMock(return_value=locations_cache)):
user_result = await flow.async_step_user(None)
assert user_result["type"] == "form"
assert user_result["step_id"] == "user"
@@ -313,17 +294,26 @@ async def test_async_migrate_entry_from_place_id(mock_hass) -> None:
"path": "/africa/algeria/ain-el-turk/",
}
),
):
), patch(
# The unique_id migration is exercised in test_entry.py; here it would
# only drag a real entity registry into a mocked hass.
"custom_components.seatemperatures._async_migrate_unique_ids",
AsyncMock(),
) as migrate_unique_ids:
migrated = await async_migrate_entry(mock_hass, entry)
assert migrated is True
mock_hass.config_entries.async_update_entry.assert_called_once()
_, kwargs = mock_hass.config_entries.async_update_entry.call_args
migrate_unique_ids.assert_awaited_once()
assert mock_hass.config_entries.async_update_entry.call_count == 2
_, kwargs = mock_hass.config_entries.async_update_entry.call_args_list[0]
assert kwargs["data"][CONF_PLACE] == "Ain El Turk"
assert kwargs["data"][CONF_COUNTRY] == "Algeria"
assert kwargs["data"][CONF_PATH] == "/africa/algeria/ain-el-turk/"
assert kwargs["unique_id"] == "/africa/algeria/ain-el-turk/"
assert kwargs["version"] == 2
assert mock_hass.config_entries.async_update_entry.call_args_list[1].kwargs == {
"version": 4
}
async def test_async_migrate_entry_fails_when_place_id_cannot_be_mapped(mock_hass) -> None:
@@ -335,3 +325,254 @@ async def test_async_migrate_entry_fails_when_place_id_cannot_be_mapped(mock_has
assert migrated is False
mock_hass.config_entries.async_update_entry.assert_not_called()
async def test_config_flow_reshows_the_form_when_the_site_is_unreachable(mock_hass) -> None:
"""An unreachable site must show a translated error, not an abort."""
flow = SeaTemperatureConfigFlow()
flow.hass = mock_hass
with patch.object(SeaTemperatureAPI, "get_map_locations", AsyncMock(return_value=None)):
result = await flow.async_step_user(None)
assert result["type"] == "form"
assert result["step_id"] == "user"
assert result["errors"] == {"base": "cannot_connect"}
async def test_config_flow_retries_after_a_failed_fetch(mock_hass) -> None:
"""Submitting the error form should fetch again instead of raising."""
flow = SeaTemperatureConfigFlow()
flow.hass = mock_hass
locations_cache = {
"sea-1": {
"name": "Copenhagen",
"country": "Denmark",
"area": "",
"path": "/europe/denmark/copenhagen/",
}
}
with patch.object(
SeaTemperatureAPI,
"get_map_locations",
AsyncMock(side_effect=[None, locations_cache]),
):
await flow.async_step_user(None)
# The retry form has no continent field, so it submits an empty mapping.
result = await flow.async_step_user({})
assert result["type"] == "form"
assert result["step_id"] == "user"
assert not result.get("errors")
assert flow._continents == ["Europe"]
# The nine first path segments /api/map-locations.json actually publishes.
PUBLISHED_CONTINENT_SLUGS = {
"africa",
"antarctica",
"asia",
"australia-and-oceania",
"central-america-and-the-caribbean",
"europe",
"middle-east",
"north-america",
"south-america",
}
async def test_continent_map_matches_the_published_slugs() -> None:
"""A stale slug adds a continent no location ever resolves to."""
assert set(CONTINENT_NAMES) == PUBLISHED_CONTINENT_SLUGS
async def test_continent_names_read_as_names() -> None:
"""Every published slug should map to one readable spelling."""
flow = SeaTemperatureConfigFlow()
names = {flow._get_continent_name(slug) for slug in PUBLISHED_CONTINENT_SLUGS}
assert names == {
"Africa",
"Antarctica",
"Asia",
"Australia and Oceania",
"Central America and the Caribbean",
"Europe",
"Middle East",
"North America",
"South America",
}
async def test_unknown_continent_slug_keeps_joining_words_small() -> None:
"""A slug the site adds later still has to read as a name."""
flow = SeaTemperatureConfigFlow()
assert flow._get_continent_name("islands-of-the-atlantic") == "Islands of the Atlantic"
def _selector_for(schema, key: str) -> SelectSelector:
"""Pull the selector a schema uses for one key."""
for marker, validator in schema.schema.items():
if str(marker) == key:
return validator
raise AssertionError(f"{key} is not in the schema")
async def test_every_step_offers_a_searchable_dropdown(mock_hass) -> None:
"""4,477 places in a plain list cannot be picked from without a search."""
flow = SeaTemperatureConfigFlow()
flow.hass = mock_hass
locations_cache = {
f"sea-{index}": {
"name": f"Place {index}",
"country": "United States",
"area": "",
"path": f"/north-america/united-states/place-{index}/",
}
for index in range(3)
}
with patch.object(
SeaTemperatureAPI, "get_map_locations", AsyncMock(return_value=locations_cache)
):
user_result = await flow.async_step_user(None)
await flow.async_step_user({CONF_CONTINENT: "North America"})
country_result = await flow.async_step_country(None)
await flow.async_step_country({CONF_COUNTRY: "United States"})
place_result = await flow.async_step_place(None)
for result, key in (
(user_result, CONF_CONTINENT),
(country_result, CONF_COUNTRY),
(place_result, CONF_PLACE),
):
selector = _selector_for(result["data_schema"], key)
assert isinstance(selector, SelectSelector), key
assert selector.config["mode"] == "dropdown", key
async def test_get_temperatures_does_not_log_its_own_error(
mock_hass, caplog: pytest.LogCaptureFixture
) -> None:
"""The coordinator logs a failed refresh, so the API layer must not."""
api = SeaTemperatureAPI(mock_hass)
with patch(
"custom_components.seatemperatures.api.async_get_clientsession"
) as mock_session:
mock_session.return_value.get.side_effect = aiohttp.ClientError("boom")
with caplog.at_level(logging.DEBUG), pytest.raises(SeaTemperatureError) as excinfo:
await api.get_temperatures("/europe/denmark/copenhagen/")
assert "boom" in str(excinfo.value)
assert [record for record in caplog.records if record.levelno >= logging.ERROR] == []
async def test_get_temperatures_rejects_an_invalid_path(mock_hass) -> None:
"""An unusable path is a failed refresh, not a silent None."""
api = SeaTemperatureAPI(mock_hass)
with pytest.raises(SeaTemperatureError):
await api.get_temperatures("https://example.com/evil/")
async def test_fetch_turns_an_api_error_into_update_failed() -> None:
"""The coordinator has to see one UpdateFailed carrying the reason."""
api = MagicMock()
api.get_temperatures = AsyncMock(side_effect=SeaTemperatureError("upstream is down"))
with pytest.raises(UpdateFailed, match="upstream is down"):
await _async_fetch(api, "/europe/denmark/copenhagen/", "Copenhagen")
async def test_every_request_carries_an_explicit_timeout(mock_hass) -> None:
"""A host that accepts the connection but never answers must not hang a refresh."""
api = SeaTemperatureAPI(mock_hass)
with patch(
"custom_components.seatemperatures.api.async_get_clientsession"
) as mock_session:
get = mock_session.return_value.get
mock_response = AsyncMock()
mock_response.text.return_value = load_fixture("copenhagen.html")
mock_response.json.return_value = {"locations": []}
mock_response.raise_for_status = MagicMock()
get.return_value.__aenter__.return_value = mock_response
await api.get_temperatures("/europe/denmark/copenhagen/")
await api.get_map_locations()
assert get.call_count == 2
for call in get.call_args_list:
assert call.kwargs["timeout"] is REQUEST_TIMEOUT
assert REQUEST_TIMEOUT.total is not None
assert REQUEST_TIMEOUT.total > 0
async def test_map_locations_are_cached_within_the_ttl(mock_hass) -> None:
"""Repeated lookups inside the TTL must not refetch 19k rows."""
api = SeaTemperatureAPI(mock_hass)
payload = {
"locations": [["sea-1", "Acharavi", "Greece", "Corfu", "/europe/greece/acharavi/"]]
}
with patch(
"custom_components.seatemperatures.api.async_get_clientsession"
) as mock_session:
mock_response = AsyncMock()
mock_response.json.return_value = payload
mock_response.raise_for_status = MagicMock()
mock_session.return_value.get.return_value.__aenter__.return_value = mock_response
first = await api.get_map_locations()
second = await api.get_map_locations()
assert mock_session.return_value.get.call_count == 1
assert first == second
assert "sea-1" in first
async def test_map_locations_are_refetched_once_the_ttl_expires(mock_hass) -> None:
"""A beach added upstream must appear without restarting Home Assistant."""
api = SeaTemperatureAPI(mock_hass)
first_payload = {
"locations": [["sea-1", "Acharavi", "Greece", "Corfu", "/europe/greece/acharavi/"]]
}
second_payload = {
"locations": [
["sea-1", "Acharavi", "Greece", "Corfu", "/europe/greece/acharavi/"],
["sea-2", "Sylt", "Germany", "", "/europe/germany/island-of-sylt/"],
]
}
with patch(
"custom_components.seatemperatures.api.async_get_clientsession"
) as mock_session:
mock_response = AsyncMock()
mock_response.json.side_effect = [first_payload, second_payload]
mock_response.raise_for_status = MagicMock()
mock_session.return_value.get.return_value.__aenter__.return_value = mock_response
with patch(
"custom_components.seatemperatures.api.time.monotonic", return_value=0.0
):
first = await api.get_map_locations()
with patch(
"custom_components.seatemperatures.api.time.monotonic",
return_value=MAP_LOCATIONS_TTL + 1.0,
):
second = await api.get_map_locations()
assert mock_session.return_value.get.call_count == 2
assert "sea-2" not in first
assert "sea-2" in second
@@ -31,15 +31,15 @@
"already_configured": "Dieser Ort ist bereits konfiguriert."
}
},
"entity": {
"sensor": {
"today": { "name": "Heute" },
"yesterday": { "name": "Gestern" },
"last_week": { "name": "Letzte Woche" },
"last_year": { "name": "Letztes Jahr" },
"average_avg": { "name": "Durchschnittstemperatur" },
"average_min": { "name": "Minimaler Durchschnitt" },
"average_max": { "name": "Maximaler Durchschnitt" }
"options": {
"step": {
"init": {
"title": "Aktualisierungsintervall",
"description": "Wie oft die Wassertemperatur abgerufen wird. Zwei Stunden genügen - die Seite veröffentlicht einen Messwert pro Tag.",
"data": {
"scan_interval_hours": "Aktualisierungsintervall (Stunden)"
}
}
}
}
}
@@ -31,15 +31,15 @@
"already_configured": "This place is already configured."
}
},
"entity": {
"sensor": {
"today": { "name": "Today" },
"yesterday": { "name": "Yesterday" },
"last_week": { "name": "Last Week" },
"last_year": { "name": "Last Year" },
"average_avg": { "name": "Average Temperature" },
"average_min": { "name": "Minimum Average" },
"average_max": { "name": "Maximum Average" }
"options": {
"step": {
"init": {
"title": "Update interval",
"description": "How often the sea temperature is fetched. Two hours is plenty - the site publishes one reading a day.",
"data": {
"scan_interval_hours": "Update interval (hours)"
}
}
}
}
}
@@ -25,15 +25,15 @@
"already_configured": "Este lugar ya está configurado."
}
},
"entity": {
"sensor": {
"today": { "name": "Hoy" },
"yesterday": { "name": "Ayer" },
"last_week": { "name": "Semana Pasada" },
"last_year": { "name": "Año Pasado" },
"average_avg": { "name": "Temperatura Media" },
"average_min": { "name": "Media Mínima" },
"average_max": { "name": "Media Máxima" }
"options": {
"step": {
"init": {
"title": "Intervalo de actualización",
"description": "Con qué frecuencia se consulta la temperatura del mar. Dos horas son suficientes: el sitio publica una lectura al día.",
"data": {
"scan_interval_hours": "Intervalo de actualización (horas)"
}
}
}
}
}
@@ -25,15 +25,15 @@
"already_configured": "Ce lieu est déjà configuré."
}
},
"entity": {
"sensor": {
"today": { "name": "Aujourd'hui" },
"yesterday": { "name": "Hier" },
"last_week": { "name": "La semaine dernière" },
"last_year": { "name": "L'année dernière" },
"average_avg": { "name": "Température moyenne" },
"average_min": { "name": "Moyenne minimale" },
"average_max": { "name": "Moyenne maximale" }
"options": {
"step": {
"init": {
"title": "Intervalle de mise à jour",
"description": "Fréquence de récupération de la température de la mer. Deux heures suffisent : le site publie une mesure par jour.",
"data": {
"scan_interval_hours": "Intervalle de mise à jour (heures)"
}
}
}
}
}
@@ -25,15 +25,15 @@
"already_configured": "Questo luogo è già configurato."
}
},
"entity": {
"sensor": {
"today": { "name": "Oggi" },
"yesterday": { "name": "Ieri" },
"last_week": { "name": "Settimana Scorsa" },
"last_year": { "name": "Anno Scorso" },
"average_avg": { "name": "Temperatura Media" },
"average_min": { "name": "Media Minima" },
"average_max": { "name": "Media Massima" }
"options": {
"step": {
"init": {
"title": "Intervallo di aggiornamento",
"description": "Con quale frequenza viene recuperata la temperatura del mare. Due ore sono sufficienti: il sito pubblica una misurazione al giorno.",
"data": {
"scan_interval_hours": "Intervallo di aggiornamento (ore)"
}
}
}
}
}