Initial Commit

This commit is contained in:
2026-06-11 11:50:50 -04:00
commit d4a69c41be
2748 changed files with 80489 additions and 0 deletions
@@ -0,0 +1,189 @@
from __future__ import annotations
import logging
from datetime import timedelta
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .api import SeaTemperatureAPI
from .const import (
CONF_AREA,
CONF_CONTINENT,
CONF_COUNTRY,
CONF_PATH,
CONF_PLACE,
CONF_PLACE_ID,
DOMAIN,
)
PLATFORMS = [Platform.SENSOR]
SCAN_INTERVAL = timedelta(hours=2) # Sea temperatures don't change frequently
_LOGGER = logging.getLogger(__name__)
async def async_setup(hass: HomeAssistant, config: dict) -> bool:
"""Set up the Sea Temperatures component."""
from homeassistant.loader import async_get_integration
integration = await async_get_integration(hass, DOMAIN)
version = integration.version or "1.0.0"
# Register static path for the card
from homeassistant.components.http import StaticPathConfig
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"
),
cache_headers=True,
)
]
)
new_url = f"/seatemperatures_frontend/sea-temperatures-card.js?v={version}"
async def _async_register_lovelace_resource(event=None):
_LOGGER.debug("Attempting to register lovelace resource")
if "lovelace" not in hass.data:
_LOGGER.warning("Lovelace not found in hass.data")
return
lovelace_data = hass.data["lovelace"]
mode = getattr(lovelace_data, "resource_mode", "storage")
resources = getattr(lovelace_data, "resources", None)
if not resources:
_LOGGER.warning("Lovelace data does not have resources")
return
if mode != "storage":
_LOGGER.warning(
"Lovelace is not in storage mode (mode is '%s'), cannot auto-register",
mode,
)
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})
except Exception as e:
_LOGGER.warning("Failed to register lovelace resource: %s", e)
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
from homeassistant.core import CoreState
if hass.state == CoreState.running:
hass.async_create_task(_async_register_lovelace_resource())
else:
hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_STARTED, _async_register_lovelace_resource
)
return True
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:
_LOGGER.error("Unsupported SeaTemperatures config entry version: %s", entry.version)
return False
if entry.version < 2:
_LOGGER.debug("Migrating SeaTemperatures entry %s from version %s", entry.entry_id, entry.version)
data = dict(entry.data)
location_path = data.get(CONF_PATH)
if not location_path:
place_id = data.get(CONF_PLACE_ID)
if not place_id:
_LOGGER.error(
"SeaTemperatures entry %s has no place_id to migrate. Remove and re-add the integration.",
entry.entry_id,
)
return False
location = await SeaTemperatureAPI(hass).get_location_by_place_id(place_id)
if location is None:
_LOGGER.error(
"SeaTemperatures place_id %s could not be mapped to a current location path. Remove and re-add the integration.",
place_id,
)
return False
data[CONF_PATH] = location[CONF_PATH]
data[CONF_PLACE] = location.get("name", data.get(CONF_PLACE, "Unknown"))
data[CONF_COUNTRY] = location.get(CONF_COUNTRY, data.get(CONF_COUNTRY, ""))
data[CONF_AREA] = location.get(CONF_AREA, data.get(CONF_AREA, ""))
data.setdefault(CONF_CONTINENT, data.get(CONF_CONTINENT, ""))
hass.config_entries.async_update_entry(
entry,
data=data,
unique_id=data[CONF_PATH],
version=2,
)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Sea Temperature from a config entry."""
hass.data.setdefault(DOMAIN, {})
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
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
coordinator = DataUpdateCoordinator(
hass,
_LOGGER,
name=f"seatemperatures_{location_key}",
update_method=async_update_data,
update_interval=SCAN_INTERVAL,
)
await coordinator.async_config_entry_first_refresh()
hass.data[DOMAIN][entry.entry_id] = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
+181
View File
@@ -0,0 +1,181 @@
from __future__ import annotations
import logging
from typing import Any
import aiohttp
from homeassistant.core import HomeAssistant
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,
)
from .parser import parse_location_page, validate_location_path
_LOGGER = logging.getLogger(__name__)
_MAP_LOCATIONS_CACHE = "map_locations_cache"
def parse_search_results(data: Any) -> list[dict[str, str]]:
"""Parse search endpoint results into normalized location dictionaries."""
if not isinstance(data, dict):
return []
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
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
def parse_map_locations(data: Any) -> dict[str, dict[str, str]]:
"""Parse map-locations payload into a place_id -> location mapping."""
if isinstance(data, dict):
raw_locations = data.get("locations", [])
else:
raw_locations = data
if not isinstance(raw_locations, list):
return {}
mapping: dict[str, dict[str, str]] = {}
for item in raw_locations:
if not isinstance(item, list) or len(item) < 5:
continue
place_id, name, country, area, path = item[:5]
if not isinstance(place_id, str) or not isinstance(name, str):
continue
if not isinstance(path, str):
continue
try:
normalized_path = validate_location_path(path)
except ValueError:
_LOGGER.debug("Ignoring map location with invalid path: %s", path)
continue
mapping[place_id] = {
"name": name,
"country": country if isinstance(country, str) else "",
"area": area if isinstance(area, str) else "",
"path": normalized_path,
}
return mapping
class SeaTemperatureAPI:
"""API to fetch sea temperature data."""
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the API."""
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()
if cache is None:
return None
location = cache.get(place_id)
if location is None and not place_id.startswith("sea-"):
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."""
try:
normalized_path = validate_location_path(location_path)
except ValueError as err:
_LOGGER.error("Invalid SeaTemperatures path %s: %s", location_path, err)
return None
url = f"{BASE_URL}{normalized_path}"
session = async_get_clientsession(self.hass)
try:
async with session.get(url, headers=self._headers) 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
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."""
domain_data = self.hass.data.setdefault(DOMAIN, {})
if _MAP_LOCATIONS_CACHE in domain_data:
return domain_data[_MAP_LOCATIONS_CACHE]
session = async_get_clientsession(self.hass)
try:
async with session.get(API_URL_MAP_LOCATIONS, headers=self._headers) 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)
return None
domain_data[_MAP_LOCATIONS_CACHE] = mapping
return mapping
@@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="oklch(45% .062 210)"
class="size-9 shrink-0 text-primary"
aria-hidden="true">
<defs>
<clipPath id="sea-globe">
<circle cx="50" cy="50" r="48"></circle>
</clipPath>
</defs>
<circle cx="50" cy="50" r="48" fill="none" stroke="oklch(45% .062 210)" stroke-width="4"></circle>
<g clip-path="url(#sea-globe)">
<g transform="translate(30 13) scale(0.85)">
<path
d="M30.328 26.044V6.954C30.328 3.12 27.208 0 23.374 0s-6.955 3.12-6.955 6.955v19.09c-3.617 2.546-5.544 6.83-5.044 11.275.619 5.504 5.064 9.96 10.569 10.596 7.46.86 13.508-4.997 13.508-12a12.04 12.04 0 0 0-5.124-9.872zm-8.219 20.441c-4.845-.56-8.758-4.482-9.304-9.327-.452-4.034 1.359-7.916 4.73-10.13a.72.72 0 0 0 .324-.603V6.955c0-3.041 2.474-5.515 5.515-5.515s5.514 2.474 5.514 5.515v19.47a.72.72 0 0 0 .324.602 10.607 10.607 0 0 1 4.8 8.889c0 6.18-5.334 11.323-11.903 10.57z"></path>
<path
d="M26.222 28.252V14.265a.207.207 0 0 0-.101-.178l-5.285-3.119a.207.207 0 0 0-.312.178v17.106l-1.526 1.003c-2.527 1.66-3.885 4.574-3.543 7.606.43 3.83 3.72 7.033 7.924 7.033 4.65 0 8.381-4.01 7.93-8.757-.443-4.65-4.595-6.438-5.087-6.885z"></path>
<path
d="M32.69 6.55h2.042a.72.72 0 0 0 0-1.44H32.69a.72.72 0 0 0 0 1.44zm0 4.134h3.293a.72.72 0 0 0 0-1.44H32.69a.72.72 0 0 0 0 1.44zm0 4.134h2.042a.72.72 0 0 0 0-1.44H32.69a.72.72 0 0 0 0 1.44zm3.293 2.694H32.69a.72.72 0 0 0 0 1.44h3.293a.72.72 0 0 0 0-1.44zm-1.251 4.133H32.69a.72.72 0 0 0 0 1.44h2.042a.72.72 0 0 0 0-1.44z"></path>
</g>
<path
d="M2 74.028c4.462.673 5.02 6.05 11.215 6.05 6.577 0 6.807-6.131 12.26-6.131 5.464 0 5.646 6.13 12.255 6.13 6.572 0 6.798-6.13 12.258-6.13 5.468 0 5.65 6.13 12.263 6.13 6.577 0 6.806-6.13 12.259-6.13 5.468 0 5.654 6.13 12.266 6.13 6.225 0 6.73-5.372 11.224-6.049v8.348c-5.675.64-6.011 6.063-11.224 6.063-5.47 0-5.652-6.13-12.266-6.13-6.578 0-6.807 6.13-12.26 6.13-5.468 0-5.65-6.13-12.262-6.13-6.577 0-6.806 6.13-12.258 6.13-5.465 0-5.647-6.13-12.256-6.13-6.577 0-6.807 6.13-12.259 6.13-5.126 0-5.61-5.43-11.215-6.063v-8.348zm11.215-4.398c6.577 0 6.807-6.13 12.26-6.13 5.464 0 5.646 6.13 12.255 6.13 6.572 0 6.798-6.13 12.258-6.13 5.468 0 5.65 6.13 12.263 6.13 6.577 0 6.806-6.13 12.259-6.13 5.468 0 5.654 6.13 12.266 6.13 6.2 0 6.778-5.392 11.224-6.056v8.354c-2.822.318-4.414 1.89-5.828 3.303-1.481 1.48-2.761 2.76-5.396 2.76-5.47 0-5.652-6.13-12.266-6.13-6.578 0-6.807 6.13-12.26 6.13-5.468 0-5.65-6.13-12.262-6.13-6.577 0-6.806 6.13-12.258 6.13-5.465 0-5.647-6.13-12.256-6.13-6.577 0-6.807 6.13-12.259 6.13-5.126 0-5.61-5.43-11.215-6.063v-8.354c4.468.668 5.005 6.056 11.215 6.056zM2 94.294v-9.818c4.462.673 5.02 6.05 11.215 6.05 6.577 0 6.807-6.13 12.26-6.13 5.464-.001 5.646 6.13 12.255 6.13 6.572 0 6.798-6.13 12.258-6.13 5.468-.001 5.65 6.13 12.263 6.13 6.577 0 6.806-6.13 12.259-6.13 5.468-.001 5.654 6.13 12.266 6.13 6.225 0 6.73-5.373 11.224-6.05v9.818a2.61 2.61 0 0 1-2.61 2.61H4.61A2.61 2.61 0 0 1 2 94.295z"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

@@ -0,0 +1,170 @@
from __future__ import annotations
import logging
from typing import Any
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.data_entry_flow import FlowResult
from .api import SeaTemperatureAPI
from .const import (
CONF_AREA,
CONF_CONTINENT,
CONF_COUNTRY,
CONF_PATH,
CONF_PLACE,
DOMAIN,
)
_LOGGER = logging.getLogger(__name__)
class SeaTemperatureConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Sea Temperature."""
VERSION = 2
def __init__(self) -> None:
"""Initialize the config flow."""
self._locations_data: dict[str, dict[str, str]] | None = None
self._continents: list[str] = []
self._countries: list[str] = []
self._places: dict[str, dict[str, str]] = {}
self._data: dict[str, Any] = {}
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the initial step (fetch map locations and select continent)."""
api = SeaTemperatureAPI(self.hass)
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")
# 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(list(continents_set))
if user_input is not None:
self._data[CONF_CONTINENT] = user_input[CONF_CONTINENT]
return await self.async_step_country()
data_schema = vol.Schema(
{
vol.Required(CONF_CONTINENT): vol.In(self._continents),
}
)
return self.async_show_form(
step_id="user",
data_schema=data_schema,
)
async def async_step_country(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the country selection step."""
selected_continent = self._data[CONF_CONTINENT]
# Extract countries for the selected continent
countries_set = set()
for loc in self._locations_data.values():
path = loc.get("path", "")
parts = [p for p in path.split("/") if p]
if parts and self._get_continent_name(parts[0]) == selected_continent:
if loc.get("country"):
countries_set.add(loc["country"])
self._countries = sorted(list(countries_set))
if user_input is not None:
self._data[CONF_COUNTRY] = user_input[CONF_COUNTRY]
return await self.async_step_place()
data_schema = vol.Schema(
{
vol.Required(CONF_COUNTRY): vol.In(self._countries),
}
)
return self.async_show_form(
step_id="country",
data_schema=data_schema,
)
async def async_step_place(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the place selection step."""
selected_continent = self._data[CONF_CONTINENT]
selected_country = self._data[CONF_COUNTRY]
# Extract places for selected country and continent
self._places = {}
for loc in self._locations_data.values():
path = loc.get("path", "")
parts = [p for p in path.split("/") if p]
if (
parts
and self._get_continent_name(parts[0]) == selected_continent
and loc.get("country") == selected_country
):
place_name = loc["name"]
if loc.get("area"):
place_name = f"{place_name} ({loc['area']})"
if place_name in self._places:
place_name = f"{place_name} [{loc['path']}]"
self._places[place_name] = loc
if user_input is not None:
selected_label = user_input[CONF_PLACE]
location = self._places[selected_label]
await self.async_set_unique_id(location["path"])
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=f"{location['name']} Sea Temperature",
data={
CONF_CONTINENT: selected_continent,
CONF_COUNTRY: selected_country,
CONF_AREA: location.get("area", ""),
CONF_PLACE: location["name"],
CONF_PATH: location["path"],
},
)
data_schema = vol.Schema(
{
vol.Required(CONF_PLACE): vol.In(
sorted(list(self._places.keys())) if self._places else []
),
}
)
return self.async_show_form(
step_id="place",
data_schema=data_schema,
last_step=True,
)
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())
@@ -0,0 +1,34 @@
"""Constants for the Sea Temperature integration."""
DOMAIN = "seatemperatures"
CONF_CONTINENT = "continent"
CONF_COUNTRY = "country"
CONF_AREA = "area"
CONF_PLACE = "place"
CONF_PLACE_ID = "place_id"
CONF_PATH = "path"
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,
)
@@ -0,0 +1,16 @@
{
"domain": "seatemperatures",
"name": "Sea Temperatures",
"codeowners": [
"@timmaurice"
],
"config_flow": true,
"dependencies": [
"http"
],
"documentation": "https://github.com/timmaurice/sea-temperatures",
"iot_class": "cloud_polling",
"issue_tracker": "https://github.com/timmaurice/sea-temperatures/issues",
"requirements": [],
"version": "3.1.0"
}
+209
View File
@@ -0,0 +1,209 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
from html import unescape
import json
import logging
import posixpath
import re
from typing import Any
from urllib.parse import unquote, urlsplit
_LOGGER = logging.getLogger(__name__)
_CHART_PAYLOAD_RE = re.compile(
r'<script[^>]*data-sea-curve-payload[^>]*>(.*?)</script>',
flags=re.IGNORECASE | re.DOTALL,
)
_DATE_RE = re.compile(r">\s*([A-Za-z]+ \d{1,2}(?:st|nd|rd|th) [A-Za-z]+, \d{4})\s*<")
@dataclass(slots=True)
class SeaTemperatureData:
"""Structured data parsed from a location page."""
date: str | None = None
today: float | None = None
yesterday: float | None = None
last_week: float | None = None
last_year: float | None = None
average_min: float | None = None
average_max: float | None = None
average_avg: float | None = None
trend_labels: list[str] | None = None
trend_temps_c: list[float] | None = None
def as_legacy_payload(self) -> dict[str, Any]:
"""Return the legacy payload shape expected by the integration."""
payload: dict[str, Any] = {"sst": {}}
sst: dict[str, Any] = payload["sst"]
if self.date is not None:
payload["date"] = self.date
for key in ("today", "yesterday", "last_week", "last_year"):
value = getattr(self, key)
if value is not None:
sst[key] = value
average = {
key: value
for key, value in {
"min": self.average_min,
"max": self.average_max,
"avg": self.average_avg,
}.items()
if value is not None
}
if average:
sst["average"] = average
if self.trend_labels and self.trend_temps_c:
payload["charts"] = {
"last_thirty": {
"labels": self.trend_labels,
"series": self.trend_temps_c,
}
}
return payload
def validate_location_path(path: str) -> str:
"""Validate and normalize a SeaTemperatures location path."""
if not isinstance(path, str) or not path.strip():
raise ValueError("Location path is empty")
parts = urlsplit(path.strip())
if parts.scheme or parts.netloc or parts.query or parts.fragment:
raise ValueError("Location path must be a relative path")
decoded_path = unquote(parts.path)
if not decoded_path.startswith("/"):
raise ValueError("Location path must start with '/'")
if "\\" in decoded_path:
raise ValueError("Location path must not contain backslashes")
segments = [segment for segment in decoded_path.split("/") if segment]
if not segments:
raise ValueError("Location path must contain at least one segment")
if any(segment in {".", ".."} for segment in segments):
raise ValueError("Location path must not contain path traversal")
normalized = posixpath.normpath("/" + "/".join(segments))
return f"{normalized}/"
def parse_location_page(html: str) -> SeaTemperatureData:
"""Parse a SeaTemperatures location page into structured data."""
data = SeaTemperatureData(
date=_parse_page_date(html),
today=_extract_summary_value(html, "Today"),
yesterday=_extract_summary_value(html, "Yesterday"),
average_avg=_extract_summary_value(html, "10-year average"),
average_min=_extract_float(
html, r'low temperature of\s*<span[^>]+data-c="([^"]+)"'
),
average_max=_extract_float(html, r'high of\s*<span[^>]+data-c="([^"]+)"'),
)
trend_labels, trend_temps_c, last_week = _parse_trend_chart(html)
data.trend_labels = trend_labels
data.trend_temps_c = trend_temps_c
data.last_week = last_week
if not any(
value is not None
for value in (
data.today,
data.yesterday,
data.average_avg,
data.average_min,
data.average_max,
data.last_week,
)
):
_LOGGER.warning("Parsed SeaTemperatures page without any temperature values")
return data
def _extract_summary_value(html: str, label: str) -> float | None:
pattern = (
rf">\s*{re.escape(label)}\s*</p>\s*<p[^>]*>\s*"
r'<span[^>]+data-c="([^"]+)"'
)
return _extract_float(html, pattern)
def _extract_float(html: str, pattern: str) -> float | None:
match = re.search(pattern, html, flags=re.IGNORECASE | re.DOTALL)
if match is None:
return None
try:
return float(match.group(1))
except (TypeError, ValueError):
_LOGGER.debug("Failed to parse float from %s", match.group(1))
return None
def _parse_page_date(html: str) -> str | None:
match = _DATE_RE.search(html)
if match is None:
return None
page_date = re.sub(r"(\d+)(st|nd|rd|th)", r"\1", match.group(1))
try:
return datetime.strptime(page_date, "%A %d %B, %Y").date().isoformat()
except ValueError:
_LOGGER.debug("Failed to parse page date from %s", page_date)
return None
def _parse_trend_chart(html: str) -> tuple[list[str] | None, list[float] | None, float | None]:
match = _CHART_PAYLOAD_RE.search(html)
if match is None:
return None, None, None
try:
chart_payload = json.loads(unescape(match.group(1)))
except json.JSONDecodeError as err:
_LOGGER.warning("Failed to parse SeaTemperatures chart payload: %s", err)
return None, None, None
if not isinstance(chart_payload, dict):
return None, None, None
raw_times = chart_payload.get("times")
raw_temps = chart_payload.get("tempsC")
if not isinstance(raw_times, list) or not isinstance(raw_temps, list):
return None, None, None
points: list[tuple[int, float]] = []
for raw_time, raw_temp in zip(raw_times, raw_temps, strict=False):
try:
points.append((int(raw_time), float(raw_temp)))
except (TypeError, ValueError):
continue
if not points:
return None, None, None
points.sort(key=lambda point: point[0])
labels = [datetime.fromtimestamp(ts, UTC).strftime("%m-%d") for ts, _ in points]
temps = [temp for _, temp in points]
latest_timestamp = points[-1][0]
# The current site no longer exposes dedicated last-week/last-year values.
# We only derive last_week when the daily chart includes the exact day.
last_week_timestamp = latest_timestamp - 7 * 24 * 60 * 60
last_week = next(
(temp for timestamp, temp in points if timestamp == last_week_timestamp),
None,
)
return labels, temps, last_week
File diff suppressed because one or more lines are too long
+172
View File
@@ -0,0 +1,172 @@
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Any
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
)
from homeassistant.util import slugify
from .const import BASE_URL, CONF_AREA, CONF_PATH, CONF_PLACE, CONF_PLACE_ID, DOMAIN
from .parser import validate_location_path
_LOGGER = logging.getLogger(__name__)
def _to_float(val: Any, round_to: int | None = None) -> float | str:
"""Safely convert a value to float if possible."""
try:
f_val = float(val)
return round(f_val, round_to) if round_to is not None else f_val
except (ValueError, TypeError):
return val
@dataclass(frozen=True, kw_only=True)
class SeaTemperatureSensorEntityDescription(SensorEntityDescription):
"""Class describing Sea Temperature sensor entities."""
data_key: str | None = None
# Single sensor per place, representing "Today's Temperature"
SENSORS: tuple[SeaTemperatureSensorEntityDescription, ...] = (
SeaTemperatureSensorEntityDescription(
key="today",
name="Temperature",
data_key="today",
icon="mdi:thermometer",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Sea Temperature sensor entry."""
coordinator = hass.data[DOMAIN][entry.entry_id]
sensors = [
SeaTemperatureSensor(coordinator, entry, description) for description in SENSORS
]
async_add_entities(sensors)
class SeaTemperatureSensor(CoordinatorEntity, SensorEntity):
"""Representation of a Sea Temperature Sensor."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: DataUpdateCoordinator,
entry: ConfigEntry,
description: SeaTemperatureSensorEntityDescription,
):
super().__init__(coordinator)
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
# Set friendly name
self._attr_name = "Temperature"
place_prefix = slugify(self._place_name)
self._attr_unique_id = f"{DOMAIN}_{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."""
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)
if self.entity_description.data_key in data:
return _to_float(data[self.entity_description.data_key])
return None
@property
def extra_state_attributes(self):
"""Return the state attributes."""
if not self.coordinator.data:
return None
data = self.coordinator.data
sst_data = data.get("sst", {})
attrs = {}
for attr_key in ["yesterday", "last_week", "last_year"]:
if attr_key in sst_data:
attrs[attr_key] = sst_data[attr_key]
if "date" in data:
attrs["date"] = data["date"]
if "average" in sst_data:
for avg_key in ["min", "max", "avg"]:
if avg_key in sst_data["average"]:
attrs[f"average_{avg_key}"] = _to_float(
sst_data["average"][avg_key], round_to=2
)
if "charts" in data:
attrs["charts"] = data["charts"]
attrs["continent"] = self._entry.data.get("continent", "")
attrs["country"] = self._entry.data.get("country", "")
attrs["area"] = self._entry.data.get(CONF_AREA, "")
attrs["place"] = self._place_name
attrs["path"] = self._entry.data.get(CONF_PATH, "")
return attrs if attrs else None
@property
def device_info(self):
"""Return device information."""
url = BASE_URL
location_path = self._entry.data.get(CONF_PATH)
if location_path:
try:
url = f"{BASE_URL}{validate_location_path(location_path)}"
except ValueError:
_LOGGER.debug("Invalid configuration URL path for %s", self._place_name)
return {
"identifiers": {(DOMAIN, self._location_key)},
"name": self._place_name,
"manufacturer": "Sea Temperatures",
"model": "Sea Temperature Monitor",
"configuration_url": url,
}
@@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="en">
<body>
<h2 class="text-2xl font-semibold text-primary">Thursday 21st May, 2026</h2>
<div class="mt-5 grid gap-4 sm:grid-cols-3">
<div>
<p class="text-lg font-semibold text-primary">Today</p>
<p><span data-c="11.83">11.83° C</span></p>
</div>
<div>
<p class="text-lg font-semibold text-primary">Yesterday</p>
<p><span data-c="11.7">11.7° C</span></p>
</div>
<div>
<p class="text-lg font-semibold text-primary">10-year average</p>
<p><span data-c="12.126000000000001">12.13° C</span></p>
</div>
</div>
<p>
The average sea temperature for Copenhagen on May 21 over the last 10 years is
<span data-c="12.126000000000001">12.13° C</span>, with a low temperature of
<span data-c="10.85">10.85° C</span> and a high of <span data-c="13.67">13.67° C</span>.
</p>
<script type="application/json" data-sea-curve-payload>{"start":1776859200,"end":1779364800,"times":[1776859200,1776945600,1777032000,1777118400,1777204800,1777291200,1777377600,1777464000,1777550400,1777636800,1777723200,1777809600,1777896000,1777982400,1778068800,1778155200,1778241600,1778328000,1778414400,1778500800,1778587200,1778673600,1778760000,1778846400,1778932800,1779019200,1779105600,1779192000,1779278400,1779364800],"tempsC":[7.46,7.22,7.52,7.99,8.08,8.27,8.48,8.73,8.86,9.24,9.57,9.9,10.35,9.11,9.24,9.34,9.49,9.58,9.67,9.81,9.99,10.13,10.31,10.64,10.82,10.73,10.78,11.34,11.7,11.83],"layout":{"padLeft":0,"padTop":28,"padRight":0,"padBottom":28,"viewW":1000,"viewH":280}}</script>
</body>
</html>
@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="en">
<body>
<h2 class="text-2xl font-semibold text-primary">Thursday 21st May, 2026</h2>
<p class="mt-1 flex items-center gap-1.5 text-sm text-foreground-muted">Florida, United States</p>
<div class="mt-5 grid gap-4 sm:grid-cols-3">
<div>
<p class="text-lg font-semibold text-primary">Today</p>
<p><span data-c="27.97">27.97° C</span></p>
</div>
<div>
<p class="text-lg font-semibold text-primary">Yesterday</p>
<p><span data-c="27.95">27.95° C</span></p>
</div>
<div>
<p class="text-lg font-semibold text-primary">10-year average</p>
<p><span data-c="27.784999999999997">27.78° C</span></p>
</div>
</div>
<p>
The average sea temperature for Miami Beach on May 21 over the last 10 years is
<span data-c="27.784999999999997">27.78° C</span>, with a low temperature of
<span data-c="26.47">26.47° C</span> and a high of <span data-c="28.71">28.71° C</span>.
</p>
<script type="application/json" data-sea-curve-payload>{"start":1776859200,"end":1779364800,"times":[1776859200,1776945600,1777032000,1777118400,1777204800,1777291200,1777377600,1777464000,1777550400,1777636800,1777723200,1777809600,1777896000,1777982400,1778068800,1778155200,1778241600,1778328000,1778414400,1778500800,1778587200,1778673600,1778760000,1778846400,1778932800,1779019200,1779105600,1779192000,1779278400,1779364800],"tempsC":[26.42,26.18,26.29,26.2,26.18,26.22,26.32,26.51,26.73,26.9,26.85,26.74,26.76,27.03,27.12,27.17,27.2,27.24,27.2,27.29,27.34,27.37,28.5,28.24,28.23,28.22,28.17,27.96,27.95,27.97],"layout":{"padLeft":0,"padTop":28,"padRight":0,"padBottom":28,"viewW":1000,"viewH":280}}</script>
</body>
</html>
@@ -0,0 +1,337 @@
from __future__ import annotations
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from custom_components.seatemperatures import async_migrate_entry
from custom_components.seatemperatures.api import (
SeaTemperatureAPI,
parse_map_locations,
parse_search_results,
)
from custom_components.seatemperatures.config_flow import SeaTemperatureConfigFlow
from custom_components.seatemperatures.const import (
CONF_AREA,
CONF_CONTINENT,
CONF_COUNTRY,
CONF_PATH,
CONF_PLACE,
CONF_PLACE_ID,
)
from custom_components.seatemperatures.parser import (
parse_location_page,
validate_location_path,
)
FIXTURES_DIR = Path(__file__).parent / "fixtures"
pytestmark = pytest.mark.asyncio
@pytest.fixture
def mock_hass():
"""Mock Home Assistant object."""
hass = AsyncMock()
hass.data = {}
hass.config_entries = SimpleNamespace(async_update_entry=MagicMock())
return hass
def load_fixture(name: str) -> str:
"""Load an HTML fixture by name."""
return (FIXTURES_DIR / name).read_text(encoding="utf-8")
async def test_parse_location_page_from_copenhagen_fixture() -> None:
"""Parse a standard location page fixture."""
data = parse_location_page(load_fixture("copenhagen.html"))
assert data.date == "2026-05-21"
assert data.today == pytest.approx(11.83)
assert data.yesterday == pytest.approx(11.7)
assert data.average_avg == pytest.approx(12.126000000000001)
assert data.average_min == pytest.approx(10.85)
assert data.average_max == pytest.approx(13.67)
assert data.last_week == pytest.approx(10.31)
assert data.trend_labels is not None
assert data.trend_labels[0] == "04-22"
assert data.trend_labels[-1] == "05-21"
assert data.trend_temps_c is not None
assert data.trend_temps_c[-1] == pytest.approx(11.83)
async def test_parse_location_page_is_not_location_specific() -> None:
"""Parse a second fixture to prove the parser is generic."""
data = parse_location_page(load_fixture("miami_beach.html"))
assert data.today == pytest.approx(27.97)
assert data.yesterday == pytest.approx(27.95)
assert data.average_avg == pytest.approx(27.784999999999997)
assert data.average_min == pytest.approx(26.47)
assert data.average_max == pytest.approx(28.71)
assert data.last_week == pytest.approx(28.5)
async def test_parse_location_page_tolerates_missing_values() -> None:
"""Missing values should produce None instead of crashing."""
data = parse_location_page("<html><body><h1>No chart</h1></body></html>")
assert data.today is None
assert data.yesterday is None
assert data.average_avg is None
assert data.average_min is None
assert data.average_max is None
assert data.last_week is None
assert data.trend_labels is None
assert data.trend_temps_c is None
async def test_parse_location_page_tolerates_malformed_chart_json(caplog: pytest.LogCaptureFixture) -> None:
"""Malformed chart JSON should be ignored with a warning."""
html = """
<html><body>
<p>Today</p><p><span data-c="15.5">15.5</span></p>
<script type="application/json" data-sea-curve-payload>{broken json</script>
</body></html>
"""
data = parse_location_page(html)
assert data.today == pytest.approx(15.5)
assert data.trend_labels is None
assert "Failed to parse SeaTemperatures chart payload" in caplog.text
async def test_validate_location_path() -> None:
"""Location paths should be normalized and validated."""
assert (
validate_location_path("/north-america/united-states/miami-beach")
== "/north-america/united-states/miami-beach/"
)
with pytest.raises(ValueError):
validate_location_path("https://seatemperatures.net/europe/denmark/copenhagen/")
with pytest.raises(ValueError):
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 = {
"locations": [
[
"sea-1",
"Ain El Turk",
"Algeria",
"",
"/africa/algeria/ain-el-turk/",
35.7438,
-0.7693,
19.1,
],
["broken"],
]
}
results = parse_map_locations(data)
assert results == {
"sea-1": {
"name": "Ain El Turk",
"country": "Algeria",
"area": "",
"path": "/africa/algeria/ain-el-turk/",
}
}
async def test_get_temperatures_from_location_page(mock_hass) -> None:
"""The API should fetch HTML and return the legacy payload shape."""
api = SeaTemperatureAPI(mock_hass)
html = load_fixture("copenhagen.html")
with patch(
"custom_components.seatemperatures.api.async_get_clientsession"
) as mock_session:
mock_response = AsyncMock()
mock_response.text.return_value = html
mock_response.raise_for_status = MagicMock()
mock_session.return_value.get.return_value.__aenter__.return_value = mock_response
result = await api.get_temperatures("/europe/denmark/copenhagen/")
assert result is not None
assert result["date"] == "2026-05-21"
assert result["sst"]["today"] == pytest.approx(11.83)
assert result["sst"]["yesterday"] == pytest.approx(11.7)
assert result["sst"]["last_week"] == pytest.approx(10.31)
assert result["sst"]["average"]["avg"] == pytest.approx(12.126000000000001)
assert result["charts"]["last_thirty"]["labels"][-1] == "05-21"
async def test_get_location_by_place_id_uses_map_locations(mock_hass) -> None:
"""Legacy IDs should resolve through map-locations data."""
api = SeaTemperatureAPI(mock_hass)
payload = {
"locations": [["canonical-36", "Boumerdas", "Algeria", "Boumerdes", "/africa/algeria/boumerdas/"]]
}
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
result = await api.get_location_by_place_id("canonical-36")
assert result == {
"name": "Boumerdas",
"country": "Algeria",
"area": "Boumerdes",
"path": "/africa/algeria/boumerdas/",
}
async def test_get_location_by_place_id_falls_back_to_sea_prefix(mock_hass) -> None:
"""Numeric legacy IDs should resolve by prefixing sea-."""
api = SeaTemperatureAPI(mock_hass)
payload = {
"locations": [["sea-5484", "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
result = await api.get_location_by_place_id("5484")
assert result == {
"name": "Acharavi",
"country": "Greece",
"area": "Corfu",
"path": "/europe/greece/acharavi/",
}
async def test_config_flow_stores_path_based_location(mock_hass) -> None:
"""Config flow should store location metadata including the path."""
flow = SeaTemperatureConfigFlow()
flow.hass = mock_hass
locations_cache = {
"sea-1": {
"name": "Miami Beach",
"country": "United States",
"area": "Florida, United States",
"path": "/north-america/united-states/miami-beach/",
}
}
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"
user_result = await flow.async_step_user({CONF_CONTINENT: "North America"})
assert user_result["type"] == "form"
assert user_result["step_id"] == "country"
country_result = await flow.async_step_country({CONF_COUNTRY: "United States"})
assert country_result["type"] == "form"
assert country_result["step_id"] == "place"
with patch.object(flow, "async_set_unique_id", AsyncMock()), patch.object(
flow, "_abort_if_unique_id_configured", MagicMock()
):
place_result = await flow.async_step_place(
{CONF_PLACE: "Miami Beach (Florida, United States)"}
)
assert place_result["type"] == "create_entry"
assert place_result["data"] == {
CONF_CONTINENT: "North America",
CONF_COUNTRY: "United States",
CONF_AREA: "Florida, United States",
CONF_PLACE: "Miami Beach",
CONF_PATH: "/north-america/united-states/miami-beach/",
}
async def test_async_migrate_entry_from_place_id(mock_hass) -> None:
"""Legacy config entries should migrate from place_id to path."""
entry = SimpleNamespace(
version=1,
entry_id="entry-1",
data={CONF_PLACE_ID: "sea-1", CONF_PLACE: "Legacy Name"},
)
with patch.object(
SeaTemperatureAPI,
"get_location_by_place_id",
AsyncMock(
return_value={
"name": "Ain El Turk",
"country": "Algeria",
"area": "",
"path": "/africa/algeria/ain-el-turk/",
}
),
):
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
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
async def test_async_migrate_entry_fails_when_place_id_cannot_be_mapped(mock_hass) -> None:
"""Migration should fail cleanly if a legacy ID no longer resolves."""
entry = SimpleNamespace(version=1, entry_id="entry-2", data={CONF_PLACE_ID: "missing"})
with patch.object(SeaTemperatureAPI, "get_location_by_place_id", AsyncMock(return_value=None)):
migrated = await async_migrate_entry(mock_hass, entry)
assert migrated is False
mock_hass.config_entries.async_update_entry.assert_not_called()
@@ -0,0 +1,45 @@
{
"config": {
"step": {
"user": {
"title": "Kontinent auswählen",
"description": "Bitte wählen Sie den Kontinent für das Gebiet aus, das Sie überwachen möchten.",
"data": {
"continent": "Kontinent"
}
},
"country": {
"title": "Land auswählen",
"description": "Bitte wählen Sie das Land aus, in dem sich das Gebiet befindet.",
"data": {
"country": "Land"
}
},
"place": {
"title": "Ort auswählen",
"description": "Bitte wählen Sie den Ort aus, den Sie überwachen möchten.",
"data": {
"place": "Ort"
}
}
},
"error": {
"cannot_connect": "Verbindung zu seatemperatures.net fehlgeschlagen. Bitte überprüfen Sie Ihre Internetverbindung.",
"unknown": "Unerwarteter Fehler."
},
"abort": {
"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" }
}
}
}
@@ -0,0 +1,45 @@
{
"config": {
"step": {
"user": {
"title": "Select Continent",
"description": "Please select the continent for the area you want to monitor.",
"data": {
"continent": "Continent"
}
},
"country": {
"title": "Select Country",
"description": "Please select the country the area is located in.",
"data": {
"country": "Country"
}
},
"place": {
"title": "Select Place",
"description": "Please select the place you want to monitor.",
"data": {
"place": "Place"
}
}
},
"error": {
"cannot_connect": "Failed to connect to seatemperatures.net. Please check your internet connection.",
"unknown": "Unexpected error."
},
"abort": {
"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" }
}
}
}
@@ -0,0 +1,39 @@
{
"config": {
"step": {
"user": {
"title": "Seleccionar Continente",
"description": "Por favor, seleccione el continente de la zona que desea monitorizar.",
"data": { "continent": "Continente" }
},
"country": {
"title": "Seleccionar País",
"description": "Por favor, seleccione el país donde se encuentra la zona.",
"data": { "country": "País" }
},
"place": {
"title": "Seleccionar Lugar",
"description": "Por favor, seleccione el lugar que desea monitorizar.",
"data": { "place": "Lugar" }
}
},
"error": {
"cannot_connect": "No se pudo conectar a seatemperatures.net. Por favor, compruebe su conexión a internet.",
"unknown": "Error inesperado."
},
"abort": {
"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" }
}
}
}
@@ -0,0 +1,39 @@
{
"config": {
"step": {
"user": {
"title": "Sélectionner le continent",
"description": "Veuillez sélectionner le continent de la zone que vous souhaitez surveiller.",
"data": { "continent": "Continent" }
},
"country": {
"title": "Sélectionner le pays",
"description": "Veuillez sélectionner le pays où se trouve la zone.",
"data": { "country": "Pays" }
},
"place": {
"title": "Sélectionner le lieu",
"description": "Veuillez sélectionner le lieu que vous souhaitez surveiller.",
"data": { "place": "Lieu" }
}
},
"error": {
"cannot_connect": "Échec de la connexion à seatemperatures.net. Veuillez vérifier votre connexion Internet.",
"unknown": "Erreur inattendue."
},
"abort": {
"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" }
}
}
}
@@ -0,0 +1,39 @@
{
"config": {
"step": {
"user": {
"title": "Seleziona Continente",
"description": "Seleziona il continente della zona che vuoi monitorare.",
"data": { "continent": "Continente" }
},
"country": {
"title": "Seleziona Paese",
"description": "Seleziona il paese in cui si trova la zona.",
"data": { "country": "Paese" }
},
"place": {
"title": "Seleziona Luogo",
"description": "Seleziona il luogo che vuoi monitorare.",
"data": { "place": "Luogo" }
}
},
"error": {
"cannot_connect": "Impossibile connettersi a seatemperatures.net. Controlla la tua connessione internet.",
"unknown": "Errore imprevisto."
},
"abort": {
"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" }
}
}
}