393 files

This commit is contained in:
Home Assistant Version Control
2026-09-27 17:16:54 +00:00
parent b05b7a897e
commit 39d97ac2db
404 changed files with 54006 additions and 11034 deletions
+21 -75
View File
@@ -1,16 +1,14 @@
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 import entity_registry as er
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import slugify
from .api import SeaTemperatureAPI, SeaTemperatureError
from .api import SeaTemperatureAPI
from .const import (
CONF_AREA,
CONF_CONTINENT,
@@ -18,12 +16,9 @@ from .const import (
CONF_PATH,
CONF_PLACE,
CONF_PLACE_ID,
CONF_SCAN_INTERVAL_HOURS,
DEFAULT_SCAN_INTERVAL_HOURS,
DOMAIN,
MAX_SCAN_INTERVAL_HOURS,
MIN_SCAN_INTERVAL_HOURS,
)
from .coordinator import SeaTemperatureConfigEntry, SeaTemperatureCoordinator
PLATFORMS = [Platform.SENSOR]
_LOGGER = logging.getLogger(__name__)
@@ -127,21 +122,6 @@ async def async_setup(hass: HomeAssistant, config: dict) -> bool:
return True
def async_scan_interval(entry: ConfigEntry) -> timedelta:
"""Return the poll interval for an entry, honouring the options flow."""
hours = entry.options.get(CONF_SCAN_INTERVAL_HOURS, DEFAULT_SCAN_INTERVAL_HOURS)
try:
hours = int(hours)
except (TypeError, ValueError):
hours = DEFAULT_SCAN_INTERVAL_HOURS
if not MIN_SCAN_INTERVAL_HOURS <= hours <= MAX_SCAN_INTERVAL_HOURS:
# The options flow bounds the form, but a hand-edited or older stored
# value reaches this reader directly - clamp both ends, not just the
# one that would hammer the site.
hours = DEFAULT_SCAN_INTERVAL_HOURS
return timedelta(hours=hours)
def entry_location_key(entry: ConfigEntry) -> str:
"""Return the key an entry's entity unique_ids are built from.
@@ -243,11 +223,17 @@ async def _async_migrate_unique_ids(hass: HomeAssistant, entry: ConfigEntry) ->
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Migrate legacy config entries from place IDs to path-based locations."""
if entry.version > 4:
_LOGGER.error("Unsupported SeaTemperatures config entry version: %s", entry.version)
_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)
_LOGGER.debug(
"Migrating SeaTemperatures entry %s from version %s",
entry.entry_id,
entry.version,
)
data = dict(entry.data)
location_path = data.get(CONF_PATH)
@@ -291,30 +277,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return True
async def _async_fetch(
api: SeaTemperatureAPI, location_path: str | None, place_name: str
) -> dict:
"""Fetch one refresh, turning an API failure into a single logged one.
The coordinator logs an UpdateFailed once and stays quiet while the failure
persists, so the API layer deliberately reports nothing of its own.
"""
if not location_path:
raise UpdateFailed(
"No location path configured. Remove and re-add the integration."
)
try:
data = await api.get_temperatures(location_path)
except SeaTemperatureError as err:
raise UpdateFailed(str(err)) from err
if not data:
raise UpdateFailed(f"Failed to fetch data for place {place_name}")
return data
async def _async_options_updated(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Reload the entry after the options flow saved a new poll interval.
@@ -327,45 +289,29 @@ async def _async_options_updated(hass: HomeAssistant, entry: ConfigEntry) -> Non
await hass.config_entries.async_reload(entry.entry_id)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(
hass: HomeAssistant, entry: SeaTemperatureConfigEntry
) -> bool:
"""Set up Sea Temperature from a config entry."""
hass.data.setdefault(DOMAIN, {})
# Registered before the first refresh so an options save during a slow or
# failing setup is not silently dropped; async_on_unload drops the
# subscription with the entry rather than stacking one per reload.
entry.async_on_unload(entry.add_update_listener(_async_options_updated))
place_name = entry.data.get(CONF_PLACE, "Unknown")
location_path = entry.data.get(CONF_PATH)
location_key = entry_location_key(entry)
api = SeaTemperatureAPI(hass)
async def async_update_data():
"""Fetch data from API."""
return await _async_fetch(api, location_path, place_name)
coordinator = DataUpdateCoordinator(
hass,
_LOGGER,
name=f"seatemperatures_{location_key}",
update_method=async_update_data,
update_interval=async_scan_interval(entry),
)
coordinator = SeaTemperatureCoordinator(hass, entry, entry_location_key(entry))
await coordinator.async_config_entry_first_refresh()
hass.data[DOMAIN][entry.entry_id] = coordinator
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(
hass: HomeAssistant, entry: SeaTemperatureConfigEntry
) -> 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
# Core drops entry.runtime_data once the unload succeeded, so there is no
# hass.data bookkeeping left to undo here.
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -19,4 +19,4 @@
<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>
</svg>

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

@@ -51,6 +51,7 @@ def _searchable(options: list[str]) -> SelectSelector:
)
)
# The first path segment of every location, as published by
# /api/map-locations.json. Slugs missing here fall back to a title-cased name,
# so a stale entry is worse than none: it produces a second spelling of a
@@ -264,9 +265,7 @@ class SeaTemperatureOptionsFlow(config_entries.OptionsFlow):
if user_input is not None:
return self.async_create_entry(
data={
CONF_SCAN_INTERVAL_HOURS: int(
user_input[CONF_SCAN_INTERVAL_HOURS]
)
CONF_SCAN_INTERVAL_HOURS: int(user_input[CONF_SCAN_INTERVAL_HOURS])
}
)
@@ -276,9 +275,7 @@ class SeaTemperatureOptionsFlow(config_entries.OptionsFlow):
data_schema = vol.Schema(
{
vol.Required(
CONF_SCAN_INTERVAL_HOURS, default=current
): NumberSelector(
vol.Required(CONF_SCAN_INTERVAL_HOURS, default=current): NumberSelector(
NumberSelectorConfig(
min=MIN_SCAN_INTERVAL_HOURS,
max=MAX_SCAN_INTERVAL_HOURS,
@@ -20,4 +20,3 @@ MAX_SCAN_INTERVAL_HOURS = 24
BASE_URL = "https://seatemperatures.net"
API_URL_MAP_LOCATIONS = f"{BASE_URL}/api/map-locations.json"
DEFAULT_USER_AGENT = "HomeAssistant seatemperatures integration"
@@ -12,5 +12,5 @@
"iot_class": "cloud_polling",
"issue_tracker": "https://github.com/timmaurice/sea-temperatures/issues",
"requirements": [],
"version": "3.3.1"
"version": "3.4.0"
}
+5 -4
View File
@@ -13,7 +13,7 @@ from urllib.parse import unquote, urlsplit
_LOGGER = logging.getLogger(__name__)
_CHART_PAYLOAD_RE = re.compile(
r'<script[^>]*data-sea-curve-payload[^>]*>(.*?)</script>',
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*<")
@@ -133,8 +133,7 @@ def parse_location_page(html: str) -> SeaTemperatureData:
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="([^"]+)"'
rf">\s*{re.escape(label)}\s*</p>\s*<p[^>]*>\s*" r'<span[^>]+data-c="([^"]+)"'
)
return _extract_float(html, pattern)
@@ -164,7 +163,9 @@ def _parse_page_date(html: str) -> str | None:
return None
def _parse_trend_chart(html: str) -> tuple[list[str] | None, list[float] | None, float | 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
@@ -1,4 +1,4 @@
function t(t,e,n,r){var i,a=arguments.length,s=a<3?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var o=t.length-1;o>=0;o--)(i=t[o])&&(s=(a<3?i(s):a>3?i(e,n,s):i(e,n))||s);return a>3&&s&&Object.defineProperty(e,n,s),s}console.groupCollapsed("%c🌊 SEA TEMPERATURES CARD%cv3.3.1","color: orange; font-weight: bold; background: black; padding: 2px 4px; border-radius: 2px 0 0 2px;","color: white; font-weight: bold; background: dimgray; padding: 2px 4px; border-radius: 0 2px 2px 0;"),console.info("A Lovelace card to display sea temperatures."),console.info("Github: https://github.com/timmaurice/sea-temperatures.git"),console.info("Sponsor: https://buymeacoffee.com/timmaurice"),console.groupEnd(),"function"==typeof SuppressedError&&SuppressedError;
function t(t,e,n,r){var i,a=arguments.length,s=a<3?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var o=t.length-1;o>=0;o--)(i=t[o])&&(s=(a<3?i(s):a>3?i(e,n,s):i(e,n))||s);return a>3&&s&&Object.defineProperty(e,n,s),s}console.groupCollapsed("%c🌊 SEA TEMPERATURES CARD%cv3.4.0","color: orange; font-weight: bold; background: black; padding: 2px 4px; border-radius: 2px 0 0 2px;","color: white; font-weight: bold; background: dimgray; padding: 2px 4px; border-radius: 0 2px 2px 0;"),console.info("A Lovelace card to display sea temperatures."),console.info("Github: https://github.com/timmaurice/sea-temperatures.git"),console.info("Sponsor: https://buymeacoffee.com/timmaurice"),console.groupEnd(),"function"==typeof SuppressedError&&SuppressedError;
/**
* @license
* Copyright 2019 Google LLC
@@ -141,7 +141,7 @@ const dt={attribute:!0,type:String,converter:b,reflect:!1,hasChanged:x},pt=(t=dt
</defs>
<text class="axis-label start" x="${l}" y="${115}">${v(b)}</text>
<text class="axis-label end" x="${r-o}" y="${115}" text-anchor="end">${v(x)}</text>
<path class="chart-area" d="${_(e)||""}" fill="url(#gradient-${t.replace(/\./g,"-")})"></path>
<path
class="chart-line"
@@ -150,17 +150,17 @@ const dt={attribute:!0,type:String,converter:b,reflect:!1,hasChanged:x},pt=(t=dt
stroke="var(--primary-color, #0077be)"
stroke-width="2"
></path>
${y(n.average_min,"min",yt(this.hass,"card.min"))}
${y(n.average_max,"max",yt(this.hass,"card.max"))}
${y(n.average_avg,"avg",yt(this.hass,"card.avg"))}
${M?Z`<circle class="extrema-dot min" cx="${u(M.date)}" cy="${f(M.value)}" r="3"></circle>`:""}
${A?Z`<circle class="extrema-dot max" cx="${u(A.date)}" cy="${f(A.value)}" r="3"></circle>`:""}
${w}
${T}
<rect
class="hover-overlay"
x="${l}"
+14 -14
View File
@@ -10,22 +10,23 @@ from homeassistant.components.sensor import (
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.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import slugify
from . import build_unique_id, entry_location_key
from .const import BASE_URL, CONF_AREA, CONF_PATH, CONF_PLACE, DOMAIN
from .coordinator import SeaTemperatureConfigEntry, SeaTemperatureCoordinator
from .parser import validate_location_path
_LOGGER = logging.getLogger(__name__)
# The coordinator does the one fetch per location; the sensor only reads its
# data and has no update method or action of its own, so nothing to serialise.
PARALLEL_UPDATES = 0
def _to_float(val: Any, round_to: int | None = None) -> float | str:
"""Safely convert a value to float if possible."""
@@ -47,9 +48,11 @@ class SeaTemperatureSensorEntityDescription(SensorEntityDescription):
SENSORS: tuple[SeaTemperatureSensorEntityDescription, ...] = (
SeaTemperatureSensorEntityDescription(
key="today",
name="Temperature",
# No name and no icon: with has_entity_name, HA names an unnamed sensor
# after its device class ("Temperature", "Temperatur", ...) in the
# user's language, and the temperature device class brings the
# mdi:thermometer default icon with it.
data_key="today",
icon="mdi:thermometer",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
@@ -59,11 +62,11 @@ SENSORS: tuple[SeaTemperatureSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: SeaTemperatureConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Sea Temperature sensor entry."""
coordinator = hass.data[DOMAIN][entry.entry_id]
coordinator = entry.runtime_data
sensors = [
SeaTemperatureSensor(coordinator, entry, description) for description in SENSORS
@@ -79,8 +82,8 @@ class SeaTemperatureSensor(CoordinatorEntity, SensorEntity):
def __init__(
self,
coordinator: DataUpdateCoordinator,
entry: ConfigEntry,
coordinator: SeaTemperatureCoordinator,
entry: SeaTemperatureConfigEntry,
description: SeaTemperatureSensorEntityDescription,
):
super().__init__(coordinator)
@@ -90,9 +93,6 @@ class SeaTemperatureSensor(CoordinatorEntity, SensorEntity):
# Shared with the unique_id migration, so both sides agree on the id.
self._location_key = entry_location_key(entry)
# Set friendly name
self._attr_name = "Temperature"
place_prefix = slugify(self._place_name)
# Slugified: the raw location path produced ids like
@@ -1,26 +0,0 @@
<!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>
@@ -1,27 +0,0 @@
<!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>
@@ -1,578 +0,0 @@
from __future__ import annotations
import logging
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import aiohttp
import pytest
from homeassistant.helpers.selector import SelectSelector
from homeassistant.helpers.update_coordinator import UpdateFailed
from custom_components.seatemperatures import _async_fetch, async_migrate_entry
from custom_components.seatemperatures.api import (
MAP_LOCATIONS_TTL,
REQUEST_TIMEOUT,
SeaTemperatureAPI,
SeaTemperatureError,
parse_map_locations,
)
from custom_components.seatemperatures.config_flow import (
CONTINENT_NAMES,
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] == "2026-04-22"
assert data.trend_labels[-1] == "2026-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_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] == "2026-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/",
}
),
), patch(
# The unique_id migration is exercised in test_entry.py; here it would
# only drag a real entity registry into a mocked hass.
"custom_components.seatemperatures._async_migrate_unique_ids",
AsyncMock(),
) as migrate_unique_ids:
migrated = await async_migrate_entry(mock_hass, entry)
assert migrated is True
migrate_unique_ids.assert_awaited_once()
assert mock_hass.config_entries.async_update_entry.call_count == 2
_, kwargs = mock_hass.config_entries.async_update_entry.call_args_list[0]
assert kwargs["data"][CONF_PLACE] == "Ain El Turk"
assert kwargs["data"][CONF_COUNTRY] == "Algeria"
assert kwargs["data"][CONF_PATH] == "/africa/algeria/ain-el-turk/"
assert kwargs["unique_id"] == "/africa/algeria/ain-el-turk/"
assert kwargs["version"] == 2
assert mock_hass.config_entries.async_update_entry.call_args_list[1].kwargs == {
"version": 4
}
async def test_async_migrate_entry_fails_when_place_id_cannot_be_mapped(mock_hass) -> None:
"""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()
async def test_config_flow_reshows_the_form_when_the_site_is_unreachable(mock_hass) -> None:
"""An unreachable site must show a translated error, not an abort."""
flow = SeaTemperatureConfigFlow()
flow.hass = mock_hass
with patch.object(SeaTemperatureAPI, "get_map_locations", AsyncMock(return_value=None)):
result = await flow.async_step_user(None)
assert result["type"] == "form"
assert result["step_id"] == "user"
assert result["errors"] == {"base": "cannot_connect"}
async def test_config_flow_retries_after_a_failed_fetch(mock_hass) -> None:
"""Submitting the error form should fetch again instead of raising."""
flow = SeaTemperatureConfigFlow()
flow.hass = mock_hass
locations_cache = {
"sea-1": {
"name": "Copenhagen",
"country": "Denmark",
"area": "",
"path": "/europe/denmark/copenhagen/",
}
}
with patch.object(
SeaTemperatureAPI,
"get_map_locations",
AsyncMock(side_effect=[None, locations_cache]),
):
await flow.async_step_user(None)
# The retry form has no continent field, so it submits an empty mapping.
result = await flow.async_step_user({})
assert result["type"] == "form"
assert result["step_id"] == "user"
assert not result.get("errors")
assert flow._continents == ["Europe"]
# The nine first path segments /api/map-locations.json actually publishes.
PUBLISHED_CONTINENT_SLUGS = {
"africa",
"antarctica",
"asia",
"australia-and-oceania",
"central-america-and-the-caribbean",
"europe",
"middle-east",
"north-america",
"south-america",
}
async def test_continent_map_matches_the_published_slugs() -> None:
"""A stale slug adds a continent no location ever resolves to."""
assert set(CONTINENT_NAMES) == PUBLISHED_CONTINENT_SLUGS
async def test_continent_names_read_as_names() -> None:
"""Every published slug should map to one readable spelling."""
flow = SeaTemperatureConfigFlow()
names = {flow._get_continent_name(slug) for slug in PUBLISHED_CONTINENT_SLUGS}
assert names == {
"Africa",
"Antarctica",
"Asia",
"Australia and Oceania",
"Central America and the Caribbean",
"Europe",
"Middle East",
"North America",
"South America",
}
async def test_unknown_continent_slug_keeps_joining_words_small() -> None:
"""A slug the site adds later still has to read as a name."""
flow = SeaTemperatureConfigFlow()
assert flow._get_continent_name("islands-of-the-atlantic") == "Islands of the Atlantic"
def _selector_for(schema, key: str) -> SelectSelector:
"""Pull the selector a schema uses for one key."""
for marker, validator in schema.schema.items():
if str(marker) == key:
return validator
raise AssertionError(f"{key} is not in the schema")
async def test_every_step_offers_a_searchable_dropdown(mock_hass) -> None:
"""4,477 places in a plain list cannot be picked from without a search."""
flow = SeaTemperatureConfigFlow()
flow.hass = mock_hass
locations_cache = {
f"sea-{index}": {
"name": f"Place {index}",
"country": "United States",
"area": "",
"path": f"/north-america/united-states/place-{index}/",
}
for index in range(3)
}
with patch.object(
SeaTemperatureAPI, "get_map_locations", AsyncMock(return_value=locations_cache)
):
user_result = await flow.async_step_user(None)
await flow.async_step_user({CONF_CONTINENT: "North America"})
country_result = await flow.async_step_country(None)
await flow.async_step_country({CONF_COUNTRY: "United States"})
place_result = await flow.async_step_place(None)
for result, key in (
(user_result, CONF_CONTINENT),
(country_result, CONF_COUNTRY),
(place_result, CONF_PLACE),
):
selector = _selector_for(result["data_schema"], key)
assert isinstance(selector, SelectSelector), key
assert selector.config["mode"] == "dropdown", key
async def test_get_temperatures_does_not_log_its_own_error(
mock_hass, caplog: pytest.LogCaptureFixture
) -> None:
"""The coordinator logs a failed refresh, so the API layer must not."""
api = SeaTemperatureAPI(mock_hass)
with patch(
"custom_components.seatemperatures.api.async_get_clientsession"
) as mock_session:
mock_session.return_value.get.side_effect = aiohttp.ClientError("boom")
with caplog.at_level(logging.DEBUG), pytest.raises(SeaTemperatureError) as excinfo:
await api.get_temperatures("/europe/denmark/copenhagen/")
assert "boom" in str(excinfo.value)
assert [record for record in caplog.records if record.levelno >= logging.ERROR] == []
async def test_get_temperatures_rejects_an_invalid_path(mock_hass) -> None:
"""An unusable path is a failed refresh, not a silent None."""
api = SeaTemperatureAPI(mock_hass)
with pytest.raises(SeaTemperatureError):
await api.get_temperatures("https://example.com/evil/")
async def test_fetch_turns_an_api_error_into_update_failed() -> None:
"""The coordinator has to see one UpdateFailed carrying the reason."""
api = MagicMock()
api.get_temperatures = AsyncMock(side_effect=SeaTemperatureError("upstream is down"))
with pytest.raises(UpdateFailed, match="upstream is down"):
await _async_fetch(api, "/europe/denmark/copenhagen/", "Copenhagen")
async def test_every_request_carries_an_explicit_timeout(mock_hass) -> None:
"""A host that accepts the connection but never answers must not hang a refresh."""
api = SeaTemperatureAPI(mock_hass)
with patch(
"custom_components.seatemperatures.api.async_get_clientsession"
) as mock_session:
get = mock_session.return_value.get
mock_response = AsyncMock()
mock_response.text.return_value = load_fixture("copenhagen.html")
mock_response.json.return_value = {"locations": []}
mock_response.raise_for_status = MagicMock()
get.return_value.__aenter__.return_value = mock_response
await api.get_temperatures("/europe/denmark/copenhagen/")
await api.get_map_locations()
assert get.call_count == 2
for call in get.call_args_list:
assert call.kwargs["timeout"] is REQUEST_TIMEOUT
assert REQUEST_TIMEOUT.total is not None
assert REQUEST_TIMEOUT.total > 0
async def test_map_locations_are_cached_within_the_ttl(mock_hass) -> None:
"""Repeated lookups inside the TTL must not refetch 19k rows."""
api = SeaTemperatureAPI(mock_hass)
payload = {
"locations": [["sea-1", "Acharavi", "Greece", "Corfu", "/europe/greece/acharavi/"]]
}
with patch(
"custom_components.seatemperatures.api.async_get_clientsession"
) as mock_session:
mock_response = AsyncMock()
mock_response.json.return_value = payload
mock_response.raise_for_status = MagicMock()
mock_session.return_value.get.return_value.__aenter__.return_value = mock_response
first = await api.get_map_locations()
second = await api.get_map_locations()
assert mock_session.return_value.get.call_count == 1
assert first == second
assert "sea-1" in first
async def test_map_locations_are_refetched_once_the_ttl_expires(mock_hass) -> None:
"""A beach added upstream must appear without restarting Home Assistant."""
api = SeaTemperatureAPI(mock_hass)
first_payload = {
"locations": [["sea-1", "Acharavi", "Greece", "Corfu", "/europe/greece/acharavi/"]]
}
second_payload = {
"locations": [
["sea-1", "Acharavi", "Greece", "Corfu", "/europe/greece/acharavi/"],
["sea-2", "Sylt", "Germany", "", "/europe/germany/island-of-sylt/"],
]
}
with patch(
"custom_components.seatemperatures.api.async_get_clientsession"
) as mock_session:
mock_response = AsyncMock()
mock_response.json.side_effect = [first_payload, second_payload]
mock_response.raise_for_status = MagicMock()
mock_session.return_value.get.return_value.__aenter__.return_value = mock_response
with patch(
"custom_components.seatemperatures.api.time.monotonic", return_value=0.0
):
first = await api.get_map_locations()
with patch(
"custom_components.seatemperatures.api.time.monotonic",
return_value=MAP_LOCATIONS_TTL + 1.0,
):
second = await api.get_map_locations()
assert mock_session.return_value.get.call_count == 2
assert "sea-2" not in first
assert "sea-2" in second