393 files
This commit is contained in:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user