Initial Commit
This commit is contained in:
@@ -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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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"
|
||||
}
|
||||
@@ -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
@@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user