From e51e3337f0de8dde5ac2c607a3785c846d43b561 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Wed, 10 Dec 2025 08:45:40 +0100 Subject: [PATCH 001/200] Set version to 1.9.0-alpha1 --- CHANGELOG.md | 9 + custom_components/pollenlevels/__init__.py | 79 ++++++- custom_components/pollenlevels/client.py | 137 +++++++++++ custom_components/pollenlevels/diagnostics.py | 7 +- custom_components/pollenlevels/manifest.json | 2 +- custom_components/pollenlevels/runtime.py | 21 ++ custom_components/pollenlevels/sensor.py | 219 ++++-------------- pyproject.toml | 2 +- tests/test_init.py | 86 ++++++- tests/test_sensor.py | 91 +++++--- 10 files changed, 430 insertions(+), 223 deletions(-) create mode 100644 custom_components/pollenlevels/client.py create mode 100644 custom_components/pollenlevels/runtime.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 26c333b3..22b960a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,13 @@ # Changelog +## [1.9.0-alpha1] - 2025-12-10 +### Changed +- Moved runtime state to config entry `runtime_data` with a shared + `GooglePollenApiClient` per entry while keeping existing sensor behaviour and + identifiers unchanged. +- Updated sensors, diagnostics, and the `pollenlevels.force_update` service to + read coordinators from runtime data so each entry reuses a single API client + for Google Pollen requests. + ## [1.8.6] - 2025-12-09 ### Changed - Parallelized the `force_update` service to refresh all entry coordinators concurrently diff --git a/custom_components/pollenlevels/__init__.py b/custom_components/pollenlevels/__init__.py index 6fd3b4ba..625f95d6 100644 --- a/custom_components/pollenlevels/__init__.py +++ b/custom_components/pollenlevels/__init__.py @@ -11,15 +11,30 @@ import asyncio import logging from collections.abc import Awaitable -from typing import Any +from typing import Any, cast import homeassistant.helpers.config_validation as cv import voluptuous as vol # Service schema validation from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady - -from .const import DOMAIN +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .client import GooglePollenApiClient +from .const import ( + CONF_API_KEY, + CONF_CREATE_FORECAST_SENSORS, + CONF_FORECAST_DAYS, + CONF_LANGUAGE_CODE, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_UPDATE_INTERVAL, + DEFAULT_FORECAST_DAYS, + DEFAULT_UPDATE_INTERVAL, + DOMAIN, +) +from .runtime import PollenLevelsConfigEntry, PollenLevelsRuntimeData +from .sensor import ForecastSensorMode, PollenDataUpdateCoordinator # Ensure YAML config is entry-only for this domain (no YAML schema). CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -41,7 +56,8 @@ async def handle_force_update_service(call: ServiceCall) -> None: tasks: list[Awaitable[None]] = [] task_entries: list[ConfigEntry] = [] for entry in entries: - coordinator = hass.data.get(DOMAIN, {}).get(entry.entry_id) + runtime = getattr(entry, "runtime_data", None) + coordinator = getattr(runtime, "coordinator", None) if coordinator: _LOGGER.info("Trigger manual refresh for entry %s", entry.entry_id) tasks.append(coordinator.async_refresh()) @@ -64,7 +80,9 @@ async def handle_force_update_service(call: ServiceCall) -> None: return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, entry: PollenLevelsConfigEntry +) -> bool: """Forward config entry to sensor platform and register options listener.""" _LOGGER.debug( "PollenLevels async_setup_entry for entry_id=%s title=%s", @@ -72,6 +90,54 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.title, ) + options = entry.options or {} + hours = int( + options.get( + CONF_UPDATE_INTERVAL, + entry.data.get(CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL), + ) + ) + forecast_days = int( + options.get( + CONF_FORECAST_DAYS, + entry.data.get(CONF_FORECAST_DAYS, DEFAULT_FORECAST_DAYS), + ) + ) + language = options.get(CONF_LANGUAGE_CODE, entry.data.get(CONF_LANGUAGE_CODE)) + mode = options.get(CONF_CREATE_FORECAST_SENSORS, ForecastSensorMode.NONE) + create_d1 = mode in (ForecastSensorMode.D1, ForecastSensorMode.D1_D2) + create_d2 = mode == ForecastSensorMode.D1_D2 + + session = async_get_clientsession(hass) + client = GooglePollenApiClient(session, entry.data[CONF_API_KEY]) + + coordinator = PollenDataUpdateCoordinator( + hass=hass, + api_key=entry.data[CONF_API_KEY], + lat=cast(float, entry.data[CONF_LATITUDE]), + lon=cast(float, entry.data[CONF_LONGITUDE]), + hours=hours, + language=language, + entry_id=entry.entry_id, + entry_title=entry.title, + forecast_days=forecast_days, + create_d1=create_d1, + create_d2=create_d2, + client=client, + ) + + try: + await coordinator.async_config_entry_first_refresh() + except ConfigEntryAuthFailed: + raise + except ConfigEntryNotReady: + raise + except Exception as err: + _LOGGER.exception("Error forwarding entry setups: %s", err) + raise ConfigEntryNotReady from err + + entry.runtime_data = PollenLevelsRuntimeData(coordinator=coordinator, client=client) + try: await hass.config_entries.async_forward_entry_setups(entry, ["sensor"]) except ConfigEntryAuthFailed: @@ -80,7 +146,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise except Exception as err: _LOGGER.exception("Error forwarding entry setups: %s", err) - # Surfaced as ConfigEntryNotReady so HA can retry later. raise ConfigEntryNotReady from err # Ensure options updates (interval/language/forecast settings) trigger reload. @@ -96,8 +161,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: "PollenLevels async_unload_entry called for entry_id=%s", entry.entry_id ) unloaded = await hass.config_entries.async_unload_platforms(entry, ["sensor"]) - if unloaded and DOMAIN in hass.data and entry.entry_id in hass.data[DOMAIN]: - hass.data[DOMAIN].pop(entry.entry_id) return unloaded diff --git a/custom_components/pollenlevels/client.py b/custom_components/pollenlevels/client.py new file mode 100644 index 00000000..748789a4 --- /dev/null +++ b/custom_components/pollenlevels/client.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +import asyncio +import logging +import random +from typing import Any + +from aiohttp import ClientError, ClientSession, ClientTimeout +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import UpdateFailed + +from .util import redact_api_key + +_LOGGER = logging.getLogger(__name__) + + +class GooglePollenApiClient: + """Thin async client wrapper for the Google Pollen API.""" + + def __init__(self, session: ClientSession, api_key: str) -> None: + self._session = session + self._api_key = api_key + + async def async_fetch_pollen_data( + self, + *, + latitude: float, + longitude: float, + days: int, + language_code: str | None, + ) -> dict[str, Any]: + """Perform the HTTP call and return the decoded payload.""" + + url = "https://pollen.googleapis.com/v1/forecast:lookup" + params = { + "key": self._api_key, + "location.latitude": f"{latitude:.6f}", + "location.longitude": f"{longitude:.6f}", + "days": days, + } + if language_code: + params["languageCode"] = language_code + + _LOGGER.debug( + "Fetching forecast (days=%s, lang_set=%s)", days, bool(language_code) + ) + + max_retries = 1 + for attempt in range(0, max_retries + 1): + try: + async with self._session.get( + url, params=params, timeout=ClientTimeout(total=10) + ) as resp: + if resp.status == 403: + raise ConfigEntryAuthFailed("Invalid API key") + + if resp.status == 429: + if attempt < max_retries: + retry_after_raw = resp.headers.get("Retry-After") + delay = 2.0 + if retry_after_raw: + try: + delay = float(retry_after_raw) + except (TypeError, ValueError): + delay = 2.0 + delay = min(delay, 5.0) + random.uniform(0.0, 0.4) + _LOGGER.warning( + "Pollen API 429 — retrying in %.2fs (attempt %d/%d)", + delay, + attempt + 1, + max_retries, + ) + await asyncio.sleep(delay) + continue + raise UpdateFailed("Quota exceeded") + + if 500 <= resp.status <= 599: + if attempt < max_retries: + delay = 0.8 * (2**attempt) + random.uniform(0.0, 0.3) + _LOGGER.warning( + "Pollen API HTTP %s — retrying in %.2fs (attempt %d/%d)", + resp.status, + delay, + attempt + 1, + max_retries, + ) + await asyncio.sleep(delay) + continue + raise UpdateFailed(f"HTTP {resp.status}") + + if 400 <= resp.status < 500 and resp.status not in (403, 429): + raise UpdateFailed(f"HTTP {resp.status}") + + if resp.status != 200: + raise UpdateFailed(f"HTTP {resp.status}") + + return await resp.json() + + except ConfigEntryAuthFailed: + raise + except TimeoutError as err: + if attempt < max_retries: + delay = 0.8 * (2**attempt) + random.uniform(0.0, 0.3) + _LOGGER.warning( + "Pollen API timeout — retrying in %.2fs (attempt %d/%d)", + delay, + attempt + 1, + max_retries, + ) + await asyncio.sleep(delay) + continue + msg = ( + redact_api_key(err, self._api_key) + or "Google Pollen API call timed out" + ) + raise UpdateFailed(f"Timeout: {msg}") from err + except ClientError as err: + if attempt < max_retries: + delay = 0.8 * (2**attempt) + random.uniform(0.0, 0.3) + _LOGGER.warning( + "Network error to Pollen API — retrying in %.2fs (attempt %d/%d)", + delay, + attempt + 1, + max_retries, + ) + await asyncio.sleep(delay) + continue + msg = redact_api_key(err, self._api_key) or ( + "Network error while calling the Google Pollen API" + ) + raise UpdateFailed(msg) from err + except Exception as err: # noqa: BLE001 + msg = redact_api_key(err, self._api_key) + _LOGGER.error("Pollen API error: %s", msg) + raise UpdateFailed(msg) from err + + raise UpdateFailed("Failed to fetch pollen data") diff --git a/custom_components/pollenlevels/diagnostics.py b/custom_components/pollenlevels/diagnostics.py index df7f3270..3eaf6fb3 100644 --- a/custom_components/pollenlevels/diagnostics.py +++ b/custom_components/pollenlevels/diagnostics.py @@ -11,7 +11,7 @@ from __future__ import annotations -from typing import Any +from typing import Any, cast from homeassistant.components.diagnostics import async_redact_data from homeassistant.config_entries import ConfigEntry @@ -26,8 +26,8 @@ CONF_LONGITUDE, CONF_UPDATE_INTERVAL, DEFAULT_FORECAST_DAYS, # use constant instead of magic number - DOMAIN, ) +from .runtime import PollenLevelsRuntimeData from .util import redact_api_key # Redact potentially sensitive values from diagnostics. @@ -57,7 +57,8 @@ async def async_get_config_entry_diagnostics( NOTE: This function must not perform any network I/O. """ - coordinator = hass.data.get(DOMAIN, {}).get(entry.entry_id) + runtime = cast(PollenLevelsRuntimeData | None, getattr(entry, "runtime_data", None)) + coordinator = getattr(runtime, "coordinator", None) options: dict[str, Any] = dict(entry.options or {}) data: dict[str, Any] = dict(entry.data or {}) diff --git a/custom_components/pollenlevels/manifest.json b/custom_components/pollenlevels/manifest.json index 9b5577b6..8d170fbc 100644 --- a/custom_components/pollenlevels/manifest.json +++ b/custom_components/pollenlevels/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "issue_tracker": "https://github.com/eXPerience83/pollenlevels/issues", - "version": "1.8.6" + "version": "1.9.0-alpha1" } diff --git a/custom_components/pollenlevels/runtime.py b/custom_components/pollenlevels/runtime.py new file mode 100644 index 00000000..590003b7 --- /dev/null +++ b/custom_components/pollenlevels/runtime.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from homeassistant.config_entries import ConfigEntry + +if TYPE_CHECKING: + from .client import GooglePollenApiClient + from .sensor import PollenDataUpdateCoordinator + + +@dataclass(slots=True) +class PollenLevelsRuntimeData: + """Runtime container for a Pollen Levels config entry.""" + + coordinator: PollenDataUpdateCoordinator + client: GooglePollenApiClient + + +type PollenLevelsConfigEntry = ConfigEntry[PollenLevelsRuntimeData] diff --git a/custom_components/pollenlevels/sensor.py b/custom_components/pollenlevels/sensor.py index 2e6d5a73..24d2bbe2 100644 --- a/custom_components/pollenlevels/sensor.py +++ b/custom_components/pollenlevels/sensor.py @@ -13,12 +13,10 @@ import asyncio import logging -import random from collections.abc import Awaitable from datetime import date, timedelta # Added `date` for DATE device class native_value -from typing import TYPE_CHECKING, Any - -import aiohttp # For explicit ClientTimeout and ClientError +from enum import StrEnum +from typing import TYPE_CHECKING, Any, cast # Modern sensor base + enums from homeassistant.components.sensor import ( @@ -29,7 +27,6 @@ from homeassistant.const import ATTR_ATTRIBUTION from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import entity_registry as er # entity-registry cleanup -from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, @@ -39,23 +36,17 @@ from homeassistant.util import dt as dt_util if TYPE_CHECKING: - from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from .client import GooglePollenApiClient from .const import ( - CONF_API_KEY, CONF_CREATE_FORECAST_SENSORS, CONF_FORECAST_DAYS, - CONF_LANGUAGE_CODE, - CONF_LATITUDE, - CONF_LONGITUDE, - CONF_UPDATE_INTERVAL, DEFAULT_ENTRY_TITLE, - DEFAULT_FORECAST_DAYS, - DEFAULT_UPDATE_INTERVAL, DOMAIN, ) +from .runtime import PollenLevelsConfigEntry, PollenLevelsRuntimeData from .util import redact_api_key _LOGGER = logging.getLogger(__name__) @@ -72,6 +63,14 @@ DEFAULT_ICON = "mdi:flower-pollen" +class ForecastSensorMode(StrEnum): + """Options for forecast sensor creation.""" + + NONE = "none" + D1 = "D+1" + D1_D2 = "D+1+2" + + def _normalize_channel(v: Any) -> int | None: """Normalize a single channel to 0..255 (accept 0..1 or 0..255 inputs). @@ -186,62 +185,30 @@ def _matches(uid: str, suffix: str) -> bool: async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: PollenLevelsConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Create coordinator and build sensors.""" - api_key = config_entry.data.get(CONF_API_KEY) - if not api_key: - _LOGGER.warning( - "Config entry %s is missing the API key; prompting reauthentication", - config_entry.entry_id, - ) - raise ConfigEntryAuthFailed("Missing API key in config entry") - # Config flow already enforces type and range on coordinates; missing values here - # would indicate a corrupted entry, so we only guard for presence. - lat = config_entry.data.get(CONF_LATITUDE) - lon = config_entry.data.get(CONF_LONGITUDE) - if lat is None or lon is None: - _LOGGER.warning( - "Config entry %s is missing coordinates; delaying setup until entry is complete", - config_entry.entry_id, - ) - raise ConfigEntryNotReady("Missing coordinates in config entry") + runtime = cast( + PollenLevelsRuntimeData | None, getattr(config_entry, "runtime_data", None) + ) + if runtime is None: + raise ConfigEntryNotReady("Runtime data not ready") + coordinator = runtime.coordinator opts = config_entry.options or {} - interval = opts.get( - CONF_UPDATE_INTERVAL, - config_entry.data.get(CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL), - ) - lang = opts.get(CONF_LANGUAGE_CODE, config_entry.data.get(CONF_LANGUAGE_CODE)) - forecast_days = int(opts.get(CONF_FORECAST_DAYS, DEFAULT_FORECAST_DAYS)) + forecast_days = int(opts.get(CONF_FORECAST_DAYS, coordinator.forecast_days)) - # Map unified selector to internal flags - mode = opts.get(CONF_CREATE_FORECAST_SENSORS, "none") - create_d1 = mode == "D+1" or mode == "D+1+2" - create_d2 = mode == "D+1+2" + mode = opts.get(CONF_CREATE_FORECAST_SENSORS, ForecastSensorMode.NONE) + create_d1 = mode in (ForecastSensorMode.D1, ForecastSensorMode.D1_D2) + create_d2 = mode == ForecastSensorMode.D1_D2 - # Decide if per-day entities are allowed *given current options* allow_d1 = create_d1 and forecast_days >= 2 allow_d2 = create_d2 and forecast_days >= 3 raw_title = config_entry.title or "" clean_title = raw_title.strip() or DEFAULT_ENTRY_TITLE - - coordinator = PollenDataUpdateCoordinator( - hass=hass, - api_key=api_key, - lat=lat, - lon=lon, - hours=interval, - language=lang, # normalized in the coordinator - entry_id=config_entry.entry_id, - entry_title=clean_title, - forecast_days=forecast_days, - create_d1=allow_d1, # pass effective flags - create_d2=allow_d2, - ) - await coordinator.async_config_entry_first_refresh() + coordinator.entry_title = clean_title data = coordinator.data or {} has_daily = ("date" in data) or any( @@ -257,8 +224,6 @@ async def async_setup_entry( hass, config_entry.entry_id, allow_d1=allow_d1, allow_d2=allow_d2 ) - hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = coordinator - sensors: list[CoordinatorEntity] = [] for code in coordinator.data: if code in ("region", "date"): @@ -294,6 +259,7 @@ def __init__( forecast_days: int, create_d1: bool, create_d2: bool, + client: GooglePollenApiClient, entry_title: str = DEFAULT_ENTRY_TITLE, ): """Initialize coordinator with configuration and interval.""" @@ -320,10 +286,10 @@ def __init__( self.forecast_days = int(forecast_days) self.create_d1 = create_d1 self.create_d2 = create_d2 + self._client = client self.data: dict[str, dict] = {} self.last_updated = None - self._session = async_get_clientsession(hass) # ------------------------------ # DRY helper for forecast attrs @@ -396,124 +362,21 @@ def _set_convenience(prefix: str, off: int) -> None: async def _async_update_data(self): """Fetch pollen data and extract sensors for current day and forecast.""" - url = "https://pollen.googleapis.com/v1/forecast:lookup" - params = { - "key": self.api_key, - "location.latitude": f"{self.lat:.6f}", - "location.longitude": f"{self.lon:.6f}", - "days": self.forecast_days, - } - if self.language: - params["languageCode"] = self.language - - # SECURITY: Do not log request parameters (avoid coords/key leakage) - _LOGGER.debug( - "Fetching forecast (days=%s, lang_set=%s)", - self.forecast_days, - bool(self.language), - ) - - # --- Minimal, safe retry policy (single retry) ----------------------- - max_retries = 1 # Keep it minimal to reduce cost/latency - for attempt in range(0, max_retries + 1): - try: - # Explicit total timeout for network call - async with self._session.get( - url, params=params, timeout=aiohttp.ClientTimeout(total=10) - ) as resp: - # Non-retryable auth logic first - if resp.status == 403: - raise ConfigEntryAuthFailed("Invalid API key") - - # 429: may be transient — respect Retry-After if present - if resp.status == 429: - if attempt < max_retries: - retry_after_raw = resp.headers.get("Retry-After") - delay = 2.0 - if retry_after_raw: - try: - delay = float(retry_after_raw) - except (TypeError, ValueError): - delay = 2.0 - # Cap delay and add small jitter to avoid herding - delay = min(delay, 5.0) + random.uniform(0.0, 0.4) - _LOGGER.warning( - "Pollen API 429 — retrying in %.2fs (attempt %d/%d)", - delay, - attempt + 1, - max_retries, - ) - await asyncio.sleep(delay) - continue - raise UpdateFailed("Quota exceeded") - - # 5xx -> retry once with short backoff - if 500 <= resp.status <= 599: - if attempt < max_retries: - delay = 0.8 * (2**attempt) + random.uniform(0.0, 0.3) - _LOGGER.warning( - "Pollen API HTTP %s — retrying in %.2fs (attempt %d/%d)", - resp.status, - delay, - attempt + 1, - max_retries, - ) - await asyncio.sleep(delay) - continue - raise UpdateFailed(f"HTTP {resp.status}") - - # Other 4xx (client errors except 403/429) are not retried - if 400 <= resp.status < 500 and resp.status not in (403, 429): - raise UpdateFailed(f"HTTP {resp.status}") - - if resp.status != 200: - raise UpdateFailed(f"HTTP {resp.status}") - - payload = await resp.json() - break # exit retry loop on success - - except ConfigEntryAuthFailed: - raise - except TimeoutError as err: - # Catch built-in TimeoutError; on modern Python (3.11+) this also - # covers asyncio.TimeoutError. - if attempt < max_retries: - delay = 0.8 * (2**attempt) + random.uniform(0.0, 0.3) - _LOGGER.warning( - "Pollen API timeout — retrying in %.2fs (attempt %d/%d)", - delay, - attempt + 1, - max_retries, - ) - await asyncio.sleep(delay) - continue - msg = redact_api_key(err, self.api_key) - if not msg: - msg = "Google Pollen API call timed out" - raise UpdateFailed(f"Timeout: {msg}") from err - - except aiohttp.ClientError as err: - # Transient client-side issues (DNS reset, connector errors, etc.) - if attempt < max_retries: - delay = 0.8 * (2**attempt) + random.uniform(0.0, 0.3) - _LOGGER.warning( - "Network error to Pollen API — retrying in %.2fs (attempt %d/%d)", - delay, - attempt + 1, - max_retries, - ) - await asyncio.sleep(delay) - continue - msg = redact_api_key(err, self.api_key) - if not msg: - msg = "Network error while calling the Google Pollen API" - raise UpdateFailed(msg) from err - - except Exception as err: # Keep previous behavior for unexpected errors - msg = redact_api_key(err, self.api_key) - _LOGGER.error("Pollen API error: %s", msg) - raise UpdateFailed(msg) from err - # -------------------------------------------------------------------- + try: + payload = await self._client.async_fetch_pollen_data( + latitude=self.lat, + longitude=self.lon, + days=self.forecast_days, + language_code=self.language, + ) + except ConfigEntryAuthFailed: + raise + except UpdateFailed: + raise + except Exception as err: # Keep previous behavior for unexpected errors + msg = redact_api_key(err, self.api_key) + _LOGGER.error("Pollen API error: %s", msg) + raise UpdateFailed(msg) from err new_data: dict[str, dict] = {} diff --git a/pyproject.toml b/pyproject.toml index fc1f494a..c7062f7c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ [project] name = "pollenlevels" -version = "1.8.6" +version = "1.9.0-alpha1" # Enforce the runtime floor aligned with upcoming HA Python 3.14 images. requires-python = ">=3.14" diff --git a/tests/test_init.py b/tests/test_init.py index 4ac49a95..332bb730 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -34,6 +34,10 @@ class _StubServiceCall: # pragma: no cover - structure only core_mod.ServiceCall = _StubServiceCall sys.modules.setdefault("homeassistant.core", core_mod) +aiohttp_client_mod = types.ModuleType("homeassistant.helpers.aiohttp_client") +aiohttp_client_mod.async_get_clientsession = lambda _hass: None +sys.modules.setdefault("homeassistant.helpers.aiohttp_client", aiohttp_client_mod) + cv_mod = sys.modules["homeassistant.helpers.config_validation"] cv_mod.config_entry_only_config_schema = lambda _domain: lambda config: config @@ -57,6 +61,36 @@ class _StubConfigEntryAuthFailed(Exception): exceptions_mod.ConfigEntryAuthFailed = _StubConfigEntryAuthFailed +update_coordinator_mod = types.ModuleType("homeassistant.helpers.update_coordinator") + + +class _StubUpdateFailed(Exception): + pass + + +class _StubDataUpdateCoordinator: + def __init__(self, hass, logger, *, name: str, update_interval): + self.hass = hass + self.logger = logger + self.name = name + self.update_interval = update_interval + self.data = {"date": {}, "region": {}} + self.last_updated = None + + async def async_config_entry_first_refresh(self): + self.last_updated = "now" + return None + + async def async_refresh(self): + return None + + +update_coordinator_mod.DataUpdateCoordinator = _StubDataUpdateCoordinator +update_coordinator_mod.UpdateFailed = _StubUpdateFailed +sys.modules.setdefault( + "homeassistant.helpers.update_coordinator", update_coordinator_mod +) + integration = importlib.import_module( "custom_components.pollenlevels.__init__" ) # noqa: E402 @@ -88,10 +122,24 @@ async def async_reload(self, entry_id: str): # pragma: no cover - used in tests class _FakeEntry: - def __init__(self, *, entry_id: str = "entry-1", title: str = "Pollen Levels"): + def __init__( + self, + *, + entry_id: str = "entry-1", + title: str = "Pollen Levels", + data: dict | None = None, + options: dict | None = None, + ): self.entry_id = entry_id self.title = title self._update_listener = None + self.data = data or { + integration.CONF_API_KEY: "key", + integration.CONF_LATITUDE: 1.0, + integration.CONF_LONGITUDE: 2.0, + } + self.options = options or {} + self.runtime_data = None def add_update_listener(self, listener): self._update_listener = listener @@ -137,7 +185,37 @@ def test_setup_entry_success_and_unload() -> None: hass = _FakeHass() entry = _FakeEntry() - hass.data[integration.DOMAIN] = {entry.entry_id: "coordinator"} + + class _StubClient: + def __init__(self, _session, _api_key): + self.session = _session + self.api_key = _api_key + + async def async_fetch_pollen_data(self, **_kwargs): + return {"region": {"source": "meta"}, "dailyInfo": []} + + class _StubCoordinator(update_coordinator_mod.DataUpdateCoordinator): + def __init__(self, *args, **kwargs): + self.api_key = kwargs["api_key"] + self.lat = kwargs["lat"] + self.lon = kwargs["lon"] + self.forecast_days = kwargs["forecast_days"] + self.language = kwargs["language"] + self.create_d1 = kwargs["create_d1"] + self.create_d2 = kwargs["create_d2"] + self.entry_id = kwargs["entry_id"] + self.entry_title = kwargs.get("entry_title") + self.last_updated = None + self.data = {"region": {"source": "meta"}, "date": {"source": "meta"}} + + async def async_config_entry_first_refresh(self): + return None + + async def async_refresh(self): + return None + + integration.GooglePollenApiClient = _StubClient + integration.PollenDataUpdateCoordinator = _StubCoordinator assert asyncio.run(integration.async_setup_entry(hass, entry)) is True @@ -145,9 +223,11 @@ def test_setup_entry_success_and_unload() -> None: assert entry._update_listener is integration._update_listener # noqa: SLF001 assert entry._on_unload is entry._update_listener # noqa: SLF001 + assert entry.runtime_data is not None + assert entry.runtime_data.coordinator.entry_id == entry.entry_id + asyncio.run(entry._update_listener(hass, entry)) # noqa: SLF001 assert hass.config_entries.reload_calls == [entry.entry_id] assert asyncio.run(integration.async_unload_entry(hass, entry)) is True assert hass.config_entries.unload_calls == [(entry, ["sensor"])] - assert hass.data[integration.DOMAIN] == {} diff --git a/tests/test_sensor.py b/tests/test_sensor.py index e00a2740..4917de67 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -246,6 +246,7 @@ def __init__( self.data = data self.options = options or {} self.entry_id = entry_id + self.runtime_data = None class FakeResponse: @@ -388,7 +389,7 @@ def test_type_sensor_preserves_source_with_single_day( } fake_session = FakeSession(payload) - monkeypatch.setattr(sensor, "async_get_clientsession", lambda _hass: fake_session) + client = sensor.GooglePollenApiClient(fake_session, "test") loop = asyncio.new_event_loop() hass = DummyHass(loop) @@ -403,6 +404,7 @@ def test_type_sensor_preserves_source_with_single_day( forecast_days=1, create_d1=False, create_d2=False, + client=client, ) try: @@ -469,7 +471,7 @@ def test_type_sensor_uses_forecast_metadata_when_today_missing( } fake_session = FakeSession(payload) - monkeypatch.setattr(sensor, "async_get_clientsession", lambda _hass: fake_session) + client = sensor.GooglePollenApiClient(fake_session, "test") loop = asyncio.new_event_loop() hass = DummyHass(loop) @@ -484,6 +486,7 @@ def test_type_sensor_uses_forecast_metadata_when_today_missing( forecast_days=5, create_d1=False, create_d2=False, + client=client, ) try: @@ -578,7 +581,7 @@ def test_plant_sensor_includes_forecast_attributes( } fake_session = FakeSession(payload) - monkeypatch.setattr(sensor, "async_get_clientsession", lambda _hass: fake_session) + client = sensor.GooglePollenApiClient(fake_session, "test") loop = asyncio.new_event_loop() hass = DummyHass(loop) @@ -593,6 +596,7 @@ def test_plant_sensor_includes_forecast_attributes( forecast_days=5, create_d1=False, create_d2=False, + client=client, ) try: @@ -667,7 +671,7 @@ def test_coordinator_raises_auth_failed(monkeypatch: pytest.MonkeyPatch) -> None """A 403 response triggers ConfigEntryAuthFailed for re-auth flows.""" fake_session = FakeSession({}, status=403) - monkeypatch.setattr(sensor, "async_get_clientsession", lambda _hass: fake_session) + client = sensor.GooglePollenApiClient(fake_session, "bad") loop = asyncio.new_event_loop() hass = DummyHass(loop) @@ -682,6 +686,7 @@ def test_coordinator_raises_auth_failed(monkeypatch: pytest.MonkeyPatch) -> None forecast_days=1, create_d1=False, create_d2=False, + client=client, ) try: @@ -709,7 +714,8 @@ async def _fast_sleep(delay: float) -> None: monkeypatch.setattr(sensor.asyncio, "sleep", _fast_sleep) monkeypatch.setattr(sensor.random, "uniform", lambda *_args, **_kwargs: 0.0) - monkeypatch.setattr(sensor, "async_get_clientsession", lambda _hass: session) + + client = sensor.GooglePollenApiClient(session, "test") loop = asyncio.new_event_loop() hass = DummyHass(loop) @@ -724,6 +730,7 @@ async def _fast_sleep(delay: float) -> None: forecast_days=1, create_d1=False, create_d2=False, + client=client, ) try: @@ -751,7 +758,8 @@ async def _fast_sleep(delay: float) -> None: monkeypatch.setattr(sensor.asyncio, "sleep", _fast_sleep) monkeypatch.setattr(sensor.random, "uniform", lambda *_args, **_kwargs: 0.0) - monkeypatch.setattr(sensor, "async_get_clientsession", lambda _hass: session) + + client = sensor.GooglePollenApiClient(session, "test") loop = asyncio.new_event_loop() hass = DummyHass(loop) @@ -766,6 +774,7 @@ async def _fast_sleep(delay: float) -> None: forecast_days=1, create_d1=False, create_d2=False, + client=client, ) try: @@ -791,7 +800,8 @@ async def _fast_sleep(delay: float) -> None: monkeypatch.setattr(sensor.asyncio, "sleep", _fast_sleep) monkeypatch.setattr(sensor.random, "uniform", lambda *_args, **_kwargs: 0.0) - monkeypatch.setattr(sensor, "async_get_clientsession", lambda _hass: session) + + client = sensor.GooglePollenApiClient(session, "test") loop = asyncio.new_event_loop() hass = DummyHass(loop) @@ -806,6 +816,7 @@ async def _fast_sleep(delay: float) -> None: forecast_days=1, create_d1=False, create_d2=False, + client=client, ) try: @@ -833,7 +844,8 @@ async def _fast_sleep(delay: float) -> None: monkeypatch.setattr(sensor.asyncio, "sleep", _fast_sleep) monkeypatch.setattr(sensor.random, "uniform", lambda *_args, **_kwargs: 0.0) - monkeypatch.setattr(sensor, "async_get_clientsession", lambda _hass: session) + + client = sensor.GooglePollenApiClient(session, "secret") loop = asyncio.new_event_loop() hass = DummyHass(loop) @@ -848,6 +860,7 @@ async def _fast_sleep(delay: float) -> None: forecast_days=1, create_d1=False, create_d2=False, + client=client, ) try: @@ -878,7 +891,7 @@ async def _noop_add_entities(_entities, _update_before_add=False): return None try: - with pytest.raises(sensor.ConfigEntryAuthFailed): + with pytest.raises(sensor.ConfigEntryNotReady): loop.run_until_complete( sensor.async_setup_entry(hass, config_entry, _noop_add_entities) ) @@ -892,16 +905,6 @@ async def test_device_info_uses_default_title_when_blank( ) -> None: """Whitespace titles fall back to the default in translation placeholders.""" - async def _stub_first_refresh(self): # type: ignore[override] - self.data = {"date": {"source": "meta"}, "region": {"source": "meta"}} - - monkeypatch.setattr( - sensor.PollenDataUpdateCoordinator, - "async_config_entry_first_refresh", - _stub_first_refresh, - ) - monkeypatch.setattr(sensor, "async_get_clientsession", lambda _hass: None) - hass = DummyHass(asyncio.get_running_loop()) config_entry = FakeConfigEntry( data={ @@ -915,6 +918,26 @@ async def _stub_first_refresh(self): # type: ignore[override] ) config_entry.title = " " + client = sensor.GooglePollenApiClient(FakeSession({}), "key") + coordinator = sensor.PollenDataUpdateCoordinator( + hass=hass, + api_key="key", + lat=1.0, + lon=2.0, + hours=sensor.DEFAULT_UPDATE_INTERVAL, + language=None, + entry_id="entry", + entry_title=config_entry.title, + forecast_days=sensor.DEFAULT_FORECAST_DAYS, + create_d1=False, + create_d2=False, + client=client, + ) + coordinator.data = {"date": {"source": "meta"}, "region": {"source": "meta"}} + config_entry.runtime_data = sensor.PollenLevelsRuntimeData( + coordinator=coordinator, client=client + ) + captured: list[Any] = [] def _capture_entities(entities, _update_before_add=False): @@ -936,16 +959,6 @@ async def test_device_info_trims_custom_title( ) -> None: """Custom titles are trimmed before reaching translation placeholders.""" - async def _stub_first_refresh(self): # type: ignore[override] - self.data = {"date": {"source": "meta"}, "region": {"source": "meta"}} - - monkeypatch.setattr( - sensor.PollenDataUpdateCoordinator, - "async_config_entry_first_refresh", - _stub_first_refresh, - ) - monkeypatch.setattr(sensor, "async_get_clientsession", lambda _hass: None) - hass = DummyHass(asyncio.get_running_loop()) config_entry = FakeConfigEntry( data={ @@ -959,6 +972,26 @@ async def _stub_first_refresh(self): # type: ignore[override] ) config_entry.title = " My Location " + client = sensor.GooglePollenApiClient(FakeSession({}), "key") + coordinator = sensor.PollenDataUpdateCoordinator( + hass=hass, + api_key="key", + lat=1.0, + lon=2.0, + hours=sensor.DEFAULT_UPDATE_INTERVAL, + language=None, + entry_id="entry", + entry_title=config_entry.title, + forecast_days=sensor.DEFAULT_FORECAST_DAYS, + create_d1=False, + create_d2=False, + client=client, + ) + coordinator.data = {"date": {"source": "meta"}, "region": {"source": "meta"}} + config_entry.runtime_data = sensor.PollenLevelsRuntimeData( + coordinator=coordinator, client=client + ) + captured: list[Any] = [] def _capture_entities(entities, _update_before_add=False): From b8d97dbbaf13a8b96a9c00342d0937605fb01646 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Wed, 10 Dec 2025 09:23:09 +0100 Subject: [PATCH 002/200] Fix test stubs and diagnostics for runtime data refactor --- custom_components/pollenlevels/runtime.py | 5 +- custom_components/pollenlevels/sensor.py | 31 ++++++- tests/test_init.py | 106 ++++++++++++++++++++++ tests/test_sensor.py | 23 +++-- 4 files changed, 156 insertions(+), 9 deletions(-) diff --git a/custom_components/pollenlevels/runtime.py b/custom_components/pollenlevels/runtime.py index 590003b7..13082e4a 100644 --- a/custom_components/pollenlevels/runtime.py +++ b/custom_components/pollenlevels/runtime.py @@ -18,4 +18,7 @@ class PollenLevelsRuntimeData: client: GooglePollenApiClient -type PollenLevelsConfigEntry = ConfigEntry[PollenLevelsRuntimeData] +if TYPE_CHECKING: + PollenLevelsConfigEntry = ConfigEntry[PollenLevelsRuntimeData] +else: + PollenLevelsConfigEntry = ConfigEntry diff --git a/custom_components/pollenlevels/sensor.py b/custom_components/pollenlevels/sensor.py index 24d2bbe2..314aef3e 100644 --- a/custom_components/pollenlevels/sensor.py +++ b/custom_components/pollenlevels/sensor.py @@ -41,9 +41,15 @@ from .client import GooglePollenApiClient from .const import ( + CONF_API_KEY, CONF_CREATE_FORECAST_SENSORS, CONF_FORECAST_DAYS, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_UPDATE_INTERVAL, DEFAULT_ENTRY_TITLE, + DEFAULT_FORECAST_DAYS, + DEFAULT_UPDATE_INTERVAL, DOMAIN, ) from .runtime import PollenLevelsConfigEntry, PollenLevelsRuntimeData @@ -51,6 +57,15 @@ _LOGGER = logging.getLogger(__name__) +__all__ = [ + "CONF_API_KEY", + "CONF_LATITUDE", + "CONF_LONGITUDE", + "CONF_UPDATE_INTERVAL", + "DEFAULT_FORECAST_DAYS", + "DEFAULT_UPDATE_INTERVAL", +] + # ---- Icons --------------------------------------------------------------- TYPE_ICONS = { @@ -239,7 +254,9 @@ async def async_setup_entry( ) _LOGGER.debug( - "Creating %d sensors: %s", len(sensors), [s.unique_id for s in sensors] + "Creating %d sensors: %s", + len(sensors), + [getattr(s, "unique_id", None) for s in sensors], ) async_add_entities(sensors, True) @@ -803,6 +820,18 @@ def device_info(self): class _BaseMetaSensor(CoordinatorEntity, SensorEntity): """Provide base for metadata sensors.""" + @property + def unique_id(self) -> str | None: + """Return the cached unique ID.""" + + return getattr(self, "_attr_unique_id", None) + + @property + def device_info(self) -> dict[str, Any] | None: + """Return cached device info for diagnostics.""" + + return getattr(self, "_attr_device_info", None) + def __init__(self, coordinator: PollenDataUpdateCoordinator): """Initialize metadata sensor. diff --git a/tests/test_init.py b/tests/test_init.py index 332bb730..82dc03df 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -34,10 +34,63 @@ class _StubServiceCall: # pragma: no cover - structure only core_mod.ServiceCall = _StubServiceCall sys.modules.setdefault("homeassistant.core", core_mod) +ha_components_mod = sys.modules.get("homeassistant.components") or types.ModuleType( + "homeassistant.components" +) +sys.modules["homeassistant.components"] = ha_components_mod + +sensor_mod = types.ModuleType("homeassistant.components.sensor") + + +class _StubSensorEntity: # pragma: no cover - structure only + pass + + +class _StubSensorDeviceClass: # pragma: no cover - structure only + DATE = "date" + TIMESTAMP = "timestamp" + + +class _StubSensorStateClass: # pragma: no cover - structure only + MEASUREMENT = "measurement" + + +sensor_mod.SensorEntity = _StubSensorEntity +sensor_mod.SensorDeviceClass = _StubSensorDeviceClass +sensor_mod.SensorStateClass = _StubSensorStateClass +sys.modules.setdefault("homeassistant.components.sensor", sensor_mod) + +const_mod = sys.modules.get("homeassistant.const") or types.ModuleType( + "homeassistant.const" +) +const_mod.ATTR_ATTRIBUTION = "Attribution" +sys.modules["homeassistant.const"] = const_mod + aiohttp_client_mod = types.ModuleType("homeassistant.helpers.aiohttp_client") aiohttp_client_mod.async_get_clientsession = lambda _hass: None sys.modules.setdefault("homeassistant.helpers.aiohttp_client", aiohttp_client_mod) +aiohttp_mod = sys.modules.get("aiohttp") or types.ModuleType("aiohttp") + + +class _StubClientError(Exception): + pass + + +class _StubClientSession: # pragma: no cover - structure only + pass + + +class _StubClientTimeout: + def __init__(self, total: float | None = None): + self.total = total + + +aiohttp_mod.ClientError = _StubClientError +aiohttp_mod.ClientSession = _StubClientSession +aiohttp_mod.ClientTimeout = _StubClientTimeout +sys.modules["aiohttp"] = aiohttp_mod + cv_mod = sys.modules["homeassistant.helpers.config_validation"] cv_mod.config_entry_only_config_schema = lambda _domain: lambda config: config @@ -45,6 +98,53 @@ class _StubServiceCall: # pragma: no cover - structure only if not hasattr(vol_mod, "Schema"): vol_mod.Schema = lambda *args, **kwargs: None +helpers_mod = sys.modules.get("homeassistant.helpers") or types.ModuleType( + "homeassistant.helpers" +) +sys.modules["homeassistant.helpers"] = helpers_mod + +entity_registry_mod = types.ModuleType("homeassistant.helpers.entity_registry") + + +def _stub_async_get(_hass): # pragma: no cover - structure only + class _Registry: + @staticmethod + def async_entries_for_config_entry(_registry, _entry_id): + return [] + + return _Registry() + + +entity_registry_mod.async_get = _stub_async_get +entity_registry_mod.async_entries_for_config_entry = lambda *args, **kwargs: [] +sys.modules.setdefault("homeassistant.helpers.entity_registry", entity_registry_mod) + +entity_mod = types.ModuleType("homeassistant.helpers.entity") + + +class _StubEntityCategory: + DIAGNOSTIC = "diagnostic" + + +entity_mod.EntityCategory = _StubEntityCategory +sys.modules.setdefault("homeassistant.helpers.entity", entity_mod) + +dt_mod = types.ModuleType("homeassistant.util.dt") + + +def _stub_utcnow(): + from datetime import UTC, datetime + + return datetime.now(UTC) + + +dt_mod.utcnow = _stub_utcnow +sys.modules.setdefault("homeassistant.util.dt", dt_mod) + +util_mod = types.ModuleType("homeassistant.util") +util_mod.dt = dt_mod +sys.modules.setdefault("homeassistant.util", util_mod) + exceptions_mod = sys.modules.setdefault( "homeassistant.exceptions", types.ModuleType("homeassistant.exceptions") ) @@ -68,6 +168,11 @@ class _StubUpdateFailed(Exception): pass +class _StubCoordinatorEntity: + def __init__(self, coordinator): + self.coordinator = coordinator + + class _StubDataUpdateCoordinator: def __init__(self, hass, logger, *, name: str, update_interval): self.hass = hass @@ -87,6 +192,7 @@ async def async_refresh(self): update_coordinator_mod.DataUpdateCoordinator = _StubDataUpdateCoordinator update_coordinator_mod.UpdateFailed = _StubUpdateFailed +update_coordinator_mod.CoordinatorEntity = _StubCoordinatorEntity sys.modules.setdefault( "homeassistant.helpers.update_coordinator", update_coordinator_mod ) diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 4917de67..7f1e617f 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -193,21 +193,26 @@ def _stub_utcnow(): util_mod.dt = dt_mod sys.modules.setdefault("homeassistant.util", util_mod) -aiohttp_mod = types.ModuleType("aiohttp") +aiohttp_mod = sys.modules.get("aiohttp") or types.ModuleType("aiohttp") class _StubClientError(Exception): pass +class _StubClientSession: # pragma: no cover - structure only + pass + + class _StubClientTimeout: def __init__(self, total: float | None = None): self.total = total aiohttp_mod.ClientError = _StubClientError +aiohttp_mod.ClientSession = _StubClientSession aiohttp_mod.ClientTimeout = _StubClientTimeout -sys.modules.setdefault("aiohttp", aiohttp_mod) +sys.modules["aiohttp"] = aiohttp_mod def _load_module(module_name: str, relative_path: str): @@ -223,6 +228,7 @@ def _load_module(module_name: str, relative_path: str): _load_module("custom_components.pollenlevels.const", "const.py") sensor = _load_module("custom_components.pollenlevels.sensor", "sensor.py") +client_mod = importlib.import_module("custom_components.pollenlevels.client") class DummyHass: @@ -713,7 +719,7 @@ async def _fast_sleep(delay: float) -> None: delays.append(delay) monkeypatch.setattr(sensor.asyncio, "sleep", _fast_sleep) - monkeypatch.setattr(sensor.random, "uniform", lambda *_args, **_kwargs: 0.0) + monkeypatch.setattr(client_mod.random, "uniform", lambda *_args, **_kwargs: 0.0) client = sensor.GooglePollenApiClient(session, "test") @@ -757,7 +763,7 @@ async def _fast_sleep(delay: float) -> None: delays.append(delay) monkeypatch.setattr(sensor.asyncio, "sleep", _fast_sleep) - monkeypatch.setattr(sensor.random, "uniform", lambda *_args, **_kwargs: 0.0) + monkeypatch.setattr(client_mod.random, "uniform", lambda *_args, **_kwargs: 0.0) client = sensor.GooglePollenApiClient(session, "test") @@ -799,7 +805,7 @@ async def _fast_sleep(delay: float) -> None: delays.append(delay) monkeypatch.setattr(sensor.asyncio, "sleep", _fast_sleep) - monkeypatch.setattr(sensor.random, "uniform", lambda *_args, **_kwargs: 0.0) + monkeypatch.setattr(client_mod.random, "uniform", lambda *_args, **_kwargs: 0.0) client = sensor.GooglePollenApiClient(session, "test") @@ -835,7 +841,10 @@ def test_coordinator_retries_then_wraps_client_error( """Client errors retry once then raise UpdateFailed with redacted message.""" session = SequenceSession( - [sensor.aiohttp.ClientError("net down"), sensor.aiohttp.ClientError("net down")] + [ + client_mod.ClientError("net down"), + client_mod.ClientError("net down"), + ] ) delays: list[float] = [] @@ -843,7 +852,7 @@ async def _fast_sleep(delay: float) -> None: delays.append(delay) monkeypatch.setattr(sensor.asyncio, "sleep", _fast_sleep) - monkeypatch.setattr(sensor.random, "uniform", lambda *_args, **_kwargs: 0.0) + monkeypatch.setattr(client_mod.random, "uniform", lambda *_args, **_kwargs: 0.0) client = sensor.GooglePollenApiClient(session, "secret") From aca99e4f1cafd1765effcac50e6ccae47219b6be Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Wed, 10 Dec 2025 09:23:16 +0100 Subject: [PATCH 003/200] Validate API key and normalize titles during setup --- CHANGELOG.md | 4 ++++ custom_components/pollenlevels/__init__.py | 14 +++++++++++--- custom_components/pollenlevels/sensor.py | 4 ---- tests/test_sensor.py | 6 ++++-- 4 files changed, 19 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 22b960a3..de4fc0d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ - Updated sensors, diagnostics, and the `pollenlevels.force_update` service to read coordinators from runtime data so each entry reuses a single API client for Google Pollen requests. +- Restored API key validation during setup to raise `ConfigEntryAuthFailed` + when the key is missing instead of retrying endlessly. +- Centralized config entry title normalization during setup so the cleaned + device titles are reused across all sensors. ## [1.8.6] - 2025-12-09 ### Changed diff --git a/custom_components/pollenlevels/__init__.py b/custom_components/pollenlevels/__init__.py index 625f95d6..534f6293 100644 --- a/custom_components/pollenlevels/__init__.py +++ b/custom_components/pollenlevels/__init__.py @@ -29,6 +29,7 @@ CONF_LATITUDE, CONF_LONGITUDE, CONF_UPDATE_INTERVAL, + DEFAULT_ENTRY_TITLE, DEFAULT_FORECAST_DAYS, DEFAULT_UPDATE_INTERVAL, DOMAIN, @@ -108,18 +109,25 @@ async def async_setup_entry( create_d1 = mode in (ForecastSensorMode.D1, ForecastSensorMode.D1_D2) create_d2 = mode == ForecastSensorMode.D1_D2 + api_key = entry.data.get(CONF_API_KEY) + if not api_key: + raise ConfigEntryAuthFailed("Missing API key") + + raw_title = entry.title or "" + clean_title = raw_title.strip() or DEFAULT_ENTRY_TITLE + session = async_get_clientsession(hass) - client = GooglePollenApiClient(session, entry.data[CONF_API_KEY]) + client = GooglePollenApiClient(session, api_key) coordinator = PollenDataUpdateCoordinator( hass=hass, - api_key=entry.data[CONF_API_KEY], + api_key=api_key, lat=cast(float, entry.data[CONF_LATITUDE]), lon=cast(float, entry.data[CONF_LONGITUDE]), hours=hours, language=language, entry_id=entry.entry_id, - entry_title=entry.title, + entry_title=clean_title, forecast_days=forecast_days, create_d1=create_d1, create_d2=create_d2, diff --git a/custom_components/pollenlevels/sensor.py b/custom_components/pollenlevels/sensor.py index 314aef3e..6e0d7158 100644 --- a/custom_components/pollenlevels/sensor.py +++ b/custom_components/pollenlevels/sensor.py @@ -221,10 +221,6 @@ async def async_setup_entry( allow_d1 = create_d1 and forecast_days >= 2 allow_d2 = create_d2 and forecast_days >= 3 - raw_title = config_entry.title or "" - clean_title = raw_title.strip() or DEFAULT_ENTRY_TITLE - coordinator.entry_title = clean_title - data = coordinator.data or {} has_daily = ("date" in data) or any( key.startswith(("type_", "plants_")) for key in data diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 7f1e617f..cbffcb4c 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -928,6 +928,7 @@ async def test_device_info_uses_default_title_when_blank( config_entry.title = " " client = sensor.GooglePollenApiClient(FakeSession({}), "key") + clean_title = sensor.DEFAULT_ENTRY_TITLE coordinator = sensor.PollenDataUpdateCoordinator( hass=hass, api_key="key", @@ -936,7 +937,7 @@ async def test_device_info_uses_default_title_when_blank( hours=sensor.DEFAULT_UPDATE_INTERVAL, language=None, entry_id="entry", - entry_title=config_entry.title, + entry_title=clean_title, forecast_days=sensor.DEFAULT_FORECAST_DAYS, create_d1=False, create_d2=False, @@ -982,6 +983,7 @@ async def test_device_info_trims_custom_title( config_entry.title = " My Location " client = sensor.GooglePollenApiClient(FakeSession({}), "key") + clean_title = config_entry.title.strip() coordinator = sensor.PollenDataUpdateCoordinator( hass=hass, api_key="key", @@ -990,7 +992,7 @@ async def test_device_info_trims_custom_title( hours=sensor.DEFAULT_UPDATE_INTERVAL, language=None, entry_id="entry", - entry_title=config_entry.title, + entry_title=clean_title, forecast_days=sensor.DEFAULT_FORECAST_DAYS, create_d1=False, create_d2=False, From 0e25be5b04b15d4dc8bc4e48b3f424019b2fb446 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Thu, 11 Dec 2025 08:13:59 +0100 Subject: [PATCH 004/200] Clamp forecast days and add regression tests --- custom_components/pollenlevels/__init__.py | 2 +- custom_components/pollenlevels/sensor.py | 18 ++++++-- tests/test_sensor.py | 54 ++++++++++++++++++++++ 3 files changed, 70 insertions(+), 4 deletions(-) diff --git a/custom_components/pollenlevels/__init__.py b/custom_components/pollenlevels/__init__.py index 534f6293..44342ccb 100644 --- a/custom_components/pollenlevels/__init__.py +++ b/custom_components/pollenlevels/__init__.py @@ -141,7 +141,7 @@ async def async_setup_entry( except ConfigEntryNotReady: raise except Exception as err: - _LOGGER.exception("Error forwarding entry setups: %s", err) + _LOGGER.exception("Error during initial data refresh: %s", err) raise ConfigEntryNotReady from err entry.runtime_data = PollenLevelsRuntimeData(coordinator=coordinator, client=client) diff --git a/custom_components/pollenlevels/sensor.py b/custom_components/pollenlevels/sensor.py index 6e0d7158..bcc67f36 100644 --- a/custom_components/pollenlevels/sensor.py +++ b/custom_components/pollenlevels/sensor.py @@ -51,6 +51,8 @@ DEFAULT_FORECAST_DAYS, DEFAULT_UPDATE_INTERVAL, DOMAIN, + MAX_FORECAST_DAYS, + MIN_FORECAST_DAYS, ) from .runtime import PollenLevelsConfigEntry, PollenLevelsRuntimeData from .util import redact_api_key @@ -214,7 +216,15 @@ async def async_setup_entry( opts = config_entry.options or {} forecast_days = int(opts.get(CONF_FORECAST_DAYS, coordinator.forecast_days)) - mode = opts.get(CONF_CREATE_FORECAST_SENSORS, ForecastSensorMode.NONE) + mode_raw = opts.get(CONF_CREATE_FORECAST_SENSORS, ForecastSensorMode.NONE) + try: + mode = ( + mode_raw + if isinstance(mode_raw, ForecastSensorMode) + else ForecastSensorMode(mode_raw) + ) + except ValueError: + mode = ForecastSensorMode.NONE create_d1 = mode in (ForecastSensorMode.D1, ForecastSensorMode.D1_D2) create_d2 = mode == ForecastSensorMode.D1_D2 @@ -295,8 +305,10 @@ def __init__( self.entry_id = entry_id self.entry_title = entry_title or DEFAULT_ENTRY_TITLE - # Options flow restricts this range; no runtime clamping needed. - self.forecast_days = int(forecast_days) + # Clamp defensively for legacy/manual entries to supported range. + self.forecast_days = max( + MIN_FORECAST_DAYS, min(MAX_FORECAST_DAYS, int(forecast_days)) + ) self.create_d1 = create_d1 self.create_d2 = create_d2 self._client = client diff --git a/tests/test_sensor.py b/tests/test_sensor.py index cbffcb4c..5edc93a6 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -427,6 +427,60 @@ def test_type_sensor_preserves_source_with_single_day( assert entry["tomorrow_value"] is None +def test_coordinator_clamps_forecast_days_low() -> None: + """Forecast days are clamped to minimum for legacy or invalid values.""" + + loop = asyncio.new_event_loop() + hass = DummyHass(loop) + client = sensor.GooglePollenApiClient(FakeSession({}), "test") + + try: + coordinator = sensor.PollenDataUpdateCoordinator( + hass=hass, + api_key="test", + lat=1.0, + lon=2.0, + hours=12, + language=None, + entry_id="entry", + forecast_days=0, + create_d1=False, + create_d2=False, + client=client, + ) + finally: + loop.close() + + assert coordinator.forecast_days == sensor.MIN_FORECAST_DAYS + + +def test_coordinator_clamps_forecast_days_high() -> None: + """Forecast days are clamped to maximum for legacy or invalid values.""" + + loop = asyncio.new_event_loop() + hass = DummyHass(loop) + client = sensor.GooglePollenApiClient(FakeSession({}), "test") + + try: + coordinator = sensor.PollenDataUpdateCoordinator( + hass=hass, + api_key="test", + lat=1.0, + lon=2.0, + hours=12, + language=None, + entry_id="entry", + forecast_days=10, + create_d1=False, + create_d2=False, + client=client, + ) + finally: + loop.close() + + assert coordinator.forecast_days == sensor.MAX_FORECAST_DAYS + + def test_type_sensor_uses_forecast_metadata_when_today_missing( monkeypatch: pytest.MonkeyPatch, ) -> None: From 5d2f09b8d3d7619501ed04c675a67a58ed95faeb Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Thu, 11 Dec 2025 08:15:38 +0100 Subject: [PATCH 005/200] Add more forecast days clamping tests --- tests/test_sensor.py | 54 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 5edc93a6..22bd5cd9 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -454,6 +454,33 @@ def test_coordinator_clamps_forecast_days_low() -> None: assert coordinator.forecast_days == sensor.MIN_FORECAST_DAYS +def test_coordinator_clamps_forecast_days_negative() -> None: + """Negative forecast days are clamped to minimum.""" + + loop = asyncio.new_event_loop() + hass = DummyHass(loop) + client = sensor.GooglePollenApiClient(FakeSession({}), "test") + + try: + coordinator = sensor.PollenDataUpdateCoordinator( + hass=hass, + api_key="test", + lat=1.0, + lon=2.0, + hours=12, + language=None, + entry_id="entry", + forecast_days=-5, + create_d1=False, + create_d2=False, + client=client, + ) + finally: + loop.close() + + assert coordinator.forecast_days == sensor.MIN_FORECAST_DAYS + + def test_coordinator_clamps_forecast_days_high() -> None: """Forecast days are clamped to maximum for legacy or invalid values.""" @@ -481,6 +508,33 @@ def test_coordinator_clamps_forecast_days_high() -> None: assert coordinator.forecast_days == sensor.MAX_FORECAST_DAYS +def test_coordinator_keeps_forecast_days_within_range() -> None: + """Valid forecast days remain unchanged after initialization.""" + + loop = asyncio.new_event_loop() + hass = DummyHass(loop) + client = sensor.GooglePollenApiClient(FakeSession({}), "test") + + try: + coordinator = sensor.PollenDataUpdateCoordinator( + hass=hass, + api_key="test", + lat=1.0, + lon=2.0, + hours=12, + language=None, + entry_id="entry", + forecast_days=3, + create_d1=False, + create_d2=False, + client=client, + ) + finally: + loop.close() + + assert coordinator.forecast_days == 3 + + def test_type_sensor_uses_forecast_metadata_when_today_missing( monkeypatch: pytest.MonkeyPatch, ) -> None: From 7929c56323ed7f0c121cdc1566acc8b438533f0f Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Thu, 11 Dec 2025 08:55:02 +0100 Subject: [PATCH 006/200] Address review feedback --- custom_components/pollenlevels/client.py | 2 -- tests/test_init.py | 15 +++++++++++++++ tests/test_sensor.py | 4 ++-- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/custom_components/pollenlevels/client.py b/custom_components/pollenlevels/client.py index 748789a4..1885b16c 100644 --- a/custom_components/pollenlevels/client.py +++ b/custom_components/pollenlevels/client.py @@ -133,5 +133,3 @@ async def async_fetch_pollen_data( msg = redact_api_key(err, self._api_key) _LOGGER.error("Pollen API error: %s", msg) raise UpdateFailed(msg) from err - - raise UpdateFailed("Failed to fetch pollen data") diff --git a/tests/test_init.py b/tests/test_init.py index 82dc03df..a443e323 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -273,6 +273,21 @@ def test_setup_entry_propagates_auth_failed() -> None: asyncio.run(integration.async_setup_entry(hass, entry)) +def test_setup_entry_missing_api_key_raises_auth_failed() -> None: + """Missing API key should trigger ConfigEntryAuthFailed.""" + + hass = _FakeHass() + entry = _FakeEntry( + data={ + integration.CONF_LATITUDE: 1.0, + integration.CONF_LONGITUDE: 2.0, + } + ) + + with pytest.raises(integration.ConfigEntryAuthFailed): + asyncio.run(integration.async_setup_entry(hass, entry)) + + def test_setup_entry_wraps_generic_error() -> None: """Unexpected errors convert to ConfigEntryNotReady for retries.""" diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 22bd5cd9..9e2ba2b8 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -990,8 +990,8 @@ async def _fast_sleep(delay: float) -> None: assert delays == [0.8] -def test_async_setup_entry_missing_api_key_triggers_reauth() -> None: - """A missing API key results in ConfigEntryAuthFailed during setup.""" +def test_async_setup_entry_raises_not_ready_if_runtime_data_missing() -> None: + """Missing runtime data causes setup to raise ConfigEntryNotReady.""" loop = asyncio.new_event_loop() hass = DummyHass(loop) From c96e812e2c5e8b4d682440679c5e01267b462712 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Thu, 11 Dec 2025 08:55:16 +0100 Subject: [PATCH 007/200] Remove redundant meta sensor overrides --- CHANGELOG.md | 4 +++- custom_components/pollenlevels/sensor.py | 12 ------------ tests/test_init.py | 12 +++++++++++- tests/test_sensor.py | 12 +++++++++++- 4 files changed, 25 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index de4fc0d2..5596cb52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,5 @@ # Changelog -## [1.9.0-alpha1] - 2025-12-10 +## [1.9.0-alpha1] - 2025-12-11 ### Changed - Moved runtime state to config entry `runtime_data` with a shared `GooglePollenApiClient` per entry while keeping existing sensor behaviour and @@ -11,6 +11,8 @@ when the key is missing instead of retrying endlessly. - Centralized config entry title normalization during setup so the cleaned device titles are reused across all sensors. +- Simplified metadata sensors by relying on inherited `unique_id` and + `device_info` properties instead of redefining them. ## [1.8.6] - 2025-12-09 ### Changed diff --git a/custom_components/pollenlevels/sensor.py b/custom_components/pollenlevels/sensor.py index bcc67f36..692350f7 100644 --- a/custom_components/pollenlevels/sensor.py +++ b/custom_components/pollenlevels/sensor.py @@ -828,18 +828,6 @@ def device_info(self): class _BaseMetaSensor(CoordinatorEntity, SensorEntity): """Provide base for metadata sensors.""" - @property - def unique_id(self) -> str | None: - """Return the cached unique ID.""" - - return getattr(self, "_attr_unique_id", None) - - @property - def device_info(self) -> dict[str, Any] | None: - """Return cached device info for diagnostics.""" - - return getattr(self, "_attr_device_info", None) - def __init__(self, coordinator: PollenDataUpdateCoordinator): """Initialize metadata sensor. diff --git a/tests/test_init.py b/tests/test_init.py index a443e323..208b0afe 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -43,7 +43,17 @@ class _StubServiceCall: # pragma: no cover - structure only class _StubSensorEntity: # pragma: no cover - structure only - pass + def __init__(self, *args, **kwargs): + self._attr_unique_id = None + self._attr_device_info = None + + @property + def unique_id(self): + return getattr(self, "_attr_unique_id", None) + + @property + def device_info(self): + return getattr(self, "_attr_device_info", None) class _StubSensorDeviceClass: # pragma: no cover - structure only diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 9e2ba2b8..d99dd1c1 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -36,7 +36,17 @@ class _StubSensorEntity: # pragma: no cover - no runtime behavior needed - pass + def __init__(self, *args, **kwargs): + self._attr_unique_id = None + self._attr_device_info: dict[str, Any] | None = None + + @property + def unique_id(self): + return getattr(self, "_attr_unique_id", None) + + @property + def device_info(self): + return getattr(self, "_attr_device_info", None) class _StubSensorDeviceClass: From 499ee8d514c24a09a1cf82f8384ef64ae0b3f536 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Thu, 11 Dec 2025 09:06:28 +0100 Subject: [PATCH 008/200] Use async_request_refresh for force_update service --- CHANGELOG.md | 3 + custom_components/pollenlevels/__init__.py | 6 +- tests/test_init.py | 69 +++++++++++++++++++++- 3 files changed, 74 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5596cb52..474e2bdc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ device titles are reused across all sensors. - Simplified metadata sensors by relying on inherited `unique_id` and `device_info` properties instead of redefining them. +- Updated the `force_update` service to queue coordinator refreshes via + `async_request_refresh` and added service coverage for entries lacking + runtime data. ## [1.8.6] - 2025-12-09 ### Changed diff --git a/custom_components/pollenlevels/__init__.py b/custom_components/pollenlevels/__init__.py index 44342ccb..4da84014 100644 --- a/custom_components/pollenlevels/__init__.py +++ b/custom_components/pollenlevels/__init__.py @@ -61,8 +61,10 @@ async def handle_force_update_service(call: ServiceCall) -> None: coordinator = getattr(runtime, "coordinator", None) if coordinator: _LOGGER.info("Trigger manual refresh for entry %s", entry.entry_id) - tasks.append(coordinator.async_refresh()) - task_entries.append(entry) + request = coordinator.async_request_refresh() + if request is not None: + tasks.append(request) + task_entries.append(entry) if tasks: results = await asyncio.gather(*tasks, return_exceptions=True) diff --git a/tests/test_init.py b/tests/test_init.py index 208b0afe..9a190784 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -7,6 +7,7 @@ import sys import types from pathlib import Path +from typing import Any import pytest @@ -199,6 +200,9 @@ async def async_config_entry_first_refresh(self): async def async_refresh(self): return None + def async_request_refresh(self): # pragma: no cover - scheduling helper + return asyncio.create_task(self.async_refresh()) + update_coordinator_mod.DataUpdateCoordinator = _StubDataUpdateCoordinator update_coordinator_mod.UpdateFailed = _StubUpdateFailed @@ -217,12 +221,14 @@ def __init__( self, forward_exception: Exception | None = None, unload_result: bool = True, + entries: list[object] | None = None, ): self._forward_exception = forward_exception self._unload_result = unload_result self.forward_calls: list[tuple[object, list[str]]] = [] self.unload_calls: list[tuple[object, list[str]]] = [] self.reload_calls: list[str] = [] + self._entries = entries or [] async def async_forward_entry_setups(self, entry, platforms): self.forward_calls.append((entry, platforms)) @@ -236,6 +242,13 @@ async def async_unload_platforms(self, entry, platforms): async def async_reload(self, entry_id: str): # pragma: no cover - used in tests self.reload_calls.append(entry_id) + def async_entries(self, domain: str | None = None): + if domain is None: + return list(self._entries) + return [ + entry for entry in self._entries if getattr(entry, "domain", None) == domain + ] + class _FakeEntry: def __init__( @@ -248,6 +261,7 @@ def __init__( ): self.entry_id = entry_id self.title = title + self.domain = integration.DOMAIN self._update_listener = None self.data = data or { integration.CONF_API_KEY: "key", @@ -268,9 +282,29 @@ def async_on_unload(self, callback): class _FakeHass: - def __init__(self, *, forward_exception: Exception | None = None): - self.config_entries = _FakeConfigEntries(forward_exception) + def __init__( + self, + *, + forward_exception: Exception | None = None, + entries: list[object] | None = None, + ): + self.config_entries = _FakeConfigEntries( + forward_exception=forward_exception, unload_result=True, entries=entries + ) self.data = {} + self.services = _ServiceRegistry() + + +class _ServiceRegistry: + def __init__(self): + self.registered: dict[tuple[str, str], Any] = {} + + def async_register(self, domain: str, service: str, handler, schema=None): + self.registered[(domain, service)] = handler + + async def async_call(self, domain: str, service: str): + handler = self.registered[(domain, service)] + await handler(_StubServiceCall()) def test_setup_entry_propagates_auth_failed() -> None: @@ -362,3 +396,34 @@ async def async_refresh(self): assert asyncio.run(integration.async_unload_entry(hass, entry)) is True assert hass.config_entries.unload_calls == [(entry, ["sensor"])] + + +def test_force_update_requests_refresh_per_entry() -> None: + """force_update should queue refresh via runtime_data coordinators and skip missing runtime data.""" + + class _StubCoordinator: + def __init__(self): + self.calls: list[str] = [] + + async def _mark(self): + self.calls.append("refresh") + + def async_request_refresh(self): + return asyncio.create_task(self._mark()) + + entry1 = _FakeEntry(entry_id="entry-1") + entry1.runtime_data = types.SimpleNamespace(coordinator=_StubCoordinator()) + entry2 = _FakeEntry(entry_id="entry-2") + entry2.runtime_data = types.SimpleNamespace(coordinator=_StubCoordinator()) + entry3 = _FakeEntry(entry_id="entry-3") + entry3.runtime_data = None + + hass = _FakeHass(entries=[entry1, entry2, entry3]) + + assert asyncio.run(integration.async_setup(hass, {})) is True + assert (integration.DOMAIN, "force_update") in hass.services.registered + + asyncio.run(hass.services.async_call(integration.DOMAIN, "force_update")) + + assert entry1.runtime_data.coordinator.calls == ["refresh"] + assert entry2.runtime_data.coordinator.calls == ["refresh"] From f97275859f253d71625aef56babfb7692cc23784 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Thu, 11 Dec 2025 09:30:31 +0100 Subject: [PATCH 009/200] Refactor retry backoff handling and normalize forecast mode --- custom_components/pollenlevels/client.py | 57 +++++++++++++++--------- custom_components/pollenlevels/sensor.py | 6 +-- 2 files changed, 36 insertions(+), 27 deletions(-) diff --git a/custom_components/pollenlevels/client.py b/custom_components/pollenlevels/client.py index 1885b16c..0c5a3ddf 100644 --- a/custom_components/pollenlevels/client.py +++ b/custom_components/pollenlevels/client.py @@ -21,6 +21,20 @@ def __init__(self, session: ClientSession, api_key: str) -> None: self._session = session self._api_key = api_key + async def _async_backoff( + self, + *, + attempt: int, + max_retries: int, + message: str, + base_args: tuple[Any, ...] = (), + ) -> None: + """Log a retry warning with jittered backoff and sleep.""" + + delay = 0.8 * (2**attempt) + random.uniform(0.0, 0.3) + _LOGGER.warning(message, *base_args, delay, attempt + 1, max_retries) + await asyncio.sleep(delay) + async def async_fetch_pollen_data( self, *, @@ -76,15 +90,15 @@ async def async_fetch_pollen_data( if 500 <= resp.status <= 599: if attempt < max_retries: - delay = 0.8 * (2**attempt) + random.uniform(0.0, 0.3) - _LOGGER.warning( - "Pollen API HTTP %s — retrying in %.2fs (attempt %d/%d)", - resp.status, - delay, - attempt + 1, - max_retries, + await self._async_backoff( + attempt=attempt, + max_retries=max_retries, + message=( + "Pollen API HTTP %s — retrying in %.2fs " + "(attempt %d/%d)" + ), + base_args=(resp.status,), ) - await asyncio.sleep(delay) continue raise UpdateFailed(f"HTTP {resp.status}") @@ -100,14 +114,13 @@ async def async_fetch_pollen_data( raise except TimeoutError as err: if attempt < max_retries: - delay = 0.8 * (2**attempt) + random.uniform(0.0, 0.3) - _LOGGER.warning( - "Pollen API timeout — retrying in %.2fs (attempt %d/%d)", - delay, - attempt + 1, - max_retries, + await self._async_backoff( + attempt=attempt, + max_retries=max_retries, + message=( + "Pollen API timeout — retrying in %.2fs " "(attempt %d/%d)" + ), ) - await asyncio.sleep(delay) continue msg = ( redact_api_key(err, self._api_key) @@ -116,14 +129,14 @@ async def async_fetch_pollen_data( raise UpdateFailed(f"Timeout: {msg}") from err except ClientError as err: if attempt < max_retries: - delay = 0.8 * (2**attempt) + random.uniform(0.0, 0.3) - _LOGGER.warning( - "Network error to Pollen API — retrying in %.2fs (attempt %d/%d)", - delay, - attempt + 1, - max_retries, + await self._async_backoff( + attempt=attempt, + max_retries=max_retries, + message=( + "Network error to Pollen API — retrying in %.2fs " + "(attempt %d/%d)" + ), ) - await asyncio.sleep(delay) continue msg = redact_api_key(err, self._api_key) or ( "Network error while calling the Google Pollen API" diff --git a/custom_components/pollenlevels/sensor.py b/custom_components/pollenlevels/sensor.py index 692350f7..7cea6b4b 100644 --- a/custom_components/pollenlevels/sensor.py +++ b/custom_components/pollenlevels/sensor.py @@ -218,11 +218,7 @@ async def async_setup_entry( mode_raw = opts.get(CONF_CREATE_FORECAST_SENSORS, ForecastSensorMode.NONE) try: - mode = ( - mode_raw - if isinstance(mode_raw, ForecastSensorMode) - else ForecastSensorMode(mode_raw) - ) + mode = ForecastSensorMode(mode_raw) except ValueError: mode = ForecastSensorMode.NONE create_d1 = mode in (ForecastSensorMode.D1, ForecastSensorMode.D1_D2) From 0090ff859a4e393a9e3f2252a0f9c76ee597a8b9 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Thu, 11 Dec 2025 11:01:55 +0100 Subject: [PATCH 010/200] Use coordinator forecast flags during sensor setup --- custom_components/pollenlevels/sensor.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/custom_components/pollenlevels/sensor.py b/custom_components/pollenlevels/sensor.py index 7cea6b4b..60517a38 100644 --- a/custom_components/pollenlevels/sensor.py +++ b/custom_components/pollenlevels/sensor.py @@ -42,7 +42,6 @@ from .client import GooglePollenApiClient from .const import ( CONF_API_KEY, - CONF_CREATE_FORECAST_SENSORS, CONF_FORECAST_DAYS, CONF_LATITUDE, CONF_LONGITUDE, @@ -215,14 +214,8 @@ async def async_setup_entry( opts = config_entry.options or {} forecast_days = int(opts.get(CONF_FORECAST_DAYS, coordinator.forecast_days)) - - mode_raw = opts.get(CONF_CREATE_FORECAST_SENSORS, ForecastSensorMode.NONE) - try: - mode = ForecastSensorMode(mode_raw) - except ValueError: - mode = ForecastSensorMode.NONE - create_d1 = mode in (ForecastSensorMode.D1, ForecastSensorMode.D1_D2) - create_d2 = mode == ForecastSensorMode.D1_D2 + create_d1 = coordinator.create_d1 + create_d2 = coordinator.create_d2 allow_d1 = create_d1 and forecast_days >= 2 allow_d2 = create_d2 and forecast_days >= 3 From 20a6c78121058def4dd1a84eb03df37b7c181a98 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Thu, 11 Dec 2025 13:26:40 +0100 Subject: [PATCH 011/200] Handle Retry-After header HTTP-date values --- custom_components/pollenlevels/client.py | 20 +++++-- tests/test_init.py | 24 ++++++++ tests/test_sensor.py | 76 ++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 4 deletions(-) diff --git a/custom_components/pollenlevels/client.py b/custom_components/pollenlevels/client.py index 0c5a3ddf..cc3bab4c 100644 --- a/custom_components/pollenlevels/client.py +++ b/custom_components/pollenlevels/client.py @@ -8,6 +8,7 @@ from aiohttp import ClientError, ClientSession, ClientTimeout from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import UpdateFailed +from homeassistant.util import dt as dt_util from .util import redact_api_key @@ -21,6 +22,20 @@ def __init__(self, session: ClientSession, api_key: str) -> None: self._session = session self._api_key = api_key + def _parse_retry_after(self, retry_after_raw: str) -> float: + """Translate a Retry-After header into a delay in seconds.""" + + try: + return float(retry_after_raw) + except (TypeError, ValueError): + retry_at = dt_util.parse_http_date(retry_after_raw) + if retry_at is not None: + delay = (retry_at - dt_util.utcnow()).total_seconds() + if delay > 0: + return delay + + return 2.0 + async def _async_backoff( self, *, @@ -73,10 +88,7 @@ async def async_fetch_pollen_data( retry_after_raw = resp.headers.get("Retry-After") delay = 2.0 if retry_after_raw: - try: - delay = float(retry_after_raw) - except (TypeError, ValueError): - delay = 2.0 + delay = self._parse_retry_after(retry_after_raw) delay = min(delay, 5.0) + random.uniform(0.0, 0.4) _LOGGER.warning( "Pollen API 429 — retrying in %.2fs (attempt %d/%d)", diff --git a/tests/test_init.py b/tests/test_init.py index 9a190784..66af68d3 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -150,6 +150,30 @@ def _stub_utcnow(): dt_mod.utcnow = _stub_utcnow + + +def _stub_parse_http_date(value: str | None): # pragma: no cover - stub only + from datetime import UTC, datetime + from email.utils import parsedate_to_datetime + + try: + parsed = parsedate_to_datetime(value) if value is not None else None + except (TypeError, ValueError, IndexError): + return None + + if parsed is None: + return None + + if parsed.tzinfo is None: + return parsed.replace(tzinfo=UTC) + + if isinstance(parsed, datetime): + return parsed + + return None + + +dt_mod.parse_http_date = _stub_parse_http_date sys.modules.setdefault("homeassistant.util.dt", dt_mod) util_mod = types.ModuleType("homeassistant.util") diff --git a/tests/test_sensor.py b/tests/test_sensor.py index d99dd1c1..89a59525 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import datetime import importlib.util import sys import types @@ -197,6 +198,30 @@ def _stub_utcnow(): dt_mod.utcnow = _stub_utcnow + + +def _stub_parse_http_date(value: str | None): # pragma: no cover - stub only + from datetime import UTC, datetime + from email.utils import parsedate_to_datetime + + try: + parsed = parsedate_to_datetime(value) if value is not None else None + except (TypeError, ValueError, IndexError): + return None + + if parsed is None: + return None + + if parsed.tzinfo is None: + return parsed.replace(tzinfo=UTC) + + if isinstance(parsed, datetime): + return parsed + + return None + + +dt_mod.parse_http_date = _stub_parse_http_date sys.modules.setdefault("homeassistant.util.dt", dt_mod) util_mod = types.ModuleType("homeassistant.util") @@ -867,6 +892,57 @@ async def _fast_sleep(delay: float) -> None: assert delays == [3.0] +def test_coordinator_retry_after_http_date(monkeypatch: pytest.MonkeyPatch) -> None: + """Retry-After as HTTP-date is converted to a delay before retry.""" + + retry_after = "Wed, 10 Dec 2025 12:00:05 GMT" + session = SequenceSession( + [ + ResponseSpec(status=429, payload={}, headers={"Retry-After": retry_after}), + ResponseSpec(status=429, payload={}, headers={"Retry-After": retry_after}), + ] + ) + delays: list[float] = [] + + async def _fast_sleep(delay: float) -> None: + delays.append(delay) + + monkeypatch.setattr(sensor.asyncio, "sleep", _fast_sleep) + monkeypatch.setattr(client_mod.random, "uniform", lambda *_args, **_kwargs: 0.0) + monkeypatch.setattr( + client_mod.dt_util, + "utcnow", + lambda: datetime.datetime(2025, 12, 10, 12, 0, 0, tzinfo=datetime.UTC), + ) + + client = sensor.GooglePollenApiClient(session, "test") + + loop = asyncio.new_event_loop() + hass = DummyHass(loop) + coordinator = sensor.PollenDataUpdateCoordinator( + hass=hass, + api_key="test", + lat=1.0, + lon=2.0, + hours=12, + language=None, + entry_id="entry", + forecast_days=1, + create_d1=False, + create_d2=False, + client=client, + ) + + try: + with pytest.raises(sensor.UpdateFailed, match="Quota exceeded"): + loop.run_until_complete(coordinator._async_update_data()) + finally: + loop.close() + + assert session.calls == 2 + assert delays == [5.0] + + def test_coordinator_retries_then_raises_on_server_errors( monkeypatch: pytest.MonkeyPatch, ) -> None: From 1e1c811a5e67c007f3ee570e80628a9dc29a3740 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Thu, 11 Dec 2025 13:26:52 +0100 Subject: [PATCH 012/200] Centralize pollen API timeout constant --- custom_components/pollenlevels/client.py | 3 ++- custom_components/pollenlevels/config_flow.py | 11 ++++++++--- custom_components/pollenlevels/const.py | 1 + 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/custom_components/pollenlevels/client.py b/custom_components/pollenlevels/client.py index cc3bab4c..10013c75 100644 --- a/custom_components/pollenlevels/client.py +++ b/custom_components/pollenlevels/client.py @@ -10,6 +10,7 @@ from homeassistant.helpers.update_coordinator import UpdateFailed from homeassistant.util import dt as dt_util +from .const import POLLEN_API_TIMEOUT from .util import redact_api_key _LOGGER = logging.getLogger(__name__) @@ -78,7 +79,7 @@ async def async_fetch_pollen_data( for attempt in range(0, max_retries + 1): try: async with self._session.get( - url, params=params, timeout=ClientTimeout(total=10) + url, params=params, timeout=ClientTimeout(total=POLLEN_API_TIMEOUT) ) as resp: if resp.status == 403: raise ConfigEntryAuthFailed("Invalid API key") diff --git a/custom_components/pollenlevels/config_flow.py b/custom_components/pollenlevels/config_flow.py index b26e416c..e32e5f71 100644 --- a/custom_components/pollenlevels/config_flow.py +++ b/custom_components/pollenlevels/config_flow.py @@ -36,6 +36,7 @@ FORECAST_SENSORS_CHOICES, MAX_FORECAST_DAYS, MIN_FORECAST_DAYS, + POLLEN_API_TIMEOUT, ) from .util import redact_api_key @@ -237,7 +238,9 @@ async def _async_validate_input( # Add explicit timeout to prevent UI hangs on provider issues async with session.get( - url, params=params, timeout=aiohttp.ClientTimeout(total=10) + url, + params=params, + timeout=aiohttp.ClientTimeout(total=POLLEN_API_TIMEOUT), ) as resp: status = resp.status if status == 403: @@ -293,14 +296,16 @@ async def _async_validate_input( except TimeoutError as err: # Catch built-in TimeoutError; on Python 3.14 this also covers asyncio.TimeoutError. _LOGGER.warning( - "Validation timeout (10s): %s", + "Validation timeout (%ss): %s", + POLLEN_API_TIMEOUT, redact_api_key(err, user_input.get(CONF_API_KEY)), ) errors["base"] = "cannot_connect" if placeholders is not None: redacted = redact_api_key(err, user_input.get(CONF_API_KEY)) placeholders["error_message"] = ( - redacted or "Validation request timed out (10 seconds)." + redacted + or f"Validation request timed out ({POLLEN_API_TIMEOUT} seconds)." ) except aiohttp.ClientError as err: _LOGGER.error( diff --git a/custom_components/pollenlevels/const.py b/custom_components/pollenlevels/const.py index 5ab18177..c6037d71 100644 --- a/custom_components/pollenlevels/const.py +++ b/custom_components/pollenlevels/const.py @@ -20,6 +20,7 @@ DEFAULT_ENTRY_TITLE = "Pollen Levels" MAX_FORECAST_DAYS = 5 MIN_FORECAST_DAYS = 1 +POLLEN_API_TIMEOUT = 10 # Allowed values for create_forecast_sensors selector FORECAST_SENSORS_CHOICES = ["none", "D+1", "D+1+2"] From fd9b0cb19002fa09969dde362ef6f9ca90b1f267 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Fri, 12 Dec 2025 07:57:15 +0100 Subject: [PATCH 013/200] Handle 401 auth responses and clean unload state --- CHANGELOG.md | 4 ++++ custom_components/pollenlevels/__init__.py | 9 +++++---- custom_components/pollenlevels/client.py | 2 +- custom_components/pollenlevels/config_flow.py | 4 ++-- tests/test_config_flow.py | 9 +++++---- tests/test_init.py | 1 + tests/test_sensor.py | 9 ++++++--- 7 files changed, 24 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 474e2bdc..ca17cb63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ - Updated sensors, diagnostics, and the `pollenlevels.force_update` service to read coordinators from runtime data so each entry reuses a single API client for Google Pollen requests. +- Treated HTTP 401 responses like 403 to surface `invalid_auth` during setup + validation and runtime calls instead of generic connection errors. - Restored API key validation during setup to raise `ConfigEntryAuthFailed` when the key is missing instead of retrying endlessly. - Centralized config entry title normalization during setup so the cleaned @@ -16,6 +18,8 @@ - Updated the `force_update` service to queue coordinator refreshes via `async_request_refresh` and added service coverage for entries lacking runtime data. +- Cleared config entry `runtime_data` after unload to drop stale coordinator + references and keep teardown tidy. ## [1.8.6] - 2025-12-09 ### Changed diff --git a/custom_components/pollenlevels/__init__.py b/custom_components/pollenlevels/__init__.py index 4da84014..1541bc8c 100644 --- a/custom_components/pollenlevels/__init__.py +++ b/custom_components/pollenlevels/__init__.py @@ -61,10 +61,9 @@ async def handle_force_update_service(call: ServiceCall) -> None: coordinator = getattr(runtime, "coordinator", None) if coordinator: _LOGGER.info("Trigger manual refresh for entry %s", entry.entry_id) - request = coordinator.async_request_refresh() - if request is not None: - tasks.append(request) - task_entries.append(entry) + refresh_coro = coordinator.async_request_refresh() + tasks.append(refresh_coro) + task_entries.append(entry) if tasks: results = await asyncio.gather(*tasks, return_exceptions=True) @@ -171,6 +170,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: "PollenLevels async_unload_entry called for entry_id=%s", entry.entry_id ) unloaded = await hass.config_entries.async_unload_platforms(entry, ["sensor"]) + if unloaded: + entry.runtime_data = None return unloaded diff --git a/custom_components/pollenlevels/client.py b/custom_components/pollenlevels/client.py index 10013c75..3e50ca0f 100644 --- a/custom_components/pollenlevels/client.py +++ b/custom_components/pollenlevels/client.py @@ -81,7 +81,7 @@ async def async_fetch_pollen_data( async with self._session.get( url, params=params, timeout=ClientTimeout(total=POLLEN_API_TIMEOUT) ) as resp: - if resp.status == 403: + if resp.status in (401, 403): raise ConfigEntryAuthFailed("Invalid API key") if resp.status == 429: diff --git a/custom_components/pollenlevels/config_flow.py b/custom_components/pollenlevels/config_flow.py index e32e5f71..7a5e99f2 100644 --- a/custom_components/pollenlevels/config_flow.py +++ b/custom_components/pollenlevels/config_flow.py @@ -243,8 +243,8 @@ async def _async_validate_input( timeout=aiohttp.ClientTimeout(total=POLLEN_API_TIMEOUT), ) as resp: status = resp.status - if status == 403: - _LOGGER.debug("Validation HTTP 403 (body omitted)") + if status in (401, 403): + _LOGGER.debug("Validation HTTP %s (body omitted)", status) errors["base"] = "invalid_auth" elif status == 429: _LOGGER.debug("Validation HTTP 429 (body omitted)") diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index fd2b89dd..753f9668 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -433,12 +433,13 @@ def _base_user_input() -> dict: } -def test_validate_input_http_403_sets_invalid_auth( - monkeypatch: pytest.MonkeyPatch, +@pytest.mark.parametrize("status", [401, 403]) +def test_validate_input_http_auth_errors_set_invalid_auth( + monkeypatch: pytest.MonkeyPatch, status: int ) -> None: - """HTTP 403 during validation should map to invalid_auth.""" + """HTTP auth failures during validation should map to invalid_auth.""" - session = _patch_client_session(monkeypatch, _StubResponse(403)) + session = _patch_client_session(monkeypatch, _StubResponse(status)) flow = PollenLevelsConfigFlow() flow.hass = SimpleNamespace() diff --git a/tests/test_init.py b/tests/test_init.py index 66af68d3..28798321 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -420,6 +420,7 @@ async def async_refresh(self): assert asyncio.run(integration.async_unload_entry(hass, entry)) is True assert hass.config_entries.unload_calls == [(entry, ["sensor"])] + assert entry.runtime_data is None def test_force_update_requests_refresh_per_entry() -> None: diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 89a59525..e05c3ec2 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -816,10 +816,13 @@ def test_cleanup_per_day_entities_removes_disabled_days( assert registry.removals == expected_entities -def test_coordinator_raises_auth_failed(monkeypatch: pytest.MonkeyPatch) -> None: - """A 403 response triggers ConfigEntryAuthFailed for re-auth flows.""" +@pytest.mark.parametrize("status", [401, 403]) +def test_coordinator_raises_auth_failed( + monkeypatch: pytest.MonkeyPatch, status: int +) -> None: + """Auth failures trigger ConfigEntryAuthFailed for re-auth flows.""" - fake_session = FakeSession({}, status=403) + fake_session = FakeSession({}, status=status) client = sensor.GooglePollenApiClient(fake_session, "bad") loop = asyncio.new_event_loop() From a964f4a7d223eb9a60165749f11253e47bff6f70 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sat, 13 Dec 2025 13:29:20 +0100 Subject: [PATCH 014/200] Handle missing source in device info --- custom_components/pollenlevels/sensor.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/custom_components/pollenlevels/sensor.py b/custom_components/pollenlevels/sensor.py index 60517a38..dd862433 100644 --- a/custom_components/pollenlevels/sensor.py +++ b/custom_components/pollenlevels/sensor.py @@ -798,6 +798,14 @@ def device_info(self): """Return device info with translation support for the group.""" info = self.coordinator.data.get(self.code, {}) or {} group = info.get("source") + if not group: + if self.code.startswith("type_"): + group = "type" + elif self.code.startswith(("plant_", "plants_")): + group = "plant" + else: + group = "meta" + device_id = f"{self.coordinator.entry_id}_{group}" translation_keys = {"type": "types", "plant": "plants", "meta": "info"} translation_key = translation_keys.get(group, "info") From 3ff400ec2fdb74de5216d0e32bc55a556dea5e00 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sat, 13 Dec 2025 18:12:07 +0100 Subject: [PATCH 015/200] Switch options flow to selectors --- CHANGELOG.md | 5 + custom_components/pollenlevels/config_flow.py | 298 +++++++++++++----- custom_components/pollenlevels/const.py | 6 + custom_components/pollenlevels/manifest.json | 2 +- pyproject.toml | 2 +- 5 files changed, 228 insertions(+), 85 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca17cb63..4b08074c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,9 @@ # Changelog +## [1.9.0-alpha2] - 2025-12-13 +### Changed +- Added stable constants for HTTP referrer support and API key helper URLs to + support upcoming flow updates. + ## [1.9.0-alpha1] - 2025-12-11 ### Changed - Moved runtime state to config entry `runtime_data` with a shared diff --git a/custom_components/pollenlevels/config_flow.py b/custom_components/pollenlevels/config_flow.py index 7a5e99f2..f313fbde 100644 --- a/custom_components/pollenlevels/config_flow.py +++ b/custom_components/pollenlevels/config_flow.py @@ -20,11 +20,25 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, CONF_NAME +from homeassistant.data_entry_flow import SectionConfig, section from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.selector import LocationSelector, LocationSelectorConfig +from homeassistant.helpers.selector import ( + LocationSelector, + LocationSelectorConfig, + NumberSelector, + NumberSelectorConfig, + NumberSelectorMode, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, + TextSelector, + TextSelectorConfig, + TextSelectorType, +) from .const import ( CONF_API_KEY, + CONF_HTTP_REFERER, CONF_CREATE_FORECAST_SENSORS, CONF_FORECAST_DAYS, CONF_LANGUAGE_CODE, @@ -36,7 +50,10 @@ FORECAST_SENSORS_CHOICES, MAX_FORECAST_DAYS, MIN_FORECAST_DAYS, + POLLEN_API_KEY_URL, POLLEN_API_TIMEOUT, + RESTRICTING_API_KEYS_URL, + SECTION_API_KEY_OPTIONS, ) from .util import redact_api_key @@ -55,10 +72,24 @@ STEP_USER_DATA_SCHEMA = vol.Schema( { vol.Required(CONF_API_KEY): str, - vol.Optional(CONF_UPDATE_INTERVAL, default=DEFAULT_UPDATE_INTERVAL): vol.All( - vol.Coerce(int), vol.Range(min=1) + vol.Optional( + CONF_UPDATE_INTERVAL, default=DEFAULT_UPDATE_INTERVAL + ): NumberSelector( + NumberSelectorConfig( + min=1, + step=1, + mode=NumberSelectorMode.BOX, + unit_of_measurement="h", + ) + ), + vol.Optional(CONF_LANGUAGE_CODE): TextSelector( + TextSelectorConfig(type=TextSelectorType.TEXT) ), - vol.Optional(CONF_LANGUAGE_CODE): str, + section(SECTION_API_KEY_OPTIONS, SectionConfig(collapsed=True)): { + vol.Optional(CONF_HTTP_REFERER, default=""): TextSelector( + TextSelectorConfig(type=TextSelectorType.TEXT) + ), + }, } ) @@ -170,12 +201,40 @@ async def _async_validate_input( ) -> tuple[dict[str, str], dict[str, Any] | None]: """Validate user or reauth input and return normalized data.""" - placeholders = description_placeholders + placeholders = ( + description_placeholders if description_placeholders is not None else {} + ) errors: dict[str, str] = {} normalized: dict[str, Any] = dict(user_input) normalized.pop(CONF_NAME, None) normalized.pop(CONF_LOCATION, None) + async def _extract_error_message( + resp: aiohttp.ClientResponse, default: str + ) -> str: + message = "" + try: + data = await resp.json() + if isinstance(data, dict): + err = data.get("error") + if isinstance(err, dict): + body_message = err.get("message") + if isinstance(body_message, str): + message = body_message + except Exception: + message = "" + + if not message: + try: + message = await resp.text() + except Exception: + message = "" + + message = (message or "").strip() or default + if len(message) > 300: + message = message[:300] + return message + latlon = None if CONF_LOCATION in user_input: latlon = _validate_location_dict(user_input.get(CONF_LOCATION)) @@ -243,20 +302,30 @@ async def _async_validate_input( timeout=aiohttp.ClientTimeout(total=POLLEN_API_TIMEOUT), ) as resp: status = resp.status - if status in (401, 403): - _LOGGER.debug("Validation HTTP %s (body omitted)", status) + if status == 401: + _LOGGER.debug("Validation HTTP 401 (body omitted)") errors["base"] = "invalid_auth" + placeholders["error_message"] = await _extract_error_message( + resp, "HTTP 401" + ) + elif status == 403: + _LOGGER.debug("Validation HTTP 403 (body omitted)") + errors["base"] = "cannot_connect" + placeholders["error_message"] = await _extract_error_message( + resp, "HTTP 403" + ) elif status == 429: _LOGGER.debug("Validation HTTP 429 (body omitted)") errors["base"] = "quota_exceeded" + placeholders["error_message"] = await _extract_error_message( + resp, "HTTP 429" + ) elif status != 200: _LOGGER.debug("Validation HTTP %s (body omitted)", status) errors["base"] = "cannot_connect" - if placeholders is not None: - # Keep user-facing message generic; HTTP status is logged above - placeholders["error_message"] = ( - "Unable to validate the API key with the pollen service." - ) + placeholders["error_message"] = await _extract_error_message( + resp, f"HTTP {status}" + ) else: raw = await resp.read() try: @@ -284,6 +353,8 @@ async def _async_validate_input( return errors, None normalized[CONF_LANGUAGE_CODE] = lang + if CONF_UPDATE_INTERVAL in normalized: + normalized[CONF_UPDATE_INTERVAL] = int(normalized[CONF_UPDATE_INTERVAL]) return errors, normalized except vol.Invalid as ve: @@ -332,18 +403,50 @@ async def _async_validate_input( async def async_step_user(self, user_input=None): """Handle initial step.""" errors: dict[str, str] = {} - description_placeholders: dict[str, Any] = {} + description_placeholders: dict[str, Any] = { + "api_key_url": POLLEN_API_KEY_URL, + "restricting_api_keys_url": RESTRICTING_API_KEYS_URL, + } if user_input: - errors, normalized = await self._async_validate_input( - user_input, - check_unique_id=True, - description_placeholders=description_placeholders, - ) - if not errors and normalized is not None: - entry_name = str(user_input.get(CONF_NAME, "")).strip() - title = entry_name or DEFAULT_ENTRY_TITLE - return self.async_create_entry(title=title, data=normalized) + sanitized_input: dict[str, Any] = dict(user_input) + section_values = sanitized_input.get(SECTION_API_KEY_OPTIONS) + raw_http_referer = sanitized_input.get(CONF_HTTP_REFERER) + if raw_http_referer is None and isinstance(section_values, dict): + raw_http_referer = section_values.get(CONF_HTTP_REFERER) + sanitized_input.pop(SECTION_API_KEY_OPTIONS, None) + + http_referer: str | None = None + if raw_http_referer is not None: + if not isinstance(raw_http_referer, str): + errors["base"] = "cannot_connect" + description_placeholders["error_message"] = ( + "Invalid HTTP referrer value." + ) + else: + http_referer = raw_http_referer.strip() + if "\r" in http_referer or "\n" in http_referer: + errors["base"] = "cannot_connect" + description_placeholders["error_message"] = ( + "Invalid HTTP referrer value." + ) + elif not http_referer: + http_referer = None + + if not errors: + sanitized_input.pop(CONF_HTTP_REFERER, None) + if http_referer: + sanitized_input[CONF_HTTP_REFERER] = http_referer + + errors, normalized = await self._async_validate_input( + sanitized_input, + check_unique_id=True, + description_placeholders=description_placeholders, + ) + if not errors and normalized is not None: + entry_name = str(user_input.get(CONF_NAME, "")).strip() + title = entry_name or DEFAULT_ENTRY_TITLE + return self.async_create_entry(title=title, data=normalized) base_schema = STEP_USER_DATA_SCHEMA.schema.copy() base_schema.update(_get_location_schema(self.hass).schema) @@ -391,6 +494,8 @@ async def async_step_reauth_confirm(self, user_input: dict[str, Any] | None = No placeholders = { "latitude": f"{self._reauth_entry.data.get(CONF_LATITUDE)}", "longitude": f"{self._reauth_entry.data.get(CONF_LONGITUDE)}", + "api_key_url": POLLEN_API_KEY_URL, + "restricting_api_keys_url": RESTRICTING_API_KEYS_URL, } if user_input: @@ -436,62 +541,6 @@ async def async_step_init(self, user_input=None): errors: dict[str, str] = {} placeholders = {"title": self.entry.title or DEFAULT_ENTRY_TITLE} - if user_input is not None: - try: - # Language: allow empty; if provided, validate & normalize. - raw_lang = user_input.get( - CONF_LANGUAGE_CODE, - self.entry.options.get( - CONF_LANGUAGE_CODE, self.entry.data.get(CONF_LANGUAGE_CODE, "") - ), - ) - lang = raw_lang.strip() if isinstance(raw_lang, str) else "" - if lang: - lang = is_valid_language_code(lang) - user_input[CONF_LANGUAGE_CODE] = lang # persist normalized - - # forecast_days within 1..5 - days = int( - user_input.get( - CONF_FORECAST_DAYS, - self.entry.options.get( - CONF_FORECAST_DAYS, DEFAULT_FORECAST_DAYS - ), - ) - ) - if days < MIN_FORECAST_DAYS or days > MAX_FORECAST_DAYS: - errors[CONF_FORECAST_DAYS] = "invalid_option_combo" - - # per-day sensors vs number of days - mode = user_input.get( - CONF_CREATE_FORECAST_SENSORS, - self.entry.options.get(CONF_CREATE_FORECAST_SENSORS, "none"), - ) - needed = 1 - if mode == "D+1": - needed = 2 - elif mode == "D+1+2": - needed = 3 - if days < needed: - errors[CONF_CREATE_FORECAST_SENSORS] = "invalid_option_combo" - - except vol.Invalid as ve: - _LOGGER.warning( - "Options language validation failed for '%s': %s", - user_input.get(CONF_LANGUAGE_CODE), - ve, - ) - errors[CONF_LANGUAGE_CODE] = _language_error_to_form_key(ve) - except Exception as err: # defensive - _LOGGER.exception( - "Options validation error: %s", - redact_api_key(err, self.entry.data.get(CONF_API_KEY)), - ) - errors["base"] = "unknown" - - if not errors: - return self.async_create_entry(title="", data=user_input) - # Defaults: prefer options, fallback to data/HA config current_interval = self.entry.options.get( CONF_UPDATE_INTERVAL, @@ -507,20 +556,103 @@ async def async_step_init(self, user_input=None): ) current_mode = self.entry.options.get(CONF_CREATE_FORECAST_SENSORS, "none") + if user_input is not None: + normalized_input: dict[str, Any] = dict(user_input) + try: + normalized_input[CONF_UPDATE_INTERVAL] = int( + user_input.get(CONF_UPDATE_INTERVAL, current_interval) + ) + normalized_input[CONF_FORECAST_DAYS] = int( + user_input.get(CONF_FORECAST_DAYS, current_days) + ) + except (TypeError, ValueError): + errors["base"] = "unknown" + + if not errors: + try: + # Language: allow empty; if provided, validate & normalize. + raw_lang = normalized_input.get( + CONF_LANGUAGE_CODE, + self.entry.options.get( + CONF_LANGUAGE_CODE, + self.entry.data.get(CONF_LANGUAGE_CODE, ""), + ), + ) + lang = raw_lang.strip() if isinstance(raw_lang, str) else "" + if lang: + lang = is_valid_language_code(lang) + normalized_input[CONF_LANGUAGE_CODE] = lang # persist normalized + + # forecast_days within 1..5 + days = normalized_input[CONF_FORECAST_DAYS] + if days < MIN_FORECAST_DAYS or days > MAX_FORECAST_DAYS: + errors[CONF_FORECAST_DAYS] = "invalid_option_combo" + + # per-day sensors vs number of days + mode = normalized_input.get( + CONF_CREATE_FORECAST_SENSORS, + self.entry.options.get(CONF_CREATE_FORECAST_SENSORS, "none"), + ) + needed = 1 + if mode == "D+1": + needed = 2 + elif mode == "D+1+2": + needed = 3 + if days < needed: + errors[CONF_CREATE_FORECAST_SENSORS] = "invalid_option_combo" + + except vol.Invalid as ve: + _LOGGER.warning( + "Options language validation failed for '%s': %s", + normalized_input.get(CONF_LANGUAGE_CODE), + ve, + ) + errors[CONF_LANGUAGE_CODE] = _language_error_to_form_key(ve) + except Exception as err: # defensive + _LOGGER.exception( + "Options validation error: %s", + redact_api_key(err, self.entry.data.get(CONF_API_KEY)), + ) + errors["base"] = "unknown" + + if not errors: + return self.async_create_entry(title="", data=normalized_input) + return self.async_show_form( step_id="init", data_schema=vol.Schema( { vol.Optional( CONF_UPDATE_INTERVAL, default=current_interval - ): vol.All(vol.Coerce(int), vol.Range(min=1)), - vol.Optional(CONF_LANGUAGE_CODE, default=current_lang): str, - vol.Optional(CONF_FORECAST_DAYS, default=current_days): vol.In( - list(range(MIN_FORECAST_DAYS, MAX_FORECAST_DAYS + 1)) + ): NumberSelector( + NumberSelectorConfig( + min=1, + step=1, + mode=NumberSelectorMode.BOX, + unit_of_measurement="h", + ) + ), + vol.Optional( + CONF_LANGUAGE_CODE, default=current_lang + ): TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT)), + vol.Optional( + CONF_FORECAST_DAYS, default=current_days + ): NumberSelector( + NumberSelectorConfig( + min=MIN_FORECAST_DAYS, + max=MAX_FORECAST_DAYS, + step=1, + mode=NumberSelectorMode.BOX, + ) ), vol.Optional( CONF_CREATE_FORECAST_SENSORS, default=current_mode - ): vol.In(FORECAST_SENSORS_CHOICES), + ): SelectSelector( + SelectSelectorConfig( + mode=SelectSelectorMode.DROPDOWN, + options=FORECAST_SENSORS_CHOICES, + ) + ), } ), errors=errors, diff --git a/custom_components/pollenlevels/const.py b/custom_components/pollenlevels/const.py index c6037d71..1c5e317e 100644 --- a/custom_components/pollenlevels/const.py +++ b/custom_components/pollenlevels/const.py @@ -7,12 +7,14 @@ CONF_LONGITUDE = "longitude" CONF_UPDATE_INTERVAL = "update_interval" CONF_LANGUAGE_CODE = "language_code" +CONF_HTTP_REFERER = "http_referer" # Forecast-related options (Phase 1.1: types only) CONF_FORECAST_DAYS = "forecast_days" CONF_CREATE_FORECAST_SENSORS = ( "create_forecast_sensors" # values: "none" | "D+1" | "D+1+2" ) +SECTION_API_KEY_OPTIONS = "api_key_options" # Defaults DEFAULT_UPDATE_INTERVAL = 6 @@ -21,6 +23,10 @@ MAX_FORECAST_DAYS = 5 MIN_FORECAST_DAYS = 1 POLLEN_API_TIMEOUT = 10 +POLLEN_API_KEY_URL = "https://developers.google.com/maps/documentation/pollen/get-api-key" +RESTRICTING_API_KEYS_URL = ( + "https://developers.google.com/maps/api-security-best-practices" +) # Allowed values for create_forecast_sensors selector FORECAST_SENSORS_CHOICES = ["none", "D+1", "D+1+2"] diff --git a/custom_components/pollenlevels/manifest.json b/custom_components/pollenlevels/manifest.json index 8d170fbc..bc3f3065 100644 --- a/custom_components/pollenlevels/manifest.json +++ b/custom_components/pollenlevels/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "issue_tracker": "https://github.com/eXPerience83/pollenlevels/issues", - "version": "1.9.0-alpha1" + "version": "1.9.0-alpha2" } diff --git a/pyproject.toml b/pyproject.toml index c7062f7c..d0a80fad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ [project] name = "pollenlevels" -version = "1.9.0-alpha1" +version = "1.9.0-alpha2" # Enforce the runtime floor aligned with upcoming HA Python 3.14 images. requires-python = ">=3.14" From ece1e3bf35c43ad9b467ff6de7836cfe1ec820ce Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sat, 13 Dec 2025 18:19:00 +0100 Subject: [PATCH 016/200] Format and document selector flow updates --- CHANGELOG.md | 7 +++++++ custom_components/pollenlevels/config_flow.py | 2 +- custom_components/pollenlevels/const.py | 4 +++- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b08074c..4565808b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,13 @@ ### Changed - Added stable constants for HTTP referrer support and API key helper URLs to support upcoming flow updates. +- Modernized the config flow with selectors, API key guidance links, and a + collapsed API key options section including an optional HTTP referrer field + stored flat in entry data. +- Enhanced setup validation to surface HTTP 401/403 API messages safely via the + form error placeholders without exposing secrets. +- Updated the options flow to use selectors while normalizing numeric fields to + integers and keeping existing validation rules and defaults intact. ## [1.9.0-alpha1] - 2025-12-11 ### Changed diff --git a/custom_components/pollenlevels/config_flow.py b/custom_components/pollenlevels/config_flow.py index f313fbde..46984492 100644 --- a/custom_components/pollenlevels/config_flow.py +++ b/custom_components/pollenlevels/config_flow.py @@ -38,9 +38,9 @@ from .const import ( CONF_API_KEY, - CONF_HTTP_REFERER, CONF_CREATE_FORECAST_SENSORS, CONF_FORECAST_DAYS, + CONF_HTTP_REFERER, CONF_LANGUAGE_CODE, CONF_UPDATE_INTERVAL, DEFAULT_ENTRY_TITLE, diff --git a/custom_components/pollenlevels/const.py b/custom_components/pollenlevels/const.py index 1c5e317e..e4cebc00 100644 --- a/custom_components/pollenlevels/const.py +++ b/custom_components/pollenlevels/const.py @@ -23,7 +23,9 @@ MAX_FORECAST_DAYS = 5 MIN_FORECAST_DAYS = 1 POLLEN_API_TIMEOUT = 10 -POLLEN_API_KEY_URL = "https://developers.google.com/maps/documentation/pollen/get-api-key" +POLLEN_API_KEY_URL = ( + "https://developers.google.com/maps/documentation/pollen/get-api-key" +) RESTRICTING_API_KEYS_URL = ( "https://developers.google.com/maps/api-security-best-practices" ) From 0c87e2f67eecb4655a714c7613cebf2ac51b84ab Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sat, 13 Dec 2025 18:48:27 +0100 Subject: [PATCH 017/200] Improve HTTP referrer validation and options mapping --- CHANGELOG.md | 2 ++ custom_components/pollenlevels/config_flow.py | 16 +++++++--------- .../pollenlevels/translations/ca.json | 3 ++- .../pollenlevels/translations/cs.json | 3 ++- .../pollenlevels/translations/da.json | 3 ++- .../pollenlevels/translations/de.json | 3 ++- .../pollenlevels/translations/en.json | 3 ++- .../pollenlevels/translations/es.json | 3 ++- .../pollenlevels/translations/fi.json | 3 ++- .../pollenlevels/translations/fr.json | 3 ++- .../pollenlevels/translations/hu.json | 3 ++- .../pollenlevels/translations/it.json | 3 ++- .../pollenlevels/translations/nb.json | 3 ++- .../pollenlevels/translations/nl.json | 3 ++- .../pollenlevels/translations/pl.json | 3 ++- .../pollenlevels/translations/pt-BR.json | 3 ++- .../pollenlevels/translations/pt-PT.json | 3 ++- .../pollenlevels/translations/ro.json | 3 ++- .../pollenlevels/translations/ru.json | 3 ++- .../pollenlevels/translations/sv.json | 3 ++- .../pollenlevels/translations/uk.json | 3 ++- .../pollenlevels/translations/zh-Hans.json | 3 ++- .../pollenlevels/translations/zh-Hant.json | 3 ++- 23 files changed, 51 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4565808b..1b0748b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ form error placeholders without exposing secrets. - Updated the options flow to use selectors while normalizing numeric fields to integers and keeping existing validation rules and defaults intact. +- Clarified HTTP referrer validation with a dedicated error to avoid confusing + connection-failure messaging when the input contains newline characters. ## [1.9.0-alpha1] - 2025-12-11 ### Changed diff --git a/custom_components/pollenlevels/config_flow.py b/custom_components/pollenlevels/config_flow.py index 46984492..d04ead27 100644 --- a/custom_components/pollenlevels/config_flow.py +++ b/custom_components/pollenlevels/config_flow.py @@ -419,16 +419,18 @@ async def async_step_user(self, user_input=None): http_referer: str | None = None if raw_http_referer is not None: if not isinstance(raw_http_referer, str): - errors["base"] = "cannot_connect" + errors["base"] = "invalid_http_referrer" description_placeholders["error_message"] = ( - "Invalid HTTP referrer value." + "Invalid HTTP referrer value. It must not contain newline" + " characters." ) else: http_referer = raw_http_referer.strip() if "\r" in http_referer or "\n" in http_referer: - errors["base"] = "cannot_connect" + errors["base"] = "invalid_http_referrer" description_placeholders["error_message"] = ( - "Invalid HTTP referrer value." + "Invalid HTTP referrer value. It must not contain newline" + " characters." ) elif not http_referer: http_referer = None @@ -593,11 +595,7 @@ async def async_step_init(self, user_input=None): CONF_CREATE_FORECAST_SENSORS, self.entry.options.get(CONF_CREATE_FORECAST_SENSORS, "none"), ) - needed = 1 - if mode == "D+1": - needed = 2 - elif mode == "D+1+2": - needed = 3 + needed = {"D+1": 2, "D+1+2": 3}.get(mode, 1) if days < needed: errors[CONF_CREATE_FORECAST_SENSORS] = "invalid_option_combo" diff --git a/custom_components/pollenlevels/translations/ca.json b/custom_components/pollenlevels/translations/ca.json index a2c68eca..7864ef4f 100644 --- a/custom_components/pollenlevels/translations/ca.json +++ b/custom_components/pollenlevels/translations/ca.json @@ -29,7 +29,8 @@ "empty": "Aquest camp no pot estar buit", "invalid_option_combo": "Augmenta els 'Dies de previsió' per cobrir els sensors per dia seleccionats.", "invalid_coordinates": "Selecciona una ubicació vàlida al mapa.", - "unknown": "Error desconegut" + "unknown": "Error desconegut", + "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters." }, "abort": { "already_configured": "Aquesta ubicació ja està configurada.", diff --git a/custom_components/pollenlevels/translations/cs.json b/custom_components/pollenlevels/translations/cs.json index 35c3b65a..edf27af8 100644 --- a/custom_components/pollenlevels/translations/cs.json +++ b/custom_components/pollenlevels/translations/cs.json @@ -29,7 +29,8 @@ "empty": "Toto pole nemůže být prázdné", "invalid_option_combo": "Zvyšte „Dny předpovědi“, aby pokryly vybrané senzory po dnech.", "invalid_coordinates": "Vyberte platné umístění na mapě.", - "unknown": "Neznámá chyba" + "unknown": "Neznámá chyba", + "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters." }, "abort": { "already_configured": "Toto umístění je již nakonfigurováno.", diff --git a/custom_components/pollenlevels/translations/da.json b/custom_components/pollenlevels/translations/da.json index 3cfc6ceb..7e35cf09 100644 --- a/custom_components/pollenlevels/translations/da.json +++ b/custom_components/pollenlevels/translations/da.json @@ -29,7 +29,8 @@ "empty": "Dette felt må ikke være tomt", "invalid_option_combo": "Forøg \"Prognosedage\" for at dække valgte sensorer pr. dag.", "invalid_coordinates": "Vælg en gyldig placering på kortet.", - "unknown": "Ukendt fejl" + "unknown": "Ukendt fejl", + "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters." }, "abort": { "already_configured": "Denne placering er allerede konfigureret.", diff --git a/custom_components/pollenlevels/translations/de.json b/custom_components/pollenlevels/translations/de.json index b260013c..ffb677f6 100644 --- a/custom_components/pollenlevels/translations/de.json +++ b/custom_components/pollenlevels/translations/de.json @@ -29,7 +29,8 @@ "empty": "Dieses Feld darf nicht leer sein", "invalid_option_combo": "Erhöhe 'Vorhersagetage', um die gewählten Tagessensoren abzudecken.", "invalid_coordinates": "Wähle einen gültigen Standort auf der Karte aus.", - "unknown": "Unbekannter Fehler" + "unknown": "Unbekannter Fehler", + "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters." }, "abort": { "already_configured": "Dieser Standort ist bereits konfiguriert.", diff --git a/custom_components/pollenlevels/translations/en.json b/custom_components/pollenlevels/translations/en.json index dffcc70a..f2380fc0 100644 --- a/custom_components/pollenlevels/translations/en.json +++ b/custom_components/pollenlevels/translations/en.json @@ -29,7 +29,8 @@ "empty": "This field cannot be empty", "invalid_option_combo": "Increase 'Forecast days' to cover selected per-day sensors.", "invalid_coordinates": "Please select a valid location on the map.", - "unknown": "Unknown error" + "unknown": "Unknown error", + "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters." }, "abort": { "already_configured": "This location is already configured.", diff --git a/custom_components/pollenlevels/translations/es.json b/custom_components/pollenlevels/translations/es.json index ebd1e46c..3c7a2a4d 100644 --- a/custom_components/pollenlevels/translations/es.json +++ b/custom_components/pollenlevels/translations/es.json @@ -29,7 +29,8 @@ "empty": "Este campo no puede estar vacío", "invalid_option_combo": "Aumenta 'Días de previsión' para cubrir los sensores por día seleccionados.", "invalid_coordinates": "Selecciona una ubicación válida en el mapa.", - "unknown": "Error desconocido" + "unknown": "Error desconocido", + "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters." }, "abort": { "already_configured": "Esta ubicación ya está configurada.", diff --git a/custom_components/pollenlevels/translations/fi.json b/custom_components/pollenlevels/translations/fi.json index 071dc24c..154bbae5 100644 --- a/custom_components/pollenlevels/translations/fi.json +++ b/custom_components/pollenlevels/translations/fi.json @@ -29,7 +29,8 @@ "empty": "Tämä kenttä ei voi olla tyhjä", "invalid_option_combo": "Lisää \"Ennustepäiviä\", jotta valitut päiväsensorit katetaan.", "invalid_coordinates": "Valitse kartalta kelvollinen sijainti.", - "unknown": "Tuntematon virhe" + "unknown": "Tuntematon virhe", + "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters." }, "abort": { "already_configured": "Tämä sijainti on jo määritetty.", diff --git a/custom_components/pollenlevels/translations/fr.json b/custom_components/pollenlevels/translations/fr.json index cf55d0f4..d50a25b6 100644 --- a/custom_components/pollenlevels/translations/fr.json +++ b/custom_components/pollenlevels/translations/fr.json @@ -29,7 +29,8 @@ "empty": "Ce champ ne peut pas être vide", "invalid_option_combo": "Augmentez le nombre de « Jours de prévision » afin de couvrir les capteurs par jour sélectionnés.", "invalid_coordinates": "Sélectionnez un emplacement valide sur la carte.", - "unknown": "Erreur inconnue" + "unknown": "Erreur inconnue", + "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters." }, "abort": { "already_configured": "Cet emplacement est déjà configuré.", diff --git a/custom_components/pollenlevels/translations/hu.json b/custom_components/pollenlevels/translations/hu.json index 0c551097..a1d60b8e 100644 --- a/custom_components/pollenlevels/translations/hu.json +++ b/custom_components/pollenlevels/translations/hu.json @@ -29,7 +29,8 @@ "empty": "A mező nem lehet üres", "invalid_option_combo": "Növeld a \"Előrejelzési napok\" értéket a kiválasztott napi szenzorok lefedéséhez.", "invalid_coordinates": "Válassz érvényes helyet a térképen.", - "unknown": "Ismeretlen hiba" + "unknown": "Ismeretlen hiba", + "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters." }, "abort": { "already_configured": "Ez a hely már konfigurálva van.", diff --git a/custom_components/pollenlevels/translations/it.json b/custom_components/pollenlevels/translations/it.json index 8ad8b823..0c5ef7fa 100644 --- a/custom_components/pollenlevels/translations/it.json +++ b/custom_components/pollenlevels/translations/it.json @@ -29,7 +29,8 @@ "empty": "Questo campo non può essere vuoto", "invalid_option_combo": "Aumenta 'Giorni di previsione' per coprire i sensori giornalieri selezionati.", "invalid_coordinates": "Seleziona una posizione valida sulla mappa.", - "unknown": "Errore sconosciuto" + "unknown": "Errore sconosciuto", + "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters." }, "abort": { "already_configured": "Questa posizione è già configurata.", diff --git a/custom_components/pollenlevels/translations/nb.json b/custom_components/pollenlevels/translations/nb.json index 3da54ef0..c92e7a97 100644 --- a/custom_components/pollenlevels/translations/nb.json +++ b/custom_components/pollenlevels/translations/nb.json @@ -29,7 +29,8 @@ "empty": "Dette feltet kan ikke være tomt", "invalid_option_combo": "Øk «Prognosedager» for å dekke valgte sensorer per dag.", "invalid_coordinates": "Velg en gyldig posisjon på kartet.", - "unknown": "Ukjent feil" + "unknown": "Ukjent feil", + "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters." }, "abort": { "already_configured": "Dette stedet er allerede konfigurert.", diff --git a/custom_components/pollenlevels/translations/nl.json b/custom_components/pollenlevels/translations/nl.json index 7da90615..742b5272 100644 --- a/custom_components/pollenlevels/translations/nl.json +++ b/custom_components/pollenlevels/translations/nl.json @@ -29,7 +29,8 @@ "empty": "Dit veld mag niet leeg zijn", "invalid_option_combo": "Verhoog 'Voorspellingsdagen' om de geselecteerde per-dag sensoren te dekken.", "invalid_coordinates": "Selecteer een geldige locatie op de kaart.", - "unknown": "Onbekende fout" + "unknown": "Onbekende fout", + "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters." }, "abort": { "already_configured": "Deze locatie is al geconfigureerd.", diff --git a/custom_components/pollenlevels/translations/pl.json b/custom_components/pollenlevels/translations/pl.json index 8af09456..3fa31254 100644 --- a/custom_components/pollenlevels/translations/pl.json +++ b/custom_components/pollenlevels/translations/pl.json @@ -29,7 +29,8 @@ "empty": "To pole nie może być puste", "invalid_option_combo": "Zwiększ 'Dni prognozy', aby objąć wybrane czujniki dzienne.", "invalid_coordinates": "Wybierz prawidłową lokalizację na mapie.", - "unknown": "Nieznany błąd" + "unknown": "Nieznany błąd", + "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters." }, "abort": { "already_configured": "Ta lokalizacja jest już skonfigurowana.", diff --git a/custom_components/pollenlevels/translations/pt-BR.json b/custom_components/pollenlevels/translations/pt-BR.json index 68f60be2..0078d075 100644 --- a/custom_components/pollenlevels/translations/pt-BR.json +++ b/custom_components/pollenlevels/translations/pt-BR.json @@ -29,7 +29,8 @@ "empty": "Este campo não pode ficar vazio", "invalid_option_combo": "Aumente \"Dias de previsão\" para cobrir os sensores por dia selecionados.", "invalid_coordinates": "Selecione um local válido no mapa.", - "unknown": "Erro desconhecido" + "unknown": "Erro desconhecido", + "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters." }, "abort": { "already_configured": "Este local já está configurado.", diff --git a/custom_components/pollenlevels/translations/pt-PT.json b/custom_components/pollenlevels/translations/pt-PT.json index a4cefb09..bc7f4a4c 100644 --- a/custom_components/pollenlevels/translations/pt-PT.json +++ b/custom_components/pollenlevels/translations/pt-PT.json @@ -29,7 +29,8 @@ "empty": "Este campo não pode estar vazio", "invalid_option_combo": "Aumente \"Dias de previsão\" para cobrir os sensores por dia selecionados.", "invalid_coordinates": "Selecione uma localização válida no mapa.", - "unknown": "Erro desconhecido" + "unknown": "Erro desconhecido", + "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters." }, "abort": { "already_configured": "Esta localização já está configurada.", diff --git a/custom_components/pollenlevels/translations/ro.json b/custom_components/pollenlevels/translations/ro.json index a32c125a..1259741c 100644 --- a/custom_components/pollenlevels/translations/ro.json +++ b/custom_components/pollenlevels/translations/ro.json @@ -29,7 +29,8 @@ "empty": "Acest câmp nu poate fi gol", "invalid_option_combo": "Măriți \"Zilele de prognoză\" pentru a acoperi senzorii selectați pe zile.", "invalid_coordinates": "Selectează o locație validă pe hartă.", - "unknown": "Eroare necunoscută" + "unknown": "Eroare necunoscută", + "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters." }, "abort": { "already_configured": "Această locație este deja configurată.", diff --git a/custom_components/pollenlevels/translations/ru.json b/custom_components/pollenlevels/translations/ru.json index 1a42b05b..c57dc76c 100644 --- a/custom_components/pollenlevels/translations/ru.json +++ b/custom_components/pollenlevels/translations/ru.json @@ -29,7 +29,8 @@ "empty": "Это поле не может быть пустым", "invalid_option_combo": "Увеличьте «Дни прогноза», чтобы охватить выбранные датчики по дням.", "invalid_coordinates": "Выберите корректное местоположение на карте.", - "unknown": "Неизвестная ошибка" + "unknown": "Неизвестная ошибка", + "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters." }, "abort": { "already_configured": "Это местоположение уже настроено.", diff --git a/custom_components/pollenlevels/translations/sv.json b/custom_components/pollenlevels/translations/sv.json index 57918c84..606024ad 100644 --- a/custom_components/pollenlevels/translations/sv.json +++ b/custom_components/pollenlevels/translations/sv.json @@ -29,7 +29,8 @@ "empty": "Detta fält får inte vara tomt", "invalid_option_combo": "Öka \"Prognosdagar\" för att täcka valda sensorer per dag.", "invalid_coordinates": "Välj en giltig plats på kartan.", - "unknown": "Okänt fel" + "unknown": "Okänt fel", + "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters." }, "abort": { "already_configured": "Den här platsen är redan konfigurerad.", diff --git a/custom_components/pollenlevels/translations/uk.json b/custom_components/pollenlevels/translations/uk.json index 388f9bfa..d5ae193c 100644 --- a/custom_components/pollenlevels/translations/uk.json +++ b/custom_components/pollenlevels/translations/uk.json @@ -29,7 +29,8 @@ "empty": "Це поле не може бути порожнім", "invalid_option_combo": "Збільшіть «Дні прогнозу», щоб охопити вибрані денні датчики.", "invalid_coordinates": "Виберіть дійсне місце на карті.", - "unknown": "Невідома помилка" + "unknown": "Невідома помилка", + "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters." }, "abort": { "already_configured": "Це розташування вже налаштовано.", diff --git a/custom_components/pollenlevels/translations/zh-Hans.json b/custom_components/pollenlevels/translations/zh-Hans.json index 1f200637..d7f3c0d0 100644 --- a/custom_components/pollenlevels/translations/zh-Hans.json +++ b/custom_components/pollenlevels/translations/zh-Hans.json @@ -29,7 +29,8 @@ "empty": "此字段不能为空", "invalid_option_combo": "请增加“预测天数”,以覆盖所选的逐日类型传感器。", "invalid_coordinates": "请在地图上选择有效的位置。", - "unknown": "未知错误" + "unknown": "未知错误", + "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters." }, "abort": { "already_configured": "该位置已配置。", diff --git a/custom_components/pollenlevels/translations/zh-Hant.json b/custom_components/pollenlevels/translations/zh-Hant.json index 0c4c4e3d..28a788bf 100644 --- a/custom_components/pollenlevels/translations/zh-Hant.json +++ b/custom_components/pollenlevels/translations/zh-Hant.json @@ -29,7 +29,8 @@ "empty": "此欄位不得為空", "invalid_option_combo": "請增加「預測天數」以涵蓋所選的逐日類型感測器。", "invalid_coordinates": "請在地圖上選擇有效的位置。", - "unknown": "未知錯誤" + "unknown": "未知錯誤", + "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters." }, "abort": { "already_configured": "此位置已設定。", From 150d65bec862fecbc04b33d43b670e543e2564f2 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sun, 14 Dec 2025 07:24:20 +0100 Subject: [PATCH 018/200] Fix referrer validation feedback and test stubs --- custom_components/pollenlevels/config_flow.py | 12 +- .../pollenlevels/translations/cs.json | 126 +++++++++--------- .../pollenlevels/translations/da.json | 126 +++++++++--------- .../pollenlevels/translations/de.json | 126 +++++++++--------- .../pollenlevels/translations/fi.json | 126 +++++++++--------- .../pollenlevels/translations/fr.json | 126 +++++++++--------- .../pollenlevels/translations/hu.json | 126 +++++++++--------- .../pollenlevels/translations/it.json | 126 +++++++++--------- .../pollenlevels/translations/nb.json | 126 +++++++++--------- .../pollenlevels/translations/nl.json | 126 +++++++++--------- .../pollenlevels/translations/pl.json | 126 +++++++++--------- .../pollenlevels/translations/pt-BR.json | 126 +++++++++--------- .../pollenlevels/translations/pt-PT.json | 126 +++++++++--------- .../pollenlevels/translations/ro.json | 126 +++++++++--------- .../pollenlevels/translations/ru.json | 126 +++++++++--------- .../pollenlevels/translations/sv.json | 126 +++++++++--------- .../pollenlevels/translations/uk.json | 126 +++++++++--------- .../pollenlevels/translations/zh-Hans.json | 126 +++++++++--------- .../pollenlevels/translations/zh-Hant.json | 126 +++++++++--------- tests/test_config_flow.py | 81 +++++++++++ 20 files changed, 1217 insertions(+), 1144 deletions(-) diff --git a/custom_components/pollenlevels/config_flow.py b/custom_components/pollenlevels/config_flow.py index d04ead27..230261c2 100644 --- a/custom_components/pollenlevels/config_flow.py +++ b/custom_components/pollenlevels/config_flow.py @@ -419,19 +419,11 @@ async def async_step_user(self, user_input=None): http_referer: str | None = None if raw_http_referer is not None: if not isinstance(raw_http_referer, str): - errors["base"] = "invalid_http_referrer" - description_placeholders["error_message"] = ( - "Invalid HTTP referrer value. It must not contain newline" - " characters." - ) + errors[CONF_HTTP_REFERER] = "invalid_http_referrer" else: http_referer = raw_http_referer.strip() if "\r" in http_referer or "\n" in http_referer: - errors["base"] = "invalid_http_referrer" - description_placeholders["error_message"] = ( - "Invalid HTTP referrer value. It must not contain newline" - " characters." - ) + errors[CONF_HTTP_REFERER] = "invalid_http_referrer" elif not http_referer: http_referer = None diff --git a/custom_components/pollenlevels/translations/cs.json b/custom_components/pollenlevels/translations/cs.json index edf27af8..6c13ed56 100644 --- a/custom_components/pollenlevels/translations/cs.json +++ b/custom_components/pollenlevels/translations/cs.json @@ -1,95 +1,95 @@ { "config": { - "step": { - "user": { - "title": "Konfigurace úrovní pylu", - "description": "Zadejte svůj klíč Google API, vyberte svou polohu na mapě, interval aktualizace (hodiny) a kód jazyka pro odpověď API.", - "data": { - "api_key": "Klíč API", - "name": "Název", - "location": "Poloha", - "update_interval": "Interval aktualizace (hodiny)", - "language_code": "Kód jazyka odpovědi API" - } - }, - "reauth_confirm": { - "title": "Znovu ověřte Pollen Levels", - "description": "Klíč API pro {latitude},{longitude} již není platný. Zadejte nový klíč, aby se obnovily aktualizace.", - "data": { - "api_key": "Klíč API" - } - } + "abort": { + "already_configured": "Toto umístění je již nakonfigurováno.", + "reauth_failed": "Opětovné ověření se nezdařilo. Zkuste to znovu.", + "reauth_successful": "Opětovné ověření proběhlo úspěšně." }, "error": { + "cannot_connect": "Nelze se připojit ke službě\n\n{error_message}", + "empty": "Toto pole nemůže být prázdné", "invalid_auth": "Neplatný klíč API", - "cannot_connect": "Nelze se připojit ke službě", - "quota_exceeded": "Překročena kvóta", + "invalid_coordinates": "Vyberte platné umístění na mapě.", + "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters.", "invalid_language": "Neplatný kód jazyka", "invalid_language_format": "Použijte kanonický kód BCP-47, například \"en\" nebo \"es-ES\".", - "empty": "Toto pole nemůže být prázdné", "invalid_option_combo": "Zvyšte „Dny předpovědi“, aby pokryly vybrané senzory po dnech.", - "invalid_coordinates": "Vyberte platné umístění na mapě.", - "unknown": "Neznámá chyba", - "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters." + "quota_exceeded": "Překročena kvóta", + "unknown": "Neznámá chyba" }, - "abort": { - "already_configured": "Toto umístění je již nakonfigurováno.", - "reauth_successful": "Opětovné ověření proběhlo úspěšně.", - "reauth_failed": "Opětovné ověření se nezdařilo. Zkuste to znovu." - } - }, - "options": { "step": { - "init": { - "title": "Pollen Levels – Možnosti", - "description": "Změňte interval aktualizace, jazyk API, dny předpovědi a senzory po dnech pro {title}.\nMožnosti senzorů po dnech (TYPY): Pouze dnes (none), Do zítra (D+1), Do pozítří (D+2).", + "reauth_confirm": { + "data": { + "api_key": "Klíč API" + }, + "description": "Klíč API pro {latitude},{longitude} již není platný. Zadejte nový klíč, aby se obnovily aktualizace.", + "title": "Znovu ověřte Pollen Levels" + }, + "user": { "data": { - "update_interval": "Interval aktualizace (hodiny)", + "api_key": "Klíč API", "language_code": "Kód jazyka odpovědi API", - "forecast_days": "Dny předpovědi (1–5)", - "create_forecast_sensors": "Rozsah senzorů po dnech (TYPY)" - } + "location": "Poloha", + "name": "Název", + "update_interval": "Interval aktualizace (hodiny)" + }, + "description": "Zadejte svůj klíč Google API, vyberte svou polohu na mapě, interval aktualizace (hodiny) a kód jazyka pro odpověď API.", + "title": "Konfigurace úrovní pylu" } - }, - "error": { - "invalid_auth": "Neplatný klíč API", - "cannot_connect": "Nelze se připojit ke službě", - "quota_exceeded": "Překročena kvóta", - "invalid_language": "Neplatný kód jazyka", - "invalid_language_format": "Použijte kanonický kód BCP-47, například \"en\" nebo \"es-ES\".", - "empty": "Toto pole nemůže být prázdné", - "invalid_option_combo": "Zvyšte „Dny předpovědi“, aby pokryly vybrané senzory po dnech.", - "unknown": "Neznámá chyba" } }, "device": { - "types": { - "name": "{title} - Typy pylu ({latitude},{longitude})" + "info": { + "name": "{title} - Informace o pylu ({latitude},{longitude})" }, "plants": { "name": "{title} - Rostliny ({latitude},{longitude})" }, - "info": { - "name": "{title} - Informace o pylu ({latitude},{longitude})" - } - }, - "services": { - "force_update": { - "name": "Vynutit aktualizaci", - "description": "Ručně obnoví údaje o pylu pro všechna nastavená místa." + "types": { + "name": "{title} - Typy pylu ({latitude},{longitude})" } }, "entity": { "sensor": { - "region": { - "name": "Oblast" - }, "date": { "name": "Datum" }, "last_updated": { "name": "Poslední aktualizace" + }, + "region": { + "name": "Oblast" + } + } + }, + "options": { + "error": { + "cannot_connect": "Nelze se připojit ke službě", + "empty": "Toto pole nemůže být prázdné", + "invalid_auth": "Neplatný klíč API", + "invalid_language": "Neplatný kód jazyka", + "invalid_language_format": "Použijte kanonický kód BCP-47, například \"en\" nebo \"es-ES\".", + "invalid_option_combo": "Zvyšte „Dny předpovědi“, aby pokryly vybrané senzory po dnech.", + "quota_exceeded": "Překročena kvóta", + "unknown": "Neznámá chyba" + }, + "step": { + "init": { + "data": { + "create_forecast_sensors": "Rozsah senzorů po dnech (TYPY)", + "forecast_days": "Dny předpovědi (1–5)", + "language_code": "Kód jazyka odpovědi API", + "update_interval": "Interval aktualizace (hodiny)" + }, + "description": "Změňte interval aktualizace, jazyk API, dny předpovědi a senzory po dnech pro {title}.\nMožnosti senzorů po dnech (TYPY): Pouze dnes (none), Do zítra (D+1), Do pozítří (D+2).", + "title": "Pollen Levels – Možnosti" } } + }, + "services": { + "force_update": { + "description": "Ručně obnoví údaje o pylu pro všechna nastavená místa.", + "name": "Vynutit aktualizaci" + } } -} +} \ No newline at end of file diff --git a/custom_components/pollenlevels/translations/da.json b/custom_components/pollenlevels/translations/da.json index 7e35cf09..1d34ba05 100644 --- a/custom_components/pollenlevels/translations/da.json +++ b/custom_components/pollenlevels/translations/da.json @@ -1,95 +1,95 @@ { "config": { - "step": { - "user": { - "title": "Konfiguration af pollenniveauer", - "description": "Angiv din Google API-nøgle, vælg din placering på kortet, opdateringsinterval (timer) og sprogkode for API-svar.", - "data": { - "api_key": "API-nøgle", - "name": "Navn", - "location": "Placering", - "update_interval": "Opdateringsinterval (timer)", - "language_code": "Sprogkode for API-svar" - } - }, - "reauth_confirm": { - "title": "Godkend Pollen Levels igen", - "description": "API-nøglen for {latitude},{longitude} er ikke længere gyldig. Indtast en ny nøgle for at genoptage opdateringerne.", - "data": { - "api_key": "API-nøgle" - } - } + "abort": { + "already_configured": "Denne placering er allerede konfigureret.", + "reauth_failed": "Genautentificering mislykkedes. Prøv igen.", + "reauth_successful": "Genautentificering fuldført." }, "error": { + "cannot_connect": "Kan ikke oprette forbindelse til tjenesten\n\n{error_message}", + "empty": "Dette felt må ikke være tomt", "invalid_auth": "Ugyldig API-nøgle", - "cannot_connect": "Kan ikke oprette forbindelse til tjenesten", - "quota_exceeded": "Kvote overskredet", + "invalid_coordinates": "Vælg en gyldig placering på kortet.", + "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters.", "invalid_language": "Ugyldig sprogkode", "invalid_language_format": "Brug en kanonisk BCP-47-kode som \"en\" eller \"es-ES\".", - "empty": "Dette felt må ikke være tomt", "invalid_option_combo": "Forøg \"Prognosedage\" for at dække valgte sensorer pr. dag.", - "invalid_coordinates": "Vælg en gyldig placering på kortet.", - "unknown": "Ukendt fejl", - "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters." + "quota_exceeded": "Kvote overskredet", + "unknown": "Ukendt fejl" }, - "abort": { - "already_configured": "Denne placering er allerede konfigureret.", - "reauth_successful": "Genautentificering fuldført.", - "reauth_failed": "Genautentificering mislykkedes. Prøv igen." - } - }, - "options": { "step": { - "init": { - "title": "Pollen Levels – Indstillinger", - "description": "Skift opdateringsinterval, API-sprog, prognosedage og sensorer pr. dag for {title}.\nIndstillinger for sensorer pr. dag (TYPER): Kun i dag (none), Til og med i morgen (D+1), Til og med overmorgen (D+2).", + "reauth_confirm": { + "data": { + "api_key": "API-nøgle" + }, + "description": "API-nøglen for {latitude},{longitude} er ikke længere gyldig. Indtast en ny nøgle for at genoptage opdateringerne.", + "title": "Godkend Pollen Levels igen" + }, + "user": { "data": { - "update_interval": "Opdateringsinterval (timer)", + "api_key": "API-nøgle", "language_code": "Sprogkode for API-svar", - "forecast_days": "Prognosedage (1–5)", - "create_forecast_sensors": "Omfang af sensorer pr. dag (TYPER)" - } + "location": "Placering", + "name": "Navn", + "update_interval": "Opdateringsinterval (timer)" + }, + "description": "Angiv din Google API-nøgle, vælg din placering på kortet, opdateringsinterval (timer) og sprogkode for API-svar.", + "title": "Konfiguration af pollenniveauer" } - }, - "error": { - "invalid_auth": "Ugyldig API-nøgle", - "cannot_connect": "Kan ikke oprette forbindelse til tjenesten", - "quota_exceeded": "Kvote overskredet", - "invalid_language": "Ugyldig sprogkode", - "invalid_language_format": "Brug en kanonisk BCP-47-kode som \"en\" eller \"es-ES\".", - "empty": "Dette felt må ikke være tomt", - "invalid_option_combo": "Forøg \"Prognosedage\" for at dække valgte sensorer pr. dag.", - "unknown": "Ukendt fejl" } }, "device": { - "types": { - "name": "{title} - Pollentyper ({latitude},{longitude})" + "info": { + "name": "{title} - Polleninfo ({latitude},{longitude})" }, "plants": { "name": "{title} - Planter ({latitude},{longitude})" }, - "info": { - "name": "{title} - Polleninfo ({latitude},{longitude})" - } - }, - "services": { - "force_update": { - "name": "Gennemtving opdatering", - "description": "Opdaterer manuelt pollendata for alle konfigurerede placeringer." + "types": { + "name": "{title} - Pollentyper ({latitude},{longitude})" } }, "entity": { "sensor": { - "region": { - "name": "Region" - }, "date": { "name": "Dato" }, "last_updated": { "name": "Sidst opdateret" + }, + "region": { + "name": "Region" + } + } + }, + "options": { + "error": { + "cannot_connect": "Kan ikke oprette forbindelse til tjenesten", + "empty": "Dette felt må ikke være tomt", + "invalid_auth": "Ugyldig API-nøgle", + "invalid_language": "Ugyldig sprogkode", + "invalid_language_format": "Brug en kanonisk BCP-47-kode som \"en\" eller \"es-ES\".", + "invalid_option_combo": "Forøg \"Prognosedage\" for at dække valgte sensorer pr. dag.", + "quota_exceeded": "Kvote overskredet", + "unknown": "Ukendt fejl" + }, + "step": { + "init": { + "data": { + "create_forecast_sensors": "Omfang af sensorer pr. dag (TYPER)", + "forecast_days": "Prognosedage (1–5)", + "language_code": "Sprogkode for API-svar", + "update_interval": "Opdateringsinterval (timer)" + }, + "description": "Skift opdateringsinterval, API-sprog, prognosedage og sensorer pr. dag for {title}.\nIndstillinger for sensorer pr. dag (TYPER): Kun i dag (none), Til og med i morgen (D+1), Til og med overmorgen (D+2).", + "title": "Pollen Levels – Indstillinger" } } + }, + "services": { + "force_update": { + "description": "Opdaterer manuelt pollendata for alle konfigurerede placeringer.", + "name": "Gennemtving opdatering" + } } -} +} \ No newline at end of file diff --git a/custom_components/pollenlevels/translations/de.json b/custom_components/pollenlevels/translations/de.json index ffb677f6..2996aebf 100644 --- a/custom_components/pollenlevels/translations/de.json +++ b/custom_components/pollenlevels/translations/de.json @@ -1,95 +1,95 @@ { "config": { - "step": { - "user": { - "title": "Pollen Levels – Konfiguration", - "description": "Gib deinen Google API-Schlüssel an, wähle deinen Standort auf der Karte, das Aktualisierungsintervall (Stunden) und den Sprachcode für die API-Antwort.", - "data": { - "api_key": "API-Schlüssel", - "name": "Name", - "location": "Standort", - "update_interval": "Aktualisierungsintervall (Stunden)", - "language_code": "Sprachcode für die API-Antwort" - } - }, - "reauth_confirm": { - "title": "Pollen Levels erneut authentifizieren", - "description": "Der API-Schlüssel für {latitude},{longitude} ist nicht mehr gültig. Gib einen neuen Schlüssel ein, um die Aktualisierungen fortzusetzen.", - "data": { - "api_key": "API-Schlüssel" - } - } + "abort": { + "already_configured": "Dieser Standort ist bereits konfiguriert.", + "reauth_failed": "Die erneute Authentifizierung ist fehlgeschlagen. Bitte versuche es erneut.", + "reauth_successful": "Die erneute Authentifizierung war erfolgreich." }, "error": { + "cannot_connect": "Verbindung zum Dienst fehlgeschlagen\n\n{error_message}", + "empty": "Dieses Feld darf nicht leer sein", "invalid_auth": "Ungültiger API-Schlüssel", - "cannot_connect": "Verbindung zum Dienst fehlgeschlagen", - "quota_exceeded": "Kontingent überschritten", + "invalid_coordinates": "Wähle einen gültigen Standort auf der Karte aus.", + "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters.", "invalid_language": "Ungültiger Sprachcode", "invalid_language_format": "Verwenden Sie einen kanonischen BCP-47-Code wie \"en\" oder \"es-ES\".", - "empty": "Dieses Feld darf nicht leer sein", "invalid_option_combo": "Erhöhe 'Vorhersagetage', um die gewählten Tagessensoren abzudecken.", - "invalid_coordinates": "Wähle einen gültigen Standort auf der Karte aus.", - "unknown": "Unbekannter Fehler", - "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters." + "quota_exceeded": "Kontingent überschritten", + "unknown": "Unbekannter Fehler" }, - "abort": { - "already_configured": "Dieser Standort ist bereits konfiguriert.", - "reauth_successful": "Die erneute Authentifizierung war erfolgreich.", - "reauth_failed": "Die erneute Authentifizierung ist fehlgeschlagen. Bitte versuche es erneut." - } - }, - "options": { "step": { - "init": { - "title": "Pollen Levels – Optionen", - "description": "Ändere Aktualisierungsintervall, API-Sprache, Vorhersagetage und Tagessensoren für {title}.\nOptionen für Tagessensoren (TYPEN): Nur heute (none), Bis morgen (D+1), Bis übermorgen (D+2).", + "reauth_confirm": { + "data": { + "api_key": "API-Schlüssel" + }, + "description": "Der API-Schlüssel für {latitude},{longitude} ist nicht mehr gültig. Gib einen neuen Schlüssel ein, um die Aktualisierungen fortzusetzen.", + "title": "Pollen Levels erneut authentifizieren" + }, + "user": { "data": { - "update_interval": "Aktualisierungsintervall (Stunden)", + "api_key": "API-Schlüssel", "language_code": "Sprachcode für die API-Antwort", - "forecast_days": "Vorhersagetage (1–5)", - "create_forecast_sensors": "Bereich der Tagessensoren (TYPEN)" - } + "location": "Standort", + "name": "Name", + "update_interval": "Aktualisierungsintervall (Stunden)" + }, + "description": "Gib deinen Google API-Schlüssel an, wähle deinen Standort auf der Karte, das Aktualisierungsintervall (Stunden) und den Sprachcode für die API-Antwort.", + "title": "Pollen Levels – Konfiguration" } - }, - "error": { - "invalid_auth": "Ungültiger API-Schlüssel", - "cannot_connect": "Verbindung zum Dienst fehlgeschlagen", - "quota_exceeded": "Kontingent überschritten", - "invalid_language": "Ungültiger Sprachcode", - "invalid_language_format": "Verwenden Sie einen kanonischen BCP-47-Code wie \"en\" oder \"es-ES\".", - "empty": "Dieses Feld darf nicht leer sein", - "invalid_option_combo": "Erhöhe 'Vorhersagetage', um die gewählten Tagessensoren abzudecken.", - "unknown": "Unbekannter Fehler" } }, "device": { - "types": { - "name": "{title} - Pollenarten ({latitude},{longitude})" + "info": { + "name": "{title} - Polleninformationen ({latitude},{longitude})" }, "plants": { "name": "{title} - Pflanzen ({latitude},{longitude})" }, - "info": { - "name": "{title} - Polleninformationen ({latitude},{longitude})" - } - }, - "services": { - "force_update": { - "name": "Aktualisierung erzwingen", - "description": "Aktualisiert die Pollendaten für alle konfigurierten Standorte manuell." + "types": { + "name": "{title} - Pollenarten ({latitude},{longitude})" } }, "entity": { "sensor": { - "region": { - "name": "Region" - }, "date": { "name": "Datum" }, "last_updated": { "name": "Letzte Aktualisierung" + }, + "region": { + "name": "Region" + } + } + }, + "options": { + "error": { + "cannot_connect": "Verbindung zum Dienst fehlgeschlagen", + "empty": "Dieses Feld darf nicht leer sein", + "invalid_auth": "Ungültiger API-Schlüssel", + "invalid_language": "Ungültiger Sprachcode", + "invalid_language_format": "Verwenden Sie einen kanonischen BCP-47-Code wie \"en\" oder \"es-ES\".", + "invalid_option_combo": "Erhöhe 'Vorhersagetage', um die gewählten Tagessensoren abzudecken.", + "quota_exceeded": "Kontingent überschritten", + "unknown": "Unbekannter Fehler" + }, + "step": { + "init": { + "data": { + "create_forecast_sensors": "Bereich der Tagessensoren (TYPEN)", + "forecast_days": "Vorhersagetage (1–5)", + "language_code": "Sprachcode für die API-Antwort", + "update_interval": "Aktualisierungsintervall (Stunden)" + }, + "description": "Ändere Aktualisierungsintervall, API-Sprache, Vorhersagetage und Tagessensoren für {title}.\nOptionen für Tagessensoren (TYPEN): Nur heute (none), Bis morgen (D+1), Bis übermorgen (D+2).", + "title": "Pollen Levels – Optionen" } } + }, + "services": { + "force_update": { + "description": "Aktualisiert die Pollendaten für alle konfigurierten Standorte manuell.", + "name": "Aktualisierung erzwingen" + } } -} +} \ No newline at end of file diff --git a/custom_components/pollenlevels/translations/fi.json b/custom_components/pollenlevels/translations/fi.json index 154bbae5..ee7ee902 100644 --- a/custom_components/pollenlevels/translations/fi.json +++ b/custom_components/pollenlevels/translations/fi.json @@ -1,95 +1,95 @@ { "config": { - "step": { - "user": { - "title": "Siitepölytason asetukset", - "description": "Anna Google API -avaimesi, valitse sijaintisi kartalta, päivitysväli (tunnit) ja API-vastauksen kielikoodi.", - "data": { - "api_key": "API-avain", - "name": "Nimi", - "location": "Sijainti", - "update_interval": "Päivitysväli (tunnit)", - "language_code": "API-vastauksen kielikoodi" - } - }, - "reauth_confirm": { - "title": "Todenna Pollen Levels uudelleen", - "description": "API-avain sijainnille {latitude},{longitude} ei ole enää voimassa. Anna uusi avain, jotta päivitykset jatkuvat.", - "data": { - "api_key": "API-avain" - } - } + "abort": { + "already_configured": "Tämä sijainti on jo määritetty.", + "reauth_failed": "Uudelleentodennus epäonnistui. Yritä uudelleen.", + "reauth_successful": "Uudelleentodennus onnistui." }, "error": { + "cannot_connect": "Palveluun ei saada yhteyttä\n\n{error_message}", + "empty": "Tämä kenttä ei voi olla tyhjä", "invalid_auth": "Virheellinen API-avain", - "cannot_connect": "Palveluun ei saada yhteyttä", - "quota_exceeded": "Kiintiö ylitetty", + "invalid_coordinates": "Valitse kartalta kelvollinen sijainti.", + "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters.", "invalid_language": "Virheellinen kielikoodi", "invalid_language_format": "Käytä kanonista BCP-47-koodia, esimerkiksi \"en\" tai \"es-ES\".", - "empty": "Tämä kenttä ei voi olla tyhjä", "invalid_option_combo": "Lisää \"Ennustepäiviä\", jotta valitut päiväsensorit katetaan.", - "invalid_coordinates": "Valitse kartalta kelvollinen sijainti.", - "unknown": "Tuntematon virhe", - "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters." + "quota_exceeded": "Kiintiö ylitetty", + "unknown": "Tuntematon virhe" }, - "abort": { - "already_configured": "Tämä sijainti on jo määritetty.", - "reauth_successful": "Uudelleentodennus onnistui.", - "reauth_failed": "Uudelleentodennus epäonnistui. Yritä uudelleen." - } - }, - "options": { "step": { - "init": { - "title": "Pollen Levels – Asetukset", - "description": "Muuta päivitysväliä, API-kieltä, ennustepäiviä ja päiväsensoreita TYYPEILLE kohteelle {title}.\nPäiväsensorien vaihtoehdot (TYYPIT): Vain tänään (none), Huomiseen asti (D+1), Ylihuomiseen asti (D+2).", + "reauth_confirm": { + "data": { + "api_key": "API-avain" + }, + "description": "API-avain sijainnille {latitude},{longitude} ei ole enää voimassa. Anna uusi avain, jotta päivitykset jatkuvat.", + "title": "Todenna Pollen Levels uudelleen" + }, + "user": { "data": { - "update_interval": "Päivitysväli (tunnit)", + "api_key": "API-avain", "language_code": "API-vastauksen kielikoodi", - "forecast_days": "Ennustepäivät (1–5)", - "create_forecast_sensors": "Päiväsensorien laajuus (TYYPIT)" - } + "location": "Sijainti", + "name": "Nimi", + "update_interval": "Päivitysväli (tunnit)" + }, + "description": "Anna Google API -avaimesi, valitse sijaintisi kartalta, päivitysväli (tunnit) ja API-vastauksen kielikoodi.", + "title": "Siitepölytason asetukset" } - }, - "error": { - "invalid_auth": "Virheellinen API-avain", - "cannot_connect": "Palveluun ei saada yhteyttä", - "quota_exceeded": "Kiintiö ylitetty", - "invalid_language": "Virheellinen kielikoodi", - "invalid_language_format": "Käytä kanonista BCP-47-koodia, esimerkiksi \"en\" tai \"es-ES\".", - "empty": "Tämä kenttä ei voi olla tyhjä", - "invalid_option_combo": "Lisää \"Ennustepäiviä\", jotta valitut päiväsensorit katetaan.", - "unknown": "Tuntematon virhe" } }, "device": { - "types": { - "name": "{title} - Siitepölytyypit ({latitude},{longitude})" + "info": { + "name": "{title} - Siitepölytiedot ({latitude},{longitude})" }, "plants": { "name": "{title} - Kasvit ({latitude},{longitude})" }, - "info": { - "name": "{title} - Siitepölytiedot ({latitude},{longitude})" - } - }, - "services": { - "force_update": { - "name": "Pakota päivitys", - "description": "Päivitä siitepölytiedot manuaalisesti kaikille määritetyille sijainneille." + "types": { + "name": "{title} - Siitepölytyypit ({latitude},{longitude})" } }, "entity": { "sensor": { - "region": { - "name": "Alue" - }, "date": { "name": "Päivämäärä" }, "last_updated": { "name": "Viimeksi päivitetty" + }, + "region": { + "name": "Alue" + } + } + }, + "options": { + "error": { + "cannot_connect": "Palveluun ei saada yhteyttä", + "empty": "Tämä kenttä ei voi olla tyhjä", + "invalid_auth": "Virheellinen API-avain", + "invalid_language": "Virheellinen kielikoodi", + "invalid_language_format": "Käytä kanonista BCP-47-koodia, esimerkiksi \"en\" tai \"es-ES\".", + "invalid_option_combo": "Lisää \"Ennustepäiviä\", jotta valitut päiväsensorit katetaan.", + "quota_exceeded": "Kiintiö ylitetty", + "unknown": "Tuntematon virhe" + }, + "step": { + "init": { + "data": { + "create_forecast_sensors": "Päiväsensorien laajuus (TYYPIT)", + "forecast_days": "Ennustepäivät (1–5)", + "language_code": "API-vastauksen kielikoodi", + "update_interval": "Päivitysväli (tunnit)" + }, + "description": "Muuta päivitysväliä, API-kieltä, ennustepäiviä ja päiväsensoreita TYYPEILLE kohteelle {title}.\nPäiväsensorien vaihtoehdot (TYYPIT): Vain tänään (none), Huomiseen asti (D+1), Ylihuomiseen asti (D+2).", + "title": "Pollen Levels – Asetukset" } } + }, + "services": { + "force_update": { + "description": "Päivitä siitepölytiedot manuaalisesti kaikille määritetyille sijainneille.", + "name": "Pakota päivitys" + } } -} +} \ No newline at end of file diff --git a/custom_components/pollenlevels/translations/fr.json b/custom_components/pollenlevels/translations/fr.json index d50a25b6..584615a8 100644 --- a/custom_components/pollenlevels/translations/fr.json +++ b/custom_components/pollenlevels/translations/fr.json @@ -1,95 +1,95 @@ { "config": { - "step": { - "user": { - "title": "Pollen Levels – Configuration", - "description": "Saisissez votre clé API Google, sélectionnez votre position sur la carte, l’intervalle de mise à jour (heures) et le code de langue pour la réponse de l’API.", - "data": { - "api_key": "Clé API", - "name": "Nom", - "location": "Emplacement", - "update_interval": "Intervalle de mise à jour (heures)", - "language_code": "Code de langue pour la réponse de l’API" - } - }, - "reauth_confirm": { - "title": "Réauthentifier Pollen Levels", - "description": "La clé API pour {latitude},{longitude} n’est plus valide. Saisissez une nouvelle clé pour reprendre les mises à jour.", - "data": { - "api_key": "Clé API" - } - } + "abort": { + "already_configured": "Cet emplacement est déjà configuré.", + "reauth_failed": "La réauthentification a échoué. Veuillez réessayer.", + "reauth_successful": "La réauthentification a réussi." }, "error": { + "cannot_connect": "Impossible de se connecter au service\n\n{error_message}", + "empty": "Ce champ ne peut pas être vide", "invalid_auth": "Clé API invalide", - "cannot_connect": "Impossible de se connecter au service", - "quota_exceeded": "Quota dépassé", + "invalid_coordinates": "Sélectionnez un emplacement valide sur la carte.", + "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters.", "invalid_language": "Code de langue invalide", "invalid_language_format": "Utilisez un code BCP-47 canonique tel que \"en\" ou \"es-ES\".", - "empty": "Ce champ ne peut pas être vide", "invalid_option_combo": "Augmentez le nombre de « Jours de prévision » afin de couvrir les capteurs par jour sélectionnés.", - "invalid_coordinates": "Sélectionnez un emplacement valide sur la carte.", - "unknown": "Erreur inconnue", - "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters." + "quota_exceeded": "Quota dépassé", + "unknown": "Erreur inconnue" }, - "abort": { - "already_configured": "Cet emplacement est déjà configuré.", - "reauth_successful": "La réauthentification a réussi.", - "reauth_failed": "La réauthentification a échoué. Veuillez réessayer." - } - }, - "options": { "step": { - "init": { - "title": "Pollen Levels – Options", - "description": "Modifiez l’intervalle de mise à jour, la langue de l’API, les jours de prévision et les capteurs par jour pour {title}.\nOptions des capteurs par jour (TYPES) : Aujourd’hui uniquement (none), Jusqu’à demain (D+1), Jusqu’au surlendemain (D+2).", + "reauth_confirm": { + "data": { + "api_key": "Clé API" + }, + "description": "La clé API pour {latitude},{longitude} n’est plus valide. Saisissez une nouvelle clé pour reprendre les mises à jour.", + "title": "Réauthentifier Pollen Levels" + }, + "user": { "data": { - "update_interval": "Intervalle de mise à jour (heures)", + "api_key": "Clé API", "language_code": "Code de langue pour la réponse de l’API", - "forecast_days": "Jours de prévision (1–5)", - "create_forecast_sensors": "Portée des capteurs par jour (TYPES)" - } + "location": "Emplacement", + "name": "Nom", + "update_interval": "Intervalle de mise à jour (heures)" + }, + "description": "Saisissez votre clé API Google, sélectionnez votre position sur la carte, l’intervalle de mise à jour (heures) et le code de langue pour la réponse de l’API.", + "title": "Pollen Levels – Configuration" } - }, - "error": { - "invalid_auth": "Clé API invalide", - "cannot_connect": "Impossible de se connecter au service", - "quota_exceeded": "Quota dépassé", - "invalid_language": "Code de langue invalide", - "invalid_language_format": "Utilisez un code BCP-47 canonique tel que \"en\" ou \"es-ES\".", - "empty": "Ce champ ne peut pas être vide", - "invalid_option_combo": "Augmentez le nombre de « Jours de prévision » afin de couvrir les capteurs par jour sélectionnés.", - "unknown": "Erreur inconnue" } }, "device": { - "types": { - "name": "{title} - Types de pollen ({latitude},{longitude})" + "info": { + "name": "{title} - Info pollen ({latitude},{longitude})" }, "plants": { "name": "{title} - Plantes ({latitude},{longitude})" }, - "info": { - "name": "{title} - Info pollen ({latitude},{longitude})" - } - }, - "services": { - "force_update": { - "name": "Forcer la mise à jour", - "description": "Actualise manuellement les données de pollen pour tous les emplacements configurés." + "types": { + "name": "{title} - Types de pollen ({latitude},{longitude})" } }, "entity": { "sensor": { - "region": { - "name": "Région" - }, "date": { "name": "Date" }, "last_updated": { "name": "Dernière mise à jour" + }, + "region": { + "name": "Région" + } + } + }, + "options": { + "error": { + "cannot_connect": "Impossible de se connecter au service", + "empty": "Ce champ ne peut pas être vide", + "invalid_auth": "Clé API invalide", + "invalid_language": "Code de langue invalide", + "invalid_language_format": "Utilisez un code BCP-47 canonique tel que \"en\" ou \"es-ES\".", + "invalid_option_combo": "Augmentez le nombre de « Jours de prévision » afin de couvrir les capteurs par jour sélectionnés.", + "quota_exceeded": "Quota dépassé", + "unknown": "Erreur inconnue" + }, + "step": { + "init": { + "data": { + "create_forecast_sensors": "Portée des capteurs par jour (TYPES)", + "forecast_days": "Jours de prévision (1–5)", + "language_code": "Code de langue pour la réponse de l’API", + "update_interval": "Intervalle de mise à jour (heures)" + }, + "description": "Modifiez l’intervalle de mise à jour, la langue de l’API, les jours de prévision et les capteurs par jour pour {title}.\nOptions des capteurs par jour (TYPES) : Aujourd’hui uniquement (none), Jusqu’à demain (D+1), Jusqu’au surlendemain (D+2).", + "title": "Pollen Levels – Options" } } + }, + "services": { + "force_update": { + "description": "Actualise manuellement les données de pollen pour tous les emplacements configurés.", + "name": "Forcer la mise à jour" + } } -} +} \ No newline at end of file diff --git a/custom_components/pollenlevels/translations/hu.json b/custom_components/pollenlevels/translations/hu.json index a1d60b8e..86ba693d 100644 --- a/custom_components/pollenlevels/translations/hu.json +++ b/custom_components/pollenlevels/translations/hu.json @@ -1,95 +1,95 @@ { "config": { - "step": { - "user": { - "title": "Pollen szintek – beállítás", - "description": "Adja meg a Google API-kulcsot, válassza ki helyszínét a térképen, a frissítési időközt (órák) és az API-válasz nyelvi kódját.", - "data": { - "api_key": "API-kulcs", - "name": "Név", - "location": "Helyszín", - "update_interval": "Frissítési időköz (óra)", - "language_code": "API-válasz nyelvi kódja" - } - }, - "reauth_confirm": { - "title": "Hitelesítsd újra a Pollen Levels-t", - "description": "A(z) {latitude},{longitude} API-kulcsa már nem érvényes. Adj meg új kulcsot a frissítések folytatásához.", - "data": { - "api_key": "API-kulcs" - } - } + "abort": { + "already_configured": "Ez a hely már konfigurálva van.", + "reauth_failed": "Az újrahitelesítés nem sikerült. Próbáld meg újra.", + "reauth_successful": "Az újrahitelesítés sikerült." }, "error": { + "cannot_connect": "Nem lehet csatlakozni a szolgáltatáshoz\n\n{error_message}", + "empty": "A mező nem lehet üres", "invalid_auth": "Érvénytelen API-kulcs", - "cannot_connect": "Nem lehet csatlakozni a szolgáltatáshoz", - "quota_exceeded": "Kvóta túllépve", + "invalid_coordinates": "Válassz érvényes helyet a térképen.", + "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters.", "invalid_language": "Érvénytelen nyelvi kód", "invalid_language_format": "Használjon kanonikus BCP-47 kódot, például \"en\" vagy \"es-ES\".", - "empty": "A mező nem lehet üres", "invalid_option_combo": "Növeld a \"Előrejelzési napok\" értéket a kiválasztott napi szenzorok lefedéséhez.", - "invalid_coordinates": "Válassz érvényes helyet a térképen.", - "unknown": "Ismeretlen hiba", - "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters." + "quota_exceeded": "Kvóta túllépve", + "unknown": "Ismeretlen hiba" }, - "abort": { - "already_configured": "Ez a hely már konfigurálva van.", - "reauth_successful": "Az újrahitelesítés sikerült.", - "reauth_failed": "Az újrahitelesítés nem sikerült. Próbáld meg újra." - } - }, - "options": { "step": { - "init": { - "title": "Pollen Levels – Beállítások", - "description": "Módosítsd a frissítési időközt, az API nyelvét, az előrejelzési napokat és a napi TÍPUS szenzorokat a(z) {title} bejegyzéshez.\nNapi TÍPUS szenzorok: Csak ma (none), Holnapig (D+1), Holnaputánig (D+2).", + "reauth_confirm": { + "data": { + "api_key": "API-kulcs" + }, + "description": "A(z) {latitude},{longitude} API-kulcsa már nem érvényes. Adj meg új kulcsot a frissítések folytatásához.", + "title": "Hitelesítsd újra a Pollen Levels-t" + }, + "user": { "data": { - "update_interval": "Frissítési időköz (óra)", + "api_key": "API-kulcs", "language_code": "API-válasz nyelvi kódja", - "forecast_days": "Előrejelzési napok (1–5)", - "create_forecast_sensors": "Napi TÍPUS szenzorok tartománya" - } + "location": "Helyszín", + "name": "Név", + "update_interval": "Frissítési időköz (óra)" + }, + "description": "Adja meg a Google API-kulcsot, válassza ki helyszínét a térképen, a frissítési időközt (órák) és az API-válasz nyelvi kódját.", + "title": "Pollen szintek – beállítás" } - }, - "error": { - "invalid_auth": "Érvénytelen API-kulcs", - "cannot_connect": "Nem lehet csatlakozni a szolgáltatáshoz", - "quota_exceeded": "Kvóta túllépve", - "invalid_language": "Érvénytelen nyelvi kód", - "invalid_language_format": "Használjon kanonikus BCP-47 kódot, például \"en\" vagy \"es-ES\".", - "empty": "A mező nem lehet üres", - "invalid_option_combo": "Növeld a \"Előrejelzési napok\" értéket a kiválasztott napi szenzorok lefedéséhez.", - "unknown": "Ismeretlen hiba" } }, "device": { - "types": { - "name": "{title} - Pollentípusok ({latitude},{longitude})" + "info": { + "name": "{title} - Polleninformáció ({latitude},{longitude})" }, "plants": { "name": "{title} - Növények ({latitude},{longitude})" }, - "info": { - "name": "{title} - Polleninformáció ({latitude},{longitude})" - } - }, - "services": { - "force_update": { - "name": "Frissítés kényszerítése", - "description": "Kézi frissítés a pollenadatokhoz minden beállított helyhez." + "types": { + "name": "{title} - Pollentípusok ({latitude},{longitude})" } }, "entity": { "sensor": { - "region": { - "name": "Régió" - }, "date": { "name": "Dátum" }, "last_updated": { "name": "Utoljára frissítve" + }, + "region": { + "name": "Régió" + } + } + }, + "options": { + "error": { + "cannot_connect": "Nem lehet csatlakozni a szolgáltatáshoz", + "empty": "A mező nem lehet üres", + "invalid_auth": "Érvénytelen API-kulcs", + "invalid_language": "Érvénytelen nyelvi kód", + "invalid_language_format": "Használjon kanonikus BCP-47 kódot, például \"en\" vagy \"es-ES\".", + "invalid_option_combo": "Növeld a \"Előrejelzési napok\" értéket a kiválasztott napi szenzorok lefedéséhez.", + "quota_exceeded": "Kvóta túllépve", + "unknown": "Ismeretlen hiba" + }, + "step": { + "init": { + "data": { + "create_forecast_sensors": "Napi TÍPUS szenzorok tartománya", + "forecast_days": "Előrejelzési napok (1–5)", + "language_code": "API-válasz nyelvi kódja", + "update_interval": "Frissítési időköz (óra)" + }, + "description": "Módosítsd a frissítési időközt, az API nyelvét, az előrejelzési napokat és a napi TÍPUS szenzorokat a(z) {title} bejegyzéshez.\nNapi TÍPUS szenzorok: Csak ma (none), Holnapig (D+1), Holnaputánig (D+2).", + "title": "Pollen Levels – Beállítások" } } + }, + "services": { + "force_update": { + "description": "Kézi frissítés a pollenadatokhoz minden beállított helyhez.", + "name": "Frissítés kényszerítése" + } } -} +} \ No newline at end of file diff --git a/custom_components/pollenlevels/translations/it.json b/custom_components/pollenlevels/translations/it.json index 0c5ef7fa..db0fc3ee 100644 --- a/custom_components/pollenlevels/translations/it.json +++ b/custom_components/pollenlevels/translations/it.json @@ -1,95 +1,95 @@ { "config": { - "step": { - "user": { - "title": "Configurazione Livelli di polline", - "description": "Inserisci la tua chiave API Google, seleziona la tua posizione sulla mappa, l’intervallo di aggiornamento (ore) e il codice lingua per la risposta dell'API.", - "data": { - "api_key": "Chiave API", - "name": "Nome", - "location": "Posizione", - "update_interval": "Intervallo di aggiornamento (ore)", - "language_code": "Codice lingua per la risposta dell'API" - } - }, - "reauth_confirm": { - "title": "Ri-autentica Pollen Levels", - "description": "La chiave API per {latitude},{longitude} non è più valida. Inserisci una nuova chiave per riprendere gli aggiornamenti.", - "data": { - "api_key": "Chiave API" - } - } + "abort": { + "already_configured": "Questa posizione è già configurata.", + "reauth_failed": "La ri-autenticazione non è riuscita. Riprova.", + "reauth_successful": "La ri-autenticazione è stata completata con successo." }, "error": { + "cannot_connect": "Impossibile connettersi al servizio\n\n{error_message}", + "empty": "Questo campo non può essere vuoto", "invalid_auth": "Chiave API non valida", - "cannot_connect": "Impossibile connettersi al servizio", - "quota_exceeded": "Quota superata", + "invalid_coordinates": "Seleziona una posizione valida sulla mappa.", + "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters.", "invalid_language": "Codice lingua non valido", "invalid_language_format": "Usa un codice BCP-47 canonico come \"en\" o \"es-ES\".", - "empty": "Questo campo non può essere vuoto", "invalid_option_combo": "Aumenta 'Giorni di previsione' per coprire i sensori giornalieri selezionati.", - "invalid_coordinates": "Seleziona una posizione valida sulla mappa.", - "unknown": "Errore sconosciuto", - "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters." + "quota_exceeded": "Quota superata", + "unknown": "Errore sconosciuto" }, - "abort": { - "already_configured": "Questa posizione è già configurata.", - "reauth_successful": "La ri-autenticazione è stata completata con successo.", - "reauth_failed": "La ri-autenticazione non è riuscita. Riprova." - } - }, - "options": { "step": { - "init": { - "title": "Pollen Levels – Opzioni", - "description": "Modifica l’intervallo di aggiornamento, la lingua della risposta dell’API, i giorni di previsione e i sensori giornalieri per i TIPI per {title}.\nOpzioni dei sensori giornalieri (TIPI): Solo oggi (none), Fino a domani (D+1), Fino a dopodomani (D+2).", + "reauth_confirm": { + "data": { + "api_key": "Chiave API" + }, + "description": "La chiave API per {latitude},{longitude} non è più valida. Inserisci una nuova chiave per riprendere gli aggiornamenti.", + "title": "Ri-autentica Pollen Levels" + }, + "user": { "data": { - "update_interval": "Intervallo di aggiornamento (ore)", + "api_key": "Chiave API", "language_code": "Codice lingua per la risposta dell'API", - "forecast_days": "Giorni di previsione (1–5)", - "create_forecast_sensors": "Ambito dei sensori per giorno (TIPI)" - } + "location": "Posizione", + "name": "Nome", + "update_interval": "Intervallo di aggiornamento (ore)" + }, + "description": "Inserisci la tua chiave API Google, seleziona la tua posizione sulla mappa, l’intervallo di aggiornamento (ore) e il codice lingua per la risposta dell'API.", + "title": "Configurazione Livelli di polline" } - }, - "error": { - "invalid_auth": "Chiave API non valida", - "cannot_connect": "Impossibile connettersi al servizio", - "quota_exceeded": "Quota superata", - "invalid_language": "Codice lingua non valido", - "invalid_language_format": "Usa un codice BCP-47 canonico come \"en\" o \"es-ES\".", - "empty": "Questo campo non può essere vuoto", - "invalid_option_combo": "Aumenta 'Giorni di previsione' per coprire i sensori giornalieri selezionati.", - "unknown": "Errore sconosciuto" } }, "device": { - "types": { - "name": "{title} - Tipi di polline ({latitude},{longitude})" + "info": { + "name": "{title} - Informazioni sul polline ({latitude},{longitude})" }, "plants": { "name": "{title} - Piante ({latitude},{longitude})" }, - "info": { - "name": "{title} - Informazioni sul polline ({latitude},{longitude})" - } - }, - "services": { - "force_update": { - "name": "Forza aggiornamento", - "description": "Aggiorna manualmente i dati dei pollini per tutte le località configurate." + "types": { + "name": "{title} - Tipi di polline ({latitude},{longitude})" } }, "entity": { "sensor": { - "region": { - "name": "Regione" - }, "date": { "name": "Data" }, "last_updated": { "name": "Ultimo aggiornamento" + }, + "region": { + "name": "Regione" + } + } + }, + "options": { + "error": { + "cannot_connect": "Impossibile connettersi al servizio", + "empty": "Questo campo non può essere vuoto", + "invalid_auth": "Chiave API non valida", + "invalid_language": "Codice lingua non valido", + "invalid_language_format": "Usa un codice BCP-47 canonico come \"en\" o \"es-ES\".", + "invalid_option_combo": "Aumenta 'Giorni di previsione' per coprire i sensori giornalieri selezionati.", + "quota_exceeded": "Quota superata", + "unknown": "Errore sconosciuto" + }, + "step": { + "init": { + "data": { + "create_forecast_sensors": "Ambito dei sensori per giorno (TIPI)", + "forecast_days": "Giorni di previsione (1–5)", + "language_code": "Codice lingua per la risposta dell'API", + "update_interval": "Intervallo di aggiornamento (ore)" + }, + "description": "Modifica l’intervallo di aggiornamento, la lingua della risposta dell’API, i giorni di previsione e i sensori giornalieri per i TIPI per {title}.\nOpzioni dei sensori giornalieri (TIPI): Solo oggi (none), Fino a domani (D+1), Fino a dopodomani (D+2).", + "title": "Pollen Levels – Opzioni" } } + }, + "services": { + "force_update": { + "description": "Aggiorna manualmente i dati dei pollini per tutte le località configurate.", + "name": "Forza aggiornamento" + } } -} +} \ No newline at end of file diff --git a/custom_components/pollenlevels/translations/nb.json b/custom_components/pollenlevels/translations/nb.json index c92e7a97..0942803a 100644 --- a/custom_components/pollenlevels/translations/nb.json +++ b/custom_components/pollenlevels/translations/nb.json @@ -1,95 +1,95 @@ { "config": { - "step": { - "user": { - "title": "Konfigurasjon av pollennivåer", - "description": "Angi Google API-nøkkel, velg posisjonen din på kartet, oppdateringsintervall (timer) og språkkode for API-svar.", - "data": { - "api_key": "API-nøkkel", - "name": "Navn", - "location": "Posisjon", - "update_interval": "Oppdateringsintervall (timer)", - "language_code": "Språkkode for API-svar" - } - }, - "reauth_confirm": { - "title": "Autentiser Pollen Levels på nytt", - "description": "API-nøkkelen for {latitude},{longitude} er ikke lenger gyldig. Angi en ny nøkkel for å gjenoppta oppdateringene.", - "data": { - "api_key": "API-nøkkel" - } - } + "abort": { + "already_configured": "Dette stedet er allerede konfigurert.", + "reauth_failed": "Ny autentisering mislyktes. Prøv igjen.", + "reauth_successful": "Ny autentisering fullført." }, "error": { + "cannot_connect": "Kan ikke koble til tjenesten\n\n{error_message}", + "empty": "Dette feltet kan ikke være tomt", "invalid_auth": "Ugyldig API-nøkkel", - "cannot_connect": "Kan ikke koble til tjenesten", - "quota_exceeded": "Kvote overskredet", + "invalid_coordinates": "Velg en gyldig posisjon på kartet.", + "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters.", "invalid_language": "Ugyldig språkkode", "invalid_language_format": "Bruk en kanonisk BCP-47-kode som \"en\" eller \"es-ES\".", - "empty": "Dette feltet kan ikke være tomt", "invalid_option_combo": "Øk «Prognosedager» for å dekke valgte sensorer per dag.", - "invalid_coordinates": "Velg en gyldig posisjon på kartet.", - "unknown": "Ukjent feil", - "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters." + "quota_exceeded": "Kvote overskredet", + "unknown": "Ukjent feil" }, - "abort": { - "already_configured": "Dette stedet er allerede konfigurert.", - "reauth_successful": "Ny autentisering fullført.", - "reauth_failed": "Ny autentisering mislyktes. Prøv igjen." - } - }, - "options": { "step": { - "init": { - "title": "Pollen Levels – Innstillinger", - "description": "Endre oppdateringsintervall, API-språk, prognosedager og sensorer per dag for {title}.\nAlternativer for sensorer per dag (TYPER): Kun i dag (none), Til og med i morgen (D+1), Til og med i overmorgen (D+2).", + "reauth_confirm": { + "data": { + "api_key": "API-nøkkel" + }, + "description": "API-nøkkelen for {latitude},{longitude} er ikke lenger gyldig. Angi en ny nøkkel for å gjenoppta oppdateringene.", + "title": "Autentiser Pollen Levels på nytt" + }, + "user": { "data": { - "update_interval": "Oppdateringsintervall (timer)", + "api_key": "API-nøkkel", "language_code": "Språkkode for API-svar", - "forecast_days": "Prognosedager (1–5)", - "create_forecast_sensors": "Omfang av sensorer per dag (TYPER)" - } + "location": "Posisjon", + "name": "Navn", + "update_interval": "Oppdateringsintervall (timer)" + }, + "description": "Angi Google API-nøkkel, velg posisjonen din på kartet, oppdateringsintervall (timer) og språkkode for API-svar.", + "title": "Konfigurasjon av pollennivåer" } - }, - "error": { - "invalid_auth": "Ugyldig API-nøkkel", - "cannot_connect": "Kan ikke koble til tjenesten", - "quota_exceeded": "Kvote overskredet", - "invalid_language": "Ugyldig språkkode", - "invalid_language_format": "Bruk en kanonisk BCP-47-kode som \"en\" eller \"es-ES\".", - "empty": "Dette feltet kan ikke være tomt", - "invalid_option_combo": "Øk «Prognosedager» for å dekke valgte sensorer per dag.", - "unknown": "Ukjent feil" } }, "device": { - "types": { - "name": "{title} - Pollentyper ({latitude},{longitude})" + "info": { + "name": "{title} - Polleninfo ({latitude},{longitude})" }, "plants": { "name": "{title} - Planter ({latitude},{longitude})" }, - "info": { - "name": "{title} - Polleninfo ({latitude},{longitude})" - } - }, - "services": { - "force_update": { - "name": "Tving oppdatering", - "description": "Oppdater pollendata manuelt for alle konfigurerte steder." + "types": { + "name": "{title} - Pollentyper ({latitude},{longitude})" } }, "entity": { "sensor": { - "region": { - "name": "Region" - }, "date": { "name": "Dato" }, "last_updated": { "name": "Sist oppdatert" + }, + "region": { + "name": "Region" + } + } + }, + "options": { + "error": { + "cannot_connect": "Kan ikke koble til tjenesten", + "empty": "Dette feltet kan ikke være tomt", + "invalid_auth": "Ugyldig API-nøkkel", + "invalid_language": "Ugyldig språkkode", + "invalid_language_format": "Bruk en kanonisk BCP-47-kode som \"en\" eller \"es-ES\".", + "invalid_option_combo": "Øk «Prognosedager» for å dekke valgte sensorer per dag.", + "quota_exceeded": "Kvote overskredet", + "unknown": "Ukjent feil" + }, + "step": { + "init": { + "data": { + "create_forecast_sensors": "Omfang av sensorer per dag (TYPER)", + "forecast_days": "Prognosedager (1–5)", + "language_code": "Språkkode for API-svar", + "update_interval": "Oppdateringsintervall (timer)" + }, + "description": "Endre oppdateringsintervall, API-språk, prognosedager og sensorer per dag for {title}.\nAlternativer for sensorer per dag (TYPER): Kun i dag (none), Til og med i morgen (D+1), Til og med i overmorgen (D+2).", + "title": "Pollen Levels – Innstillinger" } } + }, + "services": { + "force_update": { + "description": "Oppdater pollendata manuelt for alle konfigurerte steder.", + "name": "Tving oppdatering" + } } -} +} \ No newline at end of file diff --git a/custom_components/pollenlevels/translations/nl.json b/custom_components/pollenlevels/translations/nl.json index 742b5272..93c30c5a 100644 --- a/custom_components/pollenlevels/translations/nl.json +++ b/custom_components/pollenlevels/translations/nl.json @@ -1,95 +1,95 @@ { "config": { - "step": { - "user": { - "title": "Pollen Levels – Configuratie", - "description": "Voer je Google API-sleutel in, selecteer je locatie op de kaart, het update-interval (uren) en de taalcode voor de API-respons.", - "data": { - "api_key": "API-sleutel", - "name": "Naam", - "location": "Locatie", - "update_interval": "Update-interval (uren)", - "language_code": "Taalcode voor API-respons" - } - }, - "reauth_confirm": { - "title": "Autoriseer Pollen Levels opnieuw", - "description": "De API-sleutel voor {latitude},{longitude} is niet meer geldig. Voer een nieuwe sleutel in om de updates te hervatten.", - "data": { - "api_key": "API-sleutel" - } - } + "abort": { + "already_configured": "Deze locatie is al geconfigureerd.", + "reauth_failed": "Opnieuw autoriseren is mislukt. Probeer het opnieuw.", + "reauth_successful": "Opnieuw autoriseren voltooid." }, "error": { + "cannot_connect": "Kan geen verbinding maken met de service\n\n{error_message}", + "empty": "Dit veld mag niet leeg zijn", "invalid_auth": "Ongeldige API-sleutel", - "cannot_connect": "Kan geen verbinding maken met de service", - "quota_exceeded": "Limiet overschreden", + "invalid_coordinates": "Selecteer een geldige locatie op de kaart.", + "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters.", "invalid_language": "Ongeldige taalcode", "invalid_language_format": "Gebruik een canonieke BCP-47-code zoals \"en\" of \"es-ES\".", - "empty": "Dit veld mag niet leeg zijn", "invalid_option_combo": "Verhoog 'Voorspellingsdagen' om de geselecteerde per-dag sensoren te dekken.", - "invalid_coordinates": "Selecteer een geldige locatie op de kaart.", - "unknown": "Onbekende fout", - "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters." + "quota_exceeded": "Limiet overschreden", + "unknown": "Onbekende fout" }, - "abort": { - "already_configured": "Deze locatie is al geconfigureerd.", - "reauth_successful": "Opnieuw autoriseren voltooid.", - "reauth_failed": "Opnieuw autoriseren is mislukt. Probeer het opnieuw." - } - }, - "options": { "step": { - "init": { - "title": "Pollen Levels – Opties", - "description": "Wijzig het update-interval, de API-taal, het aantal voorspellingsdagen en de per-dag TYPE-sensoren voor {title}.\nOpties voor per-dag TYPE-sensoren: Alleen vandaag (none), Tot en met morgen (D+1), Tot en met overmorgen (D+2).", + "reauth_confirm": { + "data": { + "api_key": "API-sleutel" + }, + "description": "De API-sleutel voor {latitude},{longitude} is niet meer geldig. Voer een nieuwe sleutel in om de updates te hervatten.", + "title": "Autoriseer Pollen Levels opnieuw" + }, + "user": { "data": { - "update_interval": "Update-interval (uren)", + "api_key": "API-sleutel", "language_code": "Taalcode voor API-respons", - "forecast_days": "Voorspellingsdagen (1–5)", - "create_forecast_sensors": "Bereik van per-dag TYPE-sensoren" - } + "location": "Locatie", + "name": "Naam", + "update_interval": "Update-interval (uren)" + }, + "description": "Voer je Google API-sleutel in, selecteer je locatie op de kaart, het update-interval (uren) en de taalcode voor de API-respons.", + "title": "Pollen Levels – Configuratie" } - }, - "error": { - "invalid_auth": "Ongeldige API-sleutel", - "cannot_connect": "Kan geen verbinding maken met de service", - "quota_exceeded": "Limiet overschreden", - "invalid_language": "Ongeldige taalcode", - "invalid_language_format": "Gebruik een canonieke BCP-47-code zoals \"en\" of \"es-ES\".", - "empty": "Dit veld mag niet leeg zijn", - "invalid_option_combo": "Verhoog 'Voorspellingsdagen' om de geselecteerde per-dag sensoren te dekken.", - "unknown": "Onbekende fout" } }, "device": { - "types": { - "name": "{title} - Pollentypen ({latitude},{longitude})" + "info": { + "name": "{title} - Polleninfo ({latitude},{longitude})" }, "plants": { "name": "{title} - Planten ({latitude},{longitude})" }, - "info": { - "name": "{title} - Polleninfo ({latitude},{longitude})" - } - }, - "services": { - "force_update": { - "name": "Update forceren", - "description": "Ververs handmatig pollengegevens voor alle geconfigureerde locaties." + "types": { + "name": "{title} - Pollentypen ({latitude},{longitude})" } }, "entity": { "sensor": { - "region": { - "name": "Regio" - }, "date": { "name": "Datum" }, "last_updated": { "name": "Laatst bijgewerkt" + }, + "region": { + "name": "Regio" + } + } + }, + "options": { + "error": { + "cannot_connect": "Kan geen verbinding maken met de service", + "empty": "Dit veld mag niet leeg zijn", + "invalid_auth": "Ongeldige API-sleutel", + "invalid_language": "Ongeldige taalcode", + "invalid_language_format": "Gebruik een canonieke BCP-47-code zoals \"en\" of \"es-ES\".", + "invalid_option_combo": "Verhoog 'Voorspellingsdagen' om de geselecteerde per-dag sensoren te dekken.", + "quota_exceeded": "Limiet overschreden", + "unknown": "Onbekende fout" + }, + "step": { + "init": { + "data": { + "create_forecast_sensors": "Bereik van per-dag TYPE-sensoren", + "forecast_days": "Voorspellingsdagen (1–5)", + "language_code": "Taalcode voor API-respons", + "update_interval": "Update-interval (uren)" + }, + "description": "Wijzig het update-interval, de API-taal, het aantal voorspellingsdagen en de per-dag TYPE-sensoren voor {title}.\nOpties voor per-dag TYPE-sensoren: Alleen vandaag (none), Tot en met morgen (D+1), Tot en met overmorgen (D+2).", + "title": "Pollen Levels – Opties" } } + }, + "services": { + "force_update": { + "description": "Ververs handmatig pollengegevens voor alle geconfigureerde locaties.", + "name": "Update forceren" + } } -} +} \ No newline at end of file diff --git a/custom_components/pollenlevels/translations/pl.json b/custom_components/pollenlevels/translations/pl.json index 3fa31254..64a9fde3 100644 --- a/custom_components/pollenlevels/translations/pl.json +++ b/custom_components/pollenlevels/translations/pl.json @@ -1,95 +1,95 @@ { "config": { - "step": { - "user": { - "title": "Konfiguracja poziomów pyłku", - "description": "Wprowadź klucz Google API, wybierz swoją lokalizację na mapie, interwał aktualizacji (godziny) oraz kod języka odpowiedzi API.", - "data": { - "api_key": "Klucz API", - "name": "Nazwa", - "location": "Lokalizacja", - "update_interval": "Interwał aktualizacji (godziny)", - "language_code": "Kod języka odpowiedzi API" - } - }, - "reauth_confirm": { - "title": "Ponownie uwierzytelnij Pollen Levels", - "description": "Klucz API dla {latitude},{longitude} jest już nieważny. Wprowadź nowy klucz, aby wznowić aktualizacje.", - "data": { - "api_key": "Klucz API" - } - } + "abort": { + "already_configured": "Ta lokalizacja jest już skonfigurowana.", + "reauth_failed": "Ponowne uwierzytelnienie nie powiodło się. Spróbuj ponownie.", + "reauth_successful": "Ponowne uwierzytelnienie zakończone pomyślnie." }, "error": { + "cannot_connect": "Brak połączenia z usługą\n\n{error_message}", + "empty": "To pole nie może być puste", "invalid_auth": "Nieprawidłowy klucz API", - "cannot_connect": "Brak połączenia z usługą", - "quota_exceeded": "Przekroczono limit", + "invalid_coordinates": "Wybierz prawidłową lokalizację na mapie.", + "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters.", "invalid_language": "Nieprawidłowy kod języka", "invalid_language_format": "Użyj kanonicznego kodu BCP-47, np. \"en\" lub \"es-ES\".", - "empty": "To pole nie może być puste", "invalid_option_combo": "Zwiększ 'Dni prognozy', aby objąć wybrane czujniki dzienne.", - "invalid_coordinates": "Wybierz prawidłową lokalizację na mapie.", - "unknown": "Nieznany błąd", - "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters." + "quota_exceeded": "Przekroczono limit", + "unknown": "Nieznany błąd" }, - "abort": { - "already_configured": "Ta lokalizacja jest już skonfigurowana.", - "reauth_successful": "Ponowne uwierzytelnienie zakończone pomyślnie.", - "reauth_failed": "Ponowne uwierzytelnienie nie powiodło się. Spróbuj ponownie." - } - }, - "options": { "step": { - "init": { - "title": "Pollen Levels – Opcje", - "description": "Zmień interwał aktualizacji, język odpowiedzi API, liczbę dni prognozy oraz czujniki dzienne dla TYPÓW dla {title}.\nOpcje czujników dziennych (TYPY): Tylko dziś (none), Do jutra (D+1), Do pojutrza (D+2).", + "reauth_confirm": { + "data": { + "api_key": "Klucz API" + }, + "description": "Klucz API dla {latitude},{longitude} jest już nieważny. Wprowadź nowy klucz, aby wznowić aktualizacje.", + "title": "Ponownie uwierzytelnij Pollen Levels" + }, + "user": { "data": { - "update_interval": "Interwał aktualizacji (godziny)", + "api_key": "Klucz API", "language_code": "Kod języka odpowiedzi API", - "forecast_days": "Dni prognozy (1–5)", - "create_forecast_sensors": "Zakres czujników dziennych (TYPY)" - } + "location": "Lokalizacja", + "name": "Nazwa", + "update_interval": "Interwał aktualizacji (godziny)" + }, + "description": "Wprowadź klucz Google API, wybierz swoją lokalizację na mapie, interwał aktualizacji (godziny) oraz kod języka odpowiedzi API.", + "title": "Konfiguracja poziomów pyłku" } - }, - "error": { - "invalid_auth": "Nieprawidłowy klucz API", - "cannot_connect": "Brak połączenia z usługą", - "quota_exceeded": "Przekroczono limit", - "invalid_language": "Nieprawidłowy kod języka", - "invalid_language_format": "Użyj kanonicznego kodu BCP-47, np. \"en\" lub \"es-ES\".", - "empty": "To pole nie może być puste", - "invalid_option_combo": "Zwiększ 'Dni prognozy', aby objąć wybrane czujniki dzienne.", - "unknown": "Nieznany błąd" } }, "device": { - "types": { - "name": "{title} - Typy pyłków ({latitude},{longitude})" + "info": { + "name": "{title} - Informacje o pyłkach ({latitude},{longitude})" }, "plants": { "name": "{title} - Rośliny ({latitude},{longitude})" }, - "info": { - "name": "{title} - Informacje o pyłkach ({latitude},{longitude})" - } - }, - "services": { - "force_update": { - "name": "Wymuś aktualizację", - "description": "Ręcznie odśwież dane o pyłkach dla wszystkich skonfigurowanych lokalizacji." + "types": { + "name": "{title} - Typy pyłków ({latitude},{longitude})" } }, "entity": { "sensor": { - "region": { - "name": "Region" - }, "date": { "name": "Data" }, "last_updated": { "name": "Ostatnia aktualizacja" + }, + "region": { + "name": "Region" + } + } + }, + "options": { + "error": { + "cannot_connect": "Brak połączenia z usługą", + "empty": "To pole nie może być puste", + "invalid_auth": "Nieprawidłowy klucz API", + "invalid_language": "Nieprawidłowy kod języka", + "invalid_language_format": "Użyj kanonicznego kodu BCP-47, np. \"en\" lub \"es-ES\".", + "invalid_option_combo": "Zwiększ 'Dni prognozy', aby objąć wybrane czujniki dzienne.", + "quota_exceeded": "Przekroczono limit", + "unknown": "Nieznany błąd" + }, + "step": { + "init": { + "data": { + "create_forecast_sensors": "Zakres czujników dziennych (TYPY)", + "forecast_days": "Dni prognozy (1–5)", + "language_code": "Kod języka odpowiedzi API", + "update_interval": "Interwał aktualizacji (godziny)" + }, + "description": "Zmień interwał aktualizacji, język odpowiedzi API, liczbę dni prognozy oraz czujniki dzienne dla TYPÓW dla {title}.\nOpcje czujników dziennych (TYPY): Tylko dziś (none), Do jutra (D+1), Do pojutrza (D+2).", + "title": "Pollen Levels – Opcje" } } + }, + "services": { + "force_update": { + "description": "Ręcznie odśwież dane o pyłkach dla wszystkich skonfigurowanych lokalizacji.", + "name": "Wymuś aktualizację" + } } -} +} \ No newline at end of file diff --git a/custom_components/pollenlevels/translations/pt-BR.json b/custom_components/pollenlevels/translations/pt-BR.json index 0078d075..54f48eb2 100644 --- a/custom_components/pollenlevels/translations/pt-BR.json +++ b/custom_components/pollenlevels/translations/pt-BR.json @@ -1,95 +1,95 @@ { "config": { - "step": { - "user": { - "title": "Configuração dos Níveis de Pólen", - "description": "Informe sua chave da API do Google, selecione sua localização no mapa, o intervalo de atualização (horas) e o código de idioma para a resposta da API.", - "data": { - "api_key": "Chave da API", - "name": "Nome", - "location": "Localização", - "update_interval": "Intervalo de atualização (horas)", - "language_code": "Código de idioma da resposta da API" - } - }, - "reauth_confirm": { - "title": "Reautenticar Pollen Levels", - "description": "A chave da API para {latitude},{longitude} não é mais válida. Insira uma nova chave para retomar as atualizações.", - "data": { - "api_key": "Chave da API" - } - } + "abort": { + "already_configured": "Este local já está configurado.", + "reauth_failed": "A reautenticação falhou. Tente novamente.", + "reauth_successful": "Reautenticação concluída com sucesso." }, "error": { + "cannot_connect": "Não foi possível conectar ao serviço\n\n{error_message}", + "empty": "Este campo não pode ficar vazio", "invalid_auth": "Chave de API inválida", - "cannot_connect": "Não foi possível conectar ao serviço", - "quota_exceeded": "Cota excedida", + "invalid_coordinates": "Selecione um local válido no mapa.", + "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters.", "invalid_language": "Código de idioma inválido", "invalid_language_format": "Use um código BCP-47 canônico, como \"en\" ou \"es-ES\".", - "empty": "Este campo não pode ficar vazio", "invalid_option_combo": "Aumente \"Dias de previsão\" para cobrir os sensores por dia selecionados.", - "invalid_coordinates": "Selecione um local válido no mapa.", - "unknown": "Erro desconhecido", - "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters." + "quota_exceeded": "Cota excedida", + "unknown": "Erro desconhecido" }, - "abort": { - "already_configured": "Este local já está configurado.", - "reauth_successful": "Reautenticação concluída com sucesso.", - "reauth_failed": "A reautenticação falhou. Tente novamente." - } - }, - "options": { "step": { - "init": { - "title": "Pollen Levels – Opções", - "description": "Altere o intervalo de atualização, o idioma da API, os dias de previsão e os sensores por dia para {title}.\nOpções de sensores por dia (TIPOS): Apenas hoje (none), Até amanhã (D+1), Até depois de amanhã (D+2).", + "reauth_confirm": { + "data": { + "api_key": "Chave da API" + }, + "description": "A chave da API para {latitude},{longitude} não é mais válida. Insira uma nova chave para retomar as atualizações.", + "title": "Reautenticar Pollen Levels" + }, + "user": { "data": { - "update_interval": "Intervalo de atualização (horas)", + "api_key": "Chave da API", "language_code": "Código de idioma da resposta da API", - "forecast_days": "Dias de previsão (1–5)", - "create_forecast_sensors": "Escopo dos sensores por dia (TIPOS)" - } + "location": "Localização", + "name": "Nome", + "update_interval": "Intervalo de atualização (horas)" + }, + "description": "Informe sua chave da API do Google, selecione sua localização no mapa, o intervalo de atualização (horas) e o código de idioma para a resposta da API.", + "title": "Configuração dos Níveis de Pólen" } - }, - "error": { - "invalid_auth": "Chave de API inválida", - "cannot_connect": "Não foi possível conectar ao serviço", - "quota_exceeded": "Cota excedida", - "invalid_language": "Código de idioma inválido", - "invalid_language_format": "Use um código BCP-47 canônico, como \"en\" ou \"es-ES\".", - "empty": "Este campo não pode ficar vazio", - "invalid_option_combo": "Aumente \"Dias de previsão\" para cobrir os sensores por dia selecionados.", - "unknown": "Erro desconhecido" } }, "device": { - "types": { - "name": "{title} - Tipos de pólen ({latitude},{longitude})" + "info": { + "name": "{title} - Informações de pólen ({latitude},{longitude})" }, "plants": { "name": "{title} - Plantas ({latitude},{longitude})" }, - "info": { - "name": "{title} - Informações de pólen ({latitude},{longitude})" - } - }, - "services": { - "force_update": { - "name": "Forçar atualização", - "description": "Atualize manualmente os dados de pólen para todas as localizações configuradas." + "types": { + "name": "{title} - Tipos de pólen ({latitude},{longitude})" } }, "entity": { "sensor": { - "region": { - "name": "Região" - }, "date": { "name": "Data" }, "last_updated": { "name": "Última atualização" + }, + "region": { + "name": "Região" + } + } + }, + "options": { + "error": { + "cannot_connect": "Não foi possível conectar ao serviço", + "empty": "Este campo não pode ficar vazio", + "invalid_auth": "Chave de API inválida", + "invalid_language": "Código de idioma inválido", + "invalid_language_format": "Use um código BCP-47 canônico, como \"en\" ou \"es-ES\".", + "invalid_option_combo": "Aumente \"Dias de previsão\" para cobrir os sensores por dia selecionados.", + "quota_exceeded": "Cota excedida", + "unknown": "Erro desconhecido" + }, + "step": { + "init": { + "data": { + "create_forecast_sensors": "Escopo dos sensores por dia (TIPOS)", + "forecast_days": "Dias de previsão (1–5)", + "language_code": "Código de idioma da resposta da API", + "update_interval": "Intervalo de atualização (horas)" + }, + "description": "Altere o intervalo de atualização, o idioma da API, os dias de previsão e os sensores por dia para {title}.\nOpções de sensores por dia (TIPOS): Apenas hoje (none), Até amanhã (D+1), Até depois de amanhã (D+2).", + "title": "Pollen Levels – Opções" } } + }, + "services": { + "force_update": { + "description": "Atualize manualmente os dados de pólen para todas as localizações configuradas.", + "name": "Forçar atualização" + } } -} +} \ No newline at end of file diff --git a/custom_components/pollenlevels/translations/pt-PT.json b/custom_components/pollenlevels/translations/pt-PT.json index bc7f4a4c..6eb83547 100644 --- a/custom_components/pollenlevels/translations/pt-PT.json +++ b/custom_components/pollenlevels/translations/pt-PT.json @@ -1,95 +1,95 @@ { "config": { - "step": { - "user": { - "title": "Configuração dos Níveis de Pólen", - "description": "Introduza a sua chave da API do Google, selecione a sua localização no mapa, o intervalo de atualização (horas) e o código de idioma para a resposta da API.", - "data": { - "api_key": "Chave da API", - "name": "Nome", - "location": "Localização", - "update_interval": "Intervalo de atualização (horas)", - "language_code": "Código de idioma da resposta da API" - } - }, - "reauth_confirm": { - "title": "Reautenticar Pollen Levels", - "description": "A chave da API para {latitude},{longitude} deixou de ser válida. Introduza uma nova chave para retomar as atualizações.", - "data": { - "api_key": "Chave da API" - } - } + "abort": { + "already_configured": "Esta localização já está configurada.", + "reauth_failed": "A reautenticação falhou. Tente novamente.", + "reauth_successful": "Reautenticação concluída com sucesso." }, "error": { + "cannot_connect": "Não é possível ligar ao serviço\n\n{error_message}", + "empty": "Este campo não pode estar vazio", "invalid_auth": "Chave da API inválida", - "cannot_connect": "Não é possível ligar ao serviço", - "quota_exceeded": "Quota excedida", + "invalid_coordinates": "Selecione uma localização válida no mapa.", + "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters.", "invalid_language": "Código de idioma inválido", "invalid_language_format": "Use um código BCP-47 canónico, como \"en\" ou \"es-ES\".", - "empty": "Este campo não pode estar vazio", "invalid_option_combo": "Aumente \"Dias de previsão\" para cobrir os sensores por dia selecionados.", - "invalid_coordinates": "Selecione uma localização válida no mapa.", - "unknown": "Erro desconhecido", - "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters." + "quota_exceeded": "Quota excedida", + "unknown": "Erro desconhecido" }, - "abort": { - "already_configured": "Esta localização já está configurada.", - "reauth_successful": "Reautenticação concluída com sucesso.", - "reauth_failed": "A reautenticação falhou. Tente novamente." - } - }, - "options": { "step": { - "init": { - "title": "Pollen Levels – Opções", - "description": "Altere o intervalo de atualização, o idioma da API, os dias de previsão e os sensores por dia para {title}.\nOpções de sensores por dia (TIPOS): Apenas hoje (none), Até amanhã (D+1), Até depois de amanhã (D+2).", + "reauth_confirm": { + "data": { + "api_key": "Chave da API" + }, + "description": "A chave da API para {latitude},{longitude} deixou de ser válida. Introduza uma nova chave para retomar as atualizações.", + "title": "Reautenticar Pollen Levels" + }, + "user": { "data": { - "update_interval": "Intervalo de atualização (horas)", + "api_key": "Chave da API", "language_code": "Código de idioma da resposta da API", - "forecast_days": "Dias de previsão (1–5)", - "create_forecast_sensors": "Âmbito dos sensores por dia (TIPOS)" - } + "location": "Localização", + "name": "Nome", + "update_interval": "Intervalo de atualização (horas)" + }, + "description": "Introduza a sua chave da API do Google, selecione a sua localização no mapa, o intervalo de atualização (horas) e o código de idioma para a resposta da API.", + "title": "Configuração dos Níveis de Pólen" } - }, - "error": { - "invalid_auth": "Chave da API inválida", - "cannot_connect": "Não é possível ligar ao serviço", - "quota_exceeded": "Quota excedida", - "invalid_language": "Código de idioma inválido", - "invalid_language_format": "Use um código BCP-47 canónico, como \"en\" ou \"es-ES\".", - "empty": "Este campo não pode estar vazio", - "invalid_option_combo": "Aumente \"Dias de previsão\" para cobrir os sensores por dia selecionados.", - "unknown": "Erro desconhecido" } }, "device": { - "types": { - "name": "{title} - Tipos de pólen ({latitude},{longitude})" + "info": { + "name": "{title} - Informações de pólen ({latitude},{longitude})" }, "plants": { "name": "{title} - Plantas ({latitude},{longitude})" }, - "info": { - "name": "{title} - Informações de pólen ({latitude},{longitude})" - } - }, - "services": { - "force_update": { - "name": "Forçar atualização", - "description": "Atualiza manualmente os dados de pólen para todas as localizações configuradas." + "types": { + "name": "{title} - Tipos de pólen ({latitude},{longitude})" } }, "entity": { "sensor": { - "region": { - "name": "Região" - }, "date": { "name": "Data" }, "last_updated": { "name": "Última atualização" + }, + "region": { + "name": "Região" + } + } + }, + "options": { + "error": { + "cannot_connect": "Não é possível ligar ao serviço", + "empty": "Este campo não pode estar vazio", + "invalid_auth": "Chave da API inválida", + "invalid_language": "Código de idioma inválido", + "invalid_language_format": "Use um código BCP-47 canónico, como \"en\" ou \"es-ES\".", + "invalid_option_combo": "Aumente \"Dias de previsão\" para cobrir os sensores por dia selecionados.", + "quota_exceeded": "Quota excedida", + "unknown": "Erro desconhecido" + }, + "step": { + "init": { + "data": { + "create_forecast_sensors": "Âmbito dos sensores por dia (TIPOS)", + "forecast_days": "Dias de previsão (1–5)", + "language_code": "Código de idioma da resposta da API", + "update_interval": "Intervalo de atualização (horas)" + }, + "description": "Altere o intervalo de atualização, o idioma da API, os dias de previsão e os sensores por dia para {title}.\nOpções de sensores por dia (TIPOS): Apenas hoje (none), Até amanhã (D+1), Até depois de amanhã (D+2).", + "title": "Pollen Levels – Opções" } } + }, + "services": { + "force_update": { + "description": "Atualiza manualmente os dados de pólen para todas as localizações configuradas.", + "name": "Forçar atualização" + } } -} +} \ No newline at end of file diff --git a/custom_components/pollenlevels/translations/ro.json b/custom_components/pollenlevels/translations/ro.json index 1259741c..3c657253 100644 --- a/custom_components/pollenlevels/translations/ro.json +++ b/custom_components/pollenlevels/translations/ro.json @@ -1,95 +1,95 @@ { "config": { - "step": { - "user": { - "title": "Configurare Niveluri de Polen", - "description": "Introduceți cheia Google API, selectați locația pe hartă, intervalul de actualizare (ore) și codul de limbă pentru răspunsul API.", - "data": { - "api_key": "Cheie API", - "name": "Nume", - "location": "Locație", - "update_interval": "Interval de actualizare (ore)", - "language_code": "Codul limbii pentru răspunsul API" - } - }, - "reauth_confirm": { - "title": "Reautentificați Pollen Levels", - "description": "Cheia API pentru {latitude},{longitude} nu mai este valabilă. Introduceți o cheie nouă pentru a relua actualizările.", - "data": { - "api_key": "Cheie API" - } - } + "abort": { + "already_configured": "Această locație este deja configurată.", + "reauth_failed": "Reautentificarea a eșuat. Încercați din nou.", + "reauth_successful": "Reautentificarea s-a încheiat cu succes." }, "error": { + "cannot_connect": "Nu se poate conecta la serviciu\n\n{error_message}", + "empty": "Acest câmp nu poate fi gol", "invalid_auth": "Cheie API nevalidă", - "cannot_connect": "Nu se poate conecta la serviciu", - "quota_exceeded": "Cota depășită", + "invalid_coordinates": "Selectează o locație validă pe hartă.", + "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters.", "invalid_language": "Cod de limbă nevalid", "invalid_language_format": "Folosiți un cod BCP-47 canonic, de exemplu \"en\" sau \"es-ES\".", - "empty": "Acest câmp nu poate fi gol", "invalid_option_combo": "Măriți \"Zilele de prognoză\" pentru a acoperi senzorii selectați pe zile.", - "invalid_coordinates": "Selectează o locație validă pe hartă.", - "unknown": "Eroare necunoscută", - "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters." + "quota_exceeded": "Cota depășită", + "unknown": "Eroare necunoscută" }, - "abort": { - "already_configured": "Această locație este deja configurată.", - "reauth_successful": "Reautentificarea s-a încheiat cu succes.", - "reauth_failed": "Reautentificarea a eșuat. Încercați din nou." - } - }, - "options": { "step": { - "init": { - "title": "Pollen Levels – Opțiuni", - "description": "Modificați intervalul de actualizare, limba API, zilele de prognoză și senzorii pe zile pentru {title}.\nOpțiuni pentru senzorii pe zile (TIPURI): Doar azi (none), Până mâine (D+1), Până poimâine (D+2).", + "reauth_confirm": { + "data": { + "api_key": "Cheie API" + }, + "description": "Cheia API pentru {latitude},{longitude} nu mai este valabilă. Introduceți o cheie nouă pentru a relua actualizările.", + "title": "Reautentificați Pollen Levels" + }, + "user": { "data": { - "update_interval": "Interval de actualizare (ore)", + "api_key": "Cheie API", "language_code": "Codul limbii pentru răspunsul API", - "forecast_days": "Zile de prognoză (1–5)", - "create_forecast_sensors": "Domeniul senzorilor pe zile (TIPURI)" - } + "location": "Locație", + "name": "Nume", + "update_interval": "Interval de actualizare (ore)" + }, + "description": "Introduceți cheia Google API, selectați locația pe hartă, intervalul de actualizare (ore) și codul de limbă pentru răspunsul API.", + "title": "Configurare Niveluri de Polen" } - }, - "error": { - "invalid_auth": "Cheie API nevalidă", - "cannot_connect": "Nu se poate conecta la serviciu", - "quota_exceeded": "Cota depășită", - "invalid_language": "Cod de limbă nevalid", - "invalid_language_format": "Folosiți un cod BCP-47 canonic, de exemplu \"en\" sau \"es-ES\".", - "empty": "Acest câmp nu poate fi gol", - "invalid_option_combo": "Măriți \"Zilele de prognoză\" pentru a acoperi senzorii selectați pe zile.", - "unknown": "Eroare necunoscută" } }, "device": { - "types": { - "name": "{title} - Tipuri de polen ({latitude},{longitude})" + "info": { + "name": "{title} - Informații polen ({latitude},{longitude})" }, "plants": { "name": "{title} - Plante ({latitude},{longitude})" }, - "info": { - "name": "{title} - Informații polen ({latitude},{longitude})" - } - }, - "services": { - "force_update": { - "name": "Forțează actualizarea", - "description": "Actualizează manual datele despre polen pentru toate locațiile configurate." + "types": { + "name": "{title} - Tipuri de polen ({latitude},{longitude})" } }, "entity": { "sensor": { - "region": { - "name": "Regiune" - }, "date": { "name": "Data" }, "last_updated": { "name": "Ultima actualizare" + }, + "region": { + "name": "Regiune" + } + } + }, + "options": { + "error": { + "cannot_connect": "Nu se poate conecta la serviciu", + "empty": "Acest câmp nu poate fi gol", + "invalid_auth": "Cheie API nevalidă", + "invalid_language": "Cod de limbă nevalid", + "invalid_language_format": "Folosiți un cod BCP-47 canonic, de exemplu \"en\" sau \"es-ES\".", + "invalid_option_combo": "Măriți \"Zilele de prognoză\" pentru a acoperi senzorii selectați pe zile.", + "quota_exceeded": "Cota depășită", + "unknown": "Eroare necunoscută" + }, + "step": { + "init": { + "data": { + "create_forecast_sensors": "Domeniul senzorilor pe zile (TIPURI)", + "forecast_days": "Zile de prognoză (1–5)", + "language_code": "Codul limbii pentru răspunsul API", + "update_interval": "Interval de actualizare (ore)" + }, + "description": "Modificați intervalul de actualizare, limba API, zilele de prognoză și senzorii pe zile pentru {title}.\nOpțiuni pentru senzorii pe zile (TIPURI): Doar azi (none), Până mâine (D+1), Până poimâine (D+2).", + "title": "Pollen Levels – Opțiuni" } } + }, + "services": { + "force_update": { + "description": "Actualizează manual datele despre polen pentru toate locațiile configurate.", + "name": "Forțează actualizarea" + } } -} +} \ No newline at end of file diff --git a/custom_components/pollenlevels/translations/ru.json b/custom_components/pollenlevels/translations/ru.json index c57dc76c..997770bb 100644 --- a/custom_components/pollenlevels/translations/ru.json +++ b/custom_components/pollenlevels/translations/ru.json @@ -1,95 +1,95 @@ { "config": { - "step": { - "user": { - "title": "Настройка уровней пыльцы", - "description": "Введите ваш ключ Google API, выберите свое местоположение на карте, интервал обновления (в часах) и код языка ответа API.", - "data": { - "api_key": "Ключ API", - "name": "Имя", - "location": "Местоположение", - "update_interval": "Интервал обновления (в часах)", - "language_code": "Код языка ответа API" - } - }, - "reauth_confirm": { - "title": "Повторная аутентификация Pollen Levels", - "description": "Ключ API для {latitude},{longitude} больше не действителен. Введите новый ключ, чтобы возобновить обновления.", - "data": { - "api_key": "Ключ API" - } - } + "abort": { + "already_configured": "Это местоположение уже настроено.", + "reauth_failed": "Повторная аутентификация не удалась. Повторите попытку.", + "reauth_successful": "Повторная аутентификация выполнена успешно." }, "error": { + "cannot_connect": "Не удаётся подключиться к сервису\n\n{error_message}", + "empty": "Это поле не может быть пустым", "invalid_auth": "Неверный ключ API", - "cannot_connect": "Не удаётся подключиться к сервису", - "quota_exceeded": "Превышен лимит запросов", + "invalid_coordinates": "Выберите корректное местоположение на карте.", + "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters.", "invalid_language": "Неверный код языка", "invalid_language_format": "Используйте канонический код BCP-47, например \"en\" или \"es-ES\".", - "empty": "Это поле не может быть пустым", "invalid_option_combo": "Увеличьте «Дни прогноза», чтобы охватить выбранные датчики по дням.", - "invalid_coordinates": "Выберите корректное местоположение на карте.", - "unknown": "Неизвестная ошибка", - "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters." + "quota_exceeded": "Превышен лимит запросов", + "unknown": "Неизвестная ошибка" }, - "abort": { - "already_configured": "Это местоположение уже настроено.", - "reauth_successful": "Повторная аутентификация выполнена успешно.", - "reauth_failed": "Повторная аутентификация не удалась. Повторите попытку." - } - }, - "options": { "step": { - "init": { - "title": "Pollen Levels – Параметры", - "description": "Измените интервал обновления, язык ответа API, дни прогноза и дневные датчики для ТИПОВ для {title}.\nВарианты дневных датчиков (ТИПЫ): Только сегодня (none), До завтра (D+1), До послезавтра (D+2).", + "reauth_confirm": { + "data": { + "api_key": "Ключ API" + }, + "description": "Ключ API для {latitude},{longitude} больше не действителен. Введите новый ключ, чтобы возобновить обновления.", + "title": "Повторная аутентификация Pollen Levels" + }, + "user": { "data": { - "update_interval": "Интервал обновления (в часах)", + "api_key": "Ключ API", "language_code": "Код языка ответа API", - "forecast_days": "Дни прогноза (1–5)", - "create_forecast_sensors": "Диапазон дневных датчиков (ТИПЫ)" - } + "location": "Местоположение", + "name": "Имя", + "update_interval": "Интервал обновления (в часах)" + }, + "description": "Введите ваш ключ Google API, выберите свое местоположение на карте, интервал обновления (в часах) и код языка ответа API.", + "title": "Настройка уровней пыльцы" } - }, - "error": { - "invalid_auth": "Неверный ключ API", - "cannot_connect": "Не удаётся подключиться к сервису", - "quota_exceeded": "Превышен лимит запросов", - "invalid_language": "Неверный код языка", - "invalid_language_format": "Используйте канонический код BCP-47, например \"en\" или \"es-ES\".", - "empty": "Это поле не может быть пустым", - "invalid_option_combo": "Увеличьте «Дни прогноза», чтобы охватить выбранные датчики по дням.", - "unknown": "Неизвестная ошибка" } }, "device": { - "types": { - "name": "{title} - Типы пыльцы ({latitude},{longitude})" + "info": { + "name": "{title} - Информация о пыльце ({latitude},{longitude})" }, "plants": { "name": "{title} - Растения ({latitude},{longitude})" }, - "info": { - "name": "{title} - Информация о пыльце ({latitude},{longitude})" - } - }, - "services": { - "force_update": { - "name": "Принудительное обновление", - "description": "Вручную обновить данные о пыльце для всех настроенных местоположений." + "types": { + "name": "{title} - Типы пыльцы ({latitude},{longitude})" } }, "entity": { "sensor": { - "region": { - "name": "Регион" - }, "date": { "name": "Дата" }, "last_updated": { "name": "Последнее обновление" + }, + "region": { + "name": "Регион" + } + } + }, + "options": { + "error": { + "cannot_connect": "Не удаётся подключиться к сервису", + "empty": "Это поле не может быть пустым", + "invalid_auth": "Неверный ключ API", + "invalid_language": "Неверный код языка", + "invalid_language_format": "Используйте канонический код BCP-47, например \"en\" или \"es-ES\".", + "invalid_option_combo": "Увеличьте «Дни прогноза», чтобы охватить выбранные датчики по дням.", + "quota_exceeded": "Превышен лимит запросов", + "unknown": "Неизвестная ошибка" + }, + "step": { + "init": { + "data": { + "create_forecast_sensors": "Диапазон дневных датчиков (ТИПЫ)", + "forecast_days": "Дни прогноза (1–5)", + "language_code": "Код языка ответа API", + "update_interval": "Интервал обновления (в часах)" + }, + "description": "Измените интервал обновления, язык ответа API, дни прогноза и дневные датчики для ТИПОВ для {title}.\nВарианты дневных датчиков (ТИПЫ): Только сегодня (none), До завтра (D+1), До послезавтра (D+2).", + "title": "Pollen Levels – Параметры" } } + }, + "services": { + "force_update": { + "description": "Вручную обновить данные о пыльце для всех настроенных местоположений.", + "name": "Принудительное обновление" + } } -} +} \ No newline at end of file diff --git a/custom_components/pollenlevels/translations/sv.json b/custom_components/pollenlevels/translations/sv.json index 606024ad..5f698bc9 100644 --- a/custom_components/pollenlevels/translations/sv.json +++ b/custom_components/pollenlevels/translations/sv.json @@ -1,95 +1,95 @@ { "config": { - "step": { - "user": { - "title": "Konfiguration av pollennivåer", - "description": "Ange din Google API-nyckel, välj din plats på kartan, uppdateringsintervall (timmar) och språkkod för API-svar.", - "data": { - "api_key": "API-nyckel", - "name": "Namn", - "location": "Plats", - "update_interval": "Uppdateringsintervall (timmar)", - "language_code": "Språkkod för API-svar" - } - }, - "reauth_confirm": { - "title": "Autentisera Pollen Levels igen", - "description": "API-nyckeln för {latitude},{longitude} är inte längre giltig. Ange en ny nyckel för att återuppta uppdateringarna.", - "data": { - "api_key": "API-nyckel" - } - } + "abort": { + "already_configured": "Den här platsen är redan konfigurerad.", + "reauth_failed": "Återautentiseringen misslyckades. Försök igen.", + "reauth_successful": "Återautentiseringen lyckades." }, "error": { + "cannot_connect": "Kan inte ansluta till tjänsten\n\n{error_message}", + "empty": "Detta fält får inte vara tomt", "invalid_auth": "Ogiltig API-nyckel", - "cannot_connect": "Kan inte ansluta till tjänsten", - "quota_exceeded": "Kvoten har överskridits", + "invalid_coordinates": "Välj en giltig plats på kartan.", + "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters.", "invalid_language": "Ogiltig språkkod", "invalid_language_format": "Använd en kanonisk BCP-47-kod som \"en\" eller \"es-ES\".", - "empty": "Detta fält får inte vara tomt", "invalid_option_combo": "Öka \"Prognosdagar\" för att täcka valda sensorer per dag.", - "invalid_coordinates": "Välj en giltig plats på kartan.", - "unknown": "Okänt fel", - "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters." + "quota_exceeded": "Kvoten har överskridits", + "unknown": "Okänt fel" }, - "abort": { - "already_configured": "Den här platsen är redan konfigurerad.", - "reauth_successful": "Återautentiseringen lyckades.", - "reauth_failed": "Återautentiseringen misslyckades. Försök igen." - } - }, - "options": { "step": { - "init": { - "title": "Pollen Levels – Alternativ", - "description": "Ändra uppdateringsintervall, API-språk, prognosdagar och sensorer per dag för {title}.\nAlternativ för sensorer per dag (TYPER): Endast idag (none), Till och med i morgon (D+1), Till och med i övermorgon (D+2).", + "reauth_confirm": { + "data": { + "api_key": "API-nyckel" + }, + "description": "API-nyckeln för {latitude},{longitude} är inte längre giltig. Ange en ny nyckel för att återuppta uppdateringarna.", + "title": "Autentisera Pollen Levels igen" + }, + "user": { "data": { - "update_interval": "Uppdateringsintervall (timmar)", + "api_key": "API-nyckel", "language_code": "Språkkod för API-svar", - "forecast_days": "Prognosdagar (1–5)", - "create_forecast_sensors": "Omfång för sensorer per dag (TYPER)" - } + "location": "Plats", + "name": "Namn", + "update_interval": "Uppdateringsintervall (timmar)" + }, + "description": "Ange din Google API-nyckel, välj din plats på kartan, uppdateringsintervall (timmar) och språkkod för API-svar.", + "title": "Konfiguration av pollennivåer" } - }, - "error": { - "invalid_auth": "Ogiltig API-nyckel", - "cannot_connect": "Kan inte ansluta till tjänsten", - "quota_exceeded": "Kvoten har överskridits", - "invalid_language": "Ogiltig språkkod", - "invalid_language_format": "Använd en kanonisk BCP-47-kod som \"en\" eller \"es-ES\".", - "empty": "Detta fält får inte vara tomt", - "invalid_option_combo": "Öka \"Prognosdagar\" för att täcka valda sensorer per dag.", - "unknown": "Okänt fel" } }, "device": { - "types": { - "name": "{title} - Pollentyper ({latitude},{longitude})" + "info": { + "name": "{title} - Polleninfo ({latitude},{longitude})" }, "plants": { "name": "{title} - Växter ({latitude},{longitude})" }, - "info": { - "name": "{title} - Polleninfo ({latitude},{longitude})" - } - }, - "services": { - "force_update": { - "name": "Tvinga uppdatering", - "description": "Uppdatera pollendata manuellt för alla konfigurerade platser." + "types": { + "name": "{title} - Pollentyper ({latitude},{longitude})" } }, "entity": { "sensor": { - "region": { - "name": "Region" - }, "date": { "name": "Datum" }, "last_updated": { "name": "Senaste uppdatering" + }, + "region": { + "name": "Region" + } + } + }, + "options": { + "error": { + "cannot_connect": "Kan inte ansluta till tjänsten", + "empty": "Detta fält får inte vara tomt", + "invalid_auth": "Ogiltig API-nyckel", + "invalid_language": "Ogiltig språkkod", + "invalid_language_format": "Använd en kanonisk BCP-47-kod som \"en\" eller \"es-ES\".", + "invalid_option_combo": "Öka \"Prognosdagar\" för att täcka valda sensorer per dag.", + "quota_exceeded": "Kvoten har överskridits", + "unknown": "Okänt fel" + }, + "step": { + "init": { + "data": { + "create_forecast_sensors": "Omfång för sensorer per dag (TYPER)", + "forecast_days": "Prognosdagar (1–5)", + "language_code": "Språkkod för API-svar", + "update_interval": "Uppdateringsintervall (timmar)" + }, + "description": "Ändra uppdateringsintervall, API-språk, prognosdagar och sensorer per dag för {title}.\nAlternativ för sensorer per dag (TYPER): Endast idag (none), Till och med i morgon (D+1), Till och med i övermorgon (D+2).", + "title": "Pollen Levels – Alternativ" } } + }, + "services": { + "force_update": { + "description": "Uppdatera pollendata manuellt för alla konfigurerade platser.", + "name": "Tvinga uppdatering" + } } -} +} \ No newline at end of file diff --git a/custom_components/pollenlevels/translations/uk.json b/custom_components/pollenlevels/translations/uk.json index d5ae193c..47820911 100644 --- a/custom_components/pollenlevels/translations/uk.json +++ b/custom_components/pollenlevels/translations/uk.json @@ -1,95 +1,95 @@ { "config": { - "step": { - "user": { - "title": "Налаштування рівнів пилку", - "description": "Введіть свій ключ Google API, виберіть місцезнаходження на карті, інтервал оновлення (у годинах) та код мови для відповіді API.", - "data": { - "api_key": "Ключ API", - "name": "Ім'я", - "location": "Місцезнаходження", - "update_interval": "Інтервал оновлення (у годинах)", - "language_code": "Код мови відповіді API" - } - }, - "reauth_confirm": { - "title": "Повторно автентифікуйте Pollen Levels", - "description": "Ключ API для {latitude},{longitude} більше не дійсний. Введіть новий ключ, щоб відновити оновлення.", - "data": { - "api_key": "Ключ API" - } - } + "abort": { + "already_configured": "Це розташування вже налаштовано.", + "reauth_failed": "Повторна автентифікація не вдалася. Спробуйте ще раз.", + "reauth_successful": "Повторна автентифікація виконана успішно." }, "error": { + "cannot_connect": "Не вдається підключитися до сервісу\n\n{error_message}", + "empty": "Це поле не може бути порожнім", "invalid_auth": "Невірний ключ API", - "cannot_connect": "Не вдається підключитися до сервісу", - "quota_exceeded": "Перевищено ліміт запитів", + "invalid_coordinates": "Виберіть дійсне місце на карті.", + "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters.", "invalid_language": "Невірний код мови", "invalid_language_format": "Використовуйте канонічний код BCP-47, наприклад \"en\" або \"es-ES\".", - "empty": "Це поле не може бути порожнім", "invalid_option_combo": "Збільшіть «Дні прогнозу», щоб охопити вибрані денні датчики.", - "invalid_coordinates": "Виберіть дійсне місце на карті.", - "unknown": "Невідома помилка", - "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters." + "quota_exceeded": "Перевищено ліміт запитів", + "unknown": "Невідома помилка" }, - "abort": { - "already_configured": "Це розташування вже налаштовано.", - "reauth_successful": "Повторна автентифікація виконана успішно.", - "reauth_failed": "Повторна автентифікація не вдалася. Спробуйте ще раз." - } - }, - "options": { "step": { - "init": { - "title": "Pollen Levels – Параметри", - "description": "Змініть інтервал оновлення, мову відповіді API, кількість днів прогнозу та денні датчики для ТИПІВ для {title}.\nПараметри денних датчиків (ТИПИ): Лише сьогодні (none), До завтра (D+1), До післязавтра (D+2).", + "reauth_confirm": { + "data": { + "api_key": "Ключ API" + }, + "description": "Ключ API для {latitude},{longitude} більше не дійсний. Введіть новий ключ, щоб відновити оновлення.", + "title": "Повторно автентифікуйте Pollen Levels" + }, + "user": { "data": { - "update_interval": "Інтервал оновлення (у годинах)", + "api_key": "Ключ API", "language_code": "Код мови відповіді API", - "forecast_days": "Дні прогнозу (1–5)", - "create_forecast_sensors": "Діапазон денних датчиків (ТИПИ)" - } + "location": "Місцезнаходження", + "name": "Ім'я", + "update_interval": "Інтервал оновлення (у годинах)" + }, + "description": "Введіть свій ключ Google API, виберіть місцезнаходження на карті, інтервал оновлення (у годинах) та код мови для відповіді API.", + "title": "Налаштування рівнів пилку" } - }, - "error": { - "invalid_auth": "Невірний ключ API", - "cannot_connect": "Не вдається підключитися до сервісу", - "quota_exceeded": "Перевищено ліміт запитів", - "invalid_language": "Невірний код мови", - "invalid_language_format": "Використовуйте канонічний код BCP-47, наприклад \"en\" або \"es-ES\".", - "empty": "Це поле не може бути порожнім", - "invalid_option_combo": "Збільшіть «Дні прогнозу», щоб охопити вибрані денні датчики.", - "unknown": "Невідома помилка" } }, "device": { - "types": { - "name": "{title} - Типи пилку ({latitude},{longitude})" + "info": { + "name": "{title} - Інформація про пилок ({latitude},{longitude})" }, "plants": { "name": "{title} - Рослини ({latitude},{longitude})" }, - "info": { - "name": "{title} - Інформація про пилок ({latitude},{longitude})" - } - }, - "services": { - "force_update": { - "name": "Примусове оновлення", - "description": "Вручну оновити дані про пилок для всіх налаштованих місць." + "types": { + "name": "{title} - Типи пилку ({latitude},{longitude})" } }, "entity": { "sensor": { - "region": { - "name": "Регіон" - }, "date": { "name": "Дата" }, "last_updated": { "name": "Останнє оновлення" + }, + "region": { + "name": "Регіон" + } + } + }, + "options": { + "error": { + "cannot_connect": "Не вдається підключитися до сервісу", + "empty": "Це поле не може бути порожнім", + "invalid_auth": "Невірний ключ API", + "invalid_language": "Невірний код мови", + "invalid_language_format": "Використовуйте канонічний код BCP-47, наприклад \"en\" або \"es-ES\".", + "invalid_option_combo": "Збільшіть «Дні прогнозу», щоб охопити вибрані денні датчики.", + "quota_exceeded": "Перевищено ліміт запитів", + "unknown": "Невідома помилка" + }, + "step": { + "init": { + "data": { + "create_forecast_sensors": "Діапазон денних датчиків (ТИПИ)", + "forecast_days": "Дні прогнозу (1–5)", + "language_code": "Код мови відповіді API", + "update_interval": "Інтервал оновлення (у годинах)" + }, + "description": "Змініть інтервал оновлення, мову відповіді API, кількість днів прогнозу та денні датчики для ТИПІВ для {title}.\nПараметри денних датчиків (ТИПИ): Лише сьогодні (none), До завтра (D+1), До післязавтра (D+2).", + "title": "Pollen Levels – Параметри" } } + }, + "services": { + "force_update": { + "description": "Вручну оновити дані про пилок для всіх налаштованих місць.", + "name": "Примусове оновлення" + } } -} +} \ No newline at end of file diff --git a/custom_components/pollenlevels/translations/zh-Hans.json b/custom_components/pollenlevels/translations/zh-Hans.json index d7f3c0d0..db9af135 100644 --- a/custom_components/pollenlevels/translations/zh-Hans.json +++ b/custom_components/pollenlevels/translations/zh-Hans.json @@ -1,95 +1,95 @@ { "config": { - "step": { - "user": { - "title": "花粉水平配置", - "description": "请输入 Google API 密钥、在地图上选择位置、更新间隔(小时)以及 API 响应的语言代码。", - "data": { - "api_key": "API 密钥", - "name": "名称", - "location": "位置", - "update_interval": "更新间隔(小时)", - "language_code": "API 响应语言代码" - } - }, - "reauth_confirm": { - "title": "重新验证 Pollen Levels", - "description": "{latitude},{longitude} 的 API 密钥已失效。请输入新的密钥以恢复更新。", - "data": { - "api_key": "API 密钥" - } - } + "abort": { + "already_configured": "该位置已配置。", + "reauth_failed": "重新验证失败。请重试。", + "reauth_successful": "重新验证已成功完成。" }, "error": { + "cannot_connect": "无法连接到服务\n\n{error_message}", + "empty": "此字段不能为空", "invalid_auth": "无效的 API 密钥", - "cannot_connect": "无法连接到服务", - "quota_exceeded": "配额已用尽", + "invalid_coordinates": "请在地图上选择有效的位置。", + "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters.", "invalid_language": "无效的语言代码", "invalid_language_format": "请使用规范的 BCP-47 代码,例如 \"en\" 或 \"es-ES\"。", - "empty": "此字段不能为空", "invalid_option_combo": "请增加“预测天数”,以覆盖所选的逐日类型传感器。", - "invalid_coordinates": "请在地图上选择有效的位置。", - "unknown": "未知错误", - "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters." + "quota_exceeded": "配额已用尽", + "unknown": "未知错误" }, - "abort": { - "already_configured": "该位置已配置。", - "reauth_successful": "重新验证已成功完成。", - "reauth_failed": "重新验证失败。请重试。" - } - }, - "options": { "step": { - "init": { - "title": "Pollen Levels – 选项", - "description": "修改更新间隔、API 语言、预测天以及逐日类型传感器,适用于 {title}。\n逐日类型传感器选项:仅今日(none)、至明日(D+1)、至后日(D+2)。", + "reauth_confirm": { + "data": { + "api_key": "API 密钥" + }, + "description": "{latitude},{longitude} 的 API 密钥已失效。请输入新的密钥以恢复更新。", + "title": "重新验证 Pollen Levels" + }, + "user": { "data": { - "update_interval": "更新间隔(小时)", + "api_key": "API 密钥", "language_code": "API 响应语言代码", - "forecast_days": "预测天数(1–5)", - "create_forecast_sensors": "逐日类型传感器范围" - } + "location": "位置", + "name": "名称", + "update_interval": "更新间隔(小时)" + }, + "description": "请输入 Google API 密钥、在地图上选择位置、更新间隔(小时)以及 API 响应的语言代码。", + "title": "花粉水平配置" } - }, - "error": { - "invalid_auth": "无效的 API 密钥", - "cannot_connect": "无法连接到服务", - "quota_exceeded": "配额已用尽", - "invalid_language": "无效的语言代码", - "invalid_language_format": "请使用规范的 BCP-47 代码,例如 \"en\" 或 \"es-ES\"。", - "empty": "此字段不能为空", - "invalid_option_combo": "请增加“预测天数”,以覆盖所选的逐日类型传感器。", - "unknown": "未知错误" } }, "device": { - "types": { - "name": "{title} - 花粉类型 ({latitude},{longitude})" + "info": { + "name": "{title} - 花粉信息 ({latitude},{longitude})" }, "plants": { "name": "{title} - 植物 ({latitude},{longitude})" }, - "info": { - "name": "{title} - 花粉信息 ({latitude},{longitude})" - } - }, - "services": { - "force_update": { - "name": "强制更新", - "description": "为所有已配置的位置手动刷新花粉数据。" + "types": { + "name": "{title} - 花粉类型 ({latitude},{longitude})" } }, "entity": { "sensor": { - "region": { - "name": "地区" - }, "date": { "name": "日期" }, "last_updated": { "name": "上次更新时间" + }, + "region": { + "name": "地区" + } + } + }, + "options": { + "error": { + "cannot_connect": "无法连接到服务", + "empty": "此字段不能为空", + "invalid_auth": "无效的 API 密钥", + "invalid_language": "无效的语言代码", + "invalid_language_format": "请使用规范的 BCP-47 代码,例如 \"en\" 或 \"es-ES\"。", + "invalid_option_combo": "请增加“预测天数”,以覆盖所选的逐日类型传感器。", + "quota_exceeded": "配额已用尽", + "unknown": "未知错误" + }, + "step": { + "init": { + "data": { + "create_forecast_sensors": "逐日类型传感器范围", + "forecast_days": "预测天数(1–5)", + "language_code": "API 响应语言代码", + "update_interval": "更新间隔(小时)" + }, + "description": "修改更新间隔、API 语言、预测天以及逐日类型传感器,适用于 {title}。\n逐日类型传感器选项:仅今日(none)、至明日(D+1)、至后日(D+2)。", + "title": "Pollen Levels – 选项" } } + }, + "services": { + "force_update": { + "description": "为所有已配置的位置手动刷新花粉数据。", + "name": "强制更新" + } } -} +} \ No newline at end of file diff --git a/custom_components/pollenlevels/translations/zh-Hant.json b/custom_components/pollenlevels/translations/zh-Hant.json index 28a788bf..22d5892e 100644 --- a/custom_components/pollenlevels/translations/zh-Hant.json +++ b/custom_components/pollenlevels/translations/zh-Hant.json @@ -1,95 +1,95 @@ { "config": { - "step": { - "user": { - "title": "花粉水平設定", - "description": "請輸入 Google API 金鑰、在地圖上選擇位置、更新間隔(小時)以及 API 回應的語言代碼。", - "data": { - "api_key": "API 金鑰", - "name": "名稱", - "location": "位置", - "update_interval": "更新間隔(小時)", - "language_code": "API 回應語言代碼" - } - }, - "reauth_confirm": { - "title": "重新驗證 Pollen Levels", - "description": "{latitude},{longitude} 的 API 金鑰已失效。請輸入新的金鑰以恢復更新。", - "data": { - "api_key": "API 金鑰" - } - } + "abort": { + "already_configured": "此位置已設定。", + "reauth_failed": "重新驗證失敗。請再試一次。", + "reauth_successful": "重新驗證已成功完成。" }, "error": { + "cannot_connect": "無法連線到服務\n\n{error_message}", + "empty": "此欄位不得為空", "invalid_auth": "無效的 API 金鑰", - "cannot_connect": "無法連線到服務", - "quota_exceeded": "超出配額", + "invalid_coordinates": "請在地圖上選擇有效的位置。", + "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters.", "invalid_language": "無效的語言代碼", "invalid_language_format": "請使用標準的 BCP-47 代碼,例如 \"en\" 或 \"es-ES\"。", - "empty": "此欄位不得為空", "invalid_option_combo": "請增加「預測天數」以涵蓋所選的逐日類型感測器。", - "invalid_coordinates": "請在地圖上選擇有效的位置。", - "unknown": "未知錯誤", - "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters." + "quota_exceeded": "超出配額", + "unknown": "未知錯誤" }, - "abort": { - "already_configured": "此位置已設定。", - "reauth_successful": "重新驗證已成功完成。", - "reauth_failed": "重新驗證失敗。請再試一次。" - } - }, - "options": { "step": { - "init": { - "title": "Pollen Levels – 選項", - "description": "修改更新間隔、API 語言、預測天數與逐日類型感測器,適用於 {title}。\n逐日類型感測器選項:僅今日(none)、至明日(D+1)、至後日(D+2)。", + "reauth_confirm": { + "data": { + "api_key": "API 金鑰" + }, + "description": "{latitude},{longitude} 的 API 金鑰已失效。請輸入新的金鑰以恢復更新。", + "title": "重新驗證 Pollen Levels" + }, + "user": { "data": { - "update_interval": "更新間隔(小時)", + "api_key": "API 金鑰", "language_code": "API 回應語言代碼", - "forecast_days": "預測天數(1–5)", - "create_forecast_sensors": "逐日類型感測器範圍" - } + "location": "位置", + "name": "名稱", + "update_interval": "更新間隔(小時)" + }, + "description": "請輸入 Google API 金鑰、在地圖上選擇位置、更新間隔(小時)以及 API 回應的語言代碼。", + "title": "花粉水平設定" } - }, - "error": { - "invalid_auth": "無效的 API 金鑰", - "cannot_connect": "無法連線到服務", - "quota_exceeded": "超出配額", - "invalid_language": "無效的語言代碼", - "invalid_language_format": "請使用標準的 BCP-47 代碼,例如 \"en\" 或 \"es-ES\"。", - "empty": "此欄位不得為空", - "invalid_option_combo": "請增加「預測天數」以涵蓋所選的逐日類型感測器。", - "unknown": "未知錯誤" } }, "device": { - "types": { - "name": "{title} - 花粉類型 ({latitude},{longitude})" + "info": { + "name": "{title} - 花粉資訊 ({latitude},{longitude})" }, "plants": { "name": "{title} - 植物 ({latitude},{longitude})" }, - "info": { - "name": "{title} - 花粉資訊 ({latitude},{longitude})" - } - }, - "services": { - "force_update": { - "name": "強制更新", - "description": "為所有已設定的位置手動重新整理花粉資料。" + "types": { + "name": "{title} - 花粉類型 ({latitude},{longitude})" } }, "entity": { "sensor": { - "region": { - "name": "地區" - }, "date": { "name": "日期" }, "last_updated": { "name": "上次更新時間" + }, + "region": { + "name": "地區" + } + } + }, + "options": { + "error": { + "cannot_connect": "無法連線到服務", + "empty": "此欄位不得為空", + "invalid_auth": "無效的 API 金鑰", + "invalid_language": "無效的語言代碼", + "invalid_language_format": "請使用標準的 BCP-47 代碼,例如 \"en\" 或 \"es-ES\"。", + "invalid_option_combo": "請增加「預測天數」以涵蓋所選的逐日類型感測器。", + "quota_exceeded": "超出配額", + "unknown": "未知錯誤" + }, + "step": { + "init": { + "data": { + "create_forecast_sensors": "逐日類型感測器範圍", + "forecast_days": "預測天數(1–5)", + "language_code": "API 回應語言代碼", + "update_interval": "更新間隔(小時)" + }, + "description": "修改更新間隔、API 語言、預測天數與逐日類型感測器,適用於 {title}。\n逐日類型感測器選項:僅今日(none)、至明日(D+1)、至後日(D+2)。", + "title": "Pollen Levels – 選項" } } + }, + "services": { + "force_update": { + "description": "為所有已設定的位置手動重新整理花粉資料。", + "name": "強制更新" + } } -} +} \ No newline at end of file diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 753f9668..7c80cefa 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -31,6 +31,22 @@ config_entries_mod = ModuleType("homeassistant.config_entries") +data_entry_flow_mod = ModuleType("homeassistant.data_entry_flow") + + +class _SectionConfig: + def __init__(self, collapsed: bool | None = None): + self.collapsed = collapsed + + +def section(key: str, config: _SectionConfig): # noqa: ARG001 + return key + + +data_entry_flow_mod.SectionConfig = _SectionConfig +data_entry_flow_mod.section = section +sys.modules.setdefault("homeassistant.data_entry_flow", data_entry_flow_mod) + class _StubConfigFlow: def __init_subclass__(cls, **_kwargs): @@ -167,12 +183,77 @@ def __init__(self, config: _LocationSelectorConfig): self.config = config +class _NumberSelectorConfig: + def __init__( + self, + *, + min: float | None = None, + max: float | None = None, + step: float | None = None, + mode: str | None = None, + unit_of_measurement: str | None = None, + ) -> None: + self.min = min + self.max = max + self.step = step + self.mode = mode + self.unit_of_measurement = unit_of_measurement + + +class _NumberSelectorMode: + BOX = "BOX" + + +class _NumberSelector: + def __init__(self, config: _NumberSelectorConfig): + self.config = config + + +class _TextSelectorConfig: + def __init__(self, *, type: str | None = None): # noqa: A003 + self.type = type + + +class _TextSelectorType: + TEXT = "TEXT" + + +class _TextSelector: + def __init__(self, config: _TextSelectorConfig): + self.config = config + + +class _SelectSelectorConfig: + def __init__(self, *, mode: str | None = None, options=None): + self.mode = mode + self.options = options + + +class _SelectSelectorMode: + DROPDOWN = "DROPDOWN" + + +class _SelectSelector: + def __init__(self, config: _SelectSelectorConfig): + self.config = config + + selector_mod.LocationSelector = _LocationSelector selector_mod.LocationSelectorConfig = _LocationSelectorConfig +selector_mod.NumberSelector = _NumberSelector +selector_mod.NumberSelectorConfig = _NumberSelectorConfig +selector_mod.NumberSelectorMode = _NumberSelectorMode +selector_mod.TextSelector = _TextSelector +selector_mod.TextSelectorConfig = _TextSelectorConfig +selector_mod.TextSelectorType = _TextSelectorType +selector_mod.SelectSelector = _SelectSelector +selector_mod.SelectSelectorConfig = _SelectSelectorConfig +selector_mod.SelectSelectorMode = _SelectSelectorMode sys.modules.setdefault("homeassistant.helpers.selector", selector_mod) ha_mod.helpers = helpers_mod ha_mod.config_entries = config_entries_mod +ha_mod.data_entry_flow = data_entry_flow_mod aiohttp_mod = ModuleType("aiohttp") From e47aaae53f1143cc04c2a903ee4ff9e04771b4b5 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sun, 14 Dec 2025 09:46:49 +0100 Subject: [PATCH 019/200] Update auth error mapping test expectations --- tests/test_config_flow.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 7c80cefa..fe0a4207 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -514,11 +514,17 @@ def _base_user_input() -> dict: } -@pytest.mark.parametrize("status", [401, 403]) -def test_validate_input_http_auth_errors_set_invalid_auth( - monkeypatch: pytest.MonkeyPatch, status: int +@pytest.mark.parametrize( + ("status", "expected"), + [ + (401, {"base": "invalid_auth"}), + (403, {"base": "cannot_connect"}), + ], +) +def test_validate_input_http_auth_errors_map_correctly( + monkeypatch: pytest.MonkeyPatch, status: int, expected: dict ) -> None: - """HTTP auth failures during validation should map to invalid_auth.""" + """HTTP auth failures during validation should map correctly.""" session = _patch_client_session(monkeypatch, _StubResponse(status)) @@ -530,7 +536,7 @@ def test_validate_input_http_auth_errors_set_invalid_auth( ) assert session.calls - assert errors == {"base": "invalid_auth"} + assert errors == expected assert normalized is None From fab98fe1cb920409b6c1e51993bc63d5f9353335 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sun, 14 Dec 2025 09:46:57 +0100 Subject: [PATCH 020/200] Handle section schemas in translation tests --- tests/test_translations.py | 67 ++++++++++++++++++++++++++------------ 1 file changed, 46 insertions(+), 21 deletions(-) diff --git a/tests/test_translations.py b/tests/test_translations.py index 3df0661e..904a3c3a 100644 --- a/tests/test_translations.py +++ b/tests/test_translations.py @@ -122,6 +122,51 @@ def _resolve_name(name: str, mapping: dict[str, str]) -> str | None: return mapping.get(name) +def _fields_from_section_value(value: ast.AST, mapping: dict[str, str]) -> set[str]: + """Extract fields from a section(...) value.""" + + if isinstance(value, ast.Dict): + return _fields_from_schema_dict(value, mapping) + if isinstance(value, ast.Call): + if isinstance(value.func, ast.Attribute) and value.func.attr == "Schema": + return _fields_from_schema_call(value, mapping) + _fail_unexpected_ast("unexpected section value AST") + return set() + + +def _fields_from_schema_dict( + schema_dict: ast.Dict, mapping: dict[str, str] +) -> set[str]: + """Extract field keys from an AST dict representing a schema.""" + + fields: set[str] = set() + for key_node, value_node in zip(schema_dict.keys, schema_dict.values, strict=False): + if not isinstance(key_node, ast.Call): + _fail_unexpected_ast("schema key wrapper") + if isinstance(key_node.func, ast.Attribute) and key_node.func.attr in { + "Required", + "Optional", + }: + if not key_node.args: + _fail_unexpected_ast("schema key args") + selector = key_node.args[0] + if isinstance(selector, ast.Constant) and isinstance(selector.value, str): + fields.add(selector.value) + elif isinstance(selector, ast.Name): + resolved = _resolve_name(selector.id, mapping) + if resolved: + fields.add(resolved) + else: + _fail_unexpected_ast(f"unmapped selector {selector.id}") + else: + _fail_unexpected_ast("selector type") + elif isinstance(key_node.func, ast.Name) and key_node.func.id == "section": + fields.update(_fields_from_section_value(value_node, mapping)) + else: + _fail_unexpected_ast("unexpected schema call wrapper") + return fields + + def _fields_from_schema_call(call: ast.Call, mapping: dict[str, str]) -> set[str]: """Extract field keys from a vol.Schema(...) call. @@ -131,28 +176,8 @@ def _fields_from_schema_call(call: ast.Call, mapping: dict[str, str]) -> set[str if not call.args or not isinstance(call.args[0], ast.Dict): _fail_unexpected_ast("schema call arguments") - arg = call.args[0] - fields: set[str] = set() - for key in arg.keys: - if not isinstance(key, ast.Call) or not isinstance(key.func, ast.Attribute): - _fail_unexpected_ast("schema key wrapper") - if key.func.attr not in {"Required", "Optional"}: - _fail_unexpected_ast(f"unexpected schema call {key.func.attr}") - if not key.args: - _fail_unexpected_ast("schema key args") - selector = key.args[0] - if isinstance(selector, ast.Constant) and isinstance(selector.value, str): - fields.add(selector.value) - elif isinstance(selector, ast.Name): - resolved = _resolve_name(selector.id, mapping) - if resolved: - fields.add(resolved) - else: - _fail_unexpected_ast(f"unmapped selector {selector.id}") - else: - _fail_unexpected_ast("selector type") - return fields + return _fields_from_schema_dict(call.args[0], mapping) def _extract_schema_fields( From 47e49c8dfec4e22938dde22edbfb1329ec2a8490 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sun, 14 Dec 2025 09:47:02 +0100 Subject: [PATCH 021/200] Normalize numeric option inputs --- custom_components/pollenlevels/config_flow.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/custom_components/pollenlevels/config_flow.py b/custom_components/pollenlevels/config_flow.py index 230261c2..4a58dad3 100644 --- a/custom_components/pollenlevels/config_flow.py +++ b/custom_components/pollenlevels/config_flow.py @@ -551,16 +551,16 @@ async def async_step_init(self, user_input=None): current_mode = self.entry.options.get(CONF_CREATE_FORECAST_SENSORS, "none") if user_input is not None: - normalized_input: dict[str, Any] = dict(user_input) + normalized_input: dict[str, Any] = {**self.entry.options, **user_input} try: normalized_input[CONF_UPDATE_INTERVAL] = int( - user_input.get(CONF_UPDATE_INTERVAL, current_interval) + float(normalized_input.get(CONF_UPDATE_INTERVAL, current_interval)) ) normalized_input[CONF_FORECAST_DAYS] = int( - user_input.get(CONF_FORECAST_DAYS, current_days) + float(normalized_input.get(CONF_FORECAST_DAYS, current_days)) ) except (TypeError, ValueError): - errors["base"] = "unknown" + errors["base"] = "invalid_option_combo" if not errors: try: From 4d65147b4faf1a2d68f673a58819ba1d51f46581 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sun, 14 Dec 2025 17:51:56 +0100 Subject: [PATCH 022/200] Handle runtime 403 without reauth --- custom_components/pollenlevels/client.py | 49 +++++++++++++++++++++--- 1 file changed, 43 insertions(+), 6 deletions(-) diff --git a/custom_components/pollenlevels/client.py b/custom_components/pollenlevels/client.py index 3e50ca0f..db1e7bc3 100644 --- a/custom_components/pollenlevels/client.py +++ b/custom_components/pollenlevels/client.py @@ -23,6 +23,34 @@ def __init__(self, session: ClientSession, api_key: str) -> None: self._session = session self._api_key = api_key + async def _extract_error_message(self, resp: Any) -> str: + """Extract and normalize an HTTP error message.""" + + message: str | None = None + try: + data = await resp.json() + if isinstance(data, dict): + error = data.get("error") + if isinstance(error, dict): + raw_msg = error.get("message") + if isinstance(raw_msg, str): + message = raw_msg + except Exception: # noqa: BLE001 + message = None + + if not message: + try: + text = await resp.text() + message = text.strip() if isinstance(text, str) else None + except Exception: # noqa: BLE001 + message = None + + if not message: + return f"HTTP {resp.status}" + + message = message[:300] + return f"HTTP {resp.status}: {message}" + def _parse_retry_after(self, retry_after_raw: str) -> float: """Translate a Retry-After header into a delay in seconds.""" @@ -81,8 +109,13 @@ async def async_fetch_pollen_data( async with self._session.get( url, params=params, timeout=ClientTimeout(total=POLLEN_API_TIMEOUT) ) as resp: - if resp.status in (401, 403): - raise ConfigEntryAuthFailed("Invalid API key") + if resp.status == 401: + message = await self._extract_error_message(resp) + raise ConfigEntryAuthFailed(message) + + if resp.status == 403: + message = await self._extract_error_message(resp) + raise UpdateFailed(message) if resp.status == 429: if attempt < max_retries: @@ -99,7 +132,8 @@ async def async_fetch_pollen_data( ) await asyncio.sleep(delay) continue - raise UpdateFailed("Quota exceeded") + message = await self._extract_error_message(resp) + raise UpdateFailed(message) if 500 <= resp.status <= 599: if attempt < max_retries: @@ -113,13 +147,16 @@ async def async_fetch_pollen_data( base_args=(resp.status,), ) continue - raise UpdateFailed(f"HTTP {resp.status}") + message = await self._extract_error_message(resp) + raise UpdateFailed(message) if 400 <= resp.status < 500 and resp.status not in (403, 429): - raise UpdateFailed(f"HTTP {resp.status}") + message = await self._extract_error_message(resp) + raise UpdateFailed(message) if resp.status != 200: - raise UpdateFailed(f"HTTP {resp.status}") + message = await self._extract_error_message(resp) + raise UpdateFailed(message) return await resp.json() From c631f57168113bbebb9097a70e7da9942b375570 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sun, 14 Dec 2025 17:52:02 +0100 Subject: [PATCH 023/200] Pass http referer to client and update tests --- custom_components/pollenlevels/__init__.py | 5 +- custom_components/pollenlevels/client.py | 13 ++++- tests/test_init.py | 3 +- tests/test_sensor.py | 62 ++++++++++++++++++---- 4 files changed, 69 insertions(+), 14 deletions(-) diff --git a/custom_components/pollenlevels/__init__.py b/custom_components/pollenlevels/__init__.py index 1541bc8c..f2f94cdc 100644 --- a/custom_components/pollenlevels/__init__.py +++ b/custom_components/pollenlevels/__init__.py @@ -25,6 +25,7 @@ CONF_API_KEY, CONF_CREATE_FORECAST_SENSORS, CONF_FORECAST_DAYS, + CONF_HTTP_REFERER, CONF_LANGUAGE_CODE, CONF_LATITUDE, CONF_LONGITUDE, @@ -114,11 +115,13 @@ async def async_setup_entry( if not api_key: raise ConfigEntryAuthFailed("Missing API key") + http_referer = entry.data.get(CONF_HTTP_REFERER) + raw_title = entry.title or "" clean_title = raw_title.strip() or DEFAULT_ENTRY_TITLE session = async_get_clientsession(hass) - client = GooglePollenApiClient(session, api_key) + client = GooglePollenApiClient(session, api_key, http_referer) coordinator = PollenDataUpdateCoordinator( hass=hass, diff --git a/custom_components/pollenlevels/client.py b/custom_components/pollenlevels/client.py index db1e7bc3..f4fb5df9 100644 --- a/custom_components/pollenlevels/client.py +++ b/custom_components/pollenlevels/client.py @@ -19,9 +19,12 @@ class GooglePollenApiClient: """Thin async client wrapper for the Google Pollen API.""" - def __init__(self, session: ClientSession, api_key: str) -> None: + def __init__( + self, session: ClientSession, api_key: str, http_referer: str | None = None + ) -> None: self._session = session self._api_key = api_key + self._http_referer = http_referer async def _extract_error_message(self, resp: Any) -> str: """Extract and normalize an HTTP error message.""" @@ -106,8 +109,14 @@ async def async_fetch_pollen_data( max_retries = 1 for attempt in range(0, max_retries + 1): try: + headers: dict[str, str] | None = None + if self._http_referer: + headers = {"Referer": self._http_referer} async with self._session.get( - url, params=params, timeout=ClientTimeout(total=POLLEN_API_TIMEOUT) + url, + params=params, + timeout=ClientTimeout(total=POLLEN_API_TIMEOUT), + headers=headers, ) as resp: if resp.status == 401: message = await self._extract_error_message(resp) diff --git a/tests/test_init.py b/tests/test_init.py index 28798321..527e42f9 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -376,9 +376,10 @@ def test_setup_entry_success_and_unload() -> None: entry = _FakeEntry() class _StubClient: - def __init__(self, _session, _api_key): + def __init__(self, _session, _api_key, _http_referer=None): self.session = _session self.api_key = _api_key + self.http_referer = _http_referer async def async_fetch_pollen_data(self, **_kwargs): return {"region": {"source": "meta"}, "dailyInfo": []} diff --git a/tests/test_sensor.py b/tests/test_sensor.py index e05c3ec2..2249d962 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -816,13 +816,10 @@ def test_cleanup_per_day_entities_removes_disabled_days( assert registry.removals == expected_entities -@pytest.mark.parametrize("status", [401, 403]) -def test_coordinator_raises_auth_failed( - monkeypatch: pytest.MonkeyPatch, status: int -) -> None: - """Auth failures trigger ConfigEntryAuthFailed for re-auth flows.""" +def test_coordinator_raises_auth_failed() -> None: + """401 responses trigger ConfigEntryAuthFailed for re-auth flows.""" - fake_session = FakeSession({}, status=status) + fake_session = FakeSession({}, status=401) client = sensor.GooglePollenApiClient(fake_session, "bad") loop = asyncio.new_event_loop() @@ -848,6 +845,35 @@ def test_coordinator_raises_auth_failed( loop.close() +def test_coordinator_handles_forbidden() -> None: + """403 responses raise UpdateFailed without triggering re-auth.""" + + fake_session = FakeSession({"error": {"message": "Forbidden"}}, status=403) + client = sensor.GooglePollenApiClient(fake_session, "bad") + + loop = asyncio.new_event_loop() + hass = DummyHass(loop) + coordinator = sensor.PollenDataUpdateCoordinator( + hass=hass, + api_key="bad", + lat=1.0, + lon=2.0, + hours=12, + language=None, + entry_id="entry", + forecast_days=1, + create_d1=False, + create_d2=False, + client=client, + ) + + try: + with pytest.raises(sensor.UpdateFailed): + loop.run_until_complete(coordinator._async_update_data()) + finally: + loop.close() + + def test_coordinator_retries_then_raises_on_rate_limit( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -855,8 +881,16 @@ def test_coordinator_retries_then_raises_on_rate_limit( session = SequenceSession( [ - ResponseSpec(status=429, payload={}, headers={"Retry-After": "3"}), - ResponseSpec(status=429, payload={}, headers={"Retry-After": "3"}), + ResponseSpec( + status=429, + payload={"error": {"message": "Quota exceeded"}}, + headers={"Retry-After": "3"}, + ), + ResponseSpec( + status=429, + payload={"error": {"message": "Quota exceeded"}}, + headers={"Retry-After": "3"}, + ), ] ) delays: list[float] = [] @@ -901,8 +935,16 @@ def test_coordinator_retry_after_http_date(monkeypatch: pytest.MonkeyPatch) -> N retry_after = "Wed, 10 Dec 2025 12:00:05 GMT" session = SequenceSession( [ - ResponseSpec(status=429, payload={}, headers={"Retry-After": retry_after}), - ResponseSpec(status=429, payload={}, headers={"Retry-After": retry_after}), + ResponseSpec( + status=429, + payload={"error": {"message": "Quota exceeded"}}, + headers={"Retry-After": retry_after}, + ), + ResponseSpec( + status=429, + payload={"error": {"message": "Quota exceeded"}}, + headers={"Retry-After": retry_after}, + ), ] ) delays: list[float] = [] From 37a4bb27d7c33f05b7b1359afd9adcf3958708e5 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sun, 14 Dec 2025 17:52:07 +0100 Subject: [PATCH 024/200] Sanitize Referer header before request --- custom_components/pollenlevels/client.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/custom_components/pollenlevels/client.py b/custom_components/pollenlevels/client.py index f4fb5df9..05793ef8 100644 --- a/custom_components/pollenlevels/client.py +++ b/custom_components/pollenlevels/client.py @@ -110,8 +110,15 @@ async def async_fetch_pollen_data( for attempt in range(0, max_retries + 1): try: headers: dict[str, str] | None = None - if self._http_referer: - headers = {"Referer": self._http_referer} + referer = self._http_referer + if referer: + if "\r" in referer or "\n" in referer: + _LOGGER.warning( + "Ignoring http_referer containing newline characters" + ) + referer = None + else: + headers = {"Referer": referer} async with self._session.get( url, params=params, From 98214b1f1a0dd150982f581ebeeb8199fee34d8a Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sun, 14 Dec 2025 17:52:12 +0100 Subject: [PATCH 025/200] Add error_message placeholder to English cannot_connect --- custom_components/pollenlevels/translations/en.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/pollenlevels/translations/en.json b/custom_components/pollenlevels/translations/en.json index f2380fc0..7ea1be7b 100644 --- a/custom_components/pollenlevels/translations/en.json +++ b/custom_components/pollenlevels/translations/en.json @@ -53,7 +53,7 @@ }, "error": { "invalid_auth": "Invalid API key", - "cannot_connect": "Unable to connect to the pollen service.", + "cannot_connect": "Unable to connect to the pollen service.\n\n{error_message}", "quota_exceeded": "Quota exceeded", "invalid_language": "Invalid language code", "invalid_language_format": "Use a canonical BCP-47 code such as \"en\" or \"es-ES\".", From e56fcb76725ef12940e4503841c35b7eaa21d892 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sun, 14 Dec 2025 17:52:18 +0100 Subject: [PATCH 026/200] Add translation strings for API links and referrer --- .../pollenlevels/translations/ca.json | 17 ++++++++++----- .../pollenlevels/translations/cs.json | 21 ++++++++++++------- .../pollenlevels/translations/da.json | 21 ++++++++++++------- .../pollenlevels/translations/de.json | 21 ++++++++++++------- .../pollenlevels/translations/en.json | 15 +++++++++---- .../pollenlevels/translations/es.json | 17 ++++++++++----- .../pollenlevels/translations/fi.json | 21 ++++++++++++------- .../pollenlevels/translations/fr.json | 21 ++++++++++++------- .../pollenlevels/translations/hu.json | 21 ++++++++++++------- .../pollenlevels/translations/it.json | 21 ++++++++++++------- .../pollenlevels/translations/nb.json | 21 ++++++++++++------- .../pollenlevels/translations/nl.json | 21 ++++++++++++------- .../pollenlevels/translations/pl.json | 21 ++++++++++++------- .../pollenlevels/translations/pt-BR.json | 21 ++++++++++++------- .../pollenlevels/translations/pt-PT.json | 21 ++++++++++++------- .../pollenlevels/translations/ro.json | 21 ++++++++++++------- .../pollenlevels/translations/ru.json | 21 ++++++++++++------- .../pollenlevels/translations/sv.json | 21 ++++++++++++------- .../pollenlevels/translations/uk.json | 21 ++++++++++++------- .../pollenlevels/translations/zh-Hans.json | 21 ++++++++++++------- .../pollenlevels/translations/zh-Hant.json | 21 ++++++++++++------- 21 files changed, 287 insertions(+), 140 deletions(-) diff --git a/custom_components/pollenlevels/translations/ca.json b/custom_components/pollenlevels/translations/ca.json index 7864ef4f..3b3d34f6 100644 --- a/custom_components/pollenlevels/translations/ca.json +++ b/custom_components/pollenlevels/translations/ca.json @@ -3,13 +3,20 @@ "step": { "user": { "title": "Configuració de Nivells de pol·len", - "description": "Introdueix la teva clau d’API de Google, selecciona la teva ubicació al mapa, l’interval d’actualització (hores) i el codi d’idioma de la resposta de l’API.", + "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", "data": { "api_key": "Clau API", "name": "Nom", "location": "Ubicació", "update_interval": "Interval d’actualització (hores)", - "language_code": "Codi d’idioma de la resposta de l’API" + "language_code": "Codi d’idioma de la resposta de l’API", + "http_referer": "HTTP Referrer" + }, + "sections": { + "api_key_options": "Optional API key options" + }, + "data_description": { + "http_referer": "Only needed if your API key is restricted by HTTP referrers (websites)." } }, "reauth_confirm": { @@ -21,7 +28,7 @@ } }, "error": { - "invalid_auth": "Clau API no vàlida", + "invalid_auth": "Clau API no vàlida\n\n{error_message}", "cannot_connect": "No es pot connectar al servei de pol·len.\n\n{error_message}", "quota_exceeded": "Quota excedida", "invalid_language": "Codi d’idioma no vàlid", @@ -52,8 +59,8 @@ } }, "error": { - "invalid_auth": "Clau API no vàlida", - "cannot_connect": "No es pot connectar al servei de pol·len.", + "invalid_auth": "Clau API no vàlida\n\n{error_message}", + "cannot_connect": "No es pot connectar al servei de pol·len.\n\n{error_message}", "quota_exceeded": "Quota excedida", "invalid_language": "Codi d’idioma no vàlid", "invalid_language_format": "Utilitza un codi BCP-47 canònic com \"en\" o \"es-ES\".", diff --git a/custom_components/pollenlevels/translations/cs.json b/custom_components/pollenlevels/translations/cs.json index 6c13ed56..9adad335 100644 --- a/custom_components/pollenlevels/translations/cs.json +++ b/custom_components/pollenlevels/translations/cs.json @@ -8,7 +8,7 @@ "error": { "cannot_connect": "Nelze se připojit ke službě\n\n{error_message}", "empty": "Toto pole nemůže být prázdné", - "invalid_auth": "Neplatný klíč API", + "invalid_auth": "Neplatný klíč API\n\n{error_message}", "invalid_coordinates": "Vyberte platné umístění na mapě.", "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters.", "invalid_language": "Neplatný kód jazyka", @@ -31,10 +31,17 @@ "language_code": "Kód jazyka odpovědi API", "location": "Poloha", "name": "Název", - "update_interval": "Interval aktualizace (hodiny)" + "update_interval": "Interval aktualizace (hodiny)", + "http_referer": "HTTP Referrer" + }, + "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", + "title": "Konfigurace úrovní pylu", + "sections": { + "api_key_options": "Optional API key options" }, - "description": "Zadejte svůj klíč Google API, vyberte svou polohu na mapě, interval aktualizace (hodiny) a kód jazyka pro odpověď API.", - "title": "Konfigurace úrovní pylu" + "data_description": { + "http_referer": "Only needed if your API key is restricted by HTTP referrers (websites)." + } } } }, @@ -64,9 +71,9 @@ }, "options": { "error": { - "cannot_connect": "Nelze se připojit ke službě", + "cannot_connect": "Nelze se připojit ke službě\n\n{error_message}", "empty": "Toto pole nemůže být prázdné", - "invalid_auth": "Neplatný klíč API", + "invalid_auth": "Neplatný klíč API\n\n{error_message}", "invalid_language": "Neplatný kód jazyka", "invalid_language_format": "Použijte kanonický kód BCP-47, například \"en\" nebo \"es-ES\".", "invalid_option_combo": "Zvyšte „Dny předpovědi“, aby pokryly vybrané senzory po dnech.", @@ -92,4 +99,4 @@ "name": "Vynutit aktualizaci" } } -} \ No newline at end of file +} diff --git a/custom_components/pollenlevels/translations/da.json b/custom_components/pollenlevels/translations/da.json index 1d34ba05..363bd78d 100644 --- a/custom_components/pollenlevels/translations/da.json +++ b/custom_components/pollenlevels/translations/da.json @@ -8,7 +8,7 @@ "error": { "cannot_connect": "Kan ikke oprette forbindelse til tjenesten\n\n{error_message}", "empty": "Dette felt må ikke være tomt", - "invalid_auth": "Ugyldig API-nøgle", + "invalid_auth": "Ugyldig API-nøgle\n\n{error_message}", "invalid_coordinates": "Vælg en gyldig placering på kortet.", "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters.", "invalid_language": "Ugyldig sprogkode", @@ -31,10 +31,17 @@ "language_code": "Sprogkode for API-svar", "location": "Placering", "name": "Navn", - "update_interval": "Opdateringsinterval (timer)" + "update_interval": "Opdateringsinterval (timer)", + "http_referer": "HTTP Referrer" + }, + "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", + "title": "Konfiguration af pollenniveauer", + "sections": { + "api_key_options": "Optional API key options" }, - "description": "Angiv din Google API-nøgle, vælg din placering på kortet, opdateringsinterval (timer) og sprogkode for API-svar.", - "title": "Konfiguration af pollenniveauer" + "data_description": { + "http_referer": "Only needed if your API key is restricted by HTTP referrers (websites)." + } } } }, @@ -64,9 +71,9 @@ }, "options": { "error": { - "cannot_connect": "Kan ikke oprette forbindelse til tjenesten", + "cannot_connect": "Kan ikke oprette forbindelse til tjenesten\n\n{error_message}", "empty": "Dette felt må ikke være tomt", - "invalid_auth": "Ugyldig API-nøgle", + "invalid_auth": "Ugyldig API-nøgle\n\n{error_message}", "invalid_language": "Ugyldig sprogkode", "invalid_language_format": "Brug en kanonisk BCP-47-kode som \"en\" eller \"es-ES\".", "invalid_option_combo": "Forøg \"Prognosedage\" for at dække valgte sensorer pr. dag.", @@ -92,4 +99,4 @@ "name": "Gennemtving opdatering" } } -} \ No newline at end of file +} diff --git a/custom_components/pollenlevels/translations/de.json b/custom_components/pollenlevels/translations/de.json index 2996aebf..2e03d432 100644 --- a/custom_components/pollenlevels/translations/de.json +++ b/custom_components/pollenlevels/translations/de.json @@ -8,7 +8,7 @@ "error": { "cannot_connect": "Verbindung zum Dienst fehlgeschlagen\n\n{error_message}", "empty": "Dieses Feld darf nicht leer sein", - "invalid_auth": "Ungültiger API-Schlüssel", + "invalid_auth": "Ungültiger API-Schlüssel\n\n{error_message}", "invalid_coordinates": "Wähle einen gültigen Standort auf der Karte aus.", "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters.", "invalid_language": "Ungültiger Sprachcode", @@ -31,10 +31,17 @@ "language_code": "Sprachcode für die API-Antwort", "location": "Standort", "name": "Name", - "update_interval": "Aktualisierungsintervall (Stunden)" + "update_interval": "Aktualisierungsintervall (Stunden)", + "http_referer": "HTTP Referrer" + }, + "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", + "title": "Pollen Levels – Konfiguration", + "sections": { + "api_key_options": "Optional API key options" }, - "description": "Gib deinen Google API-Schlüssel an, wähle deinen Standort auf der Karte, das Aktualisierungsintervall (Stunden) und den Sprachcode für die API-Antwort.", - "title": "Pollen Levels – Konfiguration" + "data_description": { + "http_referer": "Only needed if your API key is restricted by HTTP referrers (websites)." + } } } }, @@ -64,9 +71,9 @@ }, "options": { "error": { - "cannot_connect": "Verbindung zum Dienst fehlgeschlagen", + "cannot_connect": "Verbindung zum Dienst fehlgeschlagen\n\n{error_message}", "empty": "Dieses Feld darf nicht leer sein", - "invalid_auth": "Ungültiger API-Schlüssel", + "invalid_auth": "Ungültiger API-Schlüssel\n\n{error_message}", "invalid_language": "Ungültiger Sprachcode", "invalid_language_format": "Verwenden Sie einen kanonischen BCP-47-Code wie \"en\" oder \"es-ES\".", "invalid_option_combo": "Erhöhe 'Vorhersagetage', um die gewählten Tagessensoren abzudecken.", @@ -92,4 +99,4 @@ "name": "Aktualisierung erzwingen" } } -} \ No newline at end of file +} diff --git a/custom_components/pollenlevels/translations/en.json b/custom_components/pollenlevels/translations/en.json index 7ea1be7b..9ab4b29a 100644 --- a/custom_components/pollenlevels/translations/en.json +++ b/custom_components/pollenlevels/translations/en.json @@ -3,13 +3,20 @@ "step": { "user": { "title": "Pollen Levels Configuration", - "description": "Enter your Google API Key, select your location on the map, update interval (hours) and API response language code.", + "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", "data": { "api_key": "API Key", "name": "Name", "location": "Location", "update_interval": "Update interval (hours)", - "language_code": "API response language code" + "language_code": "API response language code", + "http_referer": "HTTP Referrer" + }, + "sections": { + "api_key_options": "Optional API key options" + }, + "data_description": { + "http_referer": "Only needed if your API key is restricted by HTTP referrers (websites)." } }, "reauth_confirm": { @@ -21,7 +28,7 @@ } }, "error": { - "invalid_auth": "Invalid API key", + "invalid_auth": "Invalid API key\n\n{error_message}", "cannot_connect": "Unable to connect to the pollen service.\n\n{error_message}", "quota_exceeded": "Quota exceeded", "invalid_language": "Invalid language code", @@ -52,7 +59,7 @@ } }, "error": { - "invalid_auth": "Invalid API key", + "invalid_auth": "Invalid API key\n\n{error_message}", "cannot_connect": "Unable to connect to the pollen service.\n\n{error_message}", "quota_exceeded": "Quota exceeded", "invalid_language": "Invalid language code", diff --git a/custom_components/pollenlevels/translations/es.json b/custom_components/pollenlevels/translations/es.json index 3c7a2a4d..c601d493 100644 --- a/custom_components/pollenlevels/translations/es.json +++ b/custom_components/pollenlevels/translations/es.json @@ -3,13 +3,20 @@ "step": { "user": { "title": "Configuración de Niveles de Polen", - "description": "Introduce tu clave de API de Google, selecciona tu ubicación en el mapa, el intervalo de actualización (horas) y el código de idioma de la respuesta de la API.", + "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", "data": { "api_key": "Clave API", "name": "Nombre", "location": "Ubicación", "update_interval": "Intervalo de actualización (horas)", - "language_code": "Código de idioma de la respuesta de la API" + "language_code": "Código de idioma de la respuesta de la API", + "http_referer": "HTTP Referrer" + }, + "sections": { + "api_key_options": "Optional API key options" + }, + "data_description": { + "http_referer": "Only needed if your API key is restricted by HTTP referrers (websites)." } }, "reauth_confirm": { @@ -21,7 +28,7 @@ } }, "error": { - "invalid_auth": "Clave API inválida", + "invalid_auth": "Clave API inválida\n\n{error_message}", "cannot_connect": "No se puede conectar al servicio de polen.\n\n{error_message}", "quota_exceeded": "Cuota excedida", "invalid_language": "Código de idioma no válido", @@ -52,8 +59,8 @@ } }, "error": { - "invalid_auth": "Clave API inválida", - "cannot_connect": "No se puede conectar al servicio de polen.", + "invalid_auth": "Clave API inválida\n\n{error_message}", + "cannot_connect": "No se puede conectar al servicio de polen.\n\n{error_message}", "quota_exceeded": "Cuota excedida", "invalid_language": "Código de idioma no válido", "invalid_language_format": "Usa un código BCP-47 canónico como \"en\" o \"es-ES\".", diff --git a/custom_components/pollenlevels/translations/fi.json b/custom_components/pollenlevels/translations/fi.json index ee7ee902..391d7623 100644 --- a/custom_components/pollenlevels/translations/fi.json +++ b/custom_components/pollenlevels/translations/fi.json @@ -8,7 +8,7 @@ "error": { "cannot_connect": "Palveluun ei saada yhteyttä\n\n{error_message}", "empty": "Tämä kenttä ei voi olla tyhjä", - "invalid_auth": "Virheellinen API-avain", + "invalid_auth": "Virheellinen API-avain\n\n{error_message}", "invalid_coordinates": "Valitse kartalta kelvollinen sijainti.", "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters.", "invalid_language": "Virheellinen kielikoodi", @@ -31,10 +31,17 @@ "language_code": "API-vastauksen kielikoodi", "location": "Sijainti", "name": "Nimi", - "update_interval": "Päivitysväli (tunnit)" + "update_interval": "Päivitysväli (tunnit)", + "http_referer": "HTTP Referrer" + }, + "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", + "title": "Siitepölytason asetukset", + "sections": { + "api_key_options": "Optional API key options" }, - "description": "Anna Google API -avaimesi, valitse sijaintisi kartalta, päivitysväli (tunnit) ja API-vastauksen kielikoodi.", - "title": "Siitepölytason asetukset" + "data_description": { + "http_referer": "Only needed if your API key is restricted by HTTP referrers (websites)." + } } } }, @@ -64,9 +71,9 @@ }, "options": { "error": { - "cannot_connect": "Palveluun ei saada yhteyttä", + "cannot_connect": "Palveluun ei saada yhteyttä\n\n{error_message}", "empty": "Tämä kenttä ei voi olla tyhjä", - "invalid_auth": "Virheellinen API-avain", + "invalid_auth": "Virheellinen API-avain\n\n{error_message}", "invalid_language": "Virheellinen kielikoodi", "invalid_language_format": "Käytä kanonista BCP-47-koodia, esimerkiksi \"en\" tai \"es-ES\".", "invalid_option_combo": "Lisää \"Ennustepäiviä\", jotta valitut päiväsensorit katetaan.", @@ -92,4 +99,4 @@ "name": "Pakota päivitys" } } -} \ No newline at end of file +} diff --git a/custom_components/pollenlevels/translations/fr.json b/custom_components/pollenlevels/translations/fr.json index 584615a8..d2325f27 100644 --- a/custom_components/pollenlevels/translations/fr.json +++ b/custom_components/pollenlevels/translations/fr.json @@ -8,7 +8,7 @@ "error": { "cannot_connect": "Impossible de se connecter au service\n\n{error_message}", "empty": "Ce champ ne peut pas être vide", - "invalid_auth": "Clé API invalide", + "invalid_auth": "Clé API invalide\n\n{error_message}", "invalid_coordinates": "Sélectionnez un emplacement valide sur la carte.", "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters.", "invalid_language": "Code de langue invalide", @@ -31,10 +31,17 @@ "language_code": "Code de langue pour la réponse de l’API", "location": "Emplacement", "name": "Nom", - "update_interval": "Intervalle de mise à jour (heures)" + "update_interval": "Intervalle de mise à jour (heures)", + "http_referer": "HTTP Referrer" + }, + "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", + "title": "Pollen Levels – Configuration", + "sections": { + "api_key_options": "Optional API key options" }, - "description": "Saisissez votre clé API Google, sélectionnez votre position sur la carte, l’intervalle de mise à jour (heures) et le code de langue pour la réponse de l’API.", - "title": "Pollen Levels – Configuration" + "data_description": { + "http_referer": "Only needed if your API key is restricted by HTTP referrers (websites)." + } } } }, @@ -64,9 +71,9 @@ }, "options": { "error": { - "cannot_connect": "Impossible de se connecter au service", + "cannot_connect": "Impossible de se connecter au service\n\n{error_message}", "empty": "Ce champ ne peut pas être vide", - "invalid_auth": "Clé API invalide", + "invalid_auth": "Clé API invalide\n\n{error_message}", "invalid_language": "Code de langue invalide", "invalid_language_format": "Utilisez un code BCP-47 canonique tel que \"en\" ou \"es-ES\".", "invalid_option_combo": "Augmentez le nombre de « Jours de prévision » afin de couvrir les capteurs par jour sélectionnés.", @@ -92,4 +99,4 @@ "name": "Forcer la mise à jour" } } -} \ No newline at end of file +} diff --git a/custom_components/pollenlevels/translations/hu.json b/custom_components/pollenlevels/translations/hu.json index 86ba693d..ff4543ef 100644 --- a/custom_components/pollenlevels/translations/hu.json +++ b/custom_components/pollenlevels/translations/hu.json @@ -8,7 +8,7 @@ "error": { "cannot_connect": "Nem lehet csatlakozni a szolgáltatáshoz\n\n{error_message}", "empty": "A mező nem lehet üres", - "invalid_auth": "Érvénytelen API-kulcs", + "invalid_auth": "Érvénytelen API-kulcs\n\n{error_message}", "invalid_coordinates": "Válassz érvényes helyet a térképen.", "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters.", "invalid_language": "Érvénytelen nyelvi kód", @@ -31,10 +31,17 @@ "language_code": "API-válasz nyelvi kódja", "location": "Helyszín", "name": "Név", - "update_interval": "Frissítési időköz (óra)" + "update_interval": "Frissítési időköz (óra)", + "http_referer": "HTTP Referrer" + }, + "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", + "title": "Pollen szintek – beállítás", + "sections": { + "api_key_options": "Optional API key options" }, - "description": "Adja meg a Google API-kulcsot, válassza ki helyszínét a térképen, a frissítési időközt (órák) és az API-válasz nyelvi kódját.", - "title": "Pollen szintek – beállítás" + "data_description": { + "http_referer": "Only needed if your API key is restricted by HTTP referrers (websites)." + } } } }, @@ -64,9 +71,9 @@ }, "options": { "error": { - "cannot_connect": "Nem lehet csatlakozni a szolgáltatáshoz", + "cannot_connect": "Nem lehet csatlakozni a szolgáltatáshoz\n\n{error_message}", "empty": "A mező nem lehet üres", - "invalid_auth": "Érvénytelen API-kulcs", + "invalid_auth": "Érvénytelen API-kulcs\n\n{error_message}", "invalid_language": "Érvénytelen nyelvi kód", "invalid_language_format": "Használjon kanonikus BCP-47 kódot, például \"en\" vagy \"es-ES\".", "invalid_option_combo": "Növeld a \"Előrejelzési napok\" értéket a kiválasztott napi szenzorok lefedéséhez.", @@ -92,4 +99,4 @@ "name": "Frissítés kényszerítése" } } -} \ No newline at end of file +} diff --git a/custom_components/pollenlevels/translations/it.json b/custom_components/pollenlevels/translations/it.json index db0fc3ee..6b8109f4 100644 --- a/custom_components/pollenlevels/translations/it.json +++ b/custom_components/pollenlevels/translations/it.json @@ -8,7 +8,7 @@ "error": { "cannot_connect": "Impossibile connettersi al servizio\n\n{error_message}", "empty": "Questo campo non può essere vuoto", - "invalid_auth": "Chiave API non valida", + "invalid_auth": "Chiave API non valida\n\n{error_message}", "invalid_coordinates": "Seleziona una posizione valida sulla mappa.", "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters.", "invalid_language": "Codice lingua non valido", @@ -31,10 +31,17 @@ "language_code": "Codice lingua per la risposta dell'API", "location": "Posizione", "name": "Nome", - "update_interval": "Intervallo di aggiornamento (ore)" + "update_interval": "Intervallo di aggiornamento (ore)", + "http_referer": "HTTP Referrer" + }, + "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", + "title": "Configurazione Livelli di polline", + "sections": { + "api_key_options": "Optional API key options" }, - "description": "Inserisci la tua chiave API Google, seleziona la tua posizione sulla mappa, l’intervallo di aggiornamento (ore) e il codice lingua per la risposta dell'API.", - "title": "Configurazione Livelli di polline" + "data_description": { + "http_referer": "Only needed if your API key is restricted by HTTP referrers (websites)." + } } } }, @@ -64,9 +71,9 @@ }, "options": { "error": { - "cannot_connect": "Impossibile connettersi al servizio", + "cannot_connect": "Impossibile connettersi al servizio\n\n{error_message}", "empty": "Questo campo non può essere vuoto", - "invalid_auth": "Chiave API non valida", + "invalid_auth": "Chiave API non valida\n\n{error_message}", "invalid_language": "Codice lingua non valido", "invalid_language_format": "Usa un codice BCP-47 canonico come \"en\" o \"es-ES\".", "invalid_option_combo": "Aumenta 'Giorni di previsione' per coprire i sensori giornalieri selezionati.", @@ -92,4 +99,4 @@ "name": "Forza aggiornamento" } } -} \ No newline at end of file +} diff --git a/custom_components/pollenlevels/translations/nb.json b/custom_components/pollenlevels/translations/nb.json index 0942803a..5aa91948 100644 --- a/custom_components/pollenlevels/translations/nb.json +++ b/custom_components/pollenlevels/translations/nb.json @@ -8,7 +8,7 @@ "error": { "cannot_connect": "Kan ikke koble til tjenesten\n\n{error_message}", "empty": "Dette feltet kan ikke være tomt", - "invalid_auth": "Ugyldig API-nøkkel", + "invalid_auth": "Ugyldig API-nøkkel\n\n{error_message}", "invalid_coordinates": "Velg en gyldig posisjon på kartet.", "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters.", "invalid_language": "Ugyldig språkkode", @@ -31,10 +31,17 @@ "language_code": "Språkkode for API-svar", "location": "Posisjon", "name": "Navn", - "update_interval": "Oppdateringsintervall (timer)" + "update_interval": "Oppdateringsintervall (timer)", + "http_referer": "HTTP Referrer" + }, + "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", + "title": "Konfigurasjon av pollennivåer", + "sections": { + "api_key_options": "Optional API key options" }, - "description": "Angi Google API-nøkkel, velg posisjonen din på kartet, oppdateringsintervall (timer) og språkkode for API-svar.", - "title": "Konfigurasjon av pollennivåer" + "data_description": { + "http_referer": "Only needed if your API key is restricted by HTTP referrers (websites)." + } } } }, @@ -64,9 +71,9 @@ }, "options": { "error": { - "cannot_connect": "Kan ikke koble til tjenesten", + "cannot_connect": "Kan ikke koble til tjenesten\n\n{error_message}", "empty": "Dette feltet kan ikke være tomt", - "invalid_auth": "Ugyldig API-nøkkel", + "invalid_auth": "Ugyldig API-nøkkel\n\n{error_message}", "invalid_language": "Ugyldig språkkode", "invalid_language_format": "Bruk en kanonisk BCP-47-kode som \"en\" eller \"es-ES\".", "invalid_option_combo": "Øk «Prognosedager» for å dekke valgte sensorer per dag.", @@ -92,4 +99,4 @@ "name": "Tving oppdatering" } } -} \ No newline at end of file +} diff --git a/custom_components/pollenlevels/translations/nl.json b/custom_components/pollenlevels/translations/nl.json index 93c30c5a..3e20186a 100644 --- a/custom_components/pollenlevels/translations/nl.json +++ b/custom_components/pollenlevels/translations/nl.json @@ -8,7 +8,7 @@ "error": { "cannot_connect": "Kan geen verbinding maken met de service\n\n{error_message}", "empty": "Dit veld mag niet leeg zijn", - "invalid_auth": "Ongeldige API-sleutel", + "invalid_auth": "Ongeldige API-sleutel\n\n{error_message}", "invalid_coordinates": "Selecteer een geldige locatie op de kaart.", "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters.", "invalid_language": "Ongeldige taalcode", @@ -31,10 +31,17 @@ "language_code": "Taalcode voor API-respons", "location": "Locatie", "name": "Naam", - "update_interval": "Update-interval (uren)" + "update_interval": "Update-interval (uren)", + "http_referer": "HTTP Referrer" + }, + "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", + "title": "Pollen Levels – Configuratie", + "sections": { + "api_key_options": "Optional API key options" }, - "description": "Voer je Google API-sleutel in, selecteer je locatie op de kaart, het update-interval (uren) en de taalcode voor de API-respons.", - "title": "Pollen Levels – Configuratie" + "data_description": { + "http_referer": "Only needed if your API key is restricted by HTTP referrers (websites)." + } } } }, @@ -64,9 +71,9 @@ }, "options": { "error": { - "cannot_connect": "Kan geen verbinding maken met de service", + "cannot_connect": "Kan geen verbinding maken met de service\n\n{error_message}", "empty": "Dit veld mag niet leeg zijn", - "invalid_auth": "Ongeldige API-sleutel", + "invalid_auth": "Ongeldige API-sleutel\n\n{error_message}", "invalid_language": "Ongeldige taalcode", "invalid_language_format": "Gebruik een canonieke BCP-47-code zoals \"en\" of \"es-ES\".", "invalid_option_combo": "Verhoog 'Voorspellingsdagen' om de geselecteerde per-dag sensoren te dekken.", @@ -92,4 +99,4 @@ "name": "Update forceren" } } -} \ No newline at end of file +} diff --git a/custom_components/pollenlevels/translations/pl.json b/custom_components/pollenlevels/translations/pl.json index 64a9fde3..d0a491d0 100644 --- a/custom_components/pollenlevels/translations/pl.json +++ b/custom_components/pollenlevels/translations/pl.json @@ -8,7 +8,7 @@ "error": { "cannot_connect": "Brak połączenia z usługą\n\n{error_message}", "empty": "To pole nie może być puste", - "invalid_auth": "Nieprawidłowy klucz API", + "invalid_auth": "Nieprawidłowy klucz API\n\n{error_message}", "invalid_coordinates": "Wybierz prawidłową lokalizację na mapie.", "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters.", "invalid_language": "Nieprawidłowy kod języka", @@ -31,10 +31,17 @@ "language_code": "Kod języka odpowiedzi API", "location": "Lokalizacja", "name": "Nazwa", - "update_interval": "Interwał aktualizacji (godziny)" + "update_interval": "Interwał aktualizacji (godziny)", + "http_referer": "HTTP Referrer" + }, + "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", + "title": "Konfiguracja poziomów pyłku", + "sections": { + "api_key_options": "Optional API key options" }, - "description": "Wprowadź klucz Google API, wybierz swoją lokalizację na mapie, interwał aktualizacji (godziny) oraz kod języka odpowiedzi API.", - "title": "Konfiguracja poziomów pyłku" + "data_description": { + "http_referer": "Only needed if your API key is restricted by HTTP referrers (websites)." + } } } }, @@ -64,9 +71,9 @@ }, "options": { "error": { - "cannot_connect": "Brak połączenia z usługą", + "cannot_connect": "Brak połączenia z usługą\n\n{error_message}", "empty": "To pole nie może być puste", - "invalid_auth": "Nieprawidłowy klucz API", + "invalid_auth": "Nieprawidłowy klucz API\n\n{error_message}", "invalid_language": "Nieprawidłowy kod języka", "invalid_language_format": "Użyj kanonicznego kodu BCP-47, np. \"en\" lub \"es-ES\".", "invalid_option_combo": "Zwiększ 'Dni prognozy', aby objąć wybrane czujniki dzienne.", @@ -92,4 +99,4 @@ "name": "Wymuś aktualizację" } } -} \ No newline at end of file +} diff --git a/custom_components/pollenlevels/translations/pt-BR.json b/custom_components/pollenlevels/translations/pt-BR.json index 54f48eb2..41371a35 100644 --- a/custom_components/pollenlevels/translations/pt-BR.json +++ b/custom_components/pollenlevels/translations/pt-BR.json @@ -8,7 +8,7 @@ "error": { "cannot_connect": "Não foi possível conectar ao serviço\n\n{error_message}", "empty": "Este campo não pode ficar vazio", - "invalid_auth": "Chave de API inválida", + "invalid_auth": "Chave de API inválida\n\n{error_message}", "invalid_coordinates": "Selecione um local válido no mapa.", "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters.", "invalid_language": "Código de idioma inválido", @@ -31,10 +31,17 @@ "language_code": "Código de idioma da resposta da API", "location": "Localização", "name": "Nome", - "update_interval": "Intervalo de atualização (horas)" + "update_interval": "Intervalo de atualização (horas)", + "http_referer": "HTTP Referrer" + }, + "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", + "title": "Configuração dos Níveis de Pólen", + "sections": { + "api_key_options": "Optional API key options" }, - "description": "Informe sua chave da API do Google, selecione sua localização no mapa, o intervalo de atualização (horas) e o código de idioma para a resposta da API.", - "title": "Configuração dos Níveis de Pólen" + "data_description": { + "http_referer": "Only needed if your API key is restricted by HTTP referrers (websites)." + } } } }, @@ -64,9 +71,9 @@ }, "options": { "error": { - "cannot_connect": "Não foi possível conectar ao serviço", + "cannot_connect": "Não foi possível conectar ao serviço\n\n{error_message}", "empty": "Este campo não pode ficar vazio", - "invalid_auth": "Chave de API inválida", + "invalid_auth": "Chave de API inválida\n\n{error_message}", "invalid_language": "Código de idioma inválido", "invalid_language_format": "Use um código BCP-47 canônico, como \"en\" ou \"es-ES\".", "invalid_option_combo": "Aumente \"Dias de previsão\" para cobrir os sensores por dia selecionados.", @@ -92,4 +99,4 @@ "name": "Forçar atualização" } } -} \ No newline at end of file +} diff --git a/custom_components/pollenlevels/translations/pt-PT.json b/custom_components/pollenlevels/translations/pt-PT.json index 6eb83547..b5bf6591 100644 --- a/custom_components/pollenlevels/translations/pt-PT.json +++ b/custom_components/pollenlevels/translations/pt-PT.json @@ -8,7 +8,7 @@ "error": { "cannot_connect": "Não é possível ligar ao serviço\n\n{error_message}", "empty": "Este campo não pode estar vazio", - "invalid_auth": "Chave da API inválida", + "invalid_auth": "Chave da API inválida\n\n{error_message}", "invalid_coordinates": "Selecione uma localização válida no mapa.", "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters.", "invalid_language": "Código de idioma inválido", @@ -31,10 +31,17 @@ "language_code": "Código de idioma da resposta da API", "location": "Localização", "name": "Nome", - "update_interval": "Intervalo de atualização (horas)" + "update_interval": "Intervalo de atualização (horas)", + "http_referer": "HTTP Referrer" + }, + "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", + "title": "Configuração dos Níveis de Pólen", + "sections": { + "api_key_options": "Optional API key options" }, - "description": "Introduza a sua chave da API do Google, selecione a sua localização no mapa, o intervalo de atualização (horas) e o código de idioma para a resposta da API.", - "title": "Configuração dos Níveis de Pólen" + "data_description": { + "http_referer": "Only needed if your API key is restricted by HTTP referrers (websites)." + } } } }, @@ -64,9 +71,9 @@ }, "options": { "error": { - "cannot_connect": "Não é possível ligar ao serviço", + "cannot_connect": "Não é possível ligar ao serviço\n\n{error_message}", "empty": "Este campo não pode estar vazio", - "invalid_auth": "Chave da API inválida", + "invalid_auth": "Chave da API inválida\n\n{error_message}", "invalid_language": "Código de idioma inválido", "invalid_language_format": "Use um código BCP-47 canónico, como \"en\" ou \"es-ES\".", "invalid_option_combo": "Aumente \"Dias de previsão\" para cobrir os sensores por dia selecionados.", @@ -92,4 +99,4 @@ "name": "Forçar atualização" } } -} \ No newline at end of file +} diff --git a/custom_components/pollenlevels/translations/ro.json b/custom_components/pollenlevels/translations/ro.json index 3c657253..fde30c09 100644 --- a/custom_components/pollenlevels/translations/ro.json +++ b/custom_components/pollenlevels/translations/ro.json @@ -8,7 +8,7 @@ "error": { "cannot_connect": "Nu se poate conecta la serviciu\n\n{error_message}", "empty": "Acest câmp nu poate fi gol", - "invalid_auth": "Cheie API nevalidă", + "invalid_auth": "Cheie API nevalidă\n\n{error_message}", "invalid_coordinates": "Selectează o locație validă pe hartă.", "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters.", "invalid_language": "Cod de limbă nevalid", @@ -31,10 +31,17 @@ "language_code": "Codul limbii pentru răspunsul API", "location": "Locație", "name": "Nume", - "update_interval": "Interval de actualizare (ore)" + "update_interval": "Interval de actualizare (ore)", + "http_referer": "HTTP Referrer" + }, + "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", + "title": "Configurare Niveluri de Polen", + "sections": { + "api_key_options": "Optional API key options" }, - "description": "Introduceți cheia Google API, selectați locația pe hartă, intervalul de actualizare (ore) și codul de limbă pentru răspunsul API.", - "title": "Configurare Niveluri de Polen" + "data_description": { + "http_referer": "Only needed if your API key is restricted by HTTP referrers (websites)." + } } } }, @@ -64,9 +71,9 @@ }, "options": { "error": { - "cannot_connect": "Nu se poate conecta la serviciu", + "cannot_connect": "Nu se poate conecta la serviciu\n\n{error_message}", "empty": "Acest câmp nu poate fi gol", - "invalid_auth": "Cheie API nevalidă", + "invalid_auth": "Cheie API nevalidă\n\n{error_message}", "invalid_language": "Cod de limbă nevalid", "invalid_language_format": "Folosiți un cod BCP-47 canonic, de exemplu \"en\" sau \"es-ES\".", "invalid_option_combo": "Măriți \"Zilele de prognoză\" pentru a acoperi senzorii selectați pe zile.", @@ -92,4 +99,4 @@ "name": "Forțează actualizarea" } } -} \ No newline at end of file +} diff --git a/custom_components/pollenlevels/translations/ru.json b/custom_components/pollenlevels/translations/ru.json index 997770bb..f6cd81e2 100644 --- a/custom_components/pollenlevels/translations/ru.json +++ b/custom_components/pollenlevels/translations/ru.json @@ -8,7 +8,7 @@ "error": { "cannot_connect": "Не удаётся подключиться к сервису\n\n{error_message}", "empty": "Это поле не может быть пустым", - "invalid_auth": "Неверный ключ API", + "invalid_auth": "Неверный ключ API\n\n{error_message}", "invalid_coordinates": "Выберите корректное местоположение на карте.", "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters.", "invalid_language": "Неверный код языка", @@ -31,10 +31,17 @@ "language_code": "Код языка ответа API", "location": "Местоположение", "name": "Имя", - "update_interval": "Интервал обновления (в часах)" + "update_interval": "Интервал обновления (в часах)", + "http_referer": "HTTP Referrer" + }, + "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", + "title": "Настройка уровней пыльцы", + "sections": { + "api_key_options": "Optional API key options" }, - "description": "Введите ваш ключ Google API, выберите свое местоположение на карте, интервал обновления (в часах) и код языка ответа API.", - "title": "Настройка уровней пыльцы" + "data_description": { + "http_referer": "Only needed if your API key is restricted by HTTP referrers (websites)." + } } } }, @@ -64,9 +71,9 @@ }, "options": { "error": { - "cannot_connect": "Не удаётся подключиться к сервису", + "cannot_connect": "Не удаётся подключиться к сервису\n\n{error_message}", "empty": "Это поле не может быть пустым", - "invalid_auth": "Неверный ключ API", + "invalid_auth": "Неверный ключ API\n\n{error_message}", "invalid_language": "Неверный код языка", "invalid_language_format": "Используйте канонический код BCP-47, например \"en\" или \"es-ES\".", "invalid_option_combo": "Увеличьте «Дни прогноза», чтобы охватить выбранные датчики по дням.", @@ -92,4 +99,4 @@ "name": "Принудительное обновление" } } -} \ No newline at end of file +} diff --git a/custom_components/pollenlevels/translations/sv.json b/custom_components/pollenlevels/translations/sv.json index 5f698bc9..67d93683 100644 --- a/custom_components/pollenlevels/translations/sv.json +++ b/custom_components/pollenlevels/translations/sv.json @@ -8,7 +8,7 @@ "error": { "cannot_connect": "Kan inte ansluta till tjänsten\n\n{error_message}", "empty": "Detta fält får inte vara tomt", - "invalid_auth": "Ogiltig API-nyckel", + "invalid_auth": "Ogiltig API-nyckel\n\n{error_message}", "invalid_coordinates": "Välj en giltig plats på kartan.", "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters.", "invalid_language": "Ogiltig språkkod", @@ -31,10 +31,17 @@ "language_code": "Språkkod för API-svar", "location": "Plats", "name": "Namn", - "update_interval": "Uppdateringsintervall (timmar)" + "update_interval": "Uppdateringsintervall (timmar)", + "http_referer": "HTTP Referrer" + }, + "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", + "title": "Konfiguration av pollennivåer", + "sections": { + "api_key_options": "Optional API key options" }, - "description": "Ange din Google API-nyckel, välj din plats på kartan, uppdateringsintervall (timmar) och språkkod för API-svar.", - "title": "Konfiguration av pollennivåer" + "data_description": { + "http_referer": "Only needed if your API key is restricted by HTTP referrers (websites)." + } } } }, @@ -64,9 +71,9 @@ }, "options": { "error": { - "cannot_connect": "Kan inte ansluta till tjänsten", + "cannot_connect": "Kan inte ansluta till tjänsten\n\n{error_message}", "empty": "Detta fält får inte vara tomt", - "invalid_auth": "Ogiltig API-nyckel", + "invalid_auth": "Ogiltig API-nyckel\n\n{error_message}", "invalid_language": "Ogiltig språkkod", "invalid_language_format": "Använd en kanonisk BCP-47-kod som \"en\" eller \"es-ES\".", "invalid_option_combo": "Öka \"Prognosdagar\" för att täcka valda sensorer per dag.", @@ -92,4 +99,4 @@ "name": "Tvinga uppdatering" } } -} \ No newline at end of file +} diff --git a/custom_components/pollenlevels/translations/uk.json b/custom_components/pollenlevels/translations/uk.json index 47820911..597ea881 100644 --- a/custom_components/pollenlevels/translations/uk.json +++ b/custom_components/pollenlevels/translations/uk.json @@ -8,7 +8,7 @@ "error": { "cannot_connect": "Не вдається підключитися до сервісу\n\n{error_message}", "empty": "Це поле не може бути порожнім", - "invalid_auth": "Невірний ключ API", + "invalid_auth": "Невірний ключ API\n\n{error_message}", "invalid_coordinates": "Виберіть дійсне місце на карті.", "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters.", "invalid_language": "Невірний код мови", @@ -31,10 +31,17 @@ "language_code": "Код мови відповіді API", "location": "Місцезнаходження", "name": "Ім'я", - "update_interval": "Інтервал оновлення (у годинах)" + "update_interval": "Інтервал оновлення (у годинах)", + "http_referer": "HTTP Referrer" + }, + "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", + "title": "Налаштування рівнів пилку", + "sections": { + "api_key_options": "Optional API key options" }, - "description": "Введіть свій ключ Google API, виберіть місцезнаходження на карті, інтервал оновлення (у годинах) та код мови для відповіді API.", - "title": "Налаштування рівнів пилку" + "data_description": { + "http_referer": "Only needed if your API key is restricted by HTTP referrers (websites)." + } } } }, @@ -64,9 +71,9 @@ }, "options": { "error": { - "cannot_connect": "Не вдається підключитися до сервісу", + "cannot_connect": "Не вдається підключитися до сервісу\n\n{error_message}", "empty": "Це поле не може бути порожнім", - "invalid_auth": "Невірний ключ API", + "invalid_auth": "Невірний ключ API\n\n{error_message}", "invalid_language": "Невірний код мови", "invalid_language_format": "Використовуйте канонічний код BCP-47, наприклад \"en\" або \"es-ES\".", "invalid_option_combo": "Збільшіть «Дні прогнозу», щоб охопити вибрані денні датчики.", @@ -92,4 +99,4 @@ "name": "Примусове оновлення" } } -} \ No newline at end of file +} diff --git a/custom_components/pollenlevels/translations/zh-Hans.json b/custom_components/pollenlevels/translations/zh-Hans.json index db9af135..8605c0c5 100644 --- a/custom_components/pollenlevels/translations/zh-Hans.json +++ b/custom_components/pollenlevels/translations/zh-Hans.json @@ -8,7 +8,7 @@ "error": { "cannot_connect": "无法连接到服务\n\n{error_message}", "empty": "此字段不能为空", - "invalid_auth": "无效的 API 密钥", + "invalid_auth": "无效的 API 密钥\n\n{error_message}", "invalid_coordinates": "请在地图上选择有效的位置。", "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters.", "invalid_language": "无效的语言代码", @@ -31,10 +31,17 @@ "language_code": "API 响应语言代码", "location": "位置", "name": "名称", - "update_interval": "更新间隔(小时)" + "update_interval": "更新间隔(小时)", + "http_referer": "HTTP Referrer" + }, + "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", + "title": "花粉水平配置", + "sections": { + "api_key_options": "Optional API key options" }, - "description": "请输入 Google API 密钥、在地图上选择位置、更新间隔(小时)以及 API 响应的语言代码。", - "title": "花粉水平配置" + "data_description": { + "http_referer": "Only needed if your API key is restricted by HTTP referrers (websites)." + } } } }, @@ -64,9 +71,9 @@ }, "options": { "error": { - "cannot_connect": "无法连接到服务", + "cannot_connect": "无法连接到服务\n\n{error_message}", "empty": "此字段不能为空", - "invalid_auth": "无效的 API 密钥", + "invalid_auth": "无效的 API 密钥\n\n{error_message}", "invalid_language": "无效的语言代码", "invalid_language_format": "请使用规范的 BCP-47 代码,例如 \"en\" 或 \"es-ES\"。", "invalid_option_combo": "请增加“预测天数”,以覆盖所选的逐日类型传感器。", @@ -92,4 +99,4 @@ "name": "强制更新" } } -} \ No newline at end of file +} diff --git a/custom_components/pollenlevels/translations/zh-Hant.json b/custom_components/pollenlevels/translations/zh-Hant.json index 22d5892e..2783d71d 100644 --- a/custom_components/pollenlevels/translations/zh-Hant.json +++ b/custom_components/pollenlevels/translations/zh-Hant.json @@ -8,7 +8,7 @@ "error": { "cannot_connect": "無法連線到服務\n\n{error_message}", "empty": "此欄位不得為空", - "invalid_auth": "無效的 API 金鑰", + "invalid_auth": "無效的 API 金鑰\n\n{error_message}", "invalid_coordinates": "請在地圖上選擇有效的位置。", "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters.", "invalid_language": "無效的語言代碼", @@ -31,10 +31,17 @@ "language_code": "API 回應語言代碼", "location": "位置", "name": "名稱", - "update_interval": "更新間隔(小時)" + "update_interval": "更新間隔(小時)", + "http_referer": "HTTP Referrer" + }, + "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", + "title": "花粉水平設定", + "sections": { + "api_key_options": "Optional API key options" }, - "description": "請輸入 Google API 金鑰、在地圖上選擇位置、更新間隔(小時)以及 API 回應的語言代碼。", - "title": "花粉水平設定" + "data_description": { + "http_referer": "Only needed if your API key is restricted by HTTP referrers (websites)." + } } } }, @@ -64,9 +71,9 @@ }, "options": { "error": { - "cannot_connect": "無法連線到服務", + "cannot_connect": "無法連線到服務\n\n{error_message}", "empty": "此欄位不得為空", - "invalid_auth": "無效的 API 金鑰", + "invalid_auth": "無效的 API 金鑰\n\n{error_message}", "invalid_language": "無效的語言代碼", "invalid_language_format": "請使用標準的 BCP-47 代碼,例如 \"en\" 或 \"es-ES\"。", "invalid_option_combo": "請增加「預測天數」以涵蓋所選的逐日類型感測器。", @@ -92,4 +99,4 @@ "name": "強制更新" } } -} \ No newline at end of file +} From 33491b454dd876152be9f0caeaed6a554ebbcd26 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sun, 14 Dec 2025 17:52:24 +0100 Subject: [PATCH 027/200] Add http referrer config flow tests --- tests/test_config_flow.py | 147 +++++++++++++++++++++++++++++++++++++- 1 file changed, 144 insertions(+), 3 deletions(-) diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index fe0a4207..28743c49 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -9,6 +9,7 @@ import sys from pathlib import Path from types import ModuleType, SimpleNamespace +from typing import Any import pytest @@ -67,6 +68,9 @@ def async_show_form(self, *args, **kwargs): # pragma: no cover - not used def async_create_entry(self, *args, **kwargs): # pragma: no cover - not used return {"title": kwargs.get("title"), "data": kwargs.get("data")} + def add_suggested_values_to_schema(self, schema, suggested_values): + return schema + class _StubOptionsFlow: pass @@ -280,10 +284,15 @@ def __init__(self, error_message=""): self.error_message = error_message +class _StubSchema: + def __init__(self, schema): + self.schema = schema + + vol_mod.Invalid = _StubInvalid -vol_mod.Schema = lambda *args, **kwargs: None -vol_mod.Optional = lambda *args, **kwargs: None -vol_mod.Required = lambda *args, **kwargs: None +vol_mod.Schema = lambda schema, **kwargs: _StubSchema(schema) +vol_mod.Optional = lambda key, **kwargs: key +vol_mod.Required = lambda key, **kwargs: key vol_mod.All = lambda *args, **kwargs: None vol_mod.Coerce = lambda *args, **kwargs: None vol_mod.Range = lambda *args, **kwargs: None @@ -304,6 +313,7 @@ def __init__(self, error_message=""): ) from custom_components.pollenlevels.const import ( CONF_API_KEY, + CONF_HTTP_REFERER, CONF_LANGUAGE_CODE, CONF_UPDATE_INTERVAL, DEFAULT_ENTRY_TITLE, @@ -324,6 +334,14 @@ async def __aexit__(self, exc_type, exc, tb): # pragma: no cover - trivial async def read(self) -> bytes: return self._body + async def json(self): + import json as _json + + return _json.loads(self._body.decode()) + + async def text(self) -> str: + return self._body.decode() + class _SequenceSession: def __init__(self, responses: list[_StubResponse]) -> None: @@ -514,6 +532,103 @@ def _base_user_input() -> dict: } +def test_async_step_user_persists_http_referer() -> None: + """HTTP referrer should be trimmed and persisted when provided.""" + + flow = PollenLevelsConfigFlow() + flow.hass = SimpleNamespace( + config=SimpleNamespace(latitude=1.0, longitude=2.0, language="en") + ) + + async def fake_validate( + user_input, *, check_unique_id, description_placeholders=None + ): + assert user_input[CONF_HTTP_REFERER] == "https://example.com" + normalized = { + CONF_API_KEY: user_input[CONF_API_KEY], + CONF_LATITUDE: 1.0, + CONF_LONGITUDE: 2.0, + CONF_LANGUAGE_CODE: "en", + CONF_HTTP_REFERER: "https://example.com", + } + return {}, normalized + + flow._async_validate_input = fake_validate # type: ignore[assignment] + + user_input = { + **_base_user_input(), + CONF_UPDATE_INTERVAL: 6, + CONF_LANGUAGE_CODE: "en", + CONF_HTTP_REFERER: " https://example.com ", + } + + result = asyncio.run(flow.async_step_user(user_input)) + + assert result["data"][CONF_HTTP_REFERER] == "https://example.com" + + +def test_async_step_user_drops_blank_http_referer() -> None: + """Blank HTTP referrer values should not be persisted.""" + + flow = PollenLevelsConfigFlow() + flow.hass = SimpleNamespace( + config=SimpleNamespace(latitude=1.0, longitude=2.0, language="en") + ) + + async def fake_validate( + user_input, *, check_unique_id, description_placeholders=None + ): + assert CONF_HTTP_REFERER not in user_input + normalized = { + CONF_API_KEY: user_input[CONF_API_KEY], + CONF_LATITUDE: 1.0, + CONF_LONGITUDE: 2.0, + CONF_LANGUAGE_CODE: "en", + } + return {}, normalized + + flow._async_validate_input = fake_validate # type: ignore[assignment] + + user_input = { + **_base_user_input(), + CONF_UPDATE_INTERVAL: 6, + CONF_LANGUAGE_CODE: "en", + CONF_HTTP_REFERER: " ", + } + + result = asyncio.run(flow.async_step_user(user_input)) + + assert CONF_HTTP_REFERER not in result["data"] + + +def test_async_step_user_invalid_http_referer_sets_field_error() -> None: + """Newlines in HTTP referrer should surface a field-level error.""" + + flow = PollenLevelsConfigFlow() + flow.hass = SimpleNamespace( + config=SimpleNamespace(latitude=1.0, longitude=2.0, language="en") + ) + + captured: dict[str, Any] = {} + + def fake_show_form(*args, **kwargs): + captured.update(kwargs) + return kwargs + + flow.async_show_form = fake_show_form # type: ignore[assignment] + + user_input = { + **_base_user_input(), + CONF_UPDATE_INTERVAL: 6, + CONF_LANGUAGE_CODE: "en", + CONF_HTTP_REFERER: "http://example.com/\npath", + } + + asyncio.run(flow.async_step_user(user_input)) + + assert captured.get("errors") == {CONF_HTTP_REFERER: "invalid_http_referrer"} + + @pytest.mark.parametrize( ("status", "expected"), [ @@ -603,6 +718,32 @@ def test_validate_input_http_500_sets_error_message_placeholder( assert placeholders.get("error_message") +def test_validate_input_http_403_sets_error_message_placeholder( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """HTTP 403 should populate the cannot_connect error_message placeholder.""" + + body = b'{"error": {"message": "Forbidden for this project"}}' + session = _patch_client_session(monkeypatch, _StubResponse(status=403, body=body)) + + flow = PollenLevelsConfigFlow() + flow.hass = SimpleNamespace() + placeholders: dict[str, str] = {} + + errors, normalized = asyncio.run( + flow._async_validate_input( + _base_user_input(), + check_unique_id=False, + description_placeholders=placeholders, + ) + ) + + assert session.calls + assert errors == {"base": "cannot_connect"} + assert normalized is None + assert "Forbidden" in placeholders.get("error_message", "") + + def test_validate_input_unexpected_exception_sets_unknown( monkeypatch: pytest.MonkeyPatch, ) -> None: From 29f15111014ac63f30be1e30813605c12a03fafd Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sun, 14 Dec 2025 17:52:30 +0100 Subject: [PATCH 028/200] Document referrer option and UI guidance --- CHANGELOG.md | 4 ++++ README.md | 24 ++++++++++++++++++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b0748b2..15cd3ccf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ integers and keeping existing validation rules and defaults intact. - Clarified HTTP referrer validation with a dedicated error to avoid confusing connection-failure messaging when the input contains newline characters. +- Added optional HTTP referrer support that sends a sanitized `Referer` header + when configured while preserving the default behavior for unrestricted keys. +- Runtime HTTP 403 responses now surface detailed messages without triggering + reauthentication, keeping setup and update behavior aligned. ## [1.9.0-alpha1] - 2025-12-11 ### Changed diff --git a/README.md b/README.md index 64f694bb..0ee4e39a 100644 --- a/README.md +++ b/README.md @@ -61,12 +61,16 @@ You can change: - **Per-day TYPE sensors** via `create_forecast_sensors`: - `none` → no extra sensors - `D+1` → sensors for each TYPE with suffix `(D+1)` - - `D+1+2` → sensors for `(D+1)` and `(D+2)` + - `D+1+2` → sensors for `(D+1)` and `(D+2)` > **Validation rules:** > - `D+1` requires `forecast_days ≥ 2` > - `D+1+2` requires `forecast_days ≥ 3` +The config and options flows use modern Home Assistant selectors and include +links to Google’s API key setup and security best practices so you can follow +the recommended restrictions. + > **After saving Options:** if per-day sensors are disabled or `forecast_days` becomes insufficient, the integration **removes** any stale D+1/D+2 entities from the **Entity Registry** automatically. No manual cleanup needed. Go to **Settings → Devices & Services → Pollen Levels → Configure**. @@ -87,10 +91,26 @@ You need a valid Google Cloud API key with access to the **Maps Pollen API**. - **Application restrictions** (optional but recommended): - **HTTP referrers** (for frontend usages) or - **IP addresses** (for server-side usage, e.g. your HA host). -6. **Copy** the key and paste it in the integration setup. +6. **Copy** the key and paste it in the integration setup. + +The setup form also links directly to the Google documentation for obtaining +an API key and best-practice restrictions. 👉 See the **[FAQ](FAQ.md)** for **quota tips**, rate-limit behavior, and best practices to avoid exhausting your free tier. +### Optional HTTP Referrer header + +If your API key is restricted by HTTP referrers (website origins), you can add +an optional **HTTP Referrer** value in the advanced section of the config +flow. When set, the integration sends it as the `Referer` header on API +requests. Leave it blank for unrestricted or IP-restricted keys. + +### Troubleshooting 403 errors + +403 responses during setup or updates now include the API’s reason (when +available). They often indicate billing is disabled, the Pollen API is not +enabled, or referrer restrictions are blocking the request. + --- ## 🔧 Showing colors in the UI From 0b8ae62a3b83de2f895c13c7739eeb8e23c5a814 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sun, 14 Dec 2025 17:59:27 +0100 Subject: [PATCH 029/200] Fix translation section structure for hassfest --- custom_components/pollenlevels/translations/ca.json | 4 +++- custom_components/pollenlevels/translations/cs.json | 4 +++- custom_components/pollenlevels/translations/da.json | 4 +++- custom_components/pollenlevels/translations/de.json | 4 +++- custom_components/pollenlevels/translations/en.json | 4 +++- custom_components/pollenlevels/translations/es.json | 4 +++- custom_components/pollenlevels/translations/fi.json | 4 +++- custom_components/pollenlevels/translations/fr.json | 4 +++- custom_components/pollenlevels/translations/hu.json | 4 +++- custom_components/pollenlevels/translations/it.json | 4 +++- custom_components/pollenlevels/translations/nb.json | 4 +++- custom_components/pollenlevels/translations/nl.json | 4 +++- custom_components/pollenlevels/translations/pl.json | 4 +++- custom_components/pollenlevels/translations/pt-BR.json | 4 +++- custom_components/pollenlevels/translations/pt-PT.json | 4 +++- custom_components/pollenlevels/translations/ro.json | 4 +++- custom_components/pollenlevels/translations/ru.json | 4 +++- custom_components/pollenlevels/translations/sv.json | 4 +++- custom_components/pollenlevels/translations/uk.json | 4 +++- custom_components/pollenlevels/translations/zh-Hans.json | 4 +++- custom_components/pollenlevels/translations/zh-Hant.json | 4 +++- 21 files changed, 63 insertions(+), 21 deletions(-) diff --git a/custom_components/pollenlevels/translations/ca.json b/custom_components/pollenlevels/translations/ca.json index 3b3d34f6..60021a6c 100644 --- a/custom_components/pollenlevels/translations/ca.json +++ b/custom_components/pollenlevels/translations/ca.json @@ -13,7 +13,9 @@ "http_referer": "HTTP Referrer" }, "sections": { - "api_key_options": "Optional API key options" + "api_key_options": { + "title": "Optional API key options" + } }, "data_description": { "http_referer": "Only needed if your API key is restricted by HTTP referrers (websites)." diff --git a/custom_components/pollenlevels/translations/cs.json b/custom_components/pollenlevels/translations/cs.json index 9adad335..bdfd08c5 100644 --- a/custom_components/pollenlevels/translations/cs.json +++ b/custom_components/pollenlevels/translations/cs.json @@ -37,7 +37,9 @@ "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", "title": "Konfigurace úrovní pylu", "sections": { - "api_key_options": "Optional API key options" + "api_key_options": { + "title": "Optional API key options" + } }, "data_description": { "http_referer": "Only needed if your API key is restricted by HTTP referrers (websites)." diff --git a/custom_components/pollenlevels/translations/da.json b/custom_components/pollenlevels/translations/da.json index 363bd78d..5dc08f29 100644 --- a/custom_components/pollenlevels/translations/da.json +++ b/custom_components/pollenlevels/translations/da.json @@ -37,7 +37,9 @@ "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", "title": "Konfiguration af pollenniveauer", "sections": { - "api_key_options": "Optional API key options" + "api_key_options": { + "title": "Optional API key options" + } }, "data_description": { "http_referer": "Only needed if your API key is restricted by HTTP referrers (websites)." diff --git a/custom_components/pollenlevels/translations/de.json b/custom_components/pollenlevels/translations/de.json index 2e03d432..31ac9397 100644 --- a/custom_components/pollenlevels/translations/de.json +++ b/custom_components/pollenlevels/translations/de.json @@ -37,7 +37,9 @@ "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", "title": "Pollen Levels – Konfiguration", "sections": { - "api_key_options": "Optional API key options" + "api_key_options": { + "title": "Optional API key options" + } }, "data_description": { "http_referer": "Only needed if your API key is restricted by HTTP referrers (websites)." diff --git a/custom_components/pollenlevels/translations/en.json b/custom_components/pollenlevels/translations/en.json index 9ab4b29a..4c3c3f1c 100644 --- a/custom_components/pollenlevels/translations/en.json +++ b/custom_components/pollenlevels/translations/en.json @@ -13,7 +13,9 @@ "http_referer": "HTTP Referrer" }, "sections": { - "api_key_options": "Optional API key options" + "api_key_options": { + "title": "Optional API key options" + } }, "data_description": { "http_referer": "Only needed if your API key is restricted by HTTP referrers (websites)." diff --git a/custom_components/pollenlevels/translations/es.json b/custom_components/pollenlevels/translations/es.json index c601d493..ed9da470 100644 --- a/custom_components/pollenlevels/translations/es.json +++ b/custom_components/pollenlevels/translations/es.json @@ -13,7 +13,9 @@ "http_referer": "HTTP Referrer" }, "sections": { - "api_key_options": "Optional API key options" + "api_key_options": { + "title": "Optional API key options" + } }, "data_description": { "http_referer": "Only needed if your API key is restricted by HTTP referrers (websites)." diff --git a/custom_components/pollenlevels/translations/fi.json b/custom_components/pollenlevels/translations/fi.json index 391d7623..9f730a16 100644 --- a/custom_components/pollenlevels/translations/fi.json +++ b/custom_components/pollenlevels/translations/fi.json @@ -37,7 +37,9 @@ "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", "title": "Siitepölytason asetukset", "sections": { - "api_key_options": "Optional API key options" + "api_key_options": { + "title": "Optional API key options" + } }, "data_description": { "http_referer": "Only needed if your API key is restricted by HTTP referrers (websites)." diff --git a/custom_components/pollenlevels/translations/fr.json b/custom_components/pollenlevels/translations/fr.json index d2325f27..c7a25e7a 100644 --- a/custom_components/pollenlevels/translations/fr.json +++ b/custom_components/pollenlevels/translations/fr.json @@ -37,7 +37,9 @@ "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", "title": "Pollen Levels – Configuration", "sections": { - "api_key_options": "Optional API key options" + "api_key_options": { + "title": "Optional API key options" + } }, "data_description": { "http_referer": "Only needed if your API key is restricted by HTTP referrers (websites)." diff --git a/custom_components/pollenlevels/translations/hu.json b/custom_components/pollenlevels/translations/hu.json index ff4543ef..77b88725 100644 --- a/custom_components/pollenlevels/translations/hu.json +++ b/custom_components/pollenlevels/translations/hu.json @@ -37,7 +37,9 @@ "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", "title": "Pollen szintek – beállítás", "sections": { - "api_key_options": "Optional API key options" + "api_key_options": { + "title": "Optional API key options" + } }, "data_description": { "http_referer": "Only needed if your API key is restricted by HTTP referrers (websites)." diff --git a/custom_components/pollenlevels/translations/it.json b/custom_components/pollenlevels/translations/it.json index 6b8109f4..b5399816 100644 --- a/custom_components/pollenlevels/translations/it.json +++ b/custom_components/pollenlevels/translations/it.json @@ -37,7 +37,9 @@ "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", "title": "Configurazione Livelli di polline", "sections": { - "api_key_options": "Optional API key options" + "api_key_options": { + "title": "Optional API key options" + } }, "data_description": { "http_referer": "Only needed if your API key is restricted by HTTP referrers (websites)." diff --git a/custom_components/pollenlevels/translations/nb.json b/custom_components/pollenlevels/translations/nb.json index 5aa91948..25507d38 100644 --- a/custom_components/pollenlevels/translations/nb.json +++ b/custom_components/pollenlevels/translations/nb.json @@ -37,7 +37,9 @@ "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", "title": "Konfigurasjon av pollennivåer", "sections": { - "api_key_options": "Optional API key options" + "api_key_options": { + "title": "Optional API key options" + } }, "data_description": { "http_referer": "Only needed if your API key is restricted by HTTP referrers (websites)." diff --git a/custom_components/pollenlevels/translations/nl.json b/custom_components/pollenlevels/translations/nl.json index 3e20186a..85f2cb66 100644 --- a/custom_components/pollenlevels/translations/nl.json +++ b/custom_components/pollenlevels/translations/nl.json @@ -37,7 +37,9 @@ "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", "title": "Pollen Levels – Configuratie", "sections": { - "api_key_options": "Optional API key options" + "api_key_options": { + "title": "Optional API key options" + } }, "data_description": { "http_referer": "Only needed if your API key is restricted by HTTP referrers (websites)." diff --git a/custom_components/pollenlevels/translations/pl.json b/custom_components/pollenlevels/translations/pl.json index d0a491d0..50ca7885 100644 --- a/custom_components/pollenlevels/translations/pl.json +++ b/custom_components/pollenlevels/translations/pl.json @@ -37,7 +37,9 @@ "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", "title": "Konfiguracja poziomów pyłku", "sections": { - "api_key_options": "Optional API key options" + "api_key_options": { + "title": "Optional API key options" + } }, "data_description": { "http_referer": "Only needed if your API key is restricted by HTTP referrers (websites)." diff --git a/custom_components/pollenlevels/translations/pt-BR.json b/custom_components/pollenlevels/translations/pt-BR.json index 41371a35..de293684 100644 --- a/custom_components/pollenlevels/translations/pt-BR.json +++ b/custom_components/pollenlevels/translations/pt-BR.json @@ -37,7 +37,9 @@ "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", "title": "Configuração dos Níveis de Pólen", "sections": { - "api_key_options": "Optional API key options" + "api_key_options": { + "title": "Optional API key options" + } }, "data_description": { "http_referer": "Only needed if your API key is restricted by HTTP referrers (websites)." diff --git a/custom_components/pollenlevels/translations/pt-PT.json b/custom_components/pollenlevels/translations/pt-PT.json index b5bf6591..a3dc5b92 100644 --- a/custom_components/pollenlevels/translations/pt-PT.json +++ b/custom_components/pollenlevels/translations/pt-PT.json @@ -37,7 +37,9 @@ "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", "title": "Configuração dos Níveis de Pólen", "sections": { - "api_key_options": "Optional API key options" + "api_key_options": { + "title": "Optional API key options" + } }, "data_description": { "http_referer": "Only needed if your API key is restricted by HTTP referrers (websites)." diff --git a/custom_components/pollenlevels/translations/ro.json b/custom_components/pollenlevels/translations/ro.json index fde30c09..fe3dbed3 100644 --- a/custom_components/pollenlevels/translations/ro.json +++ b/custom_components/pollenlevels/translations/ro.json @@ -37,7 +37,9 @@ "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", "title": "Configurare Niveluri de Polen", "sections": { - "api_key_options": "Optional API key options" + "api_key_options": { + "title": "Optional API key options" + } }, "data_description": { "http_referer": "Only needed if your API key is restricted by HTTP referrers (websites)." diff --git a/custom_components/pollenlevels/translations/ru.json b/custom_components/pollenlevels/translations/ru.json index f6cd81e2..7c999c43 100644 --- a/custom_components/pollenlevels/translations/ru.json +++ b/custom_components/pollenlevels/translations/ru.json @@ -37,7 +37,9 @@ "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", "title": "Настройка уровней пыльцы", "sections": { - "api_key_options": "Optional API key options" + "api_key_options": { + "title": "Optional API key options" + } }, "data_description": { "http_referer": "Only needed if your API key is restricted by HTTP referrers (websites)." diff --git a/custom_components/pollenlevels/translations/sv.json b/custom_components/pollenlevels/translations/sv.json index 67d93683..b2bb9a05 100644 --- a/custom_components/pollenlevels/translations/sv.json +++ b/custom_components/pollenlevels/translations/sv.json @@ -37,7 +37,9 @@ "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", "title": "Konfiguration av pollennivåer", "sections": { - "api_key_options": "Optional API key options" + "api_key_options": { + "title": "Optional API key options" + } }, "data_description": { "http_referer": "Only needed if your API key is restricted by HTTP referrers (websites)." diff --git a/custom_components/pollenlevels/translations/uk.json b/custom_components/pollenlevels/translations/uk.json index 597ea881..6e06be73 100644 --- a/custom_components/pollenlevels/translations/uk.json +++ b/custom_components/pollenlevels/translations/uk.json @@ -37,7 +37,9 @@ "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", "title": "Налаштування рівнів пилку", "sections": { - "api_key_options": "Optional API key options" + "api_key_options": { + "title": "Optional API key options" + } }, "data_description": { "http_referer": "Only needed if your API key is restricted by HTTP referrers (websites)." diff --git a/custom_components/pollenlevels/translations/zh-Hans.json b/custom_components/pollenlevels/translations/zh-Hans.json index 8605c0c5..dda1a807 100644 --- a/custom_components/pollenlevels/translations/zh-Hans.json +++ b/custom_components/pollenlevels/translations/zh-Hans.json @@ -37,7 +37,9 @@ "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", "title": "花粉水平配置", "sections": { - "api_key_options": "Optional API key options" + "api_key_options": { + "title": "Optional API key options" + } }, "data_description": { "http_referer": "Only needed if your API key is restricted by HTTP referrers (websites)." diff --git a/custom_components/pollenlevels/translations/zh-Hant.json b/custom_components/pollenlevels/translations/zh-Hant.json index 2783d71d..ab471d66 100644 --- a/custom_components/pollenlevels/translations/zh-Hant.json +++ b/custom_components/pollenlevels/translations/zh-Hant.json @@ -37,7 +37,9 @@ "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", "title": "花粉水平設定", "sections": { - "api_key_options": "Optional API key options" + "api_key_options": { + "title": "Optional API key options" + } }, "data_description": { "http_referer": "Only needed if your API key is restricted by HTTP referrers (websites)." From 6fee1d230593a739b4f7901419567c23dc9c711a Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sun, 14 Dec 2025 18:09:44 +0100 Subject: [PATCH 030/200] Fix translation section schema --- custom_components/pollenlevels/translations/ca.json | 2 +- custom_components/pollenlevels/translations/cs.json | 2 +- custom_components/pollenlevels/translations/da.json | 2 +- custom_components/pollenlevels/translations/de.json | 2 +- custom_components/pollenlevels/translations/en.json | 2 +- custom_components/pollenlevels/translations/es.json | 2 +- custom_components/pollenlevels/translations/fi.json | 2 +- custom_components/pollenlevels/translations/fr.json | 2 +- custom_components/pollenlevels/translations/hu.json | 2 +- custom_components/pollenlevels/translations/it.json | 2 +- custom_components/pollenlevels/translations/nb.json | 2 +- custom_components/pollenlevels/translations/nl.json | 2 +- custom_components/pollenlevels/translations/pl.json | 2 +- custom_components/pollenlevels/translations/pt-BR.json | 2 +- custom_components/pollenlevels/translations/pt-PT.json | 2 +- custom_components/pollenlevels/translations/ro.json | 2 +- custom_components/pollenlevels/translations/ru.json | 2 +- custom_components/pollenlevels/translations/sv.json | 2 +- custom_components/pollenlevels/translations/uk.json | 2 +- custom_components/pollenlevels/translations/zh-Hans.json | 2 +- custom_components/pollenlevels/translations/zh-Hant.json | 2 +- 21 files changed, 21 insertions(+), 21 deletions(-) diff --git a/custom_components/pollenlevels/translations/ca.json b/custom_components/pollenlevels/translations/ca.json index 60021a6c..39f505aa 100644 --- a/custom_components/pollenlevels/translations/ca.json +++ b/custom_components/pollenlevels/translations/ca.json @@ -14,7 +14,7 @@ }, "sections": { "api_key_options": { - "title": "Optional API key options" + "name": "Optional API key options" } }, "data_description": { diff --git a/custom_components/pollenlevels/translations/cs.json b/custom_components/pollenlevels/translations/cs.json index bdfd08c5..f03757ba 100644 --- a/custom_components/pollenlevels/translations/cs.json +++ b/custom_components/pollenlevels/translations/cs.json @@ -38,7 +38,7 @@ "title": "Konfigurace úrovní pylu", "sections": { "api_key_options": { - "title": "Optional API key options" + "name": "Optional API key options" } }, "data_description": { diff --git a/custom_components/pollenlevels/translations/da.json b/custom_components/pollenlevels/translations/da.json index 5dc08f29..24d841d7 100644 --- a/custom_components/pollenlevels/translations/da.json +++ b/custom_components/pollenlevels/translations/da.json @@ -38,7 +38,7 @@ "title": "Konfiguration af pollenniveauer", "sections": { "api_key_options": { - "title": "Optional API key options" + "name": "Optional API key options" } }, "data_description": { diff --git a/custom_components/pollenlevels/translations/de.json b/custom_components/pollenlevels/translations/de.json index 31ac9397..9da377ad 100644 --- a/custom_components/pollenlevels/translations/de.json +++ b/custom_components/pollenlevels/translations/de.json @@ -38,7 +38,7 @@ "title": "Pollen Levels – Konfiguration", "sections": { "api_key_options": { - "title": "Optional API key options" + "name": "Optional API key options" } }, "data_description": { diff --git a/custom_components/pollenlevels/translations/en.json b/custom_components/pollenlevels/translations/en.json index 4c3c3f1c..05064234 100644 --- a/custom_components/pollenlevels/translations/en.json +++ b/custom_components/pollenlevels/translations/en.json @@ -14,7 +14,7 @@ }, "sections": { "api_key_options": { - "title": "Optional API key options" + "name": "Optional API key options" } }, "data_description": { diff --git a/custom_components/pollenlevels/translations/es.json b/custom_components/pollenlevels/translations/es.json index ed9da470..87637e9e 100644 --- a/custom_components/pollenlevels/translations/es.json +++ b/custom_components/pollenlevels/translations/es.json @@ -14,7 +14,7 @@ }, "sections": { "api_key_options": { - "title": "Optional API key options" + "name": "Optional API key options" } }, "data_description": { diff --git a/custom_components/pollenlevels/translations/fi.json b/custom_components/pollenlevels/translations/fi.json index 9f730a16..3cb30c14 100644 --- a/custom_components/pollenlevels/translations/fi.json +++ b/custom_components/pollenlevels/translations/fi.json @@ -38,7 +38,7 @@ "title": "Siitepölytason asetukset", "sections": { "api_key_options": { - "title": "Optional API key options" + "name": "Optional API key options" } }, "data_description": { diff --git a/custom_components/pollenlevels/translations/fr.json b/custom_components/pollenlevels/translations/fr.json index c7a25e7a..6d2d1865 100644 --- a/custom_components/pollenlevels/translations/fr.json +++ b/custom_components/pollenlevels/translations/fr.json @@ -38,7 +38,7 @@ "title": "Pollen Levels – Configuration", "sections": { "api_key_options": { - "title": "Optional API key options" + "name": "Optional API key options" } }, "data_description": { diff --git a/custom_components/pollenlevels/translations/hu.json b/custom_components/pollenlevels/translations/hu.json index 77b88725..7f53ee11 100644 --- a/custom_components/pollenlevels/translations/hu.json +++ b/custom_components/pollenlevels/translations/hu.json @@ -38,7 +38,7 @@ "title": "Pollen szintek – beállítás", "sections": { "api_key_options": { - "title": "Optional API key options" + "name": "Optional API key options" } }, "data_description": { diff --git a/custom_components/pollenlevels/translations/it.json b/custom_components/pollenlevels/translations/it.json index b5399816..ebe6c694 100644 --- a/custom_components/pollenlevels/translations/it.json +++ b/custom_components/pollenlevels/translations/it.json @@ -38,7 +38,7 @@ "title": "Configurazione Livelli di polline", "sections": { "api_key_options": { - "title": "Optional API key options" + "name": "Optional API key options" } }, "data_description": { diff --git a/custom_components/pollenlevels/translations/nb.json b/custom_components/pollenlevels/translations/nb.json index 25507d38..ba66b5fc 100644 --- a/custom_components/pollenlevels/translations/nb.json +++ b/custom_components/pollenlevels/translations/nb.json @@ -38,7 +38,7 @@ "title": "Konfigurasjon av pollennivåer", "sections": { "api_key_options": { - "title": "Optional API key options" + "name": "Optional API key options" } }, "data_description": { diff --git a/custom_components/pollenlevels/translations/nl.json b/custom_components/pollenlevels/translations/nl.json index 85f2cb66..d7c702ce 100644 --- a/custom_components/pollenlevels/translations/nl.json +++ b/custom_components/pollenlevels/translations/nl.json @@ -38,7 +38,7 @@ "title": "Pollen Levels – Configuratie", "sections": { "api_key_options": { - "title": "Optional API key options" + "name": "Optional API key options" } }, "data_description": { diff --git a/custom_components/pollenlevels/translations/pl.json b/custom_components/pollenlevels/translations/pl.json index 50ca7885..b62b4b15 100644 --- a/custom_components/pollenlevels/translations/pl.json +++ b/custom_components/pollenlevels/translations/pl.json @@ -38,7 +38,7 @@ "title": "Konfiguracja poziomów pyłku", "sections": { "api_key_options": { - "title": "Optional API key options" + "name": "Optional API key options" } }, "data_description": { diff --git a/custom_components/pollenlevels/translations/pt-BR.json b/custom_components/pollenlevels/translations/pt-BR.json index de293684..34660382 100644 --- a/custom_components/pollenlevels/translations/pt-BR.json +++ b/custom_components/pollenlevels/translations/pt-BR.json @@ -38,7 +38,7 @@ "title": "Configuração dos Níveis de Pólen", "sections": { "api_key_options": { - "title": "Optional API key options" + "name": "Optional API key options" } }, "data_description": { diff --git a/custom_components/pollenlevels/translations/pt-PT.json b/custom_components/pollenlevels/translations/pt-PT.json index a3dc5b92..b0dd9ffc 100644 --- a/custom_components/pollenlevels/translations/pt-PT.json +++ b/custom_components/pollenlevels/translations/pt-PT.json @@ -38,7 +38,7 @@ "title": "Configuração dos Níveis de Pólen", "sections": { "api_key_options": { - "title": "Optional API key options" + "name": "Optional API key options" } }, "data_description": { diff --git a/custom_components/pollenlevels/translations/ro.json b/custom_components/pollenlevels/translations/ro.json index fe3dbed3..893bff4f 100644 --- a/custom_components/pollenlevels/translations/ro.json +++ b/custom_components/pollenlevels/translations/ro.json @@ -38,7 +38,7 @@ "title": "Configurare Niveluri de Polen", "sections": { "api_key_options": { - "title": "Optional API key options" + "name": "Optional API key options" } }, "data_description": { diff --git a/custom_components/pollenlevels/translations/ru.json b/custom_components/pollenlevels/translations/ru.json index 7c999c43..c817885c 100644 --- a/custom_components/pollenlevels/translations/ru.json +++ b/custom_components/pollenlevels/translations/ru.json @@ -38,7 +38,7 @@ "title": "Настройка уровней пыльцы", "sections": { "api_key_options": { - "title": "Optional API key options" + "name": "Optional API key options" } }, "data_description": { diff --git a/custom_components/pollenlevels/translations/sv.json b/custom_components/pollenlevels/translations/sv.json index b2bb9a05..abfd7eb8 100644 --- a/custom_components/pollenlevels/translations/sv.json +++ b/custom_components/pollenlevels/translations/sv.json @@ -38,7 +38,7 @@ "title": "Konfiguration av pollennivåer", "sections": { "api_key_options": { - "title": "Optional API key options" + "name": "Optional API key options" } }, "data_description": { diff --git a/custom_components/pollenlevels/translations/uk.json b/custom_components/pollenlevels/translations/uk.json index 6e06be73..245a73b3 100644 --- a/custom_components/pollenlevels/translations/uk.json +++ b/custom_components/pollenlevels/translations/uk.json @@ -38,7 +38,7 @@ "title": "Налаштування рівнів пилку", "sections": { "api_key_options": { - "title": "Optional API key options" + "name": "Optional API key options" } }, "data_description": { diff --git a/custom_components/pollenlevels/translations/zh-Hans.json b/custom_components/pollenlevels/translations/zh-Hans.json index dda1a807..71801e62 100644 --- a/custom_components/pollenlevels/translations/zh-Hans.json +++ b/custom_components/pollenlevels/translations/zh-Hans.json @@ -38,7 +38,7 @@ "title": "花粉水平配置", "sections": { "api_key_options": { - "title": "Optional API key options" + "name": "Optional API key options" } }, "data_description": { diff --git a/custom_components/pollenlevels/translations/zh-Hant.json b/custom_components/pollenlevels/translations/zh-Hant.json index ab471d66..fc2443ba 100644 --- a/custom_components/pollenlevels/translations/zh-Hant.json +++ b/custom_components/pollenlevels/translations/zh-Hant.json @@ -38,7 +38,7 @@ "title": "花粉水平設定", "sections": { "api_key_options": { - "title": "Optional API key options" + "name": "Optional API key options" } }, "data_description": { From 741ccafea7aed5a5c3ffa0a2a7723734a1d402aa Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sun, 14 Dec 2025 19:30:09 +0100 Subject: [PATCH 031/200] Add Referer header to config flow validation --- custom_components/pollenlevels/config_flow.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/custom_components/pollenlevels/config_flow.py b/custom_components/pollenlevels/config_flow.py index 4a58dad3..2905de5d 100644 --- a/custom_components/pollenlevels/config_flow.py +++ b/custom_components/pollenlevels/config_flow.py @@ -209,6 +209,24 @@ async def _async_validate_input( normalized.pop(CONF_NAME, None) normalized.pop(CONF_LOCATION, None) + headers: dict[str, str] | None = None + raw_http_referer = normalized.get(CONF_HTTP_REFERER) + if raw_http_referer is not None: + if not isinstance(raw_http_referer, str): + errors[CONF_HTTP_REFERER] = "invalid_http_referrer" + return errors, None + + http_referer = raw_http_referer.strip() + if "\r" in http_referer or "\n" in http_referer: + errors[CONF_HTTP_REFERER] = "invalid_http_referrer" + return errors, None + + if http_referer: + headers = {"Referer": http_referer} + normalized[CONF_HTTP_REFERER] = http_referer + else: + normalized.pop(CONF_HTTP_REFERER, None) + async def _extract_error_message( resp: aiohttp.ClientResponse, default: str ) -> str: @@ -299,6 +317,7 @@ async def _extract_error_message( async with session.get( url, params=params, + headers=headers, timeout=aiohttp.ClientTimeout(total=POLLEN_API_TIMEOUT), ) as resp: status = resp.status From a81a20f2115b4973dd3f51f78c11c370227410f1 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sun, 14 Dec 2025 19:30:14 +0100 Subject: [PATCH 032/200] Add update interval guardrails in flows --- custom_components/pollenlevels/config_flow.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/custom_components/pollenlevels/config_flow.py b/custom_components/pollenlevels/config_flow.py index 2905de5d..2655f46f 100644 --- a/custom_components/pollenlevels/config_flow.py +++ b/custom_components/pollenlevels/config_flow.py @@ -227,6 +227,18 @@ async def _async_validate_input( else: normalized.pop(CONF_HTTP_REFERER, None) + try: + normalized[CONF_UPDATE_INTERVAL] = int( + normalized.get(CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL) + ) + except (TypeError, ValueError): + errors[CONF_UPDATE_INTERVAL] = "invalid_option_combo" + return errors, None + + if normalized[CONF_UPDATE_INTERVAL] < 1: + errors[CONF_UPDATE_INTERVAL] = "invalid_option_combo" + return errors, None + async def _extract_error_message( resp: aiohttp.ClientResponse, default: str ) -> str: @@ -372,8 +384,6 @@ async def _extract_error_message( return errors, None normalized[CONF_LANGUAGE_CODE] = lang - if CONF_UPDATE_INTERVAL in normalized: - normalized[CONF_UPDATE_INTERVAL] = int(normalized[CONF_UPDATE_INTERVAL]) return errors, normalized except vol.Invalid as ve: @@ -582,6 +592,9 @@ async def async_step_init(self, user_input=None): errors["base"] = "invalid_option_combo" if not errors: + if normalized_input[CONF_UPDATE_INTERVAL] < 1: + errors[CONF_UPDATE_INTERVAL] = "invalid_option_combo" + try: # Language: allow empty; if provided, validate & normalize. raw_lang = normalized_input.get( From bebec542aad15754b5242906efa8db3a66c816a8 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sun, 14 Dec 2025 19:30:21 +0100 Subject: [PATCH 033/200] Classify invalid API key 403 responses --- custom_components/pollenlevels/client.py | 48 +++++++++++++------ custom_components/pollenlevels/config_flow.py | 11 +++-- custom_components/pollenlevels/const.py | 17 +++++++ tests/test_config_flow.py | 26 ++++++++++ tests/test_sensor.py | 30 ++++++++++++ 5 files changed, 114 insertions(+), 18 deletions(-) diff --git a/custom_components/pollenlevels/client.py b/custom_components/pollenlevels/client.py index 05793ef8..92f927db 100644 --- a/custom_components/pollenlevels/client.py +++ b/custom_components/pollenlevels/client.py @@ -10,7 +10,7 @@ from homeassistant.helpers.update_coordinator import UpdateFailed from homeassistant.util import dt as dt_util -from .const import POLLEN_API_TIMEOUT +from .const import POLLEN_API_TIMEOUT, is_invalid_api_key_message from .util import redact_api_key _LOGGER = logging.getLogger(__name__) @@ -27,7 +27,7 @@ def __init__( self._http_referer = http_referer async def _extract_error_message(self, resp: Any) -> str: - """Extract and normalize an HTTP error message.""" + """Extract and normalize an HTTP error message without secrets.""" message: str | None = None try: @@ -37,22 +37,22 @@ async def _extract_error_message(self, resp: Any) -> str: if isinstance(error, dict): raw_msg = error.get("message") if isinstance(raw_msg, str): - message = raw_msg + message = raw_msg.strip() except Exception: # noqa: BLE001 message = None if not message: try: text = await resp.text() - message = text.strip() if isinstance(text, str) else None + if isinstance(text, str): + message = text.strip() except Exception: # noqa: BLE001 message = None - if not message: - return f"HTTP {resp.status}" + if message and len(message) > 300: + message = message[:300] - message = message[:300] - return f"HTTP {resp.status}: {message}" + return message or "" def _parse_retry_after(self, retry_after_raw: str) -> float: """Translate a Retry-After header into a delay in seconds.""" @@ -126,11 +126,19 @@ async def async_fetch_pollen_data( headers=headers, ) as resp: if resp.status == 401: - message = await self._extract_error_message(resp) + raw_message = await self._extract_error_message(resp) + message = raw_message or f"HTTP {resp.status}" + if raw_message: + message = f"HTTP {resp.status}: {raw_message}" raise ConfigEntryAuthFailed(message) if resp.status == 403: - message = await self._extract_error_message(resp) + raw_message = await self._extract_error_message(resp) + message = raw_message or f"HTTP {resp.status}" + if raw_message: + message = f"HTTP {resp.status}: {raw_message}" + if is_invalid_api_key_message(raw_message): + raise ConfigEntryAuthFailed(message or "Invalid API key") raise UpdateFailed(message) if resp.status == 429: @@ -148,7 +156,10 @@ async def async_fetch_pollen_data( ) await asyncio.sleep(delay) continue - message = await self._extract_error_message(resp) + raw_message = await self._extract_error_message(resp) + message = raw_message or f"HTTP {resp.status}" + if raw_message: + message = f"HTTP {resp.status}: {raw_message}" raise UpdateFailed(message) if 500 <= resp.status <= 599: @@ -163,15 +174,24 @@ async def async_fetch_pollen_data( base_args=(resp.status,), ) continue - message = await self._extract_error_message(resp) + raw_message = await self._extract_error_message(resp) + message = raw_message or f"HTTP {resp.status}" + if raw_message: + message = f"HTTP {resp.status}: {raw_message}" raise UpdateFailed(message) if 400 <= resp.status < 500 and resp.status not in (403, 429): - message = await self._extract_error_message(resp) + raw_message = await self._extract_error_message(resp) + message = raw_message or f"HTTP {resp.status}" + if raw_message: + message = f"HTTP {resp.status}: {raw_message}" raise UpdateFailed(message) if resp.status != 200: - message = await self._extract_error_message(resp) + raw_message = await self._extract_error_message(resp) + message = raw_message or f"HTTP {resp.status}" + if raw_message: + message = f"HTTP {resp.status}: {raw_message}" raise UpdateFailed(message) return await resp.json() diff --git a/custom_components/pollenlevels/config_flow.py b/custom_components/pollenlevels/config_flow.py index 2655f46f..29a3149b 100644 --- a/custom_components/pollenlevels/config_flow.py +++ b/custom_components/pollenlevels/config_flow.py @@ -54,6 +54,7 @@ POLLEN_API_TIMEOUT, RESTRICTING_API_KEYS_URL, SECTION_API_KEY_OPTIONS, + is_invalid_api_key_message, ) from .util import redact_api_key @@ -341,10 +342,12 @@ async def _extract_error_message( ) elif status == 403: _LOGGER.debug("Validation HTTP 403 (body omitted)") - errors["base"] = "cannot_connect" - placeholders["error_message"] = await _extract_error_message( - resp, "HTTP 403" - ) + error_message = await _extract_error_message(resp, "HTTP 403") + if is_invalid_api_key_message(error_message): + errors["base"] = "invalid_auth" + else: + errors["base"] = "cannot_connect" + placeholders["error_message"] = error_message elif status == 429: _LOGGER.debug("Validation HTTP 429 (body omitted)") errors["base"] = "quota_exceeded" diff --git a/custom_components/pollenlevels/const.py b/custom_components/pollenlevels/const.py index e4cebc00..0c3789d1 100644 --- a/custom_components/pollenlevels/const.py +++ b/custom_components/pollenlevels/const.py @@ -32,3 +32,20 @@ # Allowed values for create_forecast_sensors selector FORECAST_SENSORS_CHOICES = ["none", "D+1", "D+1+2"] + + +def is_invalid_api_key_message(message: str | None) -> bool: + """Return True if *message* strongly indicates an invalid API key.""" + + if not message: + return False + + msg = message.casefold() + signals = ( + "api key not valid", + "invalid api key", + "api_key_invalid", + "apikeynotvalid", + "api key is not valid", + ) + return any(signal in msg for signal in signals) diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 28743c49..184b7f36 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -744,6 +744,32 @@ def test_validate_input_http_403_sets_error_message_placeholder( assert "Forbidden" in placeholders.get("error_message", "") +def test_validate_input_http_403_invalid_key_maps_to_invalid_auth( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """HTTP 403 invalid API key messages should behave like invalid_auth.""" + + body = b'{"error": {"message": "API key not valid. Please pass a valid API key."}}' + session = _patch_client_session(monkeypatch, _StubResponse(status=403, body=body)) + + flow = PollenLevelsConfigFlow() + flow.hass = SimpleNamespace() + placeholders: dict[str, str] = {} + + errors, normalized = asyncio.run( + flow._async_validate_input( + _base_user_input(), + check_unique_id=False, + description_placeholders=placeholders, + ) + ) + + assert session.calls + assert errors == {"base": "invalid_auth"} + assert normalized is None + assert "api key not valid" in placeholders.get("error_message", "").lower() + + def test_validate_input_unexpected_exception_sets_unknown( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 2249d962..6bc6d394 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -874,6 +874,36 @@ def test_coordinator_handles_forbidden() -> None: loop.close() +def test_coordinator_invalid_key_message_triggers_reauth() -> None: + """403 invalid API key messages should raise ConfigEntryAuthFailed.""" + + payload = {"error": {"message": "API key not valid. Please pass a valid API key."}} + fake_session = FakeSession(payload, status=403) + client = sensor.GooglePollenApiClient(fake_session, "bad") + + loop = asyncio.new_event_loop() + hass = DummyHass(loop) + coordinator = sensor.PollenDataUpdateCoordinator( + hass=hass, + api_key="bad", + lat=1.0, + lon=2.0, + hours=12, + language=None, + entry_id="entry", + forecast_days=1, + create_d1=False, + create_d2=False, + client=client, + ) + + try: + with pytest.raises(sensor.ConfigEntryAuthFailed): + loop.run_until_complete(coordinator._async_update_data()) + finally: + loop.close() + + def test_coordinator_retries_then_raises_on_rate_limit( monkeypatch: pytest.MonkeyPatch, ) -> None: From 864bccb07bfa5dc374e57787b1d84d35b94ebbd2 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sun, 14 Dec 2025 21:04:21 +0100 Subject: [PATCH 034/200] Deduplicate error message extraction --- CHANGELOG.md | 2 + custom_components/pollenlevels/client.py | 42 ++++--------------- custom_components/pollenlevels/config_flow.py | 36 +++------------- custom_components/pollenlevels/util.py | 31 +++++++++++++- 4 files changed, 44 insertions(+), 67 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 15cd3ccf..21f4b3be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ when configured while preserving the default behavior for unrestricted keys. - Runtime HTTP 403 responses now surface detailed messages without triggering reauthentication, keeping setup and update behavior aligned. +- Deduplicated HTTP error message extraction into a shared helper used by + config validation and the runtime client to keep diagnostics consistent. ## [1.9.0-alpha1] - 2025-12-11 ### Changed diff --git a/custom_components/pollenlevels/client.py b/custom_components/pollenlevels/client.py index 92f927db..4ba87c48 100644 --- a/custom_components/pollenlevels/client.py +++ b/custom_components/pollenlevels/client.py @@ -11,7 +11,7 @@ from homeassistant.util import dt as dt_util from .const import POLLEN_API_TIMEOUT, is_invalid_api_key_message -from .util import redact_api_key +from .util import extract_error_message, redact_api_key _LOGGER = logging.getLogger(__name__) @@ -26,34 +26,6 @@ def __init__( self._api_key = api_key self._http_referer = http_referer - async def _extract_error_message(self, resp: Any) -> str: - """Extract and normalize an HTTP error message without secrets.""" - - message: str | None = None - try: - data = await resp.json() - if isinstance(data, dict): - error = data.get("error") - if isinstance(error, dict): - raw_msg = error.get("message") - if isinstance(raw_msg, str): - message = raw_msg.strip() - except Exception: # noqa: BLE001 - message = None - - if not message: - try: - text = await resp.text() - if isinstance(text, str): - message = text.strip() - except Exception: # noqa: BLE001 - message = None - - if message and len(message) > 300: - message = message[:300] - - return message or "" - def _parse_retry_after(self, retry_after_raw: str) -> float: """Translate a Retry-After header into a delay in seconds.""" @@ -126,14 +98,14 @@ async def async_fetch_pollen_data( headers=headers, ) as resp: if resp.status == 401: - raw_message = await self._extract_error_message(resp) + raw_message = await extract_error_message(resp) message = raw_message or f"HTTP {resp.status}" if raw_message: message = f"HTTP {resp.status}: {raw_message}" raise ConfigEntryAuthFailed(message) if resp.status == 403: - raw_message = await self._extract_error_message(resp) + raw_message = await extract_error_message(resp) message = raw_message or f"HTTP {resp.status}" if raw_message: message = f"HTTP {resp.status}: {raw_message}" @@ -156,7 +128,7 @@ async def async_fetch_pollen_data( ) await asyncio.sleep(delay) continue - raw_message = await self._extract_error_message(resp) + raw_message = await extract_error_message(resp) message = raw_message or f"HTTP {resp.status}" if raw_message: message = f"HTTP {resp.status}: {raw_message}" @@ -174,21 +146,21 @@ async def async_fetch_pollen_data( base_args=(resp.status,), ) continue - raw_message = await self._extract_error_message(resp) + raw_message = await extract_error_message(resp) message = raw_message or f"HTTP {resp.status}" if raw_message: message = f"HTTP {resp.status}: {raw_message}" raise UpdateFailed(message) if 400 <= resp.status < 500 and resp.status not in (403, 429): - raw_message = await self._extract_error_message(resp) + raw_message = await extract_error_message(resp) message = raw_message or f"HTTP {resp.status}" if raw_message: message = f"HTTP {resp.status}: {raw_message}" raise UpdateFailed(message) if resp.status != 200: - raw_message = await self._extract_error_message(resp) + raw_message = await extract_error_message(resp) message = raw_message or f"HTTP {resp.status}" if raw_message: message = f"HTTP {resp.status}: {raw_message}" diff --git a/custom_components/pollenlevels/config_flow.py b/custom_components/pollenlevels/config_flow.py index 29a3149b..5f1a2b94 100644 --- a/custom_components/pollenlevels/config_flow.py +++ b/custom_components/pollenlevels/config_flow.py @@ -56,7 +56,7 @@ SECTION_API_KEY_OPTIONS, is_invalid_api_key_message, ) -from .util import redact_api_key +from .util import extract_error_message, redact_api_key _LOGGER = logging.getLogger(__name__) @@ -240,32 +240,6 @@ async def _async_validate_input( errors[CONF_UPDATE_INTERVAL] = "invalid_option_combo" return errors, None - async def _extract_error_message( - resp: aiohttp.ClientResponse, default: str - ) -> str: - message = "" - try: - data = await resp.json() - if isinstance(data, dict): - err = data.get("error") - if isinstance(err, dict): - body_message = err.get("message") - if isinstance(body_message, str): - message = body_message - except Exception: - message = "" - - if not message: - try: - message = await resp.text() - except Exception: - message = "" - - message = (message or "").strip() or default - if len(message) > 300: - message = message[:300] - return message - latlon = None if CONF_LOCATION in user_input: latlon = _validate_location_dict(user_input.get(CONF_LOCATION)) @@ -337,12 +311,12 @@ async def _extract_error_message( if status == 401: _LOGGER.debug("Validation HTTP 401 (body omitted)") errors["base"] = "invalid_auth" - placeholders["error_message"] = await _extract_error_message( + placeholders["error_message"] = await extract_error_message( resp, "HTTP 401" ) elif status == 403: _LOGGER.debug("Validation HTTP 403 (body omitted)") - error_message = await _extract_error_message(resp, "HTTP 403") + error_message = await extract_error_message(resp, "HTTP 403") if is_invalid_api_key_message(error_message): errors["base"] = "invalid_auth" else: @@ -351,13 +325,13 @@ async def _extract_error_message( elif status == 429: _LOGGER.debug("Validation HTTP 429 (body omitted)") errors["base"] = "quota_exceeded" - placeholders["error_message"] = await _extract_error_message( + placeholders["error_message"] = await extract_error_message( resp, "HTTP 429" ) elif status != 200: _LOGGER.debug("Validation HTTP %s (body omitted)", status) errors["base"] = "cannot_connect" - placeholders["error_message"] = await _extract_error_message( + placeholders["error_message"] = await extract_error_message( resp, f"HTTP {status}" ) else: diff --git a/custom_components/pollenlevels/util.py b/custom_components/pollenlevels/util.py index 0529ef28..ad1747af 100644 --- a/custom_components/pollenlevels/util.py +++ b/custom_components/pollenlevels/util.py @@ -3,6 +3,35 @@ from __future__ import annotations +async def extract_error_message(resp: object, default: str = "") -> str: + """Extract and normalize an HTTP error message without secrets.""" + + message: str | None = None + try: + json_obj = await resp.json() + if isinstance(json_obj, dict): + error = json_obj.get("error") + if isinstance(error, dict): + raw_msg = error.get("message") + if isinstance(raw_msg, str): + message = raw_msg.strip() + except Exception: # noqa: BLE001 + message = None + + if not message: + try: + text = await resp.text() + if isinstance(text, str): + message = text.strip() + except Exception: # noqa: BLE001 + message = None + + message = (message or "").strip() or default + if len(message) > 300: + message = message[:300] + return message + + def redact_api_key(text: object, api_key: str | None) -> str: """Return a string representation of *text* with the API key redacted.""" @@ -25,4 +54,4 @@ def redact_api_key(text: object, api_key: str | None) -> str: # Backwards-compatible alias for modules that still import the private helper name. _redact_api_key = redact_api_key -__all__ = ["redact_api_key", "_redact_api_key"] +__all__ = ["extract_error_message", "redact_api_key", "_redact_api_key"] From 5fe7c6e6624f6bf3ccf3c1ec17ebac8e3b5a4c1b Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sun, 14 Dec 2025 22:21:46 +0100 Subject: [PATCH 035/200] Tighten update interval validation and tests --- custom_components/pollenlevels/config_flow.py | 4 +- .../pollenlevels/translations/ca.json | 6 ++- .../pollenlevels/translations/cs.json | 6 ++- .../pollenlevels/translations/da.json | 6 ++- .../pollenlevels/translations/de.json | 6 ++- .../pollenlevels/translations/en.json | 6 ++- .../pollenlevels/translations/es.json | 6 ++- .../pollenlevels/translations/fi.json | 6 ++- .../pollenlevels/translations/fr.json | 6 ++- .../pollenlevels/translations/hu.json | 6 ++- .../pollenlevels/translations/it.json | 6 ++- .../pollenlevels/translations/nb.json | 6 ++- .../pollenlevels/translations/nl.json | 6 ++- .../pollenlevels/translations/pl.json | 6 ++- .../pollenlevels/translations/pt-BR.json | 6 ++- .../pollenlevels/translations/pt-PT.json | 6 ++- .../pollenlevels/translations/ro.json | 6 ++- .../pollenlevels/translations/ru.json | 6 ++- .../pollenlevels/translations/sv.json | 6 ++- .../pollenlevels/translations/uk.json | 6 ++- .../pollenlevels/translations/zh-Hans.json | 6 ++- .../pollenlevels/translations/zh-Hant.json | 6 ++- tests/conftest.py | 3 +- tests/test_config_flow.py | 45 +++++++++++++++++++ tests/test_options_flow.py | 19 ++++++++ 25 files changed, 152 insertions(+), 45 deletions(-) diff --git a/custom_components/pollenlevels/config_flow.py b/custom_components/pollenlevels/config_flow.py index 5f1a2b94..adff17c1 100644 --- a/custom_components/pollenlevels/config_flow.py +++ b/custom_components/pollenlevels/config_flow.py @@ -237,7 +237,7 @@ async def _async_validate_input( return errors, None if normalized[CONF_UPDATE_INTERVAL] < 1: - errors[CONF_UPDATE_INTERVAL] = "invalid_option_combo" + errors[CONF_UPDATE_INTERVAL] = "invalid_update_interval" return errors, None latlon = None @@ -570,7 +570,7 @@ async def async_step_init(self, user_input=None): if not errors: if normalized_input[CONF_UPDATE_INTERVAL] < 1: - errors[CONF_UPDATE_INTERVAL] = "invalid_option_combo" + errors[CONF_UPDATE_INTERVAL] = "invalid_update_interval" try: # Language: allow empty; if provided, validate & normalize. diff --git a/custom_components/pollenlevels/translations/ca.json b/custom_components/pollenlevels/translations/ca.json index 39f505aa..079563b3 100644 --- a/custom_components/pollenlevels/translations/ca.json +++ b/custom_components/pollenlevels/translations/ca.json @@ -39,7 +39,8 @@ "invalid_option_combo": "Augmenta els 'Dies de previsió' per cobrir els sensors per dia seleccionats.", "invalid_coordinates": "Selecciona una ubicació vàlida al mapa.", "unknown": "Error desconegut", - "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters." + "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters.", + "invalid_update_interval": "Update interval must be at least 1 hour." }, "abort": { "already_configured": "Aquesta ubicació ja està configurada.", @@ -68,7 +69,8 @@ "invalid_language_format": "Utilitza un codi BCP-47 canònic com \"en\" o \"es-ES\".", "empty": "Aquest camp no pot estar buit", "invalid_option_combo": "Augmenta els 'Dies de previsió' per cobrir els sensors per dia seleccionats.", - "unknown": "Error desconegut" + "unknown": "Error desconegut", + "invalid_update_interval": "Update interval must be at least 1 hour." } }, "device": { diff --git a/custom_components/pollenlevels/translations/cs.json b/custom_components/pollenlevels/translations/cs.json index f03757ba..97f3d620 100644 --- a/custom_components/pollenlevels/translations/cs.json +++ b/custom_components/pollenlevels/translations/cs.json @@ -15,7 +15,8 @@ "invalid_language_format": "Použijte kanonický kód BCP-47, například \"en\" nebo \"es-ES\".", "invalid_option_combo": "Zvyšte „Dny předpovědi“, aby pokryly vybrané senzory po dnech.", "quota_exceeded": "Překročena kvóta", - "unknown": "Neznámá chyba" + "unknown": "Neznámá chyba", + "invalid_update_interval": "Update interval must be at least 1 hour." }, "step": { "reauth_confirm": { @@ -80,7 +81,8 @@ "invalid_language_format": "Použijte kanonický kód BCP-47, například \"en\" nebo \"es-ES\".", "invalid_option_combo": "Zvyšte „Dny předpovědi“, aby pokryly vybrané senzory po dnech.", "quota_exceeded": "Překročena kvóta", - "unknown": "Neznámá chyba" + "unknown": "Neznámá chyba", + "invalid_update_interval": "Update interval must be at least 1 hour." }, "step": { "init": { diff --git a/custom_components/pollenlevels/translations/da.json b/custom_components/pollenlevels/translations/da.json index 24d841d7..bd4f8249 100644 --- a/custom_components/pollenlevels/translations/da.json +++ b/custom_components/pollenlevels/translations/da.json @@ -15,7 +15,8 @@ "invalid_language_format": "Brug en kanonisk BCP-47-kode som \"en\" eller \"es-ES\".", "invalid_option_combo": "Forøg \"Prognosedage\" for at dække valgte sensorer pr. dag.", "quota_exceeded": "Kvote overskredet", - "unknown": "Ukendt fejl" + "unknown": "Ukendt fejl", + "invalid_update_interval": "Update interval must be at least 1 hour." }, "step": { "reauth_confirm": { @@ -80,7 +81,8 @@ "invalid_language_format": "Brug en kanonisk BCP-47-kode som \"en\" eller \"es-ES\".", "invalid_option_combo": "Forøg \"Prognosedage\" for at dække valgte sensorer pr. dag.", "quota_exceeded": "Kvote overskredet", - "unknown": "Ukendt fejl" + "unknown": "Ukendt fejl", + "invalid_update_interval": "Update interval must be at least 1 hour." }, "step": { "init": { diff --git a/custom_components/pollenlevels/translations/de.json b/custom_components/pollenlevels/translations/de.json index 9da377ad..2ea2de2b 100644 --- a/custom_components/pollenlevels/translations/de.json +++ b/custom_components/pollenlevels/translations/de.json @@ -15,7 +15,8 @@ "invalid_language_format": "Verwenden Sie einen kanonischen BCP-47-Code wie \"en\" oder \"es-ES\".", "invalid_option_combo": "Erhöhe 'Vorhersagetage', um die gewählten Tagessensoren abzudecken.", "quota_exceeded": "Kontingent überschritten", - "unknown": "Unbekannter Fehler" + "unknown": "Unbekannter Fehler", + "invalid_update_interval": "Update interval must be at least 1 hour." }, "step": { "reauth_confirm": { @@ -80,7 +81,8 @@ "invalid_language_format": "Verwenden Sie einen kanonischen BCP-47-Code wie \"en\" oder \"es-ES\".", "invalid_option_combo": "Erhöhe 'Vorhersagetage', um die gewählten Tagessensoren abzudecken.", "quota_exceeded": "Kontingent überschritten", - "unknown": "Unbekannter Fehler" + "unknown": "Unbekannter Fehler", + "invalid_update_interval": "Update interval must be at least 1 hour." }, "step": { "init": { diff --git a/custom_components/pollenlevels/translations/en.json b/custom_components/pollenlevels/translations/en.json index 05064234..0b980ab2 100644 --- a/custom_components/pollenlevels/translations/en.json +++ b/custom_components/pollenlevels/translations/en.json @@ -39,7 +39,8 @@ "invalid_option_combo": "Increase 'Forecast days' to cover selected per-day sensors.", "invalid_coordinates": "Please select a valid location on the map.", "unknown": "Unknown error", - "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters." + "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters.", + "invalid_update_interval": "Update interval must be at least 1 hour." }, "abort": { "already_configured": "This location is already configured.", @@ -68,7 +69,8 @@ "invalid_language_format": "Use a canonical BCP-47 code such as \"en\" or \"es-ES\".", "empty": "This field cannot be empty", "invalid_option_combo": "Increase 'Forecast days' to cover selected per-day sensors.", - "unknown": "Unknown error" + "unknown": "Unknown error", + "invalid_update_interval": "Update interval must be at least 1 hour." } }, "device": { diff --git a/custom_components/pollenlevels/translations/es.json b/custom_components/pollenlevels/translations/es.json index 87637e9e..59eb4387 100644 --- a/custom_components/pollenlevels/translations/es.json +++ b/custom_components/pollenlevels/translations/es.json @@ -39,7 +39,8 @@ "invalid_option_combo": "Aumenta 'Días de previsión' para cubrir los sensores por día seleccionados.", "invalid_coordinates": "Selecciona una ubicación válida en el mapa.", "unknown": "Error desconocido", - "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters." + "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters.", + "invalid_update_interval": "Update interval must be at least 1 hour." }, "abort": { "already_configured": "Esta ubicación ya está configurada.", @@ -68,7 +69,8 @@ "invalid_language_format": "Usa un código BCP-47 canónico como \"en\" o \"es-ES\".", "empty": "Este campo no puede estar vacío", "invalid_option_combo": "Aumenta 'Días de previsión' para cubrir los sensores por día seleccionados.", - "unknown": "Error desconocido" + "unknown": "Error desconocido", + "invalid_update_interval": "Update interval must be at least 1 hour." } }, "device": { diff --git a/custom_components/pollenlevels/translations/fi.json b/custom_components/pollenlevels/translations/fi.json index 3cb30c14..0628e302 100644 --- a/custom_components/pollenlevels/translations/fi.json +++ b/custom_components/pollenlevels/translations/fi.json @@ -15,7 +15,8 @@ "invalid_language_format": "Käytä kanonista BCP-47-koodia, esimerkiksi \"en\" tai \"es-ES\".", "invalid_option_combo": "Lisää \"Ennustepäiviä\", jotta valitut päiväsensorit katetaan.", "quota_exceeded": "Kiintiö ylitetty", - "unknown": "Tuntematon virhe" + "unknown": "Tuntematon virhe", + "invalid_update_interval": "Update interval must be at least 1 hour." }, "step": { "reauth_confirm": { @@ -80,7 +81,8 @@ "invalid_language_format": "Käytä kanonista BCP-47-koodia, esimerkiksi \"en\" tai \"es-ES\".", "invalid_option_combo": "Lisää \"Ennustepäiviä\", jotta valitut päiväsensorit katetaan.", "quota_exceeded": "Kiintiö ylitetty", - "unknown": "Tuntematon virhe" + "unknown": "Tuntematon virhe", + "invalid_update_interval": "Update interval must be at least 1 hour." }, "step": { "init": { diff --git a/custom_components/pollenlevels/translations/fr.json b/custom_components/pollenlevels/translations/fr.json index 6d2d1865..a98fcad8 100644 --- a/custom_components/pollenlevels/translations/fr.json +++ b/custom_components/pollenlevels/translations/fr.json @@ -15,7 +15,8 @@ "invalid_language_format": "Utilisez un code BCP-47 canonique tel que \"en\" ou \"es-ES\".", "invalid_option_combo": "Augmentez le nombre de « Jours de prévision » afin de couvrir les capteurs par jour sélectionnés.", "quota_exceeded": "Quota dépassé", - "unknown": "Erreur inconnue" + "unknown": "Erreur inconnue", + "invalid_update_interval": "Update interval must be at least 1 hour." }, "step": { "reauth_confirm": { @@ -80,7 +81,8 @@ "invalid_language_format": "Utilisez un code BCP-47 canonique tel que \"en\" ou \"es-ES\".", "invalid_option_combo": "Augmentez le nombre de « Jours de prévision » afin de couvrir les capteurs par jour sélectionnés.", "quota_exceeded": "Quota dépassé", - "unknown": "Erreur inconnue" + "unknown": "Erreur inconnue", + "invalid_update_interval": "Update interval must be at least 1 hour." }, "step": { "init": { diff --git a/custom_components/pollenlevels/translations/hu.json b/custom_components/pollenlevels/translations/hu.json index 7f53ee11..8cc36a01 100644 --- a/custom_components/pollenlevels/translations/hu.json +++ b/custom_components/pollenlevels/translations/hu.json @@ -15,7 +15,8 @@ "invalid_language_format": "Használjon kanonikus BCP-47 kódot, például \"en\" vagy \"es-ES\".", "invalid_option_combo": "Növeld a \"Előrejelzési napok\" értéket a kiválasztott napi szenzorok lefedéséhez.", "quota_exceeded": "Kvóta túllépve", - "unknown": "Ismeretlen hiba" + "unknown": "Ismeretlen hiba", + "invalid_update_interval": "Update interval must be at least 1 hour." }, "step": { "reauth_confirm": { @@ -80,7 +81,8 @@ "invalid_language_format": "Használjon kanonikus BCP-47 kódot, például \"en\" vagy \"es-ES\".", "invalid_option_combo": "Növeld a \"Előrejelzési napok\" értéket a kiválasztott napi szenzorok lefedéséhez.", "quota_exceeded": "Kvóta túllépve", - "unknown": "Ismeretlen hiba" + "unknown": "Ismeretlen hiba", + "invalid_update_interval": "Update interval must be at least 1 hour." }, "step": { "init": { diff --git a/custom_components/pollenlevels/translations/it.json b/custom_components/pollenlevels/translations/it.json index ebe6c694..6e184f0f 100644 --- a/custom_components/pollenlevels/translations/it.json +++ b/custom_components/pollenlevels/translations/it.json @@ -15,7 +15,8 @@ "invalid_language_format": "Usa un codice BCP-47 canonico come \"en\" o \"es-ES\".", "invalid_option_combo": "Aumenta 'Giorni di previsione' per coprire i sensori giornalieri selezionati.", "quota_exceeded": "Quota superata", - "unknown": "Errore sconosciuto" + "unknown": "Errore sconosciuto", + "invalid_update_interval": "Update interval must be at least 1 hour." }, "step": { "reauth_confirm": { @@ -80,7 +81,8 @@ "invalid_language_format": "Usa un codice BCP-47 canonico come \"en\" o \"es-ES\".", "invalid_option_combo": "Aumenta 'Giorni di previsione' per coprire i sensori giornalieri selezionati.", "quota_exceeded": "Quota superata", - "unknown": "Errore sconosciuto" + "unknown": "Errore sconosciuto", + "invalid_update_interval": "Update interval must be at least 1 hour." }, "step": { "init": { diff --git a/custom_components/pollenlevels/translations/nb.json b/custom_components/pollenlevels/translations/nb.json index ba66b5fc..cce70620 100644 --- a/custom_components/pollenlevels/translations/nb.json +++ b/custom_components/pollenlevels/translations/nb.json @@ -15,7 +15,8 @@ "invalid_language_format": "Bruk en kanonisk BCP-47-kode som \"en\" eller \"es-ES\".", "invalid_option_combo": "Øk «Prognosedager» for å dekke valgte sensorer per dag.", "quota_exceeded": "Kvote overskredet", - "unknown": "Ukjent feil" + "unknown": "Ukjent feil", + "invalid_update_interval": "Update interval must be at least 1 hour." }, "step": { "reauth_confirm": { @@ -80,7 +81,8 @@ "invalid_language_format": "Bruk en kanonisk BCP-47-kode som \"en\" eller \"es-ES\".", "invalid_option_combo": "Øk «Prognosedager» for å dekke valgte sensorer per dag.", "quota_exceeded": "Kvote overskredet", - "unknown": "Ukjent feil" + "unknown": "Ukjent feil", + "invalid_update_interval": "Update interval must be at least 1 hour." }, "step": { "init": { diff --git a/custom_components/pollenlevels/translations/nl.json b/custom_components/pollenlevels/translations/nl.json index d7c702ce..b76d6b20 100644 --- a/custom_components/pollenlevels/translations/nl.json +++ b/custom_components/pollenlevels/translations/nl.json @@ -15,7 +15,8 @@ "invalid_language_format": "Gebruik een canonieke BCP-47-code zoals \"en\" of \"es-ES\".", "invalid_option_combo": "Verhoog 'Voorspellingsdagen' om de geselecteerde per-dag sensoren te dekken.", "quota_exceeded": "Limiet overschreden", - "unknown": "Onbekende fout" + "unknown": "Onbekende fout", + "invalid_update_interval": "Update interval must be at least 1 hour." }, "step": { "reauth_confirm": { @@ -80,7 +81,8 @@ "invalid_language_format": "Gebruik een canonieke BCP-47-code zoals \"en\" of \"es-ES\".", "invalid_option_combo": "Verhoog 'Voorspellingsdagen' om de geselecteerde per-dag sensoren te dekken.", "quota_exceeded": "Limiet overschreden", - "unknown": "Onbekende fout" + "unknown": "Onbekende fout", + "invalid_update_interval": "Update interval must be at least 1 hour." }, "step": { "init": { diff --git a/custom_components/pollenlevels/translations/pl.json b/custom_components/pollenlevels/translations/pl.json index b62b4b15..95dfb11e 100644 --- a/custom_components/pollenlevels/translations/pl.json +++ b/custom_components/pollenlevels/translations/pl.json @@ -15,7 +15,8 @@ "invalid_language_format": "Użyj kanonicznego kodu BCP-47, np. \"en\" lub \"es-ES\".", "invalid_option_combo": "Zwiększ 'Dni prognozy', aby objąć wybrane czujniki dzienne.", "quota_exceeded": "Przekroczono limit", - "unknown": "Nieznany błąd" + "unknown": "Nieznany błąd", + "invalid_update_interval": "Update interval must be at least 1 hour." }, "step": { "reauth_confirm": { @@ -80,7 +81,8 @@ "invalid_language_format": "Użyj kanonicznego kodu BCP-47, np. \"en\" lub \"es-ES\".", "invalid_option_combo": "Zwiększ 'Dni prognozy', aby objąć wybrane czujniki dzienne.", "quota_exceeded": "Przekroczono limit", - "unknown": "Nieznany błąd" + "unknown": "Nieznany błąd", + "invalid_update_interval": "Update interval must be at least 1 hour." }, "step": { "init": { diff --git a/custom_components/pollenlevels/translations/pt-BR.json b/custom_components/pollenlevels/translations/pt-BR.json index 34660382..8678c495 100644 --- a/custom_components/pollenlevels/translations/pt-BR.json +++ b/custom_components/pollenlevels/translations/pt-BR.json @@ -15,7 +15,8 @@ "invalid_language_format": "Use um código BCP-47 canônico, como \"en\" ou \"es-ES\".", "invalid_option_combo": "Aumente \"Dias de previsão\" para cobrir os sensores por dia selecionados.", "quota_exceeded": "Cota excedida", - "unknown": "Erro desconhecido" + "unknown": "Erro desconhecido", + "invalid_update_interval": "Update interval must be at least 1 hour." }, "step": { "reauth_confirm": { @@ -80,7 +81,8 @@ "invalid_language_format": "Use um código BCP-47 canônico, como \"en\" ou \"es-ES\".", "invalid_option_combo": "Aumente \"Dias de previsão\" para cobrir os sensores por dia selecionados.", "quota_exceeded": "Cota excedida", - "unknown": "Erro desconhecido" + "unknown": "Erro desconhecido", + "invalid_update_interval": "Update interval must be at least 1 hour." }, "step": { "init": { diff --git a/custom_components/pollenlevels/translations/pt-PT.json b/custom_components/pollenlevels/translations/pt-PT.json index b0dd9ffc..03f86578 100644 --- a/custom_components/pollenlevels/translations/pt-PT.json +++ b/custom_components/pollenlevels/translations/pt-PT.json @@ -15,7 +15,8 @@ "invalid_language_format": "Use um código BCP-47 canónico, como \"en\" ou \"es-ES\".", "invalid_option_combo": "Aumente \"Dias de previsão\" para cobrir os sensores por dia selecionados.", "quota_exceeded": "Quota excedida", - "unknown": "Erro desconhecido" + "unknown": "Erro desconhecido", + "invalid_update_interval": "Update interval must be at least 1 hour." }, "step": { "reauth_confirm": { @@ -80,7 +81,8 @@ "invalid_language_format": "Use um código BCP-47 canónico, como \"en\" ou \"es-ES\".", "invalid_option_combo": "Aumente \"Dias de previsão\" para cobrir os sensores por dia selecionados.", "quota_exceeded": "Quota excedida", - "unknown": "Erro desconhecido" + "unknown": "Erro desconhecido", + "invalid_update_interval": "Update interval must be at least 1 hour." }, "step": { "init": { diff --git a/custom_components/pollenlevels/translations/ro.json b/custom_components/pollenlevels/translations/ro.json index 893bff4f..b50e0937 100644 --- a/custom_components/pollenlevels/translations/ro.json +++ b/custom_components/pollenlevels/translations/ro.json @@ -15,7 +15,8 @@ "invalid_language_format": "Folosiți un cod BCP-47 canonic, de exemplu \"en\" sau \"es-ES\".", "invalid_option_combo": "Măriți \"Zilele de prognoză\" pentru a acoperi senzorii selectați pe zile.", "quota_exceeded": "Cota depășită", - "unknown": "Eroare necunoscută" + "unknown": "Eroare necunoscută", + "invalid_update_interval": "Update interval must be at least 1 hour." }, "step": { "reauth_confirm": { @@ -80,7 +81,8 @@ "invalid_language_format": "Folosiți un cod BCP-47 canonic, de exemplu \"en\" sau \"es-ES\".", "invalid_option_combo": "Măriți \"Zilele de prognoză\" pentru a acoperi senzorii selectați pe zile.", "quota_exceeded": "Cota depășită", - "unknown": "Eroare necunoscută" + "unknown": "Eroare necunoscută", + "invalid_update_interval": "Update interval must be at least 1 hour." }, "step": { "init": { diff --git a/custom_components/pollenlevels/translations/ru.json b/custom_components/pollenlevels/translations/ru.json index c817885c..39413dca 100644 --- a/custom_components/pollenlevels/translations/ru.json +++ b/custom_components/pollenlevels/translations/ru.json @@ -15,7 +15,8 @@ "invalid_language_format": "Используйте канонический код BCP-47, например \"en\" или \"es-ES\".", "invalid_option_combo": "Увеличьте «Дни прогноза», чтобы охватить выбранные датчики по дням.", "quota_exceeded": "Превышен лимит запросов", - "unknown": "Неизвестная ошибка" + "unknown": "Неизвестная ошибка", + "invalid_update_interval": "Update interval must be at least 1 hour." }, "step": { "reauth_confirm": { @@ -80,7 +81,8 @@ "invalid_language_format": "Используйте канонический код BCP-47, например \"en\" или \"es-ES\".", "invalid_option_combo": "Увеличьте «Дни прогноза», чтобы охватить выбранные датчики по дням.", "quota_exceeded": "Превышен лимит запросов", - "unknown": "Неизвестная ошибка" + "unknown": "Неизвестная ошибка", + "invalid_update_interval": "Update interval must be at least 1 hour." }, "step": { "init": { diff --git a/custom_components/pollenlevels/translations/sv.json b/custom_components/pollenlevels/translations/sv.json index abfd7eb8..b4875a35 100644 --- a/custom_components/pollenlevels/translations/sv.json +++ b/custom_components/pollenlevels/translations/sv.json @@ -15,7 +15,8 @@ "invalid_language_format": "Använd en kanonisk BCP-47-kod som \"en\" eller \"es-ES\".", "invalid_option_combo": "Öka \"Prognosdagar\" för att täcka valda sensorer per dag.", "quota_exceeded": "Kvoten har överskridits", - "unknown": "Okänt fel" + "unknown": "Okänt fel", + "invalid_update_interval": "Update interval must be at least 1 hour." }, "step": { "reauth_confirm": { @@ -80,7 +81,8 @@ "invalid_language_format": "Använd en kanonisk BCP-47-kod som \"en\" eller \"es-ES\".", "invalid_option_combo": "Öka \"Prognosdagar\" för att täcka valda sensorer per dag.", "quota_exceeded": "Kvoten har överskridits", - "unknown": "Okänt fel" + "unknown": "Okänt fel", + "invalid_update_interval": "Update interval must be at least 1 hour." }, "step": { "init": { diff --git a/custom_components/pollenlevels/translations/uk.json b/custom_components/pollenlevels/translations/uk.json index 245a73b3..062c89f4 100644 --- a/custom_components/pollenlevels/translations/uk.json +++ b/custom_components/pollenlevels/translations/uk.json @@ -15,7 +15,8 @@ "invalid_language_format": "Використовуйте канонічний код BCP-47, наприклад \"en\" або \"es-ES\".", "invalid_option_combo": "Збільшіть «Дні прогнозу», щоб охопити вибрані денні датчики.", "quota_exceeded": "Перевищено ліміт запитів", - "unknown": "Невідома помилка" + "unknown": "Невідома помилка", + "invalid_update_interval": "Update interval must be at least 1 hour." }, "step": { "reauth_confirm": { @@ -80,7 +81,8 @@ "invalid_language_format": "Використовуйте канонічний код BCP-47, наприклад \"en\" або \"es-ES\".", "invalid_option_combo": "Збільшіть «Дні прогнозу», щоб охопити вибрані денні датчики.", "quota_exceeded": "Перевищено ліміт запитів", - "unknown": "Невідома помилка" + "unknown": "Невідома помилка", + "invalid_update_interval": "Update interval must be at least 1 hour." }, "step": { "init": { diff --git a/custom_components/pollenlevels/translations/zh-Hans.json b/custom_components/pollenlevels/translations/zh-Hans.json index 71801e62..965e70d0 100644 --- a/custom_components/pollenlevels/translations/zh-Hans.json +++ b/custom_components/pollenlevels/translations/zh-Hans.json @@ -15,7 +15,8 @@ "invalid_language_format": "请使用规范的 BCP-47 代码,例如 \"en\" 或 \"es-ES\"。", "invalid_option_combo": "请增加“预测天数”,以覆盖所选的逐日类型传感器。", "quota_exceeded": "配额已用尽", - "unknown": "未知错误" + "unknown": "未知错误", + "invalid_update_interval": "Update interval must be at least 1 hour." }, "step": { "reauth_confirm": { @@ -80,7 +81,8 @@ "invalid_language_format": "请使用规范的 BCP-47 代码,例如 \"en\" 或 \"es-ES\"。", "invalid_option_combo": "请增加“预测天数”,以覆盖所选的逐日类型传感器。", "quota_exceeded": "配额已用尽", - "unknown": "未知错误" + "unknown": "未知错误", + "invalid_update_interval": "Update interval must be at least 1 hour." }, "step": { "init": { diff --git a/custom_components/pollenlevels/translations/zh-Hant.json b/custom_components/pollenlevels/translations/zh-Hant.json index fc2443ba..eacfa1ab 100644 --- a/custom_components/pollenlevels/translations/zh-Hant.json +++ b/custom_components/pollenlevels/translations/zh-Hant.json @@ -15,7 +15,8 @@ "invalid_language_format": "請使用標準的 BCP-47 代碼,例如 \"en\" 或 \"es-ES\"。", "invalid_option_combo": "請增加「預測天數」以涵蓋所選的逐日類型感測器。", "quota_exceeded": "超出配額", - "unknown": "未知錯誤" + "unknown": "未知錯誤", + "invalid_update_interval": "Update interval must be at least 1 hour." }, "step": { "reauth_confirm": { @@ -80,7 +81,8 @@ "invalid_language_format": "請使用標準的 BCP-47 代碼,例如 \"en\" 或 \"es-ES\"。", "invalid_option_combo": "請增加「預測天數」以涵蓋所選的逐日類型感測器。", "quota_exceeded": "超出配額", - "unknown": "未知錯誤" + "unknown": "未知錯誤", + "invalid_update_interval": "Update interval must be at least 1 hour." }, "step": { "init": { diff --git a/tests/conftest.py b/tests/conftest.py index 0ceccb1c..6611eb5d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import inspect import pytest @@ -20,7 +21,7 @@ def pytest_pyfunc_call(pyfuncitem: pytest.Function) -> bool | None: """Run @pytest.mark.asyncio tests locally when no other async plugin is active.""" marker = pyfuncitem.get_closest_marker("asyncio") - if marker is None or not asyncio.iscoroutinefunction(pyfuncitem.obj): + if marker is None or not inspect.iscoroutinefunction(pyfuncitem.obj): return None # If another asyncio-aware plugin is active, let it handle the test. diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 184b7f36..a1792d98 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -629,6 +629,51 @@ def fake_show_form(*args, **kwargs): assert captured.get("errors") == {CONF_HTTP_REFERER: "invalid_http_referrer"} +def test_validate_input_sends_referer_header(monkeypatch: pytest.MonkeyPatch) -> None: + """Validation should forward the Referer header when provided.""" + + session = _patch_client_session( + monkeypatch, + _StubResponse(200, b"{\"dailyInfo\": [{\"indexInfo\": []}]}") + ) + + flow = PollenLevelsConfigFlow() + flow.hass = SimpleNamespace() + + user_input = {**_base_user_input(), CONF_HTTP_REFERER: "https://example.com"} + + errors, normalized = asyncio.run( + flow._async_validate_input(user_input, check_unique_id=False) + ) + + assert errors == {} + assert normalized is not None + assert session.calls + _, kwargs = session.calls[0] + assert kwargs.get("headers") == {"Referer": "https://example.com"} + + +def test_validate_input_update_interval_below_min_sets_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Sub-1 update intervals should surface a field error and skip I/O.""" + + session = _patch_client_session(monkeypatch, _StubResponse(200)) + + flow = PollenLevelsConfigFlow() + flow.hass = SimpleNamespace() + + user_input = {**_base_user_input(), CONF_UPDATE_INTERVAL: 0} + + errors, normalized = asyncio.run( + flow._async_validate_input(user_input, check_unique_id=False) + ) + + assert errors == {CONF_UPDATE_INTERVAL: "invalid_update_interval"} + assert normalized is None + assert not session.calls + + @pytest.mark.parametrize( ("status", "expected"), [ diff --git a/tests/test_options_flow.py b/tests/test_options_flow.py index 81afa947..45682833 100644 --- a/tests/test_options_flow.py +++ b/tests/test_options_flow.py @@ -128,3 +128,22 @@ def test_options_flow_valid_submission_returns_entry_data() -> None: CONF_LANGUAGE_CODE: "es", }, } + + +def test_options_flow_update_interval_below_min_sets_error() -> None: + """Sub-1 update intervals should raise a field error.""" + + flow = _flow() + + result = asyncio.run( + flow.async_step_init( + { + CONF_LANGUAGE_CODE: "en", + CONF_FORECAST_DAYS: 2, + CONF_CREATE_FORECAST_SENSORS: "none", + CONF_UPDATE_INTERVAL: 0, + } + ) + ) + + assert result["errors"] == {CONF_UPDATE_INTERVAL: "invalid_update_interval"} From 030d63b5ed9a0ca35dc344bfc0a07686b087eb57 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sun, 14 Dec 2025 22:36:13 +0100 Subject: [PATCH 036/200] Format test_config_flow with Black --- tests/test_config_flow.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index a1792d98..754f9a9b 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -633,8 +633,7 @@ def test_validate_input_sends_referer_header(monkeypatch: pytest.MonkeyPatch) -> """Validation should forward the Referer header when provided.""" session = _patch_client_session( - monkeypatch, - _StubResponse(200, b"{\"dailyInfo\": [{\"indexInfo\": []}]}") + monkeypatch, _StubResponse(200, b'{"dailyInfo": [{"indexInfo": []}]}') ) flow = PollenLevelsConfigFlow() From cd4ee860f2271f39281de1d00b230738f425ba01 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Mon, 15 Dec 2025 08:05:47 +0100 Subject: [PATCH 037/200] Polish config and options validation guardrails --- custom_components/pollenlevels/config_flow.py | 129 +++++++++--------- custom_components/pollenlevels/const.py | 18 +++ tests/test_config_flow.py | 21 +++ tests/test_options_flow.py | 19 +++ 4 files changed, 124 insertions(+), 63 deletions(-) diff --git a/custom_components/pollenlevels/config_flow.py b/custom_components/pollenlevels/config_flow.py index adff17c1..fad0d96c 100644 --- a/custom_components/pollenlevels/config_flow.py +++ b/custom_components/pollenlevels/config_flow.py @@ -55,6 +55,7 @@ RESTRICTING_API_KEYS_URL, SECTION_API_KEY_OPTIONS, is_invalid_api_key_message, + normalize_http_referer, ) from .util import extract_error_message, redact_api_key @@ -211,29 +212,24 @@ async def _async_validate_input( normalized.pop(CONF_LOCATION, None) headers: dict[str, str] | None = None - raw_http_referer = normalized.get(CONF_HTTP_REFERER) - if raw_http_referer is not None: - if not isinstance(raw_http_referer, str): - errors[CONF_HTTP_REFERER] = "invalid_http_referrer" - return errors, None - - http_referer = raw_http_referer.strip() - if "\r" in http_referer or "\n" in http_referer: - errors[CONF_HTTP_REFERER] = "invalid_http_referrer" - return errors, None + try: + http_referer = normalize_http_referer(normalized.get(CONF_HTTP_REFERER)) + except ValueError: + errors[CONF_HTTP_REFERER] = "invalid_http_referrer" + return errors, None - if http_referer: - headers = {"Referer": http_referer} - normalized[CONF_HTTP_REFERER] = http_referer - else: - normalized.pop(CONF_HTTP_REFERER, None) + if http_referer: + headers = {"Referer": http_referer} + normalized[CONF_HTTP_REFERER] = http_referer + else: + normalized.pop(CONF_HTTP_REFERER, None) try: normalized[CONF_UPDATE_INTERVAL] = int( normalized.get(CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL) ) except (TypeError, ValueError): - errors[CONF_UPDATE_INTERVAL] = "invalid_option_combo" + errors[CONF_UPDATE_INTERVAL] = "invalid_update_interval" return errors, None if normalized[CONF_UPDATE_INTERVAL] < 1: @@ -423,15 +419,10 @@ async def async_step_user(self, user_input=None): sanitized_input.pop(SECTION_API_KEY_OPTIONS, None) http_referer: str | None = None - if raw_http_referer is not None: - if not isinstance(raw_http_referer, str): - errors[CONF_HTTP_REFERER] = "invalid_http_referrer" - else: - http_referer = raw_http_referer.strip() - if "\r" in http_referer or "\n" in http_referer: - errors[CONF_HTTP_REFERER] = "invalid_http_referrer" - elif not http_referer: - http_referer = None + try: + http_referer = normalize_http_referer(raw_http_referer) + except ValueError: + errors[CONF_HTTP_REFERER] = "invalid_http_referrer" if not errors: sanitized_input.pop(CONF_HTTP_REFERER, None) @@ -556,12 +547,61 @@ async def async_step_init(self, user_input=None): ) current_mode = self.entry.options.get(CONF_CREATE_FORECAST_SENSORS, "none") + options_schema = vol.Schema( + { + vol.Optional( + CONF_UPDATE_INTERVAL, default=current_interval + ): NumberSelector( + NumberSelectorConfig( + min=1, + step=1, + mode=NumberSelectorMode.BOX, + unit_of_measurement="h", + ) + ), + vol.Optional(CONF_LANGUAGE_CODE, default=current_lang): TextSelector( + TextSelectorConfig(type=TextSelectorType.TEXT) + ), + vol.Optional(CONF_FORECAST_DAYS, default=current_days): NumberSelector( + NumberSelectorConfig( + min=MIN_FORECAST_DAYS, + max=MAX_FORECAST_DAYS, + step=1, + mode=NumberSelectorMode.BOX, + ) + ), + vol.Optional( + CONF_CREATE_FORECAST_SENSORS, default=current_mode + ): SelectSelector( + SelectSelectorConfig( + mode=SelectSelectorMode.DROPDOWN, + options=FORECAST_SENSORS_CHOICES, + ) + ), + } + ) + if user_input is not None: normalized_input: dict[str, Any] = {**self.entry.options, **user_input} try: normalized_input[CONF_UPDATE_INTERVAL] = int( float(normalized_input.get(CONF_UPDATE_INTERVAL, current_interval)) ) + except (TypeError, ValueError): + errors[CONF_UPDATE_INTERVAL] = "invalid_update_interval" + + if not errors and normalized_input[CONF_UPDATE_INTERVAL] < 1: + errors[CONF_UPDATE_INTERVAL] = "invalid_update_interval" + + if errors.get(CONF_UPDATE_INTERVAL): + return self.async_show_form( + step_id="init", + data_schema=options_schema, + errors=errors, + description_placeholders=placeholders, + ) + + try: normalized_input[CONF_FORECAST_DAYS] = int( float(normalized_input.get(CONF_FORECAST_DAYS, current_days)) ) @@ -569,9 +609,6 @@ async def async_step_init(self, user_input=None): errors["base"] = "invalid_option_combo" if not errors: - if normalized_input[CONF_UPDATE_INTERVAL] < 1: - errors[CONF_UPDATE_INTERVAL] = "invalid_update_interval" - try: # Language: allow empty; if provided, validate & normalize. raw_lang = normalized_input.get( @@ -619,41 +656,7 @@ async def async_step_init(self, user_input=None): return self.async_show_form( step_id="init", - data_schema=vol.Schema( - { - vol.Optional( - CONF_UPDATE_INTERVAL, default=current_interval - ): NumberSelector( - NumberSelectorConfig( - min=1, - step=1, - mode=NumberSelectorMode.BOX, - unit_of_measurement="h", - ) - ), - vol.Optional( - CONF_LANGUAGE_CODE, default=current_lang - ): TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT)), - vol.Optional( - CONF_FORECAST_DAYS, default=current_days - ): NumberSelector( - NumberSelectorConfig( - min=MIN_FORECAST_DAYS, - max=MAX_FORECAST_DAYS, - step=1, - mode=NumberSelectorMode.BOX, - ) - ), - vol.Optional( - CONF_CREATE_FORECAST_SENSORS, default=current_mode - ): SelectSelector( - SelectSelectorConfig( - mode=SelectSelectorMode.DROPDOWN, - options=FORECAST_SENSORS_CHOICES, - ) - ), - } - ), + data_schema=options_schema, errors=errors, description_placeholders=placeholders, ) diff --git a/custom_components/pollenlevels/const.py b/custom_components/pollenlevels/const.py index 0c3789d1..d4ff84bd 100644 --- a/custom_components/pollenlevels/const.py +++ b/custom_components/pollenlevels/const.py @@ -1,3 +1,5 @@ +from typing import Any + # Define constants for Pollen Levels integration DOMAIN = "pollenlevels" @@ -49,3 +51,19 @@ def is_invalid_api_key_message(message: str | None) -> bool: "api key is not valid", ) return any(signal in msg for signal in signals) + + +def normalize_http_referer(value: Any) -> str | None: + """Normalize HTTP referrer input and reject CR/LF.""" + + if value is None: + return None + + text = str(value).strip() + if not text: + return None + + if "\r" in text or "\n" in text: + raise ValueError("invalid http referer") + + return text diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 754f9a9b..38a770ea 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -673,6 +673,27 @@ def test_validate_input_update_interval_below_min_sets_error( assert not session.calls +def test_validate_input_update_interval_non_numeric_sets_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Non-numeric update intervals should surface a field error and skip I/O.""" + + session = _patch_client_session(monkeypatch, _StubResponse(200)) + + flow = PollenLevelsConfigFlow() + flow.hass = SimpleNamespace() + + user_input = {**_base_user_input(), CONF_UPDATE_INTERVAL: "abc"} + + errors, normalized = asyncio.run( + flow._async_validate_input(user_input, check_unique_id=False) + ) + + assert errors == {CONF_UPDATE_INTERVAL: "invalid_update_interval"} + assert normalized is None + assert not session.calls + + @pytest.mark.parametrize( ("status", "expected"), [ diff --git a/tests/test_options_flow.py b/tests/test_options_flow.py index 45682833..838b818d 100644 --- a/tests/test_options_flow.py +++ b/tests/test_options_flow.py @@ -147,3 +147,22 @@ def test_options_flow_update_interval_below_min_sets_error() -> None: ) assert result["errors"] == {CONF_UPDATE_INTERVAL: "invalid_update_interval"} + + +def test_options_flow_invalid_update_interval_short_circuits() -> None: + """Invalid update interval should short-circuit without extra errors.""" + + flow = _flow() + + result = asyncio.run( + flow.async_step_init( + { + CONF_LANGUAGE_CODE: "en", + CONF_FORECAST_DAYS: 0, + CONF_CREATE_FORECAST_SENSORS: "D+1+2", + CONF_UPDATE_INTERVAL: "not-a-number", + } + ) + ) + + assert result["errors"] == {CONF_UPDATE_INTERVAL: "invalid_update_interval"} From 524c6a9f7e41015531eb1102991a879b5d6adbd1 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Mon, 15 Dec 2025 08:25:19 +0100 Subject: [PATCH 038/200] Handle tolerant update interval parsing --- custom_components/pollenlevels/config_flow.py | 6 +++-- tests/test_config_flow.py | 24 +++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/custom_components/pollenlevels/config_flow.py b/custom_components/pollenlevels/config_flow.py index fad0d96c..3aba75be 100644 --- a/custom_components/pollenlevels/config_flow.py +++ b/custom_components/pollenlevels/config_flow.py @@ -225,13 +225,15 @@ async def _async_validate_input( normalized.pop(CONF_HTTP_REFERER, None) try: - normalized[CONF_UPDATE_INTERVAL] = int( - normalized.get(CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL) + parsed_update_interval = int( + float(normalized.get(CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL)) ) except (TypeError, ValueError): errors[CONF_UPDATE_INTERVAL] = "invalid_update_interval" return errors, None + normalized[CONF_UPDATE_INTERVAL] = parsed_update_interval + if normalized[CONF_UPDATE_INTERVAL] < 1: errors[CONF_UPDATE_INTERVAL] = "invalid_update_interval" return errors, None diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 38a770ea..7d30b2b4 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -673,6 +673,30 @@ def test_validate_input_update_interval_below_min_sets_error( assert not session.calls +def test_validate_input_update_interval_float_string( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Float-like strings should coerce to int and allow validation to proceed.""" + + session = _patch_client_session( + monkeypatch, _StubResponse(200, b'{"dailyInfo": [{"indexInfo": []}]}') + ) + + flow = PollenLevelsConfigFlow() + flow.hass = SimpleNamespace() + + user_input = {**_base_user_input(), CONF_UPDATE_INTERVAL: "1.0"} + + errors, normalized = asyncio.run( + flow._async_validate_input(user_input, check_unique_id=False) + ) + + assert errors == {} + assert normalized is not None + assert normalized[CONF_UPDATE_INTERVAL] == 1 + assert session.calls + + def test_validate_input_update_interval_non_numeric_sets_error( monkeypatch: pytest.MonkeyPatch, ) -> None: From 607a062ad197139cdfaa205d1d2ba126e25d05aa Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Mon, 15 Dec 2025 09:05:10 +0100 Subject: [PATCH 039/200] Localize new PR1 strings across locales --- .../pollenlevels/translations/ca.json | 14 +++++++------- .../pollenlevels/translations/cs.json | 14 +++++++------- .../pollenlevels/translations/da.json | 14 +++++++------- .../pollenlevels/translations/de.json | 14 +++++++------- .../pollenlevels/translations/es.json | 14 +++++++------- .../pollenlevels/translations/fi.json | 14 +++++++------- .../pollenlevels/translations/fr.json | 14 +++++++------- .../pollenlevels/translations/hu.json | 14 +++++++------- .../pollenlevels/translations/it.json | 16 ++++++++-------- 9 files changed, 64 insertions(+), 64 deletions(-) diff --git a/custom_components/pollenlevels/translations/ca.json b/custom_components/pollenlevels/translations/ca.json index 079563b3..3e3624d7 100644 --- a/custom_components/pollenlevels/translations/ca.json +++ b/custom_components/pollenlevels/translations/ca.json @@ -3,22 +3,22 @@ "step": { "user": { "title": "Configuració de Nivells de pol·len", - "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", + "description": "Introdueix la teva clau API de Google ([aconsegueix-la aquí]({api_key_url})) i revisa les bones pràctiques ([bones pràctiques]({restricting_api_keys_url})). Selecciona la ubicació al mapa, l’interval d’actualització (hores) i el codi d’idioma de la resposta de l’API.", "data": { "api_key": "Clau API", "name": "Nom", "location": "Ubicació", "update_interval": "Interval d’actualització (hores)", "language_code": "Codi d’idioma de la resposta de l’API", - "http_referer": "HTTP Referrer" + "http_referer": "Referer HTTP" }, "sections": { "api_key_options": { - "name": "Optional API key options" + "name": "Opcions opcionals de la clau API" } }, "data_description": { - "http_referer": "Only needed if your API key is restricted by HTTP referrers (websites)." + "http_referer": "Només cal si la teva clau API està restringida per referers HTTP (llocs web)." } }, "reauth_confirm": { @@ -39,8 +39,8 @@ "invalid_option_combo": "Augmenta els 'Dies de previsió' per cobrir els sensors per dia seleccionats.", "invalid_coordinates": "Selecciona una ubicació vàlida al mapa.", "unknown": "Error desconegut", - "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters.", - "invalid_update_interval": "Update interval must be at least 1 hour." + "invalid_http_referrer": "Valor de referer HTTP no vàlid. No pot contenir salts de línia.", + "invalid_update_interval": "L’interval d’actualització ha de ser d’almenys 1 hora." }, "abort": { "already_configured": "Aquesta ubicació ja està configurada.", @@ -70,7 +70,7 @@ "empty": "Aquest camp no pot estar buit", "invalid_option_combo": "Augmenta els 'Dies de previsió' per cobrir els sensors per dia seleccionats.", "unknown": "Error desconegut", - "invalid_update_interval": "Update interval must be at least 1 hour." + "invalid_update_interval": "L’interval d’actualització ha de ser d’almenys 1 hora." } }, "device": { diff --git a/custom_components/pollenlevels/translations/cs.json b/custom_components/pollenlevels/translations/cs.json index 97f3d620..46db9ebc 100644 --- a/custom_components/pollenlevels/translations/cs.json +++ b/custom_components/pollenlevels/translations/cs.json @@ -10,13 +10,13 @@ "empty": "Toto pole nemůže být prázdné", "invalid_auth": "Neplatný klíč API\n\n{error_message}", "invalid_coordinates": "Vyberte platné umístění na mapě.", - "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters.", + "invalid_http_referrer": "Neplatná hodnota HTTP refereru. Nesmí obsahovat znaky nového řádku.", "invalid_language": "Neplatný kód jazyka", "invalid_language_format": "Použijte kanonický kód BCP-47, například \"en\" nebo \"es-ES\".", "invalid_option_combo": "Zvyšte „Dny předpovědi“, aby pokryly vybrané senzory po dnech.", "quota_exceeded": "Překročena kvóta", "unknown": "Neznámá chyba", - "invalid_update_interval": "Update interval must be at least 1 hour." + "invalid_update_interval": "Interval aktualizace musí být alespoň 1 hodina." }, "step": { "reauth_confirm": { @@ -33,17 +33,17 @@ "location": "Poloha", "name": "Název", "update_interval": "Interval aktualizace (hodiny)", - "http_referer": "HTTP Referrer" + "http_referer": "HTTP referer" }, - "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", + "description": "Zadejte svůj Google API klíč ([získejte jej zde]({api_key_url})) a přečtěte si doporučené postupy ([doporučené postupy]({restricting_api_keys_url})). Vyberte polohu na mapě, interval aktualizace (v hodinách) a jazykový kód odpovědi API.", "title": "Konfigurace úrovní pylu", "sections": { "api_key_options": { - "name": "Optional API key options" + "name": "Volitelné možnosti API klíče" } }, "data_description": { - "http_referer": "Only needed if your API key is restricted by HTTP referrers (websites)." + "http_referer": "Potřebné pouze pokud je váš API klíč omezen podle HTTP refererů (webových stránek)." } } } @@ -82,7 +82,7 @@ "invalid_option_combo": "Zvyšte „Dny předpovědi“, aby pokryly vybrané senzory po dnech.", "quota_exceeded": "Překročena kvóta", "unknown": "Neznámá chyba", - "invalid_update_interval": "Update interval must be at least 1 hour." + "invalid_update_interval": "Interval aktualizace musí být alespoň 1 hodina." }, "step": { "init": { diff --git a/custom_components/pollenlevels/translations/da.json b/custom_components/pollenlevels/translations/da.json index bd4f8249..c1b0799b 100644 --- a/custom_components/pollenlevels/translations/da.json +++ b/custom_components/pollenlevels/translations/da.json @@ -10,13 +10,13 @@ "empty": "Dette felt må ikke være tomt", "invalid_auth": "Ugyldig API-nøgle\n\n{error_message}", "invalid_coordinates": "Vælg en gyldig placering på kortet.", - "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters.", + "invalid_http_referrer": "Ugyldig HTTP referer-værdi. Den må ikke indeholde linjeskift.", "invalid_language": "Ugyldig sprogkode", "invalid_language_format": "Brug en kanonisk BCP-47-kode som \"en\" eller \"es-ES\".", "invalid_option_combo": "Forøg \"Prognosedage\" for at dække valgte sensorer pr. dag.", "quota_exceeded": "Kvote overskredet", "unknown": "Ukendt fejl", - "invalid_update_interval": "Update interval must be at least 1 hour." + "invalid_update_interval": "Opdateringsintervallet skal være mindst 1 time." }, "step": { "reauth_confirm": { @@ -33,17 +33,17 @@ "location": "Placering", "name": "Navn", "update_interval": "Opdateringsinterval (timer)", - "http_referer": "HTTP Referrer" + "http_referer": "HTTP Referer" }, - "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", + "description": "Indtast din Google API-nøgle ([hent den her]({api_key_url})) og læs bedste praksis ([bedste praksis]({restricting_api_keys_url})). Vælg din placering på kortet, opdateringsinterval (timer) og sprogkode for API-svaret.", "title": "Konfiguration af pollenniveauer", "sections": { "api_key_options": { - "name": "Optional API key options" + "name": "Valgfrie API-nøgleindstillinger" } }, "data_description": { - "http_referer": "Only needed if your API key is restricted by HTTP referrers (websites)." + "http_referer": "Kun nødvendig hvis din API-nøgle er begrænset via HTTP referers (websteder)." } } } @@ -82,7 +82,7 @@ "invalid_option_combo": "Forøg \"Prognosedage\" for at dække valgte sensorer pr. dag.", "quota_exceeded": "Kvote overskredet", "unknown": "Ukendt fejl", - "invalid_update_interval": "Update interval must be at least 1 hour." + "invalid_update_interval": "Opdateringsintervallet skal være mindst 1 time." }, "step": { "init": { diff --git a/custom_components/pollenlevels/translations/de.json b/custom_components/pollenlevels/translations/de.json index 2ea2de2b..5ff6a113 100644 --- a/custom_components/pollenlevels/translations/de.json +++ b/custom_components/pollenlevels/translations/de.json @@ -10,13 +10,13 @@ "empty": "Dieses Feld darf nicht leer sein", "invalid_auth": "Ungültiger API-Schlüssel\n\n{error_message}", "invalid_coordinates": "Wähle einen gültigen Standort auf der Karte aus.", - "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters.", + "invalid_http_referrer": "Ungültiger HTTP-Referer-Wert. Er darf keine Zeilenumbrüche enthalten.", "invalid_language": "Ungültiger Sprachcode", "invalid_language_format": "Verwenden Sie einen kanonischen BCP-47-Code wie \"en\" oder \"es-ES\".", "invalid_option_combo": "Erhöhe 'Vorhersagetage', um die gewählten Tagessensoren abzudecken.", "quota_exceeded": "Kontingent überschritten", "unknown": "Unbekannter Fehler", - "invalid_update_interval": "Update interval must be at least 1 hour." + "invalid_update_interval": "Das Aktualisierungsintervall muss mindestens 1 Stunde betragen." }, "step": { "reauth_confirm": { @@ -33,17 +33,17 @@ "location": "Standort", "name": "Name", "update_interval": "Aktualisierungsintervall (Stunden)", - "http_referer": "HTTP Referrer" + "http_referer": "HTTP-Referer" }, - "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", + "description": "Gib deinen Google API-Schlüssel ein ([hier abrufen]({api_key_url})) und lies die Best Practices ([Best Practices]({restricting_api_keys_url})). Wähle deinen Standort auf der Karte, das Aktualisierungsintervall (Stunden) und den Sprachcode der API-Antwort.", "title": "Pollen Levels – Konfiguration", "sections": { "api_key_options": { - "name": "Optional API key options" + "name": "Optionale API-Schlüssel-Optionen" } }, "data_description": { - "http_referer": "Only needed if your API key is restricted by HTTP referrers (websites)." + "http_referer": "Nur erforderlich, wenn dein API-Schlüssel durch HTTP-Referer (Websites) eingeschränkt ist." } } } @@ -82,7 +82,7 @@ "invalid_option_combo": "Erhöhe 'Vorhersagetage', um die gewählten Tagessensoren abzudecken.", "quota_exceeded": "Kontingent überschritten", "unknown": "Unbekannter Fehler", - "invalid_update_interval": "Update interval must be at least 1 hour." + "invalid_update_interval": "Das Aktualisierungsintervall muss mindestens 1 Stunde betragen." }, "step": { "init": { diff --git a/custom_components/pollenlevels/translations/es.json b/custom_components/pollenlevels/translations/es.json index 59eb4387..b0f3dacd 100644 --- a/custom_components/pollenlevels/translations/es.json +++ b/custom_components/pollenlevels/translations/es.json @@ -3,22 +3,22 @@ "step": { "user": { "title": "Configuración de Niveles de Polen", - "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", + "description": "Introduce tu clave API de Google ([consíguela aquí]({api_key_url})) y revisa las buenas prácticas ([buenas prácticas]({restricting_api_keys_url})). Selecciona tu ubicación en el mapa, el intervalo de actualización (horas) y el código de idioma de la respuesta de la API.", "data": { "api_key": "Clave API", "name": "Nombre", "location": "Ubicación", "update_interval": "Intervalo de actualización (horas)", "language_code": "Código de idioma de la respuesta de la API", - "http_referer": "HTTP Referrer" + "http_referer": "Referer HTTP" }, "sections": { "api_key_options": { - "name": "Optional API key options" + "name": "Opciones opcionales de la clave API" } }, "data_description": { - "http_referer": "Only needed if your API key is restricted by HTTP referrers (websites)." + "http_referer": "Solo es necesario si tu clave API está restringida por referers HTTP (sitios web)." } }, "reauth_confirm": { @@ -39,8 +39,8 @@ "invalid_option_combo": "Aumenta 'Días de previsión' para cubrir los sensores por día seleccionados.", "invalid_coordinates": "Selecciona una ubicación válida en el mapa.", "unknown": "Error desconocido", - "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters.", - "invalid_update_interval": "Update interval must be at least 1 hour." + "invalid_http_referrer": "Valor de referer HTTP no válido. No debe contener saltos de línea.", + "invalid_update_interval": "El intervalo de actualización debe ser de al menos 1 hora." }, "abort": { "already_configured": "Esta ubicación ya está configurada.", @@ -70,7 +70,7 @@ "empty": "Este campo no puede estar vacío", "invalid_option_combo": "Aumenta 'Días de previsión' para cubrir los sensores por día seleccionados.", "unknown": "Error desconocido", - "invalid_update_interval": "Update interval must be at least 1 hour." + "invalid_update_interval": "El intervalo de actualización debe ser de al menos 1 hora." } }, "device": { diff --git a/custom_components/pollenlevels/translations/fi.json b/custom_components/pollenlevels/translations/fi.json index 0628e302..741cf478 100644 --- a/custom_components/pollenlevels/translations/fi.json +++ b/custom_components/pollenlevels/translations/fi.json @@ -10,13 +10,13 @@ "empty": "Tämä kenttä ei voi olla tyhjä", "invalid_auth": "Virheellinen API-avain\n\n{error_message}", "invalid_coordinates": "Valitse kartalta kelvollinen sijainti.", - "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters.", + "invalid_http_referrer": "Virheellinen HTTP referer -arvo. Se ei saa sisältää rivinvaihtomerkkejä.", "invalid_language": "Virheellinen kielikoodi", "invalid_language_format": "Käytä kanonista BCP-47-koodia, esimerkiksi \"en\" tai \"es-ES\".", "invalid_option_combo": "Lisää \"Ennustepäiviä\", jotta valitut päiväsensorit katetaan.", "quota_exceeded": "Kiintiö ylitetty", "unknown": "Tuntematon virhe", - "invalid_update_interval": "Update interval must be at least 1 hour." + "invalid_update_interval": "Päivitysvälin on oltava vähintään 1 tunti." }, "step": { "reauth_confirm": { @@ -33,17 +33,17 @@ "location": "Sijainti", "name": "Nimi", "update_interval": "Päivitysväli (tunnit)", - "http_referer": "HTTP Referrer" + "http_referer": "HTTP referer" }, - "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", + "description": "Syötä Google API -avaimesi ([hanki se täältä]({api_key_url})) ja tutustu parhaisiin käytäntöihin ([parhaat käytännöt]({restricting_api_keys_url})). Valitse sijainti kartalta, päivitysväli (tunteina) ja API-vastauksen kielikoodi.", "title": "Siitepölytason asetukset", "sections": { "api_key_options": { - "name": "Optional API key options" + "name": "Valinnaiset API-avaimen asetukset" } }, "data_description": { - "http_referer": "Only needed if your API key is restricted by HTTP referrers (websites)." + "http_referer": "Tarvitaan vain, jos API-avaimesi on rajoitettu HTTP refererien (verkkosivustojen) perusteella." } } } @@ -82,7 +82,7 @@ "invalid_option_combo": "Lisää \"Ennustepäiviä\", jotta valitut päiväsensorit katetaan.", "quota_exceeded": "Kiintiö ylitetty", "unknown": "Tuntematon virhe", - "invalid_update_interval": "Update interval must be at least 1 hour." + "invalid_update_interval": "Päivitysvälin on oltava vähintään 1 tunti." }, "step": { "init": { diff --git a/custom_components/pollenlevels/translations/fr.json b/custom_components/pollenlevels/translations/fr.json index a98fcad8..a8e3beab 100644 --- a/custom_components/pollenlevels/translations/fr.json +++ b/custom_components/pollenlevels/translations/fr.json @@ -10,13 +10,13 @@ "empty": "Ce champ ne peut pas être vide", "invalid_auth": "Clé API invalide\n\n{error_message}", "invalid_coordinates": "Sélectionnez un emplacement valide sur la carte.", - "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters.", + "invalid_http_referrer": "Valeur de HTTP referer invalide. Elle ne doit pas contenir de retours à la ligne.", "invalid_language": "Code de langue invalide", "invalid_language_format": "Utilisez un code BCP-47 canonique tel que \"en\" ou \"es-ES\".", "invalid_option_combo": "Augmentez le nombre de « Jours de prévision » afin de couvrir les capteurs par jour sélectionnés.", "quota_exceeded": "Quota dépassé", "unknown": "Erreur inconnue", - "invalid_update_interval": "Update interval must be at least 1 hour." + "invalid_update_interval": "L’intervalle de mise à jour doit être d’au moins 1 heure." }, "step": { "reauth_confirm": { @@ -33,17 +33,17 @@ "location": "Emplacement", "name": "Nom", "update_interval": "Intervalle de mise à jour (heures)", - "http_referer": "HTTP Referrer" + "http_referer": "HTTP referer" }, - "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", + "description": "Saisissez votre clé API Google ([l’obtenir ici]({api_key_url})) et consultez les bonnes pratiques ([bonnes pratiques]({restricting_api_keys_url})). Sélectionnez votre emplacement sur la carte, l’intervalle de mise à jour (heures) et le code de langue de la réponse de l’API.", "title": "Pollen Levels – Configuration", "sections": { "api_key_options": { - "name": "Optional API key options" + "name": "Options facultatives de la clé API" } }, "data_description": { - "http_referer": "Only needed if your API key is restricted by HTTP referrers (websites)." + "http_referer": "Nécessaire uniquement si votre clé API est limitée par des HTTP referers (sites web)." } } } @@ -82,7 +82,7 @@ "invalid_option_combo": "Augmentez le nombre de « Jours de prévision » afin de couvrir les capteurs par jour sélectionnés.", "quota_exceeded": "Quota dépassé", "unknown": "Erreur inconnue", - "invalid_update_interval": "Update interval must be at least 1 hour." + "invalid_update_interval": "L’intervalle de mise à jour doit être d’au moins 1 heure." }, "step": { "init": { diff --git a/custom_components/pollenlevels/translations/hu.json b/custom_components/pollenlevels/translations/hu.json index 8cc36a01..e7be285c 100644 --- a/custom_components/pollenlevels/translations/hu.json +++ b/custom_components/pollenlevels/translations/hu.json @@ -10,13 +10,13 @@ "empty": "A mező nem lehet üres", "invalid_auth": "Érvénytelen API-kulcs\n\n{error_message}", "invalid_coordinates": "Válassz érvényes helyet a térképen.", - "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters.", + "invalid_http_referrer": "Érvénytelen HTTP referer érték. Nem tartalmazhat sortörés karaktereket.", "invalid_language": "Érvénytelen nyelvi kód", "invalid_language_format": "Használjon kanonikus BCP-47 kódot, például \"en\" vagy \"es-ES\".", "invalid_option_combo": "Növeld a \"Előrejelzési napok\" értéket a kiválasztott napi szenzorok lefedéséhez.", "quota_exceeded": "Kvóta túllépve", "unknown": "Ismeretlen hiba", - "invalid_update_interval": "Update interval must be at least 1 hour." + "invalid_update_interval": "A frissítési időköznek legalább 1 órának kell lennie." }, "step": { "reauth_confirm": { @@ -33,17 +33,17 @@ "location": "Helyszín", "name": "Név", "update_interval": "Frissítési időköz (óra)", - "http_referer": "HTTP Referrer" + "http_referer": "HTTP referer" }, - "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", + "description": "Add meg a Google API-kulcsodat ([itt szerezhető be]({api_key_url})) és nézd át a bevált gyakorlatokat ([bevált gyakorlatok]({restricting_api_keys_url})). Válaszd ki a helyszínt a térképen, a frissítési időközt (órában) és az API-válasz nyelvi kódját.", "title": "Pollen szintek – beállítás", "sections": { "api_key_options": { - "name": "Optional API key options" + "name": "Opcionális API-kulcs beállítások" } }, "data_description": { - "http_referer": "Only needed if your API key is restricted by HTTP referrers (websites)." + "http_referer": "Csak akkor szükséges, ha az API-kulcsod HTTP refererek (weboldalak) alapján van korlátozva." } } } @@ -82,7 +82,7 @@ "invalid_option_combo": "Növeld a \"Előrejelzési napok\" értéket a kiválasztott napi szenzorok lefedéséhez.", "quota_exceeded": "Kvóta túllépve", "unknown": "Ismeretlen hiba", - "invalid_update_interval": "Update interval must be at least 1 hour." + "invalid_update_interval": "A frissítési időköznek legalább 1 órának kell lennie." }, "step": { "init": { diff --git a/custom_components/pollenlevels/translations/it.json b/custom_components/pollenlevels/translations/it.json index 6e184f0f..948cb2cd 100644 --- a/custom_components/pollenlevels/translations/it.json +++ b/custom_components/pollenlevels/translations/it.json @@ -10,13 +10,13 @@ "empty": "Questo campo non può essere vuoto", "invalid_auth": "Chiave API non valida\n\n{error_message}", "invalid_coordinates": "Seleziona una posizione valida sulla mappa.", - "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters.", + "invalid_http_referrer": "Valore di referer HTTP non valido. Non deve contenere caratteri di nuova riga.", "invalid_language": "Codice lingua non valido", "invalid_language_format": "Usa un codice BCP-47 canonico come \"en\" o \"es-ES\".", "invalid_option_combo": "Aumenta 'Giorni di previsione' per coprire i sensori giornalieri selezionati.", "quota_exceeded": "Quota superata", "unknown": "Errore sconosciuto", - "invalid_update_interval": "Update interval must be at least 1 hour." + "invalid_update_interval": "L’intervallo di aggiornamento deve essere di almeno 1 ora." }, "step": { "reauth_confirm": { @@ -33,17 +33,17 @@ "location": "Posizione", "name": "Nome", "update_interval": "Intervallo di aggiornamento (ore)", - "http_referer": "HTTP Referrer" + "http_referer": "HTTP referer" }, - "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", + "description": "Inserisci la tua chiave API di Google ([ottienila qui]({api_key_url})) e consulta le best practice ([best practice]({restricting_api_keys_url})). Seleziona la posizione sulla mappa, l’intervallo di aggiornamento (ore) e il codice lingua della risposta dell’API.", "title": "Configurazione Livelli di polline", "sections": { "api_key_options": { - "name": "Optional API key options" + "name": "Opzioni facoltative della chiave API" } }, "data_description": { - "http_referer": "Only needed if your API key is restricted by HTTP referrers (websites)." + "http_referer": "Necessario solo se la tua chiave API è limitata dai referer HTTP (siti web)." } } } @@ -82,7 +82,7 @@ "invalid_option_combo": "Aumenta 'Giorni di previsione' per coprire i sensori giornalieri selezionati.", "quota_exceeded": "Quota superata", "unknown": "Errore sconosciuto", - "invalid_update_interval": "Update interval must be at least 1 hour." + "invalid_update_interval": "L’intervallo di aggiornamento deve essere di almeno 1 ora." }, "step": { "init": { @@ -103,4 +103,4 @@ "name": "Forza aggiornamento" } } -} +} \ No newline at end of file From e25c0f5865a3ad690881cc46a8816246efd287c9 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Mon, 15 Dec 2025 09:51:19 +0100 Subject: [PATCH 040/200] Refine HTTP message formatting and referer sanitization --- custom_components/pollenlevels/__init__.py | 12 +++++++- custom_components/pollenlevels/client.py | 32 ++++++++++------------ 2 files changed, 25 insertions(+), 19 deletions(-) diff --git a/custom_components/pollenlevels/__init__.py b/custom_components/pollenlevels/__init__.py index f2f94cdc..3f494f86 100644 --- a/custom_components/pollenlevels/__init__.py +++ b/custom_components/pollenlevels/__init__.py @@ -115,7 +115,17 @@ async def async_setup_entry( if not api_key: raise ConfigEntryAuthFailed("Missing API key") - http_referer = entry.data.get(CONF_HTTP_REFERER) + raw_http_referer = entry.data.get(CONF_HTTP_REFERER) + http_referer: str | None = None + if isinstance(raw_http_referer, str): + candidate = raw_http_referer.strip() + if candidate and "\r" not in candidate and "\n" not in candidate: + http_referer = candidate + elif candidate and ("\r" in candidate or "\n" in candidate): + _LOGGER.warning( + "Ignoring http_referer for entry %s because it contains newline characters", + entry.entry_id, + ) raw_title = entry.title or "" clean_title = raw_title.strip() or DEFAULT_ENTRY_TITLE diff --git a/custom_components/pollenlevels/client.py b/custom_components/pollenlevels/client.py index 4ba87c48..4a177d9d 100644 --- a/custom_components/pollenlevels/client.py +++ b/custom_components/pollenlevels/client.py @@ -16,6 +16,14 @@ _LOGGER = logging.getLogger(__name__) +def _format_http_message(status: int, raw_message: str | None) -> str: + """Format an HTTP status and optional message consistently.""" + + if raw_message: + return f"HTTP {status}: {raw_message}" + return f"HTTP {status}" + + class GooglePollenApiClient: """Thin async client wrapper for the Google Pollen API.""" @@ -99,16 +107,12 @@ async def async_fetch_pollen_data( ) as resp: if resp.status == 401: raw_message = await extract_error_message(resp) - message = raw_message or f"HTTP {resp.status}" - if raw_message: - message = f"HTTP {resp.status}: {raw_message}" + message = _format_http_message(resp.status, raw_message) raise ConfigEntryAuthFailed(message) if resp.status == 403: raw_message = await extract_error_message(resp) - message = raw_message or f"HTTP {resp.status}" - if raw_message: - message = f"HTTP {resp.status}: {raw_message}" + message = _format_http_message(resp.status, raw_message) if is_invalid_api_key_message(raw_message): raise ConfigEntryAuthFailed(message or "Invalid API key") raise UpdateFailed(message) @@ -129,9 +133,7 @@ async def async_fetch_pollen_data( await asyncio.sleep(delay) continue raw_message = await extract_error_message(resp) - message = raw_message or f"HTTP {resp.status}" - if raw_message: - message = f"HTTP {resp.status}: {raw_message}" + message = _format_http_message(resp.status, raw_message) raise UpdateFailed(message) if 500 <= resp.status <= 599: @@ -147,23 +149,17 @@ async def async_fetch_pollen_data( ) continue raw_message = await extract_error_message(resp) - message = raw_message or f"HTTP {resp.status}" - if raw_message: - message = f"HTTP {resp.status}: {raw_message}" + message = _format_http_message(resp.status, raw_message) raise UpdateFailed(message) if 400 <= resp.status < 500 and resp.status not in (403, 429): raw_message = await extract_error_message(resp) - message = raw_message or f"HTTP {resp.status}" - if raw_message: - message = f"HTTP {resp.status}: {raw_message}" + message = _format_http_message(resp.status, raw_message) raise UpdateFailed(message) if resp.status != 200: raw_message = await extract_error_message(resp) - message = raw_message or f"HTTP {resp.status}" - if raw_message: - message = f"HTTP {resp.status}: {raw_message}" + message = _format_http_message(resp.status, raw_message) raise UpdateFailed(message) return await resp.json() From 7843161e81908a9dec69c26c653a8bd1278b8906 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Mon, 15 Dec 2025 10:37:14 +0100 Subject: [PATCH 041/200] Polish translation placeholders and referer labels --- custom_components/pollenlevels/translations/ca.json | 4 ++-- custom_components/pollenlevels/translations/cs.json | 4 ++-- custom_components/pollenlevels/translations/da.json | 4 ++-- custom_components/pollenlevels/translations/de.json | 4 ++-- custom_components/pollenlevels/translations/en.json | 8 ++++---- custom_components/pollenlevels/translations/es.json | 4 ++-- custom_components/pollenlevels/translations/fi.json | 4 ++-- custom_components/pollenlevels/translations/fr.json | 4 ++-- custom_components/pollenlevels/translations/hu.json | 4 ++-- custom_components/pollenlevels/translations/it.json | 6 +++--- custom_components/pollenlevels/translations/nb.json | 8 ++++---- custom_components/pollenlevels/translations/nl.json | 8 ++++---- custom_components/pollenlevels/translations/pl.json | 8 ++++---- custom_components/pollenlevels/translations/pt-BR.json | 8 ++++---- custom_components/pollenlevels/translations/pt-PT.json | 8 ++++---- custom_components/pollenlevels/translations/ro.json | 8 ++++---- custom_components/pollenlevels/translations/ru.json | 8 ++++---- custom_components/pollenlevels/translations/sv.json | 8 ++++---- custom_components/pollenlevels/translations/uk.json | 8 ++++---- custom_components/pollenlevels/translations/zh-Hans.json | 8 ++++---- custom_components/pollenlevels/translations/zh-Hant.json | 8 ++++---- 21 files changed, 67 insertions(+), 67 deletions(-) diff --git a/custom_components/pollenlevels/translations/ca.json b/custom_components/pollenlevels/translations/ca.json index 3e3624d7..1e7a3503 100644 --- a/custom_components/pollenlevels/translations/ca.json +++ b/custom_components/pollenlevels/translations/ca.json @@ -32,7 +32,7 @@ "error": { "invalid_auth": "Clau API no vàlida\n\n{error_message}", "cannot_connect": "No es pot connectar al servei de pol·len.\n\n{error_message}", - "quota_exceeded": "Quota excedida", + "quota_exceeded": "Quota excedida\n\n{error_message}", "invalid_language": "Codi d’idioma no vàlid", "invalid_language_format": "Utilitza un codi BCP-47 canònic com \"en\" o \"es-ES\".", "empty": "Aquest camp no pot estar buit", @@ -64,7 +64,7 @@ "error": { "invalid_auth": "Clau API no vàlida\n\n{error_message}", "cannot_connect": "No es pot connectar al servei de pol·len.\n\n{error_message}", - "quota_exceeded": "Quota excedida", + "quota_exceeded": "Quota excedida\n\n{error_message}", "invalid_language": "Codi d’idioma no vàlid", "invalid_language_format": "Utilitza un codi BCP-47 canònic com \"en\" o \"es-ES\".", "empty": "Aquest camp no pot estar buit", diff --git a/custom_components/pollenlevels/translations/cs.json b/custom_components/pollenlevels/translations/cs.json index 46db9ebc..5c8ca1ae 100644 --- a/custom_components/pollenlevels/translations/cs.json +++ b/custom_components/pollenlevels/translations/cs.json @@ -14,7 +14,7 @@ "invalid_language": "Neplatný kód jazyka", "invalid_language_format": "Použijte kanonický kód BCP-47, například \"en\" nebo \"es-ES\".", "invalid_option_combo": "Zvyšte „Dny předpovědi“, aby pokryly vybrané senzory po dnech.", - "quota_exceeded": "Překročena kvóta", + "quota_exceeded": "Překročena kvóta\n\n{error_message}", "unknown": "Neznámá chyba", "invalid_update_interval": "Interval aktualizace musí být alespoň 1 hodina." }, @@ -80,7 +80,7 @@ "invalid_language": "Neplatný kód jazyka", "invalid_language_format": "Použijte kanonický kód BCP-47, například \"en\" nebo \"es-ES\".", "invalid_option_combo": "Zvyšte „Dny předpovědi“, aby pokryly vybrané senzory po dnech.", - "quota_exceeded": "Překročena kvóta", + "quota_exceeded": "Překročena kvóta\n\n{error_message}", "unknown": "Neznámá chyba", "invalid_update_interval": "Interval aktualizace musí být alespoň 1 hodina." }, diff --git a/custom_components/pollenlevels/translations/da.json b/custom_components/pollenlevels/translations/da.json index c1b0799b..dc14d3aa 100644 --- a/custom_components/pollenlevels/translations/da.json +++ b/custom_components/pollenlevels/translations/da.json @@ -14,7 +14,7 @@ "invalid_language": "Ugyldig sprogkode", "invalid_language_format": "Brug en kanonisk BCP-47-kode som \"en\" eller \"es-ES\".", "invalid_option_combo": "Forøg \"Prognosedage\" for at dække valgte sensorer pr. dag.", - "quota_exceeded": "Kvote overskredet", + "quota_exceeded": "Kvote overskredet\n\n{error_message}", "unknown": "Ukendt fejl", "invalid_update_interval": "Opdateringsintervallet skal være mindst 1 time." }, @@ -80,7 +80,7 @@ "invalid_language": "Ugyldig sprogkode", "invalid_language_format": "Brug en kanonisk BCP-47-kode som \"en\" eller \"es-ES\".", "invalid_option_combo": "Forøg \"Prognosedage\" for at dække valgte sensorer pr. dag.", - "quota_exceeded": "Kvote overskredet", + "quota_exceeded": "Kvote overskredet\n\n{error_message}", "unknown": "Ukendt fejl", "invalid_update_interval": "Opdateringsintervallet skal være mindst 1 time." }, diff --git a/custom_components/pollenlevels/translations/de.json b/custom_components/pollenlevels/translations/de.json index 5ff6a113..a3975ee4 100644 --- a/custom_components/pollenlevels/translations/de.json +++ b/custom_components/pollenlevels/translations/de.json @@ -14,7 +14,7 @@ "invalid_language": "Ungültiger Sprachcode", "invalid_language_format": "Verwenden Sie einen kanonischen BCP-47-Code wie \"en\" oder \"es-ES\".", "invalid_option_combo": "Erhöhe 'Vorhersagetage', um die gewählten Tagessensoren abzudecken.", - "quota_exceeded": "Kontingent überschritten", + "quota_exceeded": "Kontingent überschritten\n\n{error_message}", "unknown": "Unbekannter Fehler", "invalid_update_interval": "Das Aktualisierungsintervall muss mindestens 1 Stunde betragen." }, @@ -80,7 +80,7 @@ "invalid_language": "Ungültiger Sprachcode", "invalid_language_format": "Verwenden Sie einen kanonischen BCP-47-Code wie \"en\" oder \"es-ES\".", "invalid_option_combo": "Erhöhe 'Vorhersagetage', um die gewählten Tagessensoren abzudecken.", - "quota_exceeded": "Kontingent überschritten", + "quota_exceeded": "Kontingent überschritten\n\n{error_message}", "unknown": "Unbekannter Fehler", "invalid_update_interval": "Das Aktualisierungsintervall muss mindestens 1 Stunde betragen." }, diff --git a/custom_components/pollenlevels/translations/en.json b/custom_components/pollenlevels/translations/en.json index 0b980ab2..b6afe39d 100644 --- a/custom_components/pollenlevels/translations/en.json +++ b/custom_components/pollenlevels/translations/en.json @@ -10,7 +10,7 @@ "location": "Location", "update_interval": "Update interval (hours)", "language_code": "API response language code", - "http_referer": "HTTP Referrer" + "http_referer": "HTTP Referer" }, "sections": { "api_key_options": { @@ -18,7 +18,7 @@ } }, "data_description": { - "http_referer": "Only needed if your API key is restricted by HTTP referrers (websites)." + "http_referer": "Only needed if your API key is restricted by HTTP Referers (websites)." } }, "reauth_confirm": { @@ -32,7 +32,7 @@ "error": { "invalid_auth": "Invalid API key\n\n{error_message}", "cannot_connect": "Unable to connect to the pollen service.\n\n{error_message}", - "quota_exceeded": "Quota exceeded", + "quota_exceeded": "Quota exceeded\n\n{error_message}", "invalid_language": "Invalid language code", "invalid_language_format": "Use a canonical BCP-47 code such as \"en\" or \"es-ES\".", "empty": "This field cannot be empty", @@ -64,7 +64,7 @@ "error": { "invalid_auth": "Invalid API key\n\n{error_message}", "cannot_connect": "Unable to connect to the pollen service.\n\n{error_message}", - "quota_exceeded": "Quota exceeded", + "quota_exceeded": "Quota exceeded\n\n{error_message}", "invalid_language": "Invalid language code", "invalid_language_format": "Use a canonical BCP-47 code such as \"en\" or \"es-ES\".", "empty": "This field cannot be empty", diff --git a/custom_components/pollenlevels/translations/es.json b/custom_components/pollenlevels/translations/es.json index b0f3dacd..c7c94331 100644 --- a/custom_components/pollenlevels/translations/es.json +++ b/custom_components/pollenlevels/translations/es.json @@ -32,7 +32,7 @@ "error": { "invalid_auth": "Clave API inválida\n\n{error_message}", "cannot_connect": "No se puede conectar al servicio de polen.\n\n{error_message}", - "quota_exceeded": "Cuota excedida", + "quota_exceeded": "Cuota excedida\n\n{error_message}", "invalid_language": "Código de idioma no válido", "invalid_language_format": "Usa un código BCP-47 canónico como \"en\" o \"es-ES\".", "empty": "Este campo no puede estar vacío", @@ -64,7 +64,7 @@ "error": { "invalid_auth": "Clave API inválida\n\n{error_message}", "cannot_connect": "No se puede conectar al servicio de polen.\n\n{error_message}", - "quota_exceeded": "Cuota excedida", + "quota_exceeded": "Cuota excedida\n\n{error_message}", "invalid_language": "Código de idioma no válido", "invalid_language_format": "Usa un código BCP-47 canónico como \"en\" o \"es-ES\".", "empty": "Este campo no puede estar vacío", diff --git a/custom_components/pollenlevels/translations/fi.json b/custom_components/pollenlevels/translations/fi.json index 741cf478..c2e9c36e 100644 --- a/custom_components/pollenlevels/translations/fi.json +++ b/custom_components/pollenlevels/translations/fi.json @@ -14,7 +14,7 @@ "invalid_language": "Virheellinen kielikoodi", "invalid_language_format": "Käytä kanonista BCP-47-koodia, esimerkiksi \"en\" tai \"es-ES\".", "invalid_option_combo": "Lisää \"Ennustepäiviä\", jotta valitut päiväsensorit katetaan.", - "quota_exceeded": "Kiintiö ylitetty", + "quota_exceeded": "Kiintiö ylitetty\n\n{error_message}", "unknown": "Tuntematon virhe", "invalid_update_interval": "Päivitysvälin on oltava vähintään 1 tunti." }, @@ -80,7 +80,7 @@ "invalid_language": "Virheellinen kielikoodi", "invalid_language_format": "Käytä kanonista BCP-47-koodia, esimerkiksi \"en\" tai \"es-ES\".", "invalid_option_combo": "Lisää \"Ennustepäiviä\", jotta valitut päiväsensorit katetaan.", - "quota_exceeded": "Kiintiö ylitetty", + "quota_exceeded": "Kiintiö ylitetty\n\n{error_message}", "unknown": "Tuntematon virhe", "invalid_update_interval": "Päivitysvälin on oltava vähintään 1 tunti." }, diff --git a/custom_components/pollenlevels/translations/fr.json b/custom_components/pollenlevels/translations/fr.json index a8e3beab..08047ba7 100644 --- a/custom_components/pollenlevels/translations/fr.json +++ b/custom_components/pollenlevels/translations/fr.json @@ -14,7 +14,7 @@ "invalid_language": "Code de langue invalide", "invalid_language_format": "Utilisez un code BCP-47 canonique tel que \"en\" ou \"es-ES\".", "invalid_option_combo": "Augmentez le nombre de « Jours de prévision » afin de couvrir les capteurs par jour sélectionnés.", - "quota_exceeded": "Quota dépassé", + "quota_exceeded": "Quota dépassé\n\n{error_message}", "unknown": "Erreur inconnue", "invalid_update_interval": "L’intervalle de mise à jour doit être d’au moins 1 heure." }, @@ -80,7 +80,7 @@ "invalid_language": "Code de langue invalide", "invalid_language_format": "Utilisez un code BCP-47 canonique tel que \"en\" ou \"es-ES\".", "invalid_option_combo": "Augmentez le nombre de « Jours de prévision » afin de couvrir les capteurs par jour sélectionnés.", - "quota_exceeded": "Quota dépassé", + "quota_exceeded": "Quota dépassé\n\n{error_message}", "unknown": "Erreur inconnue", "invalid_update_interval": "L’intervalle de mise à jour doit être d’au moins 1 heure." }, diff --git a/custom_components/pollenlevels/translations/hu.json b/custom_components/pollenlevels/translations/hu.json index e7be285c..72775844 100644 --- a/custom_components/pollenlevels/translations/hu.json +++ b/custom_components/pollenlevels/translations/hu.json @@ -14,7 +14,7 @@ "invalid_language": "Érvénytelen nyelvi kód", "invalid_language_format": "Használjon kanonikus BCP-47 kódot, például \"en\" vagy \"es-ES\".", "invalid_option_combo": "Növeld a \"Előrejelzési napok\" értéket a kiválasztott napi szenzorok lefedéséhez.", - "quota_exceeded": "Kvóta túllépve", + "quota_exceeded": "Kvóta túllépve\n\n{error_message}", "unknown": "Ismeretlen hiba", "invalid_update_interval": "A frissítési időköznek legalább 1 órának kell lennie." }, @@ -80,7 +80,7 @@ "invalid_language": "Érvénytelen nyelvi kód", "invalid_language_format": "Használjon kanonikus BCP-47 kódot, például \"en\" vagy \"es-ES\".", "invalid_option_combo": "Növeld a \"Előrejelzési napok\" értéket a kiválasztott napi szenzorok lefedéséhez.", - "quota_exceeded": "Kvóta túllépve", + "quota_exceeded": "Kvóta túllépve\n\n{error_message}", "unknown": "Ismeretlen hiba", "invalid_update_interval": "A frissítési időköznek legalább 1 órának kell lennie." }, diff --git a/custom_components/pollenlevels/translations/it.json b/custom_components/pollenlevels/translations/it.json index 948cb2cd..9f01197e 100644 --- a/custom_components/pollenlevels/translations/it.json +++ b/custom_components/pollenlevels/translations/it.json @@ -14,7 +14,7 @@ "invalid_language": "Codice lingua non valido", "invalid_language_format": "Usa un codice BCP-47 canonico come \"en\" o \"es-ES\".", "invalid_option_combo": "Aumenta 'Giorni di previsione' per coprire i sensori giornalieri selezionati.", - "quota_exceeded": "Quota superata", + "quota_exceeded": "Quota superata\n\n{error_message}", "unknown": "Errore sconosciuto", "invalid_update_interval": "L’intervallo di aggiornamento deve essere di almeno 1 ora." }, @@ -80,7 +80,7 @@ "invalid_language": "Codice lingua non valido", "invalid_language_format": "Usa un codice BCP-47 canonico come \"en\" o \"es-ES\".", "invalid_option_combo": "Aumenta 'Giorni di previsione' per coprire i sensori giornalieri selezionati.", - "quota_exceeded": "Quota superata", + "quota_exceeded": "Quota superata\n\n{error_message}", "unknown": "Errore sconosciuto", "invalid_update_interval": "L’intervallo di aggiornamento deve essere di almeno 1 ora." }, @@ -103,4 +103,4 @@ "name": "Forza aggiornamento" } } -} \ No newline at end of file +} diff --git a/custom_components/pollenlevels/translations/nb.json b/custom_components/pollenlevels/translations/nb.json index cce70620..8776943a 100644 --- a/custom_components/pollenlevels/translations/nb.json +++ b/custom_components/pollenlevels/translations/nb.json @@ -14,7 +14,7 @@ "invalid_language": "Ugyldig språkkode", "invalid_language_format": "Bruk en kanonisk BCP-47-kode som \"en\" eller \"es-ES\".", "invalid_option_combo": "Øk «Prognosedager» for å dekke valgte sensorer per dag.", - "quota_exceeded": "Kvote overskredet", + "quota_exceeded": "Kvote overskredet\n\n{error_message}", "unknown": "Ukjent feil", "invalid_update_interval": "Update interval must be at least 1 hour." }, @@ -33,7 +33,7 @@ "location": "Posisjon", "name": "Navn", "update_interval": "Oppdateringsintervall (timer)", - "http_referer": "HTTP Referrer" + "http_referer": "HTTP Referer" }, "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", "title": "Konfigurasjon av pollennivåer", @@ -43,7 +43,7 @@ } }, "data_description": { - "http_referer": "Only needed if your API key is restricted by HTTP referrers (websites)." + "http_referer": "Only needed if your API key is restricted by HTTP Referers (websites)." } } } @@ -80,7 +80,7 @@ "invalid_language": "Ugyldig språkkode", "invalid_language_format": "Bruk en kanonisk BCP-47-kode som \"en\" eller \"es-ES\".", "invalid_option_combo": "Øk «Prognosedager» for å dekke valgte sensorer per dag.", - "quota_exceeded": "Kvote overskredet", + "quota_exceeded": "Kvote overskredet\n\n{error_message}", "unknown": "Ukjent feil", "invalid_update_interval": "Update interval must be at least 1 hour." }, diff --git a/custom_components/pollenlevels/translations/nl.json b/custom_components/pollenlevels/translations/nl.json index b76d6b20..65e4b313 100644 --- a/custom_components/pollenlevels/translations/nl.json +++ b/custom_components/pollenlevels/translations/nl.json @@ -14,7 +14,7 @@ "invalid_language": "Ongeldige taalcode", "invalid_language_format": "Gebruik een canonieke BCP-47-code zoals \"en\" of \"es-ES\".", "invalid_option_combo": "Verhoog 'Voorspellingsdagen' om de geselecteerde per-dag sensoren te dekken.", - "quota_exceeded": "Limiet overschreden", + "quota_exceeded": "Limiet overschreden\n\n{error_message}", "unknown": "Onbekende fout", "invalid_update_interval": "Update interval must be at least 1 hour." }, @@ -33,7 +33,7 @@ "location": "Locatie", "name": "Naam", "update_interval": "Update-interval (uren)", - "http_referer": "HTTP Referrer" + "http_referer": "HTTP Referer" }, "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", "title": "Pollen Levels – Configuratie", @@ -43,7 +43,7 @@ } }, "data_description": { - "http_referer": "Only needed if your API key is restricted by HTTP referrers (websites)." + "http_referer": "Only needed if your API key is restricted by HTTP Referers (websites)." } } } @@ -80,7 +80,7 @@ "invalid_language": "Ongeldige taalcode", "invalid_language_format": "Gebruik een canonieke BCP-47-code zoals \"en\" of \"es-ES\".", "invalid_option_combo": "Verhoog 'Voorspellingsdagen' om de geselecteerde per-dag sensoren te dekken.", - "quota_exceeded": "Limiet overschreden", + "quota_exceeded": "Limiet overschreden\n\n{error_message}", "unknown": "Onbekende fout", "invalid_update_interval": "Update interval must be at least 1 hour." }, diff --git a/custom_components/pollenlevels/translations/pl.json b/custom_components/pollenlevels/translations/pl.json index 95dfb11e..d58ca3ec 100644 --- a/custom_components/pollenlevels/translations/pl.json +++ b/custom_components/pollenlevels/translations/pl.json @@ -14,7 +14,7 @@ "invalid_language": "Nieprawidłowy kod języka", "invalid_language_format": "Użyj kanonicznego kodu BCP-47, np. \"en\" lub \"es-ES\".", "invalid_option_combo": "Zwiększ 'Dni prognozy', aby objąć wybrane czujniki dzienne.", - "quota_exceeded": "Przekroczono limit", + "quota_exceeded": "Przekroczono limit\n\n{error_message}", "unknown": "Nieznany błąd", "invalid_update_interval": "Update interval must be at least 1 hour." }, @@ -33,7 +33,7 @@ "location": "Lokalizacja", "name": "Nazwa", "update_interval": "Interwał aktualizacji (godziny)", - "http_referer": "HTTP Referrer" + "http_referer": "HTTP Referer" }, "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", "title": "Konfiguracja poziomów pyłku", @@ -43,7 +43,7 @@ } }, "data_description": { - "http_referer": "Only needed if your API key is restricted by HTTP referrers (websites)." + "http_referer": "Only needed if your API key is restricted by HTTP Referers (websites)." } } } @@ -80,7 +80,7 @@ "invalid_language": "Nieprawidłowy kod języka", "invalid_language_format": "Użyj kanonicznego kodu BCP-47, np. \"en\" lub \"es-ES\".", "invalid_option_combo": "Zwiększ 'Dni prognozy', aby objąć wybrane czujniki dzienne.", - "quota_exceeded": "Przekroczono limit", + "quota_exceeded": "Przekroczono limit\n\n{error_message}", "unknown": "Nieznany błąd", "invalid_update_interval": "Update interval must be at least 1 hour." }, diff --git a/custom_components/pollenlevels/translations/pt-BR.json b/custom_components/pollenlevels/translations/pt-BR.json index 8678c495..ca820d17 100644 --- a/custom_components/pollenlevels/translations/pt-BR.json +++ b/custom_components/pollenlevels/translations/pt-BR.json @@ -14,7 +14,7 @@ "invalid_language": "Código de idioma inválido", "invalid_language_format": "Use um código BCP-47 canônico, como \"en\" ou \"es-ES\".", "invalid_option_combo": "Aumente \"Dias de previsão\" para cobrir os sensores por dia selecionados.", - "quota_exceeded": "Cota excedida", + "quota_exceeded": "Cota excedida\n\n{error_message}", "unknown": "Erro desconhecido", "invalid_update_interval": "Update interval must be at least 1 hour." }, @@ -33,7 +33,7 @@ "location": "Localização", "name": "Nome", "update_interval": "Intervalo de atualização (horas)", - "http_referer": "HTTP Referrer" + "http_referer": "HTTP Referer" }, "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", "title": "Configuração dos Níveis de Pólen", @@ -43,7 +43,7 @@ } }, "data_description": { - "http_referer": "Only needed if your API key is restricted by HTTP referrers (websites)." + "http_referer": "Only needed if your API key is restricted by HTTP Referers (websites)." } } } @@ -80,7 +80,7 @@ "invalid_language": "Código de idioma inválido", "invalid_language_format": "Use um código BCP-47 canônico, como \"en\" ou \"es-ES\".", "invalid_option_combo": "Aumente \"Dias de previsão\" para cobrir os sensores por dia selecionados.", - "quota_exceeded": "Cota excedida", + "quota_exceeded": "Cota excedida\n\n{error_message}", "unknown": "Erro desconhecido", "invalid_update_interval": "Update interval must be at least 1 hour." }, diff --git a/custom_components/pollenlevels/translations/pt-PT.json b/custom_components/pollenlevels/translations/pt-PT.json index 03f86578..48f81893 100644 --- a/custom_components/pollenlevels/translations/pt-PT.json +++ b/custom_components/pollenlevels/translations/pt-PT.json @@ -14,7 +14,7 @@ "invalid_language": "Código de idioma inválido", "invalid_language_format": "Use um código BCP-47 canónico, como \"en\" ou \"es-ES\".", "invalid_option_combo": "Aumente \"Dias de previsão\" para cobrir os sensores por dia selecionados.", - "quota_exceeded": "Quota excedida", + "quota_exceeded": "Quota excedida\n\n{error_message}", "unknown": "Erro desconhecido", "invalid_update_interval": "Update interval must be at least 1 hour." }, @@ -33,7 +33,7 @@ "location": "Localização", "name": "Nome", "update_interval": "Intervalo de atualização (horas)", - "http_referer": "HTTP Referrer" + "http_referer": "HTTP Referer" }, "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", "title": "Configuração dos Níveis de Pólen", @@ -43,7 +43,7 @@ } }, "data_description": { - "http_referer": "Only needed if your API key is restricted by HTTP referrers (websites)." + "http_referer": "Only needed if your API key is restricted by HTTP Referers (websites)." } } } @@ -80,7 +80,7 @@ "invalid_language": "Código de idioma inválido", "invalid_language_format": "Use um código BCP-47 canónico, como \"en\" ou \"es-ES\".", "invalid_option_combo": "Aumente \"Dias de previsão\" para cobrir os sensores por dia selecionados.", - "quota_exceeded": "Quota excedida", + "quota_exceeded": "Quota excedida\n\n{error_message}", "unknown": "Erro desconhecido", "invalid_update_interval": "Update interval must be at least 1 hour." }, diff --git a/custom_components/pollenlevels/translations/ro.json b/custom_components/pollenlevels/translations/ro.json index b50e0937..eb0d0825 100644 --- a/custom_components/pollenlevels/translations/ro.json +++ b/custom_components/pollenlevels/translations/ro.json @@ -14,7 +14,7 @@ "invalid_language": "Cod de limbă nevalid", "invalid_language_format": "Folosiți un cod BCP-47 canonic, de exemplu \"en\" sau \"es-ES\".", "invalid_option_combo": "Măriți \"Zilele de prognoză\" pentru a acoperi senzorii selectați pe zile.", - "quota_exceeded": "Cota depășită", + "quota_exceeded": "Cota depășită\n\n{error_message}", "unknown": "Eroare necunoscută", "invalid_update_interval": "Update interval must be at least 1 hour." }, @@ -33,7 +33,7 @@ "location": "Locație", "name": "Nume", "update_interval": "Interval de actualizare (ore)", - "http_referer": "HTTP Referrer" + "http_referer": "HTTP Referer" }, "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", "title": "Configurare Niveluri de Polen", @@ -43,7 +43,7 @@ } }, "data_description": { - "http_referer": "Only needed if your API key is restricted by HTTP referrers (websites)." + "http_referer": "Only needed if your API key is restricted by HTTP Referers (websites)." } } } @@ -80,7 +80,7 @@ "invalid_language": "Cod de limbă nevalid", "invalid_language_format": "Folosiți un cod BCP-47 canonic, de exemplu \"en\" sau \"es-ES\".", "invalid_option_combo": "Măriți \"Zilele de prognoză\" pentru a acoperi senzorii selectați pe zile.", - "quota_exceeded": "Cota depășită", + "quota_exceeded": "Cota depășită\n\n{error_message}", "unknown": "Eroare necunoscută", "invalid_update_interval": "Update interval must be at least 1 hour." }, diff --git a/custom_components/pollenlevels/translations/ru.json b/custom_components/pollenlevels/translations/ru.json index 39413dca..cd22963c 100644 --- a/custom_components/pollenlevels/translations/ru.json +++ b/custom_components/pollenlevels/translations/ru.json @@ -14,7 +14,7 @@ "invalid_language": "Неверный код языка", "invalid_language_format": "Используйте канонический код BCP-47, например \"en\" или \"es-ES\".", "invalid_option_combo": "Увеличьте «Дни прогноза», чтобы охватить выбранные датчики по дням.", - "quota_exceeded": "Превышен лимит запросов", + "quota_exceeded": "Превышен лимит запросов\n\n{error_message}", "unknown": "Неизвестная ошибка", "invalid_update_interval": "Update interval must be at least 1 hour." }, @@ -33,7 +33,7 @@ "location": "Местоположение", "name": "Имя", "update_interval": "Интервал обновления (в часах)", - "http_referer": "HTTP Referrer" + "http_referer": "HTTP Referer" }, "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", "title": "Настройка уровней пыльцы", @@ -43,7 +43,7 @@ } }, "data_description": { - "http_referer": "Only needed if your API key is restricted by HTTP referrers (websites)." + "http_referer": "Only needed if your API key is restricted by HTTP Referers (websites)." } } } @@ -80,7 +80,7 @@ "invalid_language": "Неверный код языка", "invalid_language_format": "Используйте канонический код BCP-47, например \"en\" или \"es-ES\".", "invalid_option_combo": "Увеличьте «Дни прогноза», чтобы охватить выбранные датчики по дням.", - "quota_exceeded": "Превышен лимит запросов", + "quota_exceeded": "Превышен лимит запросов\n\n{error_message}", "unknown": "Неизвестная ошибка", "invalid_update_interval": "Update interval must be at least 1 hour." }, diff --git a/custom_components/pollenlevels/translations/sv.json b/custom_components/pollenlevels/translations/sv.json index b4875a35..9eb9f03a 100644 --- a/custom_components/pollenlevels/translations/sv.json +++ b/custom_components/pollenlevels/translations/sv.json @@ -14,7 +14,7 @@ "invalid_language": "Ogiltig språkkod", "invalid_language_format": "Använd en kanonisk BCP-47-kod som \"en\" eller \"es-ES\".", "invalid_option_combo": "Öka \"Prognosdagar\" för att täcka valda sensorer per dag.", - "quota_exceeded": "Kvoten har överskridits", + "quota_exceeded": "Kvoten har överskridits\n\n{error_message}", "unknown": "Okänt fel", "invalid_update_interval": "Update interval must be at least 1 hour." }, @@ -33,7 +33,7 @@ "location": "Plats", "name": "Namn", "update_interval": "Uppdateringsintervall (timmar)", - "http_referer": "HTTP Referrer" + "http_referer": "HTTP Referer" }, "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", "title": "Konfiguration av pollennivåer", @@ -43,7 +43,7 @@ } }, "data_description": { - "http_referer": "Only needed if your API key is restricted by HTTP referrers (websites)." + "http_referer": "Only needed if your API key is restricted by HTTP Referers (websites)." } } } @@ -80,7 +80,7 @@ "invalid_language": "Ogiltig språkkod", "invalid_language_format": "Använd en kanonisk BCP-47-kod som \"en\" eller \"es-ES\".", "invalid_option_combo": "Öka \"Prognosdagar\" för att täcka valda sensorer per dag.", - "quota_exceeded": "Kvoten har överskridits", + "quota_exceeded": "Kvoten har överskridits\n\n{error_message}", "unknown": "Okänt fel", "invalid_update_interval": "Update interval must be at least 1 hour." }, diff --git a/custom_components/pollenlevels/translations/uk.json b/custom_components/pollenlevels/translations/uk.json index 062c89f4..b7b4340d 100644 --- a/custom_components/pollenlevels/translations/uk.json +++ b/custom_components/pollenlevels/translations/uk.json @@ -14,7 +14,7 @@ "invalid_language": "Невірний код мови", "invalid_language_format": "Використовуйте канонічний код BCP-47, наприклад \"en\" або \"es-ES\".", "invalid_option_combo": "Збільшіть «Дні прогнозу», щоб охопити вибрані денні датчики.", - "quota_exceeded": "Перевищено ліміт запитів", + "quota_exceeded": "Перевищено ліміт запитів\n\n{error_message}", "unknown": "Невідома помилка", "invalid_update_interval": "Update interval must be at least 1 hour." }, @@ -33,7 +33,7 @@ "location": "Місцезнаходження", "name": "Ім'я", "update_interval": "Інтервал оновлення (у годинах)", - "http_referer": "HTTP Referrer" + "http_referer": "HTTP Referer" }, "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", "title": "Налаштування рівнів пилку", @@ -43,7 +43,7 @@ } }, "data_description": { - "http_referer": "Only needed if your API key is restricted by HTTP referrers (websites)." + "http_referer": "Only needed if your API key is restricted by HTTP Referers (websites)." } } } @@ -80,7 +80,7 @@ "invalid_language": "Невірний код мови", "invalid_language_format": "Використовуйте канонічний код BCP-47, наприклад \"en\" або \"es-ES\".", "invalid_option_combo": "Збільшіть «Дні прогнозу», щоб охопити вибрані денні датчики.", - "quota_exceeded": "Перевищено ліміт запитів", + "quota_exceeded": "Перевищено ліміт запитів\n\n{error_message}", "unknown": "Невідома помилка", "invalid_update_interval": "Update interval must be at least 1 hour." }, diff --git a/custom_components/pollenlevels/translations/zh-Hans.json b/custom_components/pollenlevels/translations/zh-Hans.json index 965e70d0..ce8e4dfd 100644 --- a/custom_components/pollenlevels/translations/zh-Hans.json +++ b/custom_components/pollenlevels/translations/zh-Hans.json @@ -14,7 +14,7 @@ "invalid_language": "无效的语言代码", "invalid_language_format": "请使用规范的 BCP-47 代码,例如 \"en\" 或 \"es-ES\"。", "invalid_option_combo": "请增加“预测天数”,以覆盖所选的逐日类型传感器。", - "quota_exceeded": "配额已用尽", + "quota_exceeded": "配额已用尽\n\n{error_message}", "unknown": "未知错误", "invalid_update_interval": "Update interval must be at least 1 hour." }, @@ -33,7 +33,7 @@ "location": "位置", "name": "名称", "update_interval": "更新间隔(小时)", - "http_referer": "HTTP Referrer" + "http_referer": "HTTP Referer" }, "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", "title": "花粉水平配置", @@ -43,7 +43,7 @@ } }, "data_description": { - "http_referer": "Only needed if your API key is restricted by HTTP referrers (websites)." + "http_referer": "Only needed if your API key is restricted by HTTP Referers (websites)." } } } @@ -80,7 +80,7 @@ "invalid_language": "无效的语言代码", "invalid_language_format": "请使用规范的 BCP-47 代码,例如 \"en\" 或 \"es-ES\"。", "invalid_option_combo": "请增加“预测天数”,以覆盖所选的逐日类型传感器。", - "quota_exceeded": "配额已用尽", + "quota_exceeded": "配额已用尽\n\n{error_message}", "unknown": "未知错误", "invalid_update_interval": "Update interval must be at least 1 hour." }, diff --git a/custom_components/pollenlevels/translations/zh-Hant.json b/custom_components/pollenlevels/translations/zh-Hant.json index eacfa1ab..70810a46 100644 --- a/custom_components/pollenlevels/translations/zh-Hant.json +++ b/custom_components/pollenlevels/translations/zh-Hant.json @@ -14,7 +14,7 @@ "invalid_language": "無效的語言代碼", "invalid_language_format": "請使用標準的 BCP-47 代碼,例如 \"en\" 或 \"es-ES\"。", "invalid_option_combo": "請增加「預測天數」以涵蓋所選的逐日類型感測器。", - "quota_exceeded": "超出配額", + "quota_exceeded": "超出配額\n\n{error_message}", "unknown": "未知錯誤", "invalid_update_interval": "Update interval must be at least 1 hour." }, @@ -33,7 +33,7 @@ "location": "位置", "name": "名稱", "update_interval": "更新間隔(小時)", - "http_referer": "HTTP Referrer" + "http_referer": "HTTP Referer" }, "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", "title": "花粉水平設定", @@ -43,7 +43,7 @@ } }, "data_description": { - "http_referer": "Only needed if your API key is restricted by HTTP referrers (websites)." + "http_referer": "Only needed if your API key is restricted by HTTP Referers (websites)." } } } @@ -80,7 +80,7 @@ "invalid_language": "無效的語言代碼", "invalid_language_format": "請使用標準的 BCP-47 代碼,例如 \"en\" 或 \"es-ES\"。", "invalid_option_combo": "請增加「預測天數」以涵蓋所選的逐日類型感測器。", - "quota_exceeded": "超出配額", + "quota_exceeded": "超出配額\n\n{error_message}", "unknown": "未知錯誤", "invalid_update_interval": "Update interval must be at least 1 hour." }, From e8fc473ec0dc13b7bee81302feab6fd7cc34f391 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Mon, 15 Dec 2025 10:37:22 +0100 Subject: [PATCH 042/200] Cleanup invalid key fallback in 403 handler --- custom_components/pollenlevels/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/pollenlevels/client.py b/custom_components/pollenlevels/client.py index 4a177d9d..d86ca751 100644 --- a/custom_components/pollenlevels/client.py +++ b/custom_components/pollenlevels/client.py @@ -114,7 +114,7 @@ async def async_fetch_pollen_data( raw_message = await extract_error_message(resp) message = _format_http_message(resp.status, raw_message) if is_invalid_api_key_message(raw_message): - raise ConfigEntryAuthFailed(message or "Invalid API key") + raise ConfigEntryAuthFailed(message) raise UpdateFailed(message) if resp.status == 429: From b3563ec558a4f4a81effa1cc9bd79f05e4597fa9 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Mon, 15 Dec 2025 16:06:19 +0100 Subject: [PATCH 043/200] Align translation placeholders and Referer wording --- custom_components/pollenlevels/translations/ca.json | 6 +++--- custom_components/pollenlevels/translations/cs.json | 6 +++--- custom_components/pollenlevels/translations/da.json | 4 ++-- custom_components/pollenlevels/translations/de.json | 4 ++-- custom_components/pollenlevels/translations/es.json | 6 +++--- custom_components/pollenlevels/translations/fi.json | 6 +++--- custom_components/pollenlevels/translations/fr.json | 6 +++--- custom_components/pollenlevels/translations/hu.json | 6 +++--- custom_components/pollenlevels/translations/it.json | 6 +++--- custom_components/pollenlevels/translations/nb.json | 2 +- custom_components/pollenlevels/translations/nl.json | 2 +- custom_components/pollenlevels/translations/pl.json | 2 +- custom_components/pollenlevels/translations/pt-BR.json | 2 +- custom_components/pollenlevels/translations/pt-PT.json | 2 +- custom_components/pollenlevels/translations/ro.json | 2 +- custom_components/pollenlevels/translations/ru.json | 2 +- custom_components/pollenlevels/translations/sv.json | 2 +- custom_components/pollenlevels/translations/uk.json | 2 +- custom_components/pollenlevels/translations/zh-Hans.json | 2 +- custom_components/pollenlevels/translations/zh-Hant.json | 2 +- 20 files changed, 36 insertions(+), 36 deletions(-) diff --git a/custom_components/pollenlevels/translations/ca.json b/custom_components/pollenlevels/translations/ca.json index 1e7a3503..c840f391 100644 --- a/custom_components/pollenlevels/translations/ca.json +++ b/custom_components/pollenlevels/translations/ca.json @@ -10,7 +10,7 @@ "location": "Ubicació", "update_interval": "Interval d’actualització (hores)", "language_code": "Codi d’idioma de la resposta de l’API", - "http_referer": "Referer HTTP" + "http_referer": "HTTP Referer" }, "sections": { "api_key_options": { @@ -18,7 +18,7 @@ } }, "data_description": { - "http_referer": "Només cal si la teva clau API està restringida per referers HTTP (llocs web)." + "http_referer": "Només cal si la teva clau API està restringida per Referers HTTP (llocs web)." } }, "reauth_confirm": { @@ -103,4 +103,4 @@ } } } -} +} \ No newline at end of file diff --git a/custom_components/pollenlevels/translations/cs.json b/custom_components/pollenlevels/translations/cs.json index 5c8ca1ae..98e4aeba 100644 --- a/custom_components/pollenlevels/translations/cs.json +++ b/custom_components/pollenlevels/translations/cs.json @@ -33,7 +33,7 @@ "location": "Poloha", "name": "Název", "update_interval": "Interval aktualizace (hodiny)", - "http_referer": "HTTP referer" + "http_referer": "HTTP Referer" }, "description": "Zadejte svůj Google API klíč ([získejte jej zde]({api_key_url})) a přečtěte si doporučené postupy ([doporučené postupy]({restricting_api_keys_url})). Vyberte polohu na mapě, interval aktualizace (v hodinách) a jazykový kód odpovědi API.", "title": "Konfigurace úrovní pylu", @@ -43,7 +43,7 @@ } }, "data_description": { - "http_referer": "Potřebné pouze pokud je váš API klíč omezen podle HTTP refererů (webových stránek)." + "http_referer": "Potřebné pouze pokud je váš API klíč omezen podle HTTP Refererů (webových stránek)." } } } @@ -103,4 +103,4 @@ "name": "Vynutit aktualizaci" } } -} +} \ No newline at end of file diff --git a/custom_components/pollenlevels/translations/da.json b/custom_components/pollenlevels/translations/da.json index dc14d3aa..d2dfd940 100644 --- a/custom_components/pollenlevels/translations/da.json +++ b/custom_components/pollenlevels/translations/da.json @@ -43,7 +43,7 @@ } }, "data_description": { - "http_referer": "Kun nødvendig hvis din API-nøgle er begrænset via HTTP referers (websteder)." + "http_referer": "Kun nødvendig hvis din API-nøgle er begrænset via HTTP Referers (websteder)." } } } @@ -103,4 +103,4 @@ "name": "Gennemtving opdatering" } } -} +} \ No newline at end of file diff --git a/custom_components/pollenlevels/translations/de.json b/custom_components/pollenlevels/translations/de.json index a3975ee4..c4053396 100644 --- a/custom_components/pollenlevels/translations/de.json +++ b/custom_components/pollenlevels/translations/de.json @@ -33,7 +33,7 @@ "location": "Standort", "name": "Name", "update_interval": "Aktualisierungsintervall (Stunden)", - "http_referer": "HTTP-Referer" + "http_referer": "HTTP Referer" }, "description": "Gib deinen Google API-Schlüssel ein ([hier abrufen]({api_key_url})) und lies die Best Practices ([Best Practices]({restricting_api_keys_url})). Wähle deinen Standort auf der Karte, das Aktualisierungsintervall (Stunden) und den Sprachcode der API-Antwort.", "title": "Pollen Levels – Konfiguration", @@ -103,4 +103,4 @@ "name": "Aktualisierung erzwingen" } } -} +} \ No newline at end of file diff --git a/custom_components/pollenlevels/translations/es.json b/custom_components/pollenlevels/translations/es.json index c7c94331..81ce9f69 100644 --- a/custom_components/pollenlevels/translations/es.json +++ b/custom_components/pollenlevels/translations/es.json @@ -10,7 +10,7 @@ "location": "Ubicación", "update_interval": "Intervalo de actualización (horas)", "language_code": "Código de idioma de la respuesta de la API", - "http_referer": "Referer HTTP" + "http_referer": "HTTP Referer" }, "sections": { "api_key_options": { @@ -18,7 +18,7 @@ } }, "data_description": { - "http_referer": "Solo es necesario si tu clave API está restringida por referers HTTP (sitios web)." + "http_referer": "Solo es necesario si tu clave API está restringida por Referers HTTP (sitios web)." } }, "reauth_confirm": { @@ -103,4 +103,4 @@ } } } -} +} \ No newline at end of file diff --git a/custom_components/pollenlevels/translations/fi.json b/custom_components/pollenlevels/translations/fi.json index c2e9c36e..1b23b5c4 100644 --- a/custom_components/pollenlevels/translations/fi.json +++ b/custom_components/pollenlevels/translations/fi.json @@ -33,7 +33,7 @@ "location": "Sijainti", "name": "Nimi", "update_interval": "Päivitysväli (tunnit)", - "http_referer": "HTTP referer" + "http_referer": "HTTP Referer" }, "description": "Syötä Google API -avaimesi ([hanki se täältä]({api_key_url})) ja tutustu parhaisiin käytäntöihin ([parhaat käytännöt]({restricting_api_keys_url})). Valitse sijainti kartalta, päivitysväli (tunteina) ja API-vastauksen kielikoodi.", "title": "Siitepölytason asetukset", @@ -43,7 +43,7 @@ } }, "data_description": { - "http_referer": "Tarvitaan vain, jos API-avaimesi on rajoitettu HTTP refererien (verkkosivustojen) perusteella." + "http_referer": "Tarvitaan vain, jos API-avaimesi on rajoitettu HTTP Refererien (verkkosivustojen) perusteella." } } } @@ -103,4 +103,4 @@ "name": "Pakota päivitys" } } -} +} \ No newline at end of file diff --git a/custom_components/pollenlevels/translations/fr.json b/custom_components/pollenlevels/translations/fr.json index 08047ba7..e88afe48 100644 --- a/custom_components/pollenlevels/translations/fr.json +++ b/custom_components/pollenlevels/translations/fr.json @@ -33,7 +33,7 @@ "location": "Emplacement", "name": "Nom", "update_interval": "Intervalle de mise à jour (heures)", - "http_referer": "HTTP referer" + "http_referer": "HTTP Referer" }, "description": "Saisissez votre clé API Google ([l’obtenir ici]({api_key_url})) et consultez les bonnes pratiques ([bonnes pratiques]({restricting_api_keys_url})). Sélectionnez votre emplacement sur la carte, l’intervalle de mise à jour (heures) et le code de langue de la réponse de l’API.", "title": "Pollen Levels – Configuration", @@ -43,7 +43,7 @@ } }, "data_description": { - "http_referer": "Nécessaire uniquement si votre clé API est limitée par des HTTP referers (sites web)." + "http_referer": "Nécessaire uniquement si votre clé API est limitée par des HTTP Referers (sites web)." } } } @@ -103,4 +103,4 @@ "name": "Forcer la mise à jour" } } -} +} \ No newline at end of file diff --git a/custom_components/pollenlevels/translations/hu.json b/custom_components/pollenlevels/translations/hu.json index 72775844..aa8b12c5 100644 --- a/custom_components/pollenlevels/translations/hu.json +++ b/custom_components/pollenlevels/translations/hu.json @@ -33,7 +33,7 @@ "location": "Helyszín", "name": "Név", "update_interval": "Frissítési időköz (óra)", - "http_referer": "HTTP referer" + "http_referer": "HTTP Referer" }, "description": "Add meg a Google API-kulcsodat ([itt szerezhető be]({api_key_url})) és nézd át a bevált gyakorlatokat ([bevált gyakorlatok]({restricting_api_keys_url})). Válaszd ki a helyszínt a térképen, a frissítési időközt (órában) és az API-válasz nyelvi kódját.", "title": "Pollen szintek – beállítás", @@ -43,7 +43,7 @@ } }, "data_description": { - "http_referer": "Csak akkor szükséges, ha az API-kulcsod HTTP refererek (weboldalak) alapján van korlátozva." + "http_referer": "Csak akkor szükséges, ha az API-kulcsod HTTP Refererek (weboldalak) alapján van korlátozva." } } } @@ -103,4 +103,4 @@ "name": "Frissítés kényszerítése" } } -} +} \ No newline at end of file diff --git a/custom_components/pollenlevels/translations/it.json b/custom_components/pollenlevels/translations/it.json index 9f01197e..899db6d7 100644 --- a/custom_components/pollenlevels/translations/it.json +++ b/custom_components/pollenlevels/translations/it.json @@ -33,7 +33,7 @@ "location": "Posizione", "name": "Nome", "update_interval": "Intervallo di aggiornamento (ore)", - "http_referer": "HTTP referer" + "http_referer": "HTTP Referer" }, "description": "Inserisci la tua chiave API di Google ([ottienila qui]({api_key_url})) e consulta le best practice ([best practice]({restricting_api_keys_url})). Seleziona la posizione sulla mappa, l’intervallo di aggiornamento (ore) e il codice lingua della risposta dell’API.", "title": "Configurazione Livelli di polline", @@ -43,7 +43,7 @@ } }, "data_description": { - "http_referer": "Necessario solo se la tua chiave API è limitata dai referer HTTP (siti web)." + "http_referer": "Necessario solo se la tua chiave API è limitata dai Referer HTTP (siti web)." } } } @@ -103,4 +103,4 @@ "name": "Forza aggiornamento" } } -} +} \ No newline at end of file diff --git a/custom_components/pollenlevels/translations/nb.json b/custom_components/pollenlevels/translations/nb.json index 8776943a..a949ee95 100644 --- a/custom_components/pollenlevels/translations/nb.json +++ b/custom_components/pollenlevels/translations/nb.json @@ -103,4 +103,4 @@ "name": "Tving oppdatering" } } -} +} \ No newline at end of file diff --git a/custom_components/pollenlevels/translations/nl.json b/custom_components/pollenlevels/translations/nl.json index 65e4b313..e673b6a3 100644 --- a/custom_components/pollenlevels/translations/nl.json +++ b/custom_components/pollenlevels/translations/nl.json @@ -103,4 +103,4 @@ "name": "Update forceren" } } -} +} \ No newline at end of file diff --git a/custom_components/pollenlevels/translations/pl.json b/custom_components/pollenlevels/translations/pl.json index d58ca3ec..915b3bf6 100644 --- a/custom_components/pollenlevels/translations/pl.json +++ b/custom_components/pollenlevels/translations/pl.json @@ -103,4 +103,4 @@ "name": "Wymuś aktualizację" } } -} +} \ No newline at end of file diff --git a/custom_components/pollenlevels/translations/pt-BR.json b/custom_components/pollenlevels/translations/pt-BR.json index ca820d17..09e0315c 100644 --- a/custom_components/pollenlevels/translations/pt-BR.json +++ b/custom_components/pollenlevels/translations/pt-BR.json @@ -103,4 +103,4 @@ "name": "Forçar atualização" } } -} +} \ No newline at end of file diff --git a/custom_components/pollenlevels/translations/pt-PT.json b/custom_components/pollenlevels/translations/pt-PT.json index 48f81893..3c4b92e0 100644 --- a/custom_components/pollenlevels/translations/pt-PT.json +++ b/custom_components/pollenlevels/translations/pt-PT.json @@ -103,4 +103,4 @@ "name": "Forçar atualização" } } -} +} \ No newline at end of file diff --git a/custom_components/pollenlevels/translations/ro.json b/custom_components/pollenlevels/translations/ro.json index eb0d0825..9a8c94bb 100644 --- a/custom_components/pollenlevels/translations/ro.json +++ b/custom_components/pollenlevels/translations/ro.json @@ -103,4 +103,4 @@ "name": "Forțează actualizarea" } } -} +} \ No newline at end of file diff --git a/custom_components/pollenlevels/translations/ru.json b/custom_components/pollenlevels/translations/ru.json index cd22963c..ad425821 100644 --- a/custom_components/pollenlevels/translations/ru.json +++ b/custom_components/pollenlevels/translations/ru.json @@ -103,4 +103,4 @@ "name": "Принудительное обновление" } } -} +} \ No newline at end of file diff --git a/custom_components/pollenlevels/translations/sv.json b/custom_components/pollenlevels/translations/sv.json index 9eb9f03a..e59b2ca3 100644 --- a/custom_components/pollenlevels/translations/sv.json +++ b/custom_components/pollenlevels/translations/sv.json @@ -103,4 +103,4 @@ "name": "Tvinga uppdatering" } } -} +} \ No newline at end of file diff --git a/custom_components/pollenlevels/translations/uk.json b/custom_components/pollenlevels/translations/uk.json index b7b4340d..57cccc58 100644 --- a/custom_components/pollenlevels/translations/uk.json +++ b/custom_components/pollenlevels/translations/uk.json @@ -103,4 +103,4 @@ "name": "Примусове оновлення" } } -} +} \ No newline at end of file diff --git a/custom_components/pollenlevels/translations/zh-Hans.json b/custom_components/pollenlevels/translations/zh-Hans.json index ce8e4dfd..698ca7c2 100644 --- a/custom_components/pollenlevels/translations/zh-Hans.json +++ b/custom_components/pollenlevels/translations/zh-Hans.json @@ -103,4 +103,4 @@ "name": "强制更新" } } -} +} \ No newline at end of file diff --git a/custom_components/pollenlevels/translations/zh-Hant.json b/custom_components/pollenlevels/translations/zh-Hant.json index 70810a46..fc8182a3 100644 --- a/custom_components/pollenlevels/translations/zh-Hant.json +++ b/custom_components/pollenlevels/translations/zh-Hant.json @@ -103,4 +103,4 @@ "name": "強制更新" } } -} +} \ No newline at end of file From 3a5b45c5aeae2d686afb6e9457380efb4d603721 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Mon, 15 Dec 2025 16:06:26 +0100 Subject: [PATCH 044/200] chore: refresh alpha2 changelog date --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21f4b3be..8597ba67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,5 @@ # Changelog -## [1.9.0-alpha2] - 2025-12-13 +## [1.9.0-alpha2] - 2025-12-15 ### Changed - Added stable constants for HTTP referrer support and API key helper URLs to support upcoming flow updates. @@ -18,6 +18,8 @@ reauthentication, keeping setup and update behavior aligned. - Deduplicated HTTP error message extraction into a shared helper used by config validation and the runtime client to keep diagnostics consistent. +- Standardized translation locales for HTTP Referer wording, quota-exceeded + details, and invalid update interval errors to keep UI feedback aligned. ## [1.9.0-alpha1] - 2025-12-11 ### Changed From 7d8a43f531d3fbefa1586e483f389bf8804d7ce1 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Mon, 15 Dec 2025 17:15:12 +0100 Subject: [PATCH 045/200] Simplify referrer handling and localize translations --- custom_components/pollenlevels/client.py | 11 ++----- custom_components/pollenlevels/config_flow.py | 31 +++++++------------ .../pollenlevels/translations/nb.json | 12 +++---- .../pollenlevels/translations/nl.json | 12 +++---- .../pollenlevels/translations/pl.json | 12 +++---- .../pollenlevels/translations/pt-BR.json | 12 +++---- .../pollenlevels/translations/pt-PT.json | 12 +++---- .../pollenlevels/translations/ro.json | 12 +++---- .../pollenlevels/translations/ru.json | 12 +++---- .../pollenlevels/translations/sv.json | 12 +++---- .../pollenlevels/translations/uk.json | 12 +++---- .../pollenlevels/translations/zh-Hans.json | 12 +++---- .../pollenlevels/translations/zh-Hant.json | 12 +++---- tests/test_config_flow.py | 9 ++++-- 14 files changed, 86 insertions(+), 97 deletions(-) diff --git a/custom_components/pollenlevels/client.py b/custom_components/pollenlevels/client.py index d86ca751..c45ff41f 100644 --- a/custom_components/pollenlevels/client.py +++ b/custom_components/pollenlevels/client.py @@ -90,15 +90,8 @@ async def async_fetch_pollen_data( for attempt in range(0, max_retries + 1): try: headers: dict[str, str] | None = None - referer = self._http_referer - if referer: - if "\r" in referer or "\n" in referer: - _LOGGER.warning( - "Ignoring http_referer containing newline characters" - ) - referer = None - else: - headers = {"Referer": referer} + if self._http_referer: + headers = {"Referer": self._http_referer} async with self._session.get( url, params=params, diff --git a/custom_components/pollenlevels/config_flow.py b/custom_components/pollenlevels/config_flow.py index 3aba75be..1760bb61 100644 --- a/custom_components/pollenlevels/config_flow.py +++ b/custom_components/pollenlevels/config_flow.py @@ -420,26 +420,19 @@ async def async_step_user(self, user_input=None): raw_http_referer = section_values.get(CONF_HTTP_REFERER) sanitized_input.pop(SECTION_API_KEY_OPTIONS, None) - http_referer: str | None = None - try: - http_referer = normalize_http_referer(raw_http_referer) - except ValueError: - errors[CONF_HTTP_REFERER] = "invalid_http_referrer" + sanitized_input.pop(CONF_HTTP_REFERER, None) + if raw_http_referer: + sanitized_input[CONF_HTTP_REFERER] = raw_http_referer - if not errors: - sanitized_input.pop(CONF_HTTP_REFERER, None) - if http_referer: - sanitized_input[CONF_HTTP_REFERER] = http_referer - - errors, normalized = await self._async_validate_input( - sanitized_input, - check_unique_id=True, - description_placeholders=description_placeholders, - ) - if not errors and normalized is not None: - entry_name = str(user_input.get(CONF_NAME, "")).strip() - title = entry_name or DEFAULT_ENTRY_TITLE - return self.async_create_entry(title=title, data=normalized) + errors, normalized = await self._async_validate_input( + sanitized_input, + check_unique_id=True, + description_placeholders=description_placeholders, + ) + if not errors and normalized is not None: + entry_name = str(user_input.get(CONF_NAME, "")).strip() + title = entry_name or DEFAULT_ENTRY_TITLE + return self.async_create_entry(title=title, data=normalized) base_schema = STEP_USER_DATA_SCHEMA.schema.copy() base_schema.update(_get_location_schema(self.hass).schema) diff --git a/custom_components/pollenlevels/translations/nb.json b/custom_components/pollenlevels/translations/nb.json index a949ee95..a732fa9d 100644 --- a/custom_components/pollenlevels/translations/nb.json +++ b/custom_components/pollenlevels/translations/nb.json @@ -10,13 +10,13 @@ "empty": "Dette feltet kan ikke være tomt", "invalid_auth": "Ugyldig API-nøkkel\n\n{error_message}", "invalid_coordinates": "Velg en gyldig posisjon på kartet.", - "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters.", + "invalid_http_referrer": "Ugyldig HTTP Referer-verdi. Den kan ikke inneholde linjeskift.", "invalid_language": "Ugyldig språkkode", "invalid_language_format": "Bruk en kanonisk BCP-47-kode som \"en\" eller \"es-ES\".", "invalid_option_combo": "Øk «Prognosedager» for å dekke valgte sensorer per dag.", "quota_exceeded": "Kvote overskredet\n\n{error_message}", "unknown": "Ukjent feil", - "invalid_update_interval": "Update interval must be at least 1 hour." + "invalid_update_interval": "Oppdateringsintervallet må være minst 1 time." }, "step": { "reauth_confirm": { @@ -35,15 +35,15 @@ "update_interval": "Oppdateringsintervall (timer)", "http_referer": "HTTP Referer" }, - "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", + "description": "Oppgi Google API-nøkkelen din ([få den her]({api_key_url})) og les beste praksis ([beste praksis]({restricting_api_keys_url})). Velg posisjonen din på kartet, oppdateringsintervallet (timer) og språkkoden for API-svaret.", "title": "Konfigurasjon av pollennivåer", "sections": { "api_key_options": { - "name": "Optional API key options" + "name": "Valgfrie API-nøkkelalternativer" } }, "data_description": { - "http_referer": "Only needed if your API key is restricted by HTTP Referers (websites)." + "http_referer": "Bare nødvendig hvis API-nøkkelen din er begrenset av HTTP Referer (nettsteder)." } } } @@ -82,7 +82,7 @@ "invalid_option_combo": "Øk «Prognosedager» for å dekke valgte sensorer per dag.", "quota_exceeded": "Kvote overskredet\n\n{error_message}", "unknown": "Ukjent feil", - "invalid_update_interval": "Update interval must be at least 1 hour." + "invalid_update_interval": "Oppdateringsintervallet må være minst 1 time." }, "step": { "init": { diff --git a/custom_components/pollenlevels/translations/nl.json b/custom_components/pollenlevels/translations/nl.json index e673b6a3..f0362b29 100644 --- a/custom_components/pollenlevels/translations/nl.json +++ b/custom_components/pollenlevels/translations/nl.json @@ -10,13 +10,13 @@ "empty": "Dit veld mag niet leeg zijn", "invalid_auth": "Ongeldige API-sleutel\n\n{error_message}", "invalid_coordinates": "Selecteer een geldige locatie op de kaart.", - "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters.", + "invalid_http_referrer": "Ongeldige HTTP Referer-waarde. Deze mag geen regeleinden bevatten.", "invalid_language": "Ongeldige taalcode", "invalid_language_format": "Gebruik een canonieke BCP-47-code zoals \"en\" of \"es-ES\".", "invalid_option_combo": "Verhoog 'Voorspellingsdagen' om de geselecteerde per-dag sensoren te dekken.", "quota_exceeded": "Limiet overschreden\n\n{error_message}", "unknown": "Onbekende fout", - "invalid_update_interval": "Update interval must be at least 1 hour." + "invalid_update_interval": "Het update-interval moet minimaal 1 uur zijn." }, "step": { "reauth_confirm": { @@ -35,15 +35,15 @@ "update_interval": "Update-interval (uren)", "http_referer": "HTTP Referer" }, - "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", + "description": "Voer je Google API-sleutel in ([haal hem hier]({api_key_url})) en bekijk de best practices ([best practices]({restricting_api_keys_url})). Selecteer je locatie op de kaart, het update-interval (uren) en de taalcode van de API-respons.", "title": "Pollen Levels – Configuratie", "sections": { "api_key_options": { - "name": "Optional API key options" + "name": "Optionele API-sleutelopties" } }, "data_description": { - "http_referer": "Only needed if your API key is restricted by HTTP Referers (websites)." + "http_referer": "Alleen nodig als je API-sleutel is beperkt tot HTTP Referer (websites)." } } } @@ -82,7 +82,7 @@ "invalid_option_combo": "Verhoog 'Voorspellingsdagen' om de geselecteerde per-dag sensoren te dekken.", "quota_exceeded": "Limiet overschreden\n\n{error_message}", "unknown": "Onbekende fout", - "invalid_update_interval": "Update interval must be at least 1 hour." + "invalid_update_interval": "Het update-interval moet minimaal 1 uur zijn." }, "step": { "init": { diff --git a/custom_components/pollenlevels/translations/pl.json b/custom_components/pollenlevels/translations/pl.json index 915b3bf6..35a700bd 100644 --- a/custom_components/pollenlevels/translations/pl.json +++ b/custom_components/pollenlevels/translations/pl.json @@ -10,13 +10,13 @@ "empty": "To pole nie może być puste", "invalid_auth": "Nieprawidłowy klucz API\n\n{error_message}", "invalid_coordinates": "Wybierz prawidłową lokalizację na mapie.", - "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters.", + "invalid_http_referrer": "Nieprawidłowa wartość HTTP Referer. Nie może zawierać znaków nowej linii.", "invalid_language": "Nieprawidłowy kod języka", "invalid_language_format": "Użyj kanonicznego kodu BCP-47, np. \"en\" lub \"es-ES\".", "invalid_option_combo": "Zwiększ 'Dni prognozy', aby objąć wybrane czujniki dzienne.", "quota_exceeded": "Przekroczono limit\n\n{error_message}", "unknown": "Nieznany błąd", - "invalid_update_interval": "Update interval must be at least 1 hour." + "invalid_update_interval": "Interwał aktualizacji musi wynosić co najmniej 1 godzinę." }, "step": { "reauth_confirm": { @@ -35,15 +35,15 @@ "update_interval": "Interwał aktualizacji (godziny)", "http_referer": "HTTP Referer" }, - "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", + "description": "Wprowadź swój klucz Google API ([uzyskaj go tutaj]({api_key_url})) i zapoznaj się z dobrymi praktykami ([dobre praktyki]({restricting_api_keys_url})). Wybierz lokalizację na mapie, interwał aktualizacji (godziny) oraz kod języka odpowiedzi API.", "title": "Konfiguracja poziomów pyłku", "sections": { "api_key_options": { - "name": "Optional API key options" + "name": "Opcjonalne opcje klucza API" } }, "data_description": { - "http_referer": "Only needed if your API key is restricted by HTTP Referers (websites)." + "http_referer": "Wymagane tylko, jeśli Twój klucz API jest ograniczony przez HTTP Referer (witryny)." } } } @@ -82,7 +82,7 @@ "invalid_option_combo": "Zwiększ 'Dni prognozy', aby objąć wybrane czujniki dzienne.", "quota_exceeded": "Przekroczono limit\n\n{error_message}", "unknown": "Nieznany błąd", - "invalid_update_interval": "Update interval must be at least 1 hour." + "invalid_update_interval": "Interwał aktualizacji musi wynosić co najmniej 1 godzinę." }, "step": { "init": { diff --git a/custom_components/pollenlevels/translations/pt-BR.json b/custom_components/pollenlevels/translations/pt-BR.json index 09e0315c..74e3ca68 100644 --- a/custom_components/pollenlevels/translations/pt-BR.json +++ b/custom_components/pollenlevels/translations/pt-BR.json @@ -10,13 +10,13 @@ "empty": "Este campo não pode ficar vazio", "invalid_auth": "Chave de API inválida\n\n{error_message}", "invalid_coordinates": "Selecione um local válido no mapa.", - "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters.", + "invalid_http_referrer": "Valor de HTTP Referer inválido. Não deve conter quebras de linha.", "invalid_language": "Código de idioma inválido", "invalid_language_format": "Use um código BCP-47 canônico, como \"en\" ou \"es-ES\".", "invalid_option_combo": "Aumente \"Dias de previsão\" para cobrir os sensores por dia selecionados.", "quota_exceeded": "Cota excedida\n\n{error_message}", "unknown": "Erro desconhecido", - "invalid_update_interval": "Update interval must be at least 1 hour." + "invalid_update_interval": "O intervalo de atualização deve ser de pelo menos 1 hora." }, "step": { "reauth_confirm": { @@ -35,15 +35,15 @@ "update_interval": "Intervalo de atualização (horas)", "http_referer": "HTTP Referer" }, - "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", + "description": "Insira sua chave de API do Google ([obtenha aqui]({api_key_url})) e consulte as melhores práticas ([melhores práticas]({restricting_api_keys_url})). Selecione sua localização no mapa, o intervalo de atualização (horas) e o código de idioma da resposta da API.", "title": "Configuração dos Níveis de Pólen", "sections": { "api_key_options": { - "name": "Optional API key options" + "name": "Opções opcionais de chave de API" } }, "data_description": { - "http_referer": "Only needed if your API key is restricted by HTTP Referers (websites)." + "http_referer": "Necessário apenas se sua chave de API estiver restrita por HTTP Referer (sites)." } } } @@ -82,7 +82,7 @@ "invalid_option_combo": "Aumente \"Dias de previsão\" para cobrir os sensores por dia selecionados.", "quota_exceeded": "Cota excedida\n\n{error_message}", "unknown": "Erro desconhecido", - "invalid_update_interval": "Update interval must be at least 1 hour." + "invalid_update_interval": "O intervalo de atualização deve ser de pelo menos 1 hora." }, "step": { "init": { diff --git a/custom_components/pollenlevels/translations/pt-PT.json b/custom_components/pollenlevels/translations/pt-PT.json index 3c4b92e0..9f8747d7 100644 --- a/custom_components/pollenlevels/translations/pt-PT.json +++ b/custom_components/pollenlevels/translations/pt-PT.json @@ -10,13 +10,13 @@ "empty": "Este campo não pode estar vazio", "invalid_auth": "Chave da API inválida\n\n{error_message}", "invalid_coordinates": "Selecione uma localização válida no mapa.", - "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters.", + "invalid_http_referrer": "Valor de HTTP Referer inválido. Não pode conter quebras de linha.", "invalid_language": "Código de idioma inválido", "invalid_language_format": "Use um código BCP-47 canónico, como \"en\" ou \"es-ES\".", "invalid_option_combo": "Aumente \"Dias de previsão\" para cobrir os sensores por dia selecionados.", "quota_exceeded": "Quota excedida\n\n{error_message}", "unknown": "Erro desconhecido", - "invalid_update_interval": "Update interval must be at least 1 hour." + "invalid_update_interval": "O intervalo de atualização deve ser de pelo menos 1 hora." }, "step": { "reauth_confirm": { @@ -35,15 +35,15 @@ "update_interval": "Intervalo de atualização (horas)", "http_referer": "HTTP Referer" }, - "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", + "description": "Introduza a sua chave de API do Google ([obtenha-a aqui]({api_key_url})) e reveja as melhores práticas ([melhores práticas]({restricting_api_keys_url})). Selecione a sua localização no mapa, o intervalo de atualização (horas) e o código de idioma da resposta da API.", "title": "Configuração dos Níveis de Pólen", "sections": { "api_key_options": { - "name": "Optional API key options" + "name": "Opções opcionais da chave de API" } }, "data_description": { - "http_referer": "Only needed if your API key is restricted by HTTP Referers (websites)." + "http_referer": "Apenas necessário se a sua chave de API estiver limitada por HTTP Referer (sites)." } } } @@ -82,7 +82,7 @@ "invalid_option_combo": "Aumente \"Dias de previsão\" para cobrir os sensores por dia selecionados.", "quota_exceeded": "Quota excedida\n\n{error_message}", "unknown": "Erro desconhecido", - "invalid_update_interval": "Update interval must be at least 1 hour." + "invalid_update_interval": "O intervalo de atualização deve ser de pelo menos 1 hora." }, "step": { "init": { diff --git a/custom_components/pollenlevels/translations/ro.json b/custom_components/pollenlevels/translations/ro.json index 9a8c94bb..07b8d0e7 100644 --- a/custom_components/pollenlevels/translations/ro.json +++ b/custom_components/pollenlevels/translations/ro.json @@ -10,13 +10,13 @@ "empty": "Acest câmp nu poate fi gol", "invalid_auth": "Cheie API nevalidă\n\n{error_message}", "invalid_coordinates": "Selectează o locație validă pe hartă.", - "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters.", + "invalid_http_referrer": "Valoare HTTP Referer invalidă. Nu trebuie să conțină caractere de linie nouă.", "invalid_language": "Cod de limbă nevalid", "invalid_language_format": "Folosiți un cod BCP-47 canonic, de exemplu \"en\" sau \"es-ES\".", "invalid_option_combo": "Măriți \"Zilele de prognoză\" pentru a acoperi senzorii selectați pe zile.", "quota_exceeded": "Cota depășită\n\n{error_message}", "unknown": "Eroare necunoscută", - "invalid_update_interval": "Update interval must be at least 1 hour." + "invalid_update_interval": "Intervalul de actualizare trebuie să fie de cel puțin 1 oră." }, "step": { "reauth_confirm": { @@ -35,15 +35,15 @@ "update_interval": "Interval de actualizare (ore)", "http_referer": "HTTP Referer" }, - "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", + "description": "Introdu cheia ta API Google ([obține-o aici]({api_key_url})) și consultă cele mai bune practici ([cele mai bune practici]({restricting_api_keys_url})). Selectează locația pe hartă, intervalul de actualizare (ore) și codul de limbă al răspunsului API.", "title": "Configurare Niveluri de Polen", "sections": { "api_key_options": { - "name": "Optional API key options" + "name": "Opțiuni opționale pentru cheia API" } }, "data_description": { - "http_referer": "Only needed if your API key is restricted by HTTP Referers (websites)." + "http_referer": "Necesar doar dacă cheia API este restricționată după HTTP Referer (site-uri web)." } } } @@ -82,7 +82,7 @@ "invalid_option_combo": "Măriți \"Zilele de prognoză\" pentru a acoperi senzorii selectați pe zile.", "quota_exceeded": "Cota depășită\n\n{error_message}", "unknown": "Eroare necunoscută", - "invalid_update_interval": "Update interval must be at least 1 hour." + "invalid_update_interval": "Intervalul de actualizare trebuie să fie de cel puțin 1 oră." }, "step": { "init": { diff --git a/custom_components/pollenlevels/translations/ru.json b/custom_components/pollenlevels/translations/ru.json index ad425821..87ccc0c0 100644 --- a/custom_components/pollenlevels/translations/ru.json +++ b/custom_components/pollenlevels/translations/ru.json @@ -10,13 +10,13 @@ "empty": "Это поле не может быть пустым", "invalid_auth": "Неверный ключ API\n\n{error_message}", "invalid_coordinates": "Выберите корректное местоположение на карте.", - "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters.", + "invalid_http_referrer": "Неверное значение HTTP Referer. Оно не должно содержать символы новой строки.", "invalid_language": "Неверный код языка", "invalid_language_format": "Используйте канонический код BCP-47, например \"en\" или \"es-ES\".", "invalid_option_combo": "Увеличьте «Дни прогноза», чтобы охватить выбранные датчики по дням.", "quota_exceeded": "Превышен лимит запросов\n\n{error_message}", "unknown": "Неизвестная ошибка", - "invalid_update_interval": "Update interval must be at least 1 hour." + "invalid_update_interval": "Интервал обновления должен быть не менее 1 часа." }, "step": { "reauth_confirm": { @@ -35,15 +35,15 @@ "update_interval": "Интервал обновления (в часах)", "http_referer": "HTTP Referer" }, - "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", + "description": "Введите ключ Google API ([получите его здесь]({api_key_url})) и изучите рекомендации ([лучшие практики]({restricting_api_keys_url})). Выберите местоположение на карте, интервал обновления (часы) и языковой код ответа API.", "title": "Настройка уровней пыльцы", "sections": { "api_key_options": { - "name": "Optional API key options" + "name": "Дополнительные параметры ключа API" } }, "data_description": { - "http_referer": "Only needed if your API key is restricted by HTTP Referers (websites)." + "http_referer": "Нужно только если ваш ключ API ограничен HTTP Referer (веб-сайты)." } } } @@ -82,7 +82,7 @@ "invalid_option_combo": "Увеличьте «Дни прогноза», чтобы охватить выбранные датчики по дням.", "quota_exceeded": "Превышен лимит запросов\n\n{error_message}", "unknown": "Неизвестная ошибка", - "invalid_update_interval": "Update interval must be at least 1 hour." + "invalid_update_interval": "Интервал обновления должен быть не менее 1 часа." }, "step": { "init": { diff --git a/custom_components/pollenlevels/translations/sv.json b/custom_components/pollenlevels/translations/sv.json index e59b2ca3..5fb168da 100644 --- a/custom_components/pollenlevels/translations/sv.json +++ b/custom_components/pollenlevels/translations/sv.json @@ -10,13 +10,13 @@ "empty": "Detta fält får inte vara tomt", "invalid_auth": "Ogiltig API-nyckel\n\n{error_message}", "invalid_coordinates": "Välj en giltig plats på kartan.", - "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters.", + "invalid_http_referrer": "Ogiltigt HTTP Referer-värde. Det får inte innehålla radbrytningar.", "invalid_language": "Ogiltig språkkod", "invalid_language_format": "Använd en kanonisk BCP-47-kod som \"en\" eller \"es-ES\".", "invalid_option_combo": "Öka \"Prognosdagar\" för att täcka valda sensorer per dag.", "quota_exceeded": "Kvoten har överskridits\n\n{error_message}", "unknown": "Okänt fel", - "invalid_update_interval": "Update interval must be at least 1 hour." + "invalid_update_interval": "Uppdateringsintervallet måste vara minst 1 timme." }, "step": { "reauth_confirm": { @@ -35,15 +35,15 @@ "update_interval": "Uppdateringsintervall (timmar)", "http_referer": "HTTP Referer" }, - "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", + "description": "Ange din Google API-nyckel ([hämta den här]({api_key_url})) och läs bästa praxis ([bästa praxis]({restricting_api_keys_url})). Välj din plats på kartan, uppdateringsintervallet (timmar) och språkkoden för API-svaret.", "title": "Konfiguration av pollennivåer", "sections": { "api_key_options": { - "name": "Optional API key options" + "name": "Valfria API-nyckelalternativ" } }, "data_description": { - "http_referer": "Only needed if your API key is restricted by HTTP Referers (websites)." + "http_referer": "Endast nödvändigt om din API-nyckel är begränsad av HTTP Referer (webbplatser)." } } } @@ -82,7 +82,7 @@ "invalid_option_combo": "Öka \"Prognosdagar\" för att täcka valda sensorer per dag.", "quota_exceeded": "Kvoten har överskridits\n\n{error_message}", "unknown": "Okänt fel", - "invalid_update_interval": "Update interval must be at least 1 hour." + "invalid_update_interval": "Uppdateringsintervallet måste vara minst 1 timme." }, "step": { "init": { diff --git a/custom_components/pollenlevels/translations/uk.json b/custom_components/pollenlevels/translations/uk.json index 57cccc58..d41890b2 100644 --- a/custom_components/pollenlevels/translations/uk.json +++ b/custom_components/pollenlevels/translations/uk.json @@ -10,13 +10,13 @@ "empty": "Це поле не може бути порожнім", "invalid_auth": "Невірний ключ API\n\n{error_message}", "invalid_coordinates": "Виберіть дійсне місце на карті.", - "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters.", + "invalid_http_referrer": "Неприпустиме значення HTTP Referer. Воно не повинно містити символів нового рядка.", "invalid_language": "Невірний код мови", "invalid_language_format": "Використовуйте канонічний код BCP-47, наприклад \"en\" або \"es-ES\".", "invalid_option_combo": "Збільшіть «Дні прогнозу», щоб охопити вибрані денні датчики.", "quota_exceeded": "Перевищено ліміт запитів\n\n{error_message}", "unknown": "Невідома помилка", - "invalid_update_interval": "Update interval must be at least 1 hour." + "invalid_update_interval": "Інтервал оновлення має бути щонайменше 1 година." }, "step": { "reauth_confirm": { @@ -35,15 +35,15 @@ "update_interval": "Інтервал оновлення (у годинах)", "http_referer": "HTTP Referer" }, - "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", + "description": "Введіть свій ключ Google API ([отримайте його тут]({api_key_url})) та ознайомтеся з найкращими практиками ([найкращі практики]({restricting_api_keys_url})). Виберіть місце на карті, інтервал оновлення (години) і код мови відповіді API.", "title": "Налаштування рівнів пилку", "sections": { "api_key_options": { - "name": "Optional API key options" + "name": "Додаткові параметри ключа API" } }, "data_description": { - "http_referer": "Only needed if your API key is restricted by HTTP Referers (websites)." + "http_referer": "Потрібно лише якщо ваш ключ API обмежено через HTTP Referer (вебсайти)." } } } @@ -82,7 +82,7 @@ "invalid_option_combo": "Збільшіть «Дні прогнозу», щоб охопити вибрані денні датчики.", "quota_exceeded": "Перевищено ліміт запитів\n\n{error_message}", "unknown": "Невідома помилка", - "invalid_update_interval": "Update interval must be at least 1 hour." + "invalid_update_interval": "Інтервал оновлення має бути щонайменше 1 година." }, "step": { "init": { diff --git a/custom_components/pollenlevels/translations/zh-Hans.json b/custom_components/pollenlevels/translations/zh-Hans.json index 698ca7c2..e242665f 100644 --- a/custom_components/pollenlevels/translations/zh-Hans.json +++ b/custom_components/pollenlevels/translations/zh-Hans.json @@ -10,13 +10,13 @@ "empty": "此字段不能为空", "invalid_auth": "无效的 API 密钥\n\n{error_message}", "invalid_coordinates": "请在地图上选择有效的位置。", - "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters.", + "invalid_http_referrer": "无效的 HTTP Referer 值。它不能包含换行符。", "invalid_language": "无效的语言代码", "invalid_language_format": "请使用规范的 BCP-47 代码,例如 \"en\" 或 \"es-ES\"。", "invalid_option_combo": "请增加“预测天数”,以覆盖所选的逐日类型传感器。", "quota_exceeded": "配额已用尽\n\n{error_message}", "unknown": "未知错误", - "invalid_update_interval": "Update interval must be at least 1 hour." + "invalid_update_interval": "更新间隔必须至少为 1 小时。" }, "step": { "reauth_confirm": { @@ -35,15 +35,15 @@ "update_interval": "更新间隔(小时)", "http_referer": "HTTP Referer" }, - "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", + "description": "输入你的 Google API 密钥([在此获取]({api_key_url}))并查看最佳实践([最佳实践]({restricting_api_keys_url})). 在地图上选择位置、更新间隔(小时)以及 API 响应的语言代码。", "title": "花粉水平配置", "sections": { "api_key_options": { - "name": "Optional API key options" + "name": "可选的 API 密钥选项" } }, "data_description": { - "http_referer": "Only needed if your API key is restricted by HTTP Referers (websites)." + "http_referer": "仅在你的 API 密钥受 HTTP Referer(网站)限制时需要。" } } } @@ -82,7 +82,7 @@ "invalid_option_combo": "请增加“预测天数”,以覆盖所选的逐日类型传感器。", "quota_exceeded": "配额已用尽\n\n{error_message}", "unknown": "未知错误", - "invalid_update_interval": "Update interval must be at least 1 hour." + "invalid_update_interval": "更新间隔必须至少为 1 小时。" }, "step": { "init": { diff --git a/custom_components/pollenlevels/translations/zh-Hant.json b/custom_components/pollenlevels/translations/zh-Hant.json index fc8182a3..1a112061 100644 --- a/custom_components/pollenlevels/translations/zh-Hant.json +++ b/custom_components/pollenlevels/translations/zh-Hant.json @@ -10,13 +10,13 @@ "empty": "此欄位不得為空", "invalid_auth": "無效的 API 金鑰\n\n{error_message}", "invalid_coordinates": "請在地圖上選擇有效的位置。", - "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters.", + "invalid_http_referrer": "無效的 HTTP Referer 值。不得包含換行符。", "invalid_language": "無效的語言代碼", "invalid_language_format": "請使用標準的 BCP-47 代碼,例如 \"en\" 或 \"es-ES\"。", "invalid_option_combo": "請增加「預測天數」以涵蓋所選的逐日類型感測器。", "quota_exceeded": "超出配額\n\n{error_message}", "unknown": "未知錯誤", - "invalid_update_interval": "Update interval must be at least 1 hour." + "invalid_update_interval": "更新間隔必須至少為 1 小時。" }, "step": { "reauth_confirm": { @@ -35,15 +35,15 @@ "update_interval": "更新間隔(小時)", "http_referer": "HTTP Referer" }, - "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", + "description": "輸入你的 Google API 金鑰([在此取得]({api_key_url})) 並查看最佳實務([最佳實務]({restricting_api_keys_url})). 在地圖上選擇位置、更新間隔(小時)以及 API 回應的語言代碼。", "title": "花粉水平設定", "sections": { "api_key_options": { - "name": "Optional API key options" + "name": "可選的 API 金鑰選項" } }, "data_description": { - "http_referer": "Only needed if your API key is restricted by HTTP Referers (websites)." + "http_referer": "僅在你的 API 金鑰受 HTTP Referer(網站)限制時需要。" } } } @@ -82,7 +82,7 @@ "invalid_option_combo": "請增加「預測天數」以涵蓋所選的逐日類型感測器。", "quota_exceeded": "超出配額\n\n{error_message}", "unknown": "未知錯誤", - "invalid_update_interval": "Update interval must be at least 1 hour." + "invalid_update_interval": "更新間隔必須至少為 1 小時。" }, "step": { "init": { diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 7d30b2b4..3589e950 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -317,6 +317,7 @@ def __init__(self, schema): CONF_LANGUAGE_CODE, CONF_UPDATE_INTERVAL, DEFAULT_ENTRY_TITLE, + normalize_http_referer, ) @@ -543,13 +544,14 @@ def test_async_step_user_persists_http_referer() -> None: async def fake_validate( user_input, *, check_unique_id, description_placeholders=None ): - assert user_input[CONF_HTTP_REFERER] == "https://example.com" + http_referer = normalize_http_referer(user_input.get(CONF_HTTP_REFERER)) + assert http_referer == "https://example.com" normalized = { CONF_API_KEY: user_input[CONF_API_KEY], CONF_LATITUDE: 1.0, CONF_LONGITUDE: 2.0, CONF_LANGUAGE_CODE: "en", - CONF_HTTP_REFERER: "https://example.com", + CONF_HTTP_REFERER: http_referer, } return {}, normalized @@ -578,7 +580,8 @@ def test_async_step_user_drops_blank_http_referer() -> None: async def fake_validate( user_input, *, check_unique_id, description_placeholders=None ): - assert CONF_HTTP_REFERER not in user_input + http_referer = normalize_http_referer(user_input.get(CONF_HTTP_REFERER)) + assert http_referer is None normalized = { CONF_API_KEY: user_input[CONF_API_KEY], CONF_LATITUDE: 1.0, From 6decd0695d633bbb1126c3a9a10949dca7fbcd0d Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Tue, 16 Dec 2025 10:59:08 +0100 Subject: [PATCH 046/200] Align Referer wording across translations --- custom_components/pollenlevels/translations/ca.json | 2 +- custom_components/pollenlevels/translations/cs.json | 2 +- custom_components/pollenlevels/translations/da.json | 2 +- custom_components/pollenlevels/translations/de.json | 2 +- custom_components/pollenlevels/translations/en.json | 2 +- custom_components/pollenlevels/translations/es.json | 2 +- custom_components/pollenlevels/translations/fi.json | 2 +- custom_components/pollenlevels/translations/fr.json | 2 +- custom_components/pollenlevels/translations/hu.json | 2 +- custom_components/pollenlevels/translations/it.json | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/custom_components/pollenlevels/translations/ca.json b/custom_components/pollenlevels/translations/ca.json index c840f391..98723cfc 100644 --- a/custom_components/pollenlevels/translations/ca.json +++ b/custom_components/pollenlevels/translations/ca.json @@ -39,7 +39,7 @@ "invalid_option_combo": "Augmenta els 'Dies de previsió' per cobrir els sensors per dia seleccionats.", "invalid_coordinates": "Selecciona una ubicació vàlida al mapa.", "unknown": "Error desconegut", - "invalid_http_referrer": "Valor de referer HTTP no vàlid. No pot contenir salts de línia.", + "invalid_http_referrer": "Valor de HTTP Referer no vàlid. No pot contenir salts de línia.", "invalid_update_interval": "L’interval d’actualització ha de ser d’almenys 1 hora." }, "abort": { diff --git a/custom_components/pollenlevels/translations/cs.json b/custom_components/pollenlevels/translations/cs.json index 98e4aeba..f14e1c02 100644 --- a/custom_components/pollenlevels/translations/cs.json +++ b/custom_components/pollenlevels/translations/cs.json @@ -10,7 +10,7 @@ "empty": "Toto pole nemůže být prázdné", "invalid_auth": "Neplatný klíč API\n\n{error_message}", "invalid_coordinates": "Vyberte platné umístění na mapě.", - "invalid_http_referrer": "Neplatná hodnota HTTP refereru. Nesmí obsahovat znaky nového řádku.", + "invalid_http_referrer": "Neplatná hodnota HTTP Referer. Nesmí obsahovat znaky nového řádku.", "invalid_language": "Neplatný kód jazyka", "invalid_language_format": "Použijte kanonický kód BCP-47, například \"en\" nebo \"es-ES\".", "invalid_option_combo": "Zvyšte „Dny předpovědi“, aby pokryly vybrané senzory po dnech.", diff --git a/custom_components/pollenlevels/translations/da.json b/custom_components/pollenlevels/translations/da.json index d2dfd940..ee85ec2f 100644 --- a/custom_components/pollenlevels/translations/da.json +++ b/custom_components/pollenlevels/translations/da.json @@ -10,7 +10,7 @@ "empty": "Dette felt må ikke være tomt", "invalid_auth": "Ugyldig API-nøgle\n\n{error_message}", "invalid_coordinates": "Vælg en gyldig placering på kortet.", - "invalid_http_referrer": "Ugyldig HTTP referer-værdi. Den må ikke indeholde linjeskift.", + "invalid_http_referrer": "Ugyldig HTTP Referer-værdi. Den må ikke indeholde linjeskift.", "invalid_language": "Ugyldig sprogkode", "invalid_language_format": "Brug en kanonisk BCP-47-kode som \"en\" eller \"es-ES\".", "invalid_option_combo": "Forøg \"Prognosedage\" for at dække valgte sensorer pr. dag.", diff --git a/custom_components/pollenlevels/translations/de.json b/custom_components/pollenlevels/translations/de.json index c4053396..54eff42a 100644 --- a/custom_components/pollenlevels/translations/de.json +++ b/custom_components/pollenlevels/translations/de.json @@ -10,7 +10,7 @@ "empty": "Dieses Feld darf nicht leer sein", "invalid_auth": "Ungültiger API-Schlüssel\n\n{error_message}", "invalid_coordinates": "Wähle einen gültigen Standort auf der Karte aus.", - "invalid_http_referrer": "Ungültiger HTTP-Referer-Wert. Er darf keine Zeilenumbrüche enthalten.", + "invalid_http_referrer": "Ungültiger HTTP Referer-Wert. Er darf keine Zeilenumbrüche enthalten.", "invalid_language": "Ungültiger Sprachcode", "invalid_language_format": "Verwenden Sie einen kanonischen BCP-47-Code wie \"en\" oder \"es-ES\".", "invalid_option_combo": "Erhöhe 'Vorhersagetage', um die gewählten Tagessensoren abzudecken.", diff --git a/custom_components/pollenlevels/translations/en.json b/custom_components/pollenlevels/translations/en.json index b6afe39d..7426d0cb 100644 --- a/custom_components/pollenlevels/translations/en.json +++ b/custom_components/pollenlevels/translations/en.json @@ -39,7 +39,7 @@ "invalid_option_combo": "Increase 'Forecast days' to cover selected per-day sensors.", "invalid_coordinates": "Please select a valid location on the map.", "unknown": "Unknown error", - "invalid_http_referrer": "Invalid HTTP referrer value. It must not contain newline characters.", + "invalid_http_referrer": "Invalid HTTP Referer value. It must not contain newline characters.", "invalid_update_interval": "Update interval must be at least 1 hour." }, "abort": { diff --git a/custom_components/pollenlevels/translations/es.json b/custom_components/pollenlevels/translations/es.json index 81ce9f69..cb29e79c 100644 --- a/custom_components/pollenlevels/translations/es.json +++ b/custom_components/pollenlevels/translations/es.json @@ -39,7 +39,7 @@ "invalid_option_combo": "Aumenta 'Días de previsión' para cubrir los sensores por día seleccionados.", "invalid_coordinates": "Selecciona una ubicación válida en el mapa.", "unknown": "Error desconocido", - "invalid_http_referrer": "Valor de referer HTTP no válido. No debe contener saltos de línea.", + "invalid_http_referrer": "Valor de HTTP Referer no válido. No debe contener saltos de línea.", "invalid_update_interval": "El intervalo de actualización debe ser de al menos 1 hora." }, "abort": { diff --git a/custom_components/pollenlevels/translations/fi.json b/custom_components/pollenlevels/translations/fi.json index 1b23b5c4..9dd57179 100644 --- a/custom_components/pollenlevels/translations/fi.json +++ b/custom_components/pollenlevels/translations/fi.json @@ -10,7 +10,7 @@ "empty": "Tämä kenttä ei voi olla tyhjä", "invalid_auth": "Virheellinen API-avain\n\n{error_message}", "invalid_coordinates": "Valitse kartalta kelvollinen sijainti.", - "invalid_http_referrer": "Virheellinen HTTP referer -arvo. Se ei saa sisältää rivinvaihtomerkkejä.", + "invalid_http_referrer": "Virheellinen HTTP Referer -arvo. Se ei saa sisältää rivinvaihtomerkkejä.", "invalid_language": "Virheellinen kielikoodi", "invalid_language_format": "Käytä kanonista BCP-47-koodia, esimerkiksi \"en\" tai \"es-ES\".", "invalid_option_combo": "Lisää \"Ennustepäiviä\", jotta valitut päiväsensorit katetaan.", diff --git a/custom_components/pollenlevels/translations/fr.json b/custom_components/pollenlevels/translations/fr.json index e88afe48..0cbb6428 100644 --- a/custom_components/pollenlevels/translations/fr.json +++ b/custom_components/pollenlevels/translations/fr.json @@ -10,7 +10,7 @@ "empty": "Ce champ ne peut pas être vide", "invalid_auth": "Clé API invalide\n\n{error_message}", "invalid_coordinates": "Sélectionnez un emplacement valide sur la carte.", - "invalid_http_referrer": "Valeur de HTTP referer invalide. Elle ne doit pas contenir de retours à la ligne.", + "invalid_http_referrer": "Valeur de HTTP Referer invalide. Elle ne doit pas contenir de retours à la ligne.", "invalid_language": "Code de langue invalide", "invalid_language_format": "Utilisez un code BCP-47 canonique tel que \"en\" ou \"es-ES\".", "invalid_option_combo": "Augmentez le nombre de « Jours de prévision » afin de couvrir les capteurs par jour sélectionnés.", diff --git a/custom_components/pollenlevels/translations/hu.json b/custom_components/pollenlevels/translations/hu.json index aa8b12c5..d825169e 100644 --- a/custom_components/pollenlevels/translations/hu.json +++ b/custom_components/pollenlevels/translations/hu.json @@ -10,7 +10,7 @@ "empty": "A mező nem lehet üres", "invalid_auth": "Érvénytelen API-kulcs\n\n{error_message}", "invalid_coordinates": "Válassz érvényes helyet a térképen.", - "invalid_http_referrer": "Érvénytelen HTTP referer érték. Nem tartalmazhat sortörés karaktereket.", + "invalid_http_referrer": "Érvénytelen HTTP Referer érték. Nem tartalmazhat sortörés karaktereket.", "invalid_language": "Érvénytelen nyelvi kód", "invalid_language_format": "Használjon kanonikus BCP-47 kódot, például \"en\" vagy \"es-ES\".", "invalid_option_combo": "Növeld a \"Előrejelzési napok\" értéket a kiválasztott napi szenzorok lefedéséhez.", diff --git a/custom_components/pollenlevels/translations/it.json b/custom_components/pollenlevels/translations/it.json index 899db6d7..12228ab6 100644 --- a/custom_components/pollenlevels/translations/it.json +++ b/custom_components/pollenlevels/translations/it.json @@ -10,7 +10,7 @@ "empty": "Questo campo non può essere vuoto", "invalid_auth": "Chiave API non valida\n\n{error_message}", "invalid_coordinates": "Seleziona una posizione valida sulla mappa.", - "invalid_http_referrer": "Valore di referer HTTP non valido. Non deve contenere caratteri di nuova riga.", + "invalid_http_referrer": "Valore di HTTP Referer non valido. Non deve contenere caratteri di nuova riga.", "invalid_language": "Codice lingua non valido", "invalid_language_format": "Usa un codice BCP-47 canonico come \"en\" o \"es-ES\".", "invalid_option_combo": "Aumenta 'Giorni di previsione' per coprire i sensori giornalieri selezionati.", From 7dc37bbe13602ca3571d04381862af234cd7d657 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Tue, 16 Dec 2025 11:03:00 +0100 Subject: [PATCH 047/200] Fix Italian HTTP Referer wording --- custom_components/pollenlevels/translations/it.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/custom_components/pollenlevels/translations/it.json b/custom_components/pollenlevels/translations/it.json index 12228ab6..6a8e82e6 100644 --- a/custom_components/pollenlevels/translations/it.json +++ b/custom_components/pollenlevels/translations/it.json @@ -10,7 +10,7 @@ "empty": "Questo campo non può essere vuoto", "invalid_auth": "Chiave API non valida\n\n{error_message}", "invalid_coordinates": "Seleziona una posizione valida sulla mappa.", - "invalid_http_referrer": "Valore di HTTP Referer non valido. Non deve contenere caratteri di nuova riga.", + "invalid_http_referrer": "Valore HTTP Referer non valido. Non deve contenere caratteri di nuova riga.", "invalid_language": "Codice lingua non valido", "invalid_language_format": "Usa un codice BCP-47 canonico come \"en\" o \"es-ES\".", "invalid_option_combo": "Aumenta 'Giorni di previsione' per coprire i sensori giornalieri selezionati.", @@ -43,7 +43,7 @@ } }, "data_description": { - "http_referer": "Necessario solo se la tua chiave API è limitata dai Referer HTTP (siti web)." + "http_referer": "Necessario solo se la tua chiave API è limitata da HTTP Referer (siti web)." } } } @@ -103,4 +103,4 @@ "name": "Forza aggiornamento" } } -} \ No newline at end of file +} From fb174bbd2dae369d1e76094164e328ccef11a2c2 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Tue, 16 Dec 2025 11:03:09 +0100 Subject: [PATCH 048/200] Fix Chinese Referer descriptions --- custom_components/pollenlevels/translations/zh-Hans.json | 4 ++-- custom_components/pollenlevels/translations/zh-Hant.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/custom_components/pollenlevels/translations/zh-Hans.json b/custom_components/pollenlevels/translations/zh-Hans.json index e242665f..650ffa77 100644 --- a/custom_components/pollenlevels/translations/zh-Hans.json +++ b/custom_components/pollenlevels/translations/zh-Hans.json @@ -35,7 +35,7 @@ "update_interval": "更新间隔(小时)", "http_referer": "HTTP Referer" }, - "description": "输入你的 Google API 密钥([在此获取]({api_key_url}))并查看最佳实践([最佳实践]({restricting_api_keys_url})). 在地图上选择位置、更新间隔(小时)以及 API 响应的语言代码。", + "description": "输入你的 Google API 密钥([在此获取]({api_key_url}))并查看最佳实践([最佳实践]({restricting_api_keys_url}))。在地图上选择位置、更新间隔(小时)以及 API 响应的语言代码。", "title": "花粉水平配置", "sections": { "api_key_options": { @@ -103,4 +103,4 @@ "name": "强制更新" } } -} \ No newline at end of file +} diff --git a/custom_components/pollenlevels/translations/zh-Hant.json b/custom_components/pollenlevels/translations/zh-Hant.json index 1a112061..2f184b5d 100644 --- a/custom_components/pollenlevels/translations/zh-Hant.json +++ b/custom_components/pollenlevels/translations/zh-Hant.json @@ -35,7 +35,7 @@ "update_interval": "更新間隔(小時)", "http_referer": "HTTP Referer" }, - "description": "輸入你的 Google API 金鑰([在此取得]({api_key_url})) 並查看最佳實務([最佳實務]({restricting_api_keys_url})). 在地圖上選擇位置、更新間隔(小時)以及 API 回應的語言代碼。", + "description": "輸入你的 Google API 金鑰([在此取得]({api_key_url}))並查看最佳實務([最佳實務]({restricting_api_keys_url}))。在地圖上選擇位置、更新間隔(小時)以及 API 回應的語言代碼。", "title": "花粉水平設定", "sections": { "api_key_options": { @@ -103,4 +103,4 @@ "name": "強制更新" } } -} \ No newline at end of file +} From 9de8ec39978ebffeb350f440330e836405652806 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Tue, 16 Dec 2025 11:03:15 +0100 Subject: [PATCH 049/200] Update changelog date and notes --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8597ba67..4a4a4646 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,5 @@ # Changelog -## [1.9.0-alpha2] - 2025-12-15 +## [1.9.0-alpha2] - 2025-12-16 ### Changed - Added stable constants for HTTP referrer support and API key helper URLs to support upcoming flow updates. @@ -20,6 +20,8 @@ config validation and the runtime client to keep diagnostics consistent. - Standardized translation locales for HTTP Referer wording, quota-exceeded details, and invalid update interval errors to keep UI feedback aligned. +- Simplified HTTP Referer handling during validation/runtime and finalized + locale wording, including corrected Chinese setup descriptions. ## [1.9.0-alpha1] - 2025-12-11 ### Changed From 9390ee4d229238fbdd29399f45a7aa353ce12ef4 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Tue, 16 Dec 2025 11:57:30 +0100 Subject: [PATCH 050/200] Normalize http referer setup handling --- custom_components/pollenlevels/__init__.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/custom_components/pollenlevels/__init__.py b/custom_components/pollenlevels/__init__.py index 3f494f86..ccef9945 100644 --- a/custom_components/pollenlevels/__init__.py +++ b/custom_components/pollenlevels/__init__.py @@ -34,6 +34,7 @@ DEFAULT_FORECAST_DAYS, DEFAULT_UPDATE_INTERVAL, DOMAIN, + normalize_http_referer, ) from .runtime import PollenLevelsConfigEntry, PollenLevelsRuntimeData from .sensor import ForecastSensorMode, PollenDataUpdateCoordinator @@ -115,17 +116,14 @@ async def async_setup_entry( if not api_key: raise ConfigEntryAuthFailed("Missing API key") - raw_http_referer = entry.data.get(CONF_HTTP_REFERER) http_referer: str | None = None - if isinstance(raw_http_referer, str): - candidate = raw_http_referer.strip() - if candidate and "\r" not in candidate and "\n" not in candidate: - http_referer = candidate - elif candidate and ("\r" in candidate or "\n" in candidate): - _LOGGER.warning( - "Ignoring http_referer for entry %s because it contains newline characters", - entry.entry_id, - ) + try: + http_referer = normalize_http_referer(entry.data.get(CONF_HTTP_REFERER)) + except ValueError: + _LOGGER.warning( + "Ignoring http_referer for entry %s because it contains newline characters", + entry.entry_id, + ) raw_title = entry.title or "" clean_title = raw_title.strip() or DEFAULT_ENTRY_TITLE From a3af3d2bfa52b99ad65848999cf80b4cc2c1f522 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Tue, 16 Dec 2025 12:24:03 +0100 Subject: [PATCH 051/200] Update custom_components/pollenlevels/config_flow.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- custom_components/pollenlevels/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/pollenlevels/config_flow.py b/custom_components/pollenlevels/config_flow.py index 1760bb61..39deba8e 100644 --- a/custom_components/pollenlevels/config_flow.py +++ b/custom_components/pollenlevels/config_flow.py @@ -601,7 +601,7 @@ async def async_step_init(self, user_input=None): float(normalized_input.get(CONF_FORECAST_DAYS, current_days)) ) except (TypeError, ValueError): - errors["base"] = "invalid_option_combo" + errors[CONF_FORECAST_DAYS] = "invalid_option_combo" if not errors: try: From d412bc2b3aaf213113f84fbd0e78a99a08fd9158 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Tue, 16 Dec 2025 12:48:25 +0100 Subject: [PATCH 052/200] Improve forecast days validation errors --- custom_components/pollenlevels/config_flow.py | 4 ++-- custom_components/pollenlevels/translations/ca.json | 5 +++-- custom_components/pollenlevels/translations/cs.json | 5 +++-- custom_components/pollenlevels/translations/da.json | 5 +++-- custom_components/pollenlevels/translations/de.json | 5 +++-- custom_components/pollenlevels/translations/en.json | 3 ++- custom_components/pollenlevels/translations/es.json | 5 +++-- custom_components/pollenlevels/translations/fi.json | 5 +++-- custom_components/pollenlevels/translations/fr.json | 5 +++-- custom_components/pollenlevels/translations/hu.json | 5 +++-- custom_components/pollenlevels/translations/it.json | 3 ++- custom_components/pollenlevels/translations/nb.json | 5 +++-- custom_components/pollenlevels/translations/nl.json | 5 +++-- custom_components/pollenlevels/translations/pl.json | 5 +++-- custom_components/pollenlevels/translations/pt-BR.json | 5 +++-- custom_components/pollenlevels/translations/pt-PT.json | 5 +++-- custom_components/pollenlevels/translations/ro.json | 5 +++-- custom_components/pollenlevels/translations/ru.json | 5 +++-- custom_components/pollenlevels/translations/sv.json | 5 +++-- custom_components/pollenlevels/translations/uk.json | 5 +++-- custom_components/pollenlevels/translations/zh-Hans.json | 3 ++- custom_components/pollenlevels/translations/zh-Hant.json | 3 ++- 22 files changed, 61 insertions(+), 40 deletions(-) diff --git a/custom_components/pollenlevels/config_flow.py b/custom_components/pollenlevels/config_flow.py index 39deba8e..0ebed041 100644 --- a/custom_components/pollenlevels/config_flow.py +++ b/custom_components/pollenlevels/config_flow.py @@ -601,7 +601,7 @@ async def async_step_init(self, user_input=None): float(normalized_input.get(CONF_FORECAST_DAYS, current_days)) ) except (TypeError, ValueError): - errors[CONF_FORECAST_DAYS] = "invalid_option_combo" + errors[CONF_FORECAST_DAYS] = "invalid_forecast_days" if not errors: try: @@ -621,7 +621,7 @@ async def async_step_init(self, user_input=None): # forecast_days within 1..5 days = normalized_input[CONF_FORECAST_DAYS] if days < MIN_FORECAST_DAYS or days > MAX_FORECAST_DAYS: - errors[CONF_FORECAST_DAYS] = "invalid_option_combo" + errors[CONF_FORECAST_DAYS] = "invalid_forecast_days" # per-day sensors vs number of days mode = normalized_input.get( diff --git a/custom_components/pollenlevels/translations/ca.json b/custom_components/pollenlevels/translations/ca.json index 98723cfc..5b949d1b 100644 --- a/custom_components/pollenlevels/translations/ca.json +++ b/custom_components/pollenlevels/translations/ca.json @@ -70,7 +70,8 @@ "empty": "Aquest camp no pot estar buit", "invalid_option_combo": "Augmenta els 'Dies de previsió' per cobrir els sensors per dia seleccionats.", "unknown": "Error desconegut", - "invalid_update_interval": "L’interval d’actualització ha de ser d’almenys 1 hora." + "invalid_update_interval": "L’interval d’actualització ha de ser d’almenys 1 hora.", + "invalid_forecast_days": "Introduïu un nombre vàlid de dies de previsió entre 1 i 5." } }, "device": { @@ -103,4 +104,4 @@ } } } -} \ No newline at end of file +} diff --git a/custom_components/pollenlevels/translations/cs.json b/custom_components/pollenlevels/translations/cs.json index f14e1c02..188ef598 100644 --- a/custom_components/pollenlevels/translations/cs.json +++ b/custom_components/pollenlevels/translations/cs.json @@ -82,7 +82,8 @@ "invalid_option_combo": "Zvyšte „Dny předpovědi“, aby pokryly vybrané senzory po dnech.", "quota_exceeded": "Překročena kvóta\n\n{error_message}", "unknown": "Neznámá chyba", - "invalid_update_interval": "Interval aktualizace musí být alespoň 1 hodina." + "invalid_update_interval": "Interval aktualizace musí být alespoň 1 hodina.", + "invalid_forecast_days": "Zadejte platný počet dní předpovědi v rozmezí 1 až 5." }, "step": { "init": { @@ -103,4 +104,4 @@ "name": "Vynutit aktualizaci" } } -} \ No newline at end of file +} diff --git a/custom_components/pollenlevels/translations/da.json b/custom_components/pollenlevels/translations/da.json index ee85ec2f..442f1877 100644 --- a/custom_components/pollenlevels/translations/da.json +++ b/custom_components/pollenlevels/translations/da.json @@ -82,7 +82,8 @@ "invalid_option_combo": "Forøg \"Prognosedage\" for at dække valgte sensorer pr. dag.", "quota_exceeded": "Kvote overskredet\n\n{error_message}", "unknown": "Ukendt fejl", - "invalid_update_interval": "Opdateringsintervallet skal være mindst 1 time." + "invalid_update_interval": "Opdateringsintervallet skal være mindst 1 time.", + "invalid_forecast_days": "Angiv et gyldigt antal prognosedage mellem 1 og 5." }, "step": { "init": { @@ -103,4 +104,4 @@ "name": "Gennemtving opdatering" } } -} \ No newline at end of file +} diff --git a/custom_components/pollenlevels/translations/de.json b/custom_components/pollenlevels/translations/de.json index 54eff42a..341ce5f3 100644 --- a/custom_components/pollenlevels/translations/de.json +++ b/custom_components/pollenlevels/translations/de.json @@ -82,7 +82,8 @@ "invalid_option_combo": "Erhöhe 'Vorhersagetage', um die gewählten Tagessensoren abzudecken.", "quota_exceeded": "Kontingent überschritten\n\n{error_message}", "unknown": "Unbekannter Fehler", - "invalid_update_interval": "Das Aktualisierungsintervall muss mindestens 1 Stunde betragen." + "invalid_update_interval": "Das Aktualisierungsintervall muss mindestens 1 Stunde betragen.", + "invalid_forecast_days": "Geben Sie eine gültige Anzahl an Prognosetagen zwischen 1 und 5 ein." }, "step": { "init": { @@ -103,4 +104,4 @@ "name": "Aktualisierung erzwingen" } } -} \ No newline at end of file +} diff --git a/custom_components/pollenlevels/translations/en.json b/custom_components/pollenlevels/translations/en.json index 7426d0cb..de55be00 100644 --- a/custom_components/pollenlevels/translations/en.json +++ b/custom_components/pollenlevels/translations/en.json @@ -70,7 +70,8 @@ "empty": "This field cannot be empty", "invalid_option_combo": "Increase 'Forecast days' to cover selected per-day sensors.", "unknown": "Unknown error", - "invalid_update_interval": "Update interval must be at least 1 hour." + "invalid_update_interval": "Update interval must be at least 1 hour.", + "invalid_forecast_days": "Enter a valid number of forecast days between 1 and 5." } }, "device": { diff --git a/custom_components/pollenlevels/translations/es.json b/custom_components/pollenlevels/translations/es.json index cb29e79c..a5784ab5 100644 --- a/custom_components/pollenlevels/translations/es.json +++ b/custom_components/pollenlevels/translations/es.json @@ -70,7 +70,8 @@ "empty": "Este campo no puede estar vacío", "invalid_option_combo": "Aumenta 'Días de previsión' para cubrir los sensores por día seleccionados.", "unknown": "Error desconocido", - "invalid_update_interval": "El intervalo de actualización debe ser de al menos 1 hora." + "invalid_update_interval": "El intervalo de actualización debe ser de al menos 1 hora.", + "invalid_forecast_days": "Introduce un número válido de días de previsión entre 1 y 5." } }, "device": { @@ -103,4 +104,4 @@ } } } -} \ No newline at end of file +} diff --git a/custom_components/pollenlevels/translations/fi.json b/custom_components/pollenlevels/translations/fi.json index 9dd57179..7009807e 100644 --- a/custom_components/pollenlevels/translations/fi.json +++ b/custom_components/pollenlevels/translations/fi.json @@ -82,7 +82,8 @@ "invalid_option_combo": "Lisää \"Ennustepäiviä\", jotta valitut päiväsensorit katetaan.", "quota_exceeded": "Kiintiö ylitetty\n\n{error_message}", "unknown": "Tuntematon virhe", - "invalid_update_interval": "Päivitysvälin on oltava vähintään 1 tunti." + "invalid_update_interval": "Päivitysvälin on oltava vähintään 1 tunti.", + "invalid_forecast_days": "Anna kelvollinen ennustepäivien määrä väliltä 1–5." }, "step": { "init": { @@ -103,4 +104,4 @@ "name": "Pakota päivitys" } } -} \ No newline at end of file +} diff --git a/custom_components/pollenlevels/translations/fr.json b/custom_components/pollenlevels/translations/fr.json index 0cbb6428..7c172de9 100644 --- a/custom_components/pollenlevels/translations/fr.json +++ b/custom_components/pollenlevels/translations/fr.json @@ -82,7 +82,8 @@ "invalid_option_combo": "Augmentez le nombre de « Jours de prévision » afin de couvrir les capteurs par jour sélectionnés.", "quota_exceeded": "Quota dépassé\n\n{error_message}", "unknown": "Erreur inconnue", - "invalid_update_interval": "L’intervalle de mise à jour doit être d’au moins 1 heure." + "invalid_update_interval": "L’intervalle de mise à jour doit être d’au moins 1 heure.", + "invalid_forecast_days": "Saisissez un nombre de jours de prévision valide entre 1 et 5." }, "step": { "init": { @@ -103,4 +104,4 @@ "name": "Forcer la mise à jour" } } -} \ No newline at end of file +} diff --git a/custom_components/pollenlevels/translations/hu.json b/custom_components/pollenlevels/translations/hu.json index d825169e..e7e6230f 100644 --- a/custom_components/pollenlevels/translations/hu.json +++ b/custom_components/pollenlevels/translations/hu.json @@ -82,7 +82,8 @@ "invalid_option_combo": "Növeld a \"Előrejelzési napok\" értéket a kiválasztott napi szenzorok lefedéséhez.", "quota_exceeded": "Kvóta túllépve\n\n{error_message}", "unknown": "Ismeretlen hiba", - "invalid_update_interval": "A frissítési időköznek legalább 1 órának kell lennie." + "invalid_update_interval": "A frissítési időköznek legalább 1 órának kell lennie.", + "invalid_forecast_days": "Adjon meg érvényes előrejelzési napok számát 1 és 5 között." }, "step": { "init": { @@ -103,4 +104,4 @@ "name": "Frissítés kényszerítése" } } -} \ No newline at end of file +} diff --git a/custom_components/pollenlevels/translations/it.json b/custom_components/pollenlevels/translations/it.json index 6a8e82e6..ff250ff8 100644 --- a/custom_components/pollenlevels/translations/it.json +++ b/custom_components/pollenlevels/translations/it.json @@ -82,7 +82,8 @@ "invalid_option_combo": "Aumenta 'Giorni di previsione' per coprire i sensori giornalieri selezionati.", "quota_exceeded": "Quota superata\n\n{error_message}", "unknown": "Errore sconosciuto", - "invalid_update_interval": "L’intervallo di aggiornamento deve essere di almeno 1 ora." + "invalid_update_interval": "L’intervallo di aggiornamento deve essere di almeno 1 ora.", + "invalid_forecast_days": "Inserisci un numero valido di giorni di previsione tra 1 e 5." }, "step": { "init": { diff --git a/custom_components/pollenlevels/translations/nb.json b/custom_components/pollenlevels/translations/nb.json index a732fa9d..eae98b75 100644 --- a/custom_components/pollenlevels/translations/nb.json +++ b/custom_components/pollenlevels/translations/nb.json @@ -82,7 +82,8 @@ "invalid_option_combo": "Øk «Prognosedager» for å dekke valgte sensorer per dag.", "quota_exceeded": "Kvote overskredet\n\n{error_message}", "unknown": "Ukjent feil", - "invalid_update_interval": "Oppdateringsintervallet må være minst 1 time." + "invalid_update_interval": "Oppdateringsintervallet må være minst 1 time.", + "invalid_forecast_days": "Angi et gyldig antall prognosedager mellom 1 og 5." }, "step": { "init": { @@ -103,4 +104,4 @@ "name": "Tving oppdatering" } } -} \ No newline at end of file +} diff --git a/custom_components/pollenlevels/translations/nl.json b/custom_components/pollenlevels/translations/nl.json index f0362b29..d1743126 100644 --- a/custom_components/pollenlevels/translations/nl.json +++ b/custom_components/pollenlevels/translations/nl.json @@ -82,7 +82,8 @@ "invalid_option_combo": "Verhoog 'Voorspellingsdagen' om de geselecteerde per-dag sensoren te dekken.", "quota_exceeded": "Limiet overschreden\n\n{error_message}", "unknown": "Onbekende fout", - "invalid_update_interval": "Het update-interval moet minimaal 1 uur zijn." + "invalid_update_interval": "Het update-interval moet minimaal 1 uur zijn.", + "invalid_forecast_days": "Voer een geldig aantal voorspellingsdagen in tussen 1 en 5." }, "step": { "init": { @@ -103,4 +104,4 @@ "name": "Update forceren" } } -} \ No newline at end of file +} diff --git a/custom_components/pollenlevels/translations/pl.json b/custom_components/pollenlevels/translations/pl.json index 35a700bd..742612f5 100644 --- a/custom_components/pollenlevels/translations/pl.json +++ b/custom_components/pollenlevels/translations/pl.json @@ -82,7 +82,8 @@ "invalid_option_combo": "Zwiększ 'Dni prognozy', aby objąć wybrane czujniki dzienne.", "quota_exceeded": "Przekroczono limit\n\n{error_message}", "unknown": "Nieznany błąd", - "invalid_update_interval": "Interwał aktualizacji musi wynosić co najmniej 1 godzinę." + "invalid_update_interval": "Interwał aktualizacji musi wynosić co najmniej 1 godzinę.", + "invalid_forecast_days": "Wprowadź prawidłową liczbę dni prognozy od 1 do 5." }, "step": { "init": { @@ -103,4 +104,4 @@ "name": "Wymuś aktualizację" } } -} \ No newline at end of file +} diff --git a/custom_components/pollenlevels/translations/pt-BR.json b/custom_components/pollenlevels/translations/pt-BR.json index 74e3ca68..551b3664 100644 --- a/custom_components/pollenlevels/translations/pt-BR.json +++ b/custom_components/pollenlevels/translations/pt-BR.json @@ -82,7 +82,8 @@ "invalid_option_combo": "Aumente \"Dias de previsão\" para cobrir os sensores por dia selecionados.", "quota_exceeded": "Cota excedida\n\n{error_message}", "unknown": "Erro desconhecido", - "invalid_update_interval": "O intervalo de atualização deve ser de pelo menos 1 hora." + "invalid_update_interval": "O intervalo de atualização deve ser de pelo menos 1 hora.", + "invalid_forecast_days": "Insira um número válido de dias de previsão entre 1 e 5." }, "step": { "init": { @@ -103,4 +104,4 @@ "name": "Forçar atualização" } } -} \ No newline at end of file +} diff --git a/custom_components/pollenlevels/translations/pt-PT.json b/custom_components/pollenlevels/translations/pt-PT.json index 9f8747d7..0a767634 100644 --- a/custom_components/pollenlevels/translations/pt-PT.json +++ b/custom_components/pollenlevels/translations/pt-PT.json @@ -82,7 +82,8 @@ "invalid_option_combo": "Aumente \"Dias de previsão\" para cobrir os sensores por dia selecionados.", "quota_exceeded": "Quota excedida\n\n{error_message}", "unknown": "Erro desconhecido", - "invalid_update_interval": "O intervalo de atualização deve ser de pelo menos 1 hora." + "invalid_update_interval": "O intervalo de atualização deve ser de pelo menos 1 hora.", + "invalid_forecast_days": "Introduza um número válido de dias de previsão entre 1 e 5." }, "step": { "init": { @@ -103,4 +104,4 @@ "name": "Forçar atualização" } } -} \ No newline at end of file +} diff --git a/custom_components/pollenlevels/translations/ro.json b/custom_components/pollenlevels/translations/ro.json index 07b8d0e7..ab9410ed 100644 --- a/custom_components/pollenlevels/translations/ro.json +++ b/custom_components/pollenlevels/translations/ro.json @@ -82,7 +82,8 @@ "invalid_option_combo": "Măriți \"Zilele de prognoză\" pentru a acoperi senzorii selectați pe zile.", "quota_exceeded": "Cota depășită\n\n{error_message}", "unknown": "Eroare necunoscută", - "invalid_update_interval": "Intervalul de actualizare trebuie să fie de cel puțin 1 oră." + "invalid_update_interval": "Intervalul de actualizare trebuie să fie de cel puțin 1 oră.", + "invalid_forecast_days": "Introduceți un număr valid de zile de prognoză între 1 și 5." }, "step": { "init": { @@ -103,4 +104,4 @@ "name": "Forțează actualizarea" } } -} \ No newline at end of file +} diff --git a/custom_components/pollenlevels/translations/ru.json b/custom_components/pollenlevels/translations/ru.json index 87ccc0c0..2540ca9f 100644 --- a/custom_components/pollenlevels/translations/ru.json +++ b/custom_components/pollenlevels/translations/ru.json @@ -82,7 +82,8 @@ "invalid_option_combo": "Увеличьте «Дни прогноза», чтобы охватить выбранные датчики по дням.", "quota_exceeded": "Превышен лимит запросов\n\n{error_message}", "unknown": "Неизвестная ошибка", - "invalid_update_interval": "Интервал обновления должен быть не менее 1 часа." + "invalid_update_interval": "Интервал обновления должен быть не менее 1 часа.", + "invalid_forecast_days": "Введите допустимое количество дней прогноза от 1 до 5." }, "step": { "init": { @@ -103,4 +104,4 @@ "name": "Принудительное обновление" } } -} \ No newline at end of file +} diff --git a/custom_components/pollenlevels/translations/sv.json b/custom_components/pollenlevels/translations/sv.json index 5fb168da..4130a279 100644 --- a/custom_components/pollenlevels/translations/sv.json +++ b/custom_components/pollenlevels/translations/sv.json @@ -82,7 +82,8 @@ "invalid_option_combo": "Öka \"Prognosdagar\" för att täcka valda sensorer per dag.", "quota_exceeded": "Kvoten har överskridits\n\n{error_message}", "unknown": "Okänt fel", - "invalid_update_interval": "Uppdateringsintervallet måste vara minst 1 timme." + "invalid_update_interval": "Uppdateringsintervallet måste vara minst 1 timme.", + "invalid_forecast_days": "Ange ett giltigt antal prognosdagar mellan 1 och 5." }, "step": { "init": { @@ -103,4 +104,4 @@ "name": "Tvinga uppdatering" } } -} \ No newline at end of file +} diff --git a/custom_components/pollenlevels/translations/uk.json b/custom_components/pollenlevels/translations/uk.json index d41890b2..c34d1a8a 100644 --- a/custom_components/pollenlevels/translations/uk.json +++ b/custom_components/pollenlevels/translations/uk.json @@ -82,7 +82,8 @@ "invalid_option_combo": "Збільшіть «Дні прогнозу», щоб охопити вибрані денні датчики.", "quota_exceeded": "Перевищено ліміт запитів\n\n{error_message}", "unknown": "Невідома помилка", - "invalid_update_interval": "Інтервал оновлення має бути щонайменше 1 година." + "invalid_update_interval": "Інтервал оновлення має бути щонайменше 1 година.", + "invalid_forecast_days": "Вкажіть дійсну кількість днів прогнозу від 1 до 5." }, "step": { "init": { @@ -103,4 +104,4 @@ "name": "Примусове оновлення" } } -} \ No newline at end of file +} diff --git a/custom_components/pollenlevels/translations/zh-Hans.json b/custom_components/pollenlevels/translations/zh-Hans.json index 650ffa77..f99818bd 100644 --- a/custom_components/pollenlevels/translations/zh-Hans.json +++ b/custom_components/pollenlevels/translations/zh-Hans.json @@ -82,7 +82,8 @@ "invalid_option_combo": "请增加“预测天数”,以覆盖所选的逐日类型传感器。", "quota_exceeded": "配额已用尽\n\n{error_message}", "unknown": "未知错误", - "invalid_update_interval": "更新间隔必须至少为 1 小时。" + "invalid_update_interval": "更新间隔必须至少为 1 小时。", + "invalid_forecast_days": "请输入 1 到 5 之间有效的预测天数。" }, "step": { "init": { diff --git a/custom_components/pollenlevels/translations/zh-Hant.json b/custom_components/pollenlevels/translations/zh-Hant.json index 2f184b5d..d7c8c3fb 100644 --- a/custom_components/pollenlevels/translations/zh-Hant.json +++ b/custom_components/pollenlevels/translations/zh-Hant.json @@ -82,7 +82,8 @@ "invalid_option_combo": "請增加「預測天數」以涵蓋所選的逐日類型感測器。", "quota_exceeded": "超出配額\n\n{error_message}", "unknown": "未知錯誤", - "invalid_update_interval": "更新間隔必須至少為 1 小時。" + "invalid_update_interval": "更新間隔必須至少為 1 小時。", + "invalid_forecast_days": "請輸入 1 到 5 之間的有效預測天數。" }, "step": { "init": { From 8497072cdce2c6261ad7b3ab54db58fdd91e3daf Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Tue, 16 Dec 2025 13:08:19 +0100 Subject: [PATCH 053/200] Fix forecast days options test and changelog --- CHANGELOG.md | 2 ++ tests/test_options_flow.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a4a4646..03926f89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,8 @@ details, and invalid update interval errors to keep UI feedback aligned. - Simplified HTTP Referer handling during validation/runtime and finalized locale wording, including corrected Chinese setup descriptions. +- Updated the options flow regression test to expect the new + `invalid_forecast_days` error code for out-of-range values. ## [1.9.0-alpha1] - 2025-12-11 ### Changed diff --git a/tests/test_options_flow.py b/tests/test_options_flow.py index 838b818d..a62153be 100644 --- a/tests/test_options_flow.py +++ b/tests/test_options_flow.py @@ -79,7 +79,7 @@ def test_options_flow_forecast_days_below_min_sets_error() -> None: ) assert result["errors"] == { - CONF_FORECAST_DAYS: "invalid_option_combo", + CONF_FORECAST_DAYS: "invalid_forecast_days", CONF_CREATE_FORECAST_SENSORS: "invalid_option_combo", } From ccf5d06981df145a70533ead9a62f674b4e576c4 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Tue, 16 Dec 2025 15:41:00 +0100 Subject: [PATCH 054/200] Refactor options flow numeric validation --- CHANGELOG.md | 2 + custom_components/pollenlevels/config_flow.py | 137 +++++++++++------- 2 files changed, 83 insertions(+), 56 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 03926f89..056272f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,8 @@ locale wording, including corrected Chinese setup descriptions. - Updated the options flow regression test to expect the new `invalid_forecast_days` error code for out-of-range values. +- Consolidated numeric options validation in the options flow through a shared + helper to reduce duplication for interval and forecast day checks. ## [1.9.0-alpha1] - 2025-12-11 ### Changed diff --git a/custom_components/pollenlevels/config_flow.py b/custom_components/pollenlevels/config_flow.py index 0ebed041..18a32822 100644 --- a/custom_components/pollenlevels/config_flow.py +++ b/custom_components/pollenlevels/config_flow.py @@ -180,6 +180,30 @@ def _validate_location_dict( return lat, lon +def _parse_int_option( + value: Any, + default: int, + *, + min_value: int | None = None, + max_value: int | None = None, + error_key: str | None = None, +) -> tuple[int, str | None]: + """Parse a numeric option to int and enforce bounds.""" + + try: + parsed = int(float(value if value is not None else default)) + except (TypeError, ValueError): + return default, error_key + + if min_value is not None and parsed < min_value: + return parsed, error_key + + if max_value is not None and parsed > max_value: + return parsed, error_key + + return parsed, None + + class PollenLevelsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Config flow for Pollen Levels.""" @@ -578,15 +602,15 @@ async def async_step_init(self, user_input=None): if user_input is not None: normalized_input: dict[str, Any] = {**self.entry.options, **user_input} - try: - normalized_input[CONF_UPDATE_INTERVAL] = int( - float(normalized_input.get(CONF_UPDATE_INTERVAL, current_interval)) - ) - except (TypeError, ValueError): - errors[CONF_UPDATE_INTERVAL] = "invalid_update_interval" - - if not errors and normalized_input[CONF_UPDATE_INTERVAL] < 1: - errors[CONF_UPDATE_INTERVAL] = "invalid_update_interval" + interval_value, interval_error = _parse_int_option( + normalized_input.get(CONF_UPDATE_INTERVAL, current_interval), + current_interval, + min_value=1, + error_key="invalid_update_interval", + ) + normalized_input[CONF_UPDATE_INTERVAL] = interval_value + if interval_error: + errors[CONF_UPDATE_INTERVAL] = interval_error if errors.get(CONF_UPDATE_INTERVAL): return self.async_show_form( @@ -596,55 +620,56 @@ async def async_step_init(self, user_input=None): description_placeholders=placeholders, ) - try: - normalized_input[CONF_FORECAST_DAYS] = int( - float(normalized_input.get(CONF_FORECAST_DAYS, current_days)) - ) - except (TypeError, ValueError): - errors[CONF_FORECAST_DAYS] = "invalid_forecast_days" + forecast_days, days_error = _parse_int_option( + normalized_input.get(CONF_FORECAST_DAYS, current_days), + current_days, + min_value=MIN_FORECAST_DAYS, + max_value=MAX_FORECAST_DAYS, + error_key="invalid_forecast_days", + ) + normalized_input[CONF_FORECAST_DAYS] = forecast_days + if days_error: + errors[CONF_FORECAST_DAYS] = days_error - if not errors: - try: - # Language: allow empty; if provided, validate & normalize. - raw_lang = normalized_input.get( + try: + # Language: allow empty; if provided, validate & normalize. + raw_lang = normalized_input.get( + CONF_LANGUAGE_CODE, + self.entry.options.get( CONF_LANGUAGE_CODE, - self.entry.options.get( - CONF_LANGUAGE_CODE, - self.entry.data.get(CONF_LANGUAGE_CODE, ""), - ), - ) - lang = raw_lang.strip() if isinstance(raw_lang, str) else "" - if lang: - lang = is_valid_language_code(lang) - normalized_input[CONF_LANGUAGE_CODE] = lang # persist normalized - - # forecast_days within 1..5 - days = normalized_input[CONF_FORECAST_DAYS] - if days < MIN_FORECAST_DAYS or days > MAX_FORECAST_DAYS: - errors[CONF_FORECAST_DAYS] = "invalid_forecast_days" - - # per-day sensors vs number of days - mode = normalized_input.get( - CONF_CREATE_FORECAST_SENSORS, - self.entry.options.get(CONF_CREATE_FORECAST_SENSORS, "none"), - ) - needed = {"D+1": 2, "D+1+2": 3}.get(mode, 1) - if days < needed: - errors[CONF_CREATE_FORECAST_SENSORS] = "invalid_option_combo" - - except vol.Invalid as ve: - _LOGGER.warning( - "Options language validation failed for '%s': %s", - normalized_input.get(CONF_LANGUAGE_CODE), - ve, - ) - errors[CONF_LANGUAGE_CODE] = _language_error_to_form_key(ve) - except Exception as err: # defensive - _LOGGER.exception( - "Options validation error: %s", - redact_api_key(err, self.entry.data.get(CONF_API_KEY)), - ) - errors["base"] = "unknown" + self.entry.data.get(CONF_LANGUAGE_CODE, ""), + ), + ) + lang = raw_lang.strip() if isinstance(raw_lang, str) else "" + if lang: + lang = is_valid_language_code(lang) + normalized_input[CONF_LANGUAGE_CODE] = lang # persist normalized + + # forecast_days within 1..5 + days = normalized_input[CONF_FORECAST_DAYS] + + # per-day sensors vs number of days + mode = normalized_input.get( + CONF_CREATE_FORECAST_SENSORS, + self.entry.options.get(CONF_CREATE_FORECAST_SENSORS, "none"), + ) + needed = {"D+1": 2, "D+1+2": 3}.get(mode, 1) + if days < needed: + errors[CONF_CREATE_FORECAST_SENSORS] = "invalid_option_combo" + + except vol.Invalid as ve: + _LOGGER.warning( + "Options language validation failed for '%s': %s", + normalized_input.get(CONF_LANGUAGE_CODE), + ve, + ) + errors[CONF_LANGUAGE_CODE] = _language_error_to_form_key(ve) + except Exception as err: # defensive + _LOGGER.exception( + "Options validation error: %s", + redact_api_key(err, self.entry.data.get(CONF_API_KEY)), + ) + errors["base"] = "unknown" if not errors: return self.async_create_entry(title="", data=normalized_input) From 4ae7717c6d7a8a019affcff37685cb18451cf851 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Tue, 16 Dec 2025 22:38:20 +0100 Subject: [PATCH 055/200] Update README.md Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0ee4e39a..678e0581 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ You can change: - **Per-day TYPE sensors** via `create_forecast_sensors`: - `none` → no extra sensors - `D+1` → sensors for each TYPE with suffix `(D+1)` - - `D+1+2` → sensors for `(D+1)` and `(D+2)` + - `D+1+2` → sensors for `(D+1)` and `(D+2)` > **Validation rules:** > - `D+1` requires `forecast_days ≥ 2` From 1f7ee88a65fdc588ab52e065f2504306dabcc0fb Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Tue, 16 Dec 2025 22:38:50 +0100 Subject: [PATCH 056/200] Update custom_components/pollenlevels/config_flow.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- custom_components/pollenlevels/config_flow.py | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/custom_components/pollenlevels/config_flow.py b/custom_components/pollenlevels/config_flow.py index 18a32822..88d2a8e9 100644 --- a/custom_components/pollenlevels/config_flow.py +++ b/custom_components/pollenlevels/config_flow.py @@ -248,18 +248,15 @@ async def _async_validate_input( else: normalized.pop(CONF_HTTP_REFERER, None) - try: - parsed_update_interval = int( - float(normalized.get(CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL)) - ) - except (TypeError, ValueError): - errors[CONF_UPDATE_INTERVAL] = "invalid_update_interval" - return errors, None - - normalized[CONF_UPDATE_INTERVAL] = parsed_update_interval - - if normalized[CONF_UPDATE_INTERVAL] < 1: - errors[CONF_UPDATE_INTERVAL] = "invalid_update_interval" + interval_value, interval_error = _parse_int_option( + normalized.get(CONF_UPDATE_INTERVAL), + default=DEFAULT_UPDATE_INTERVAL, + min_value=1, + error_key="invalid_update_interval", + ) + normalized[CONF_UPDATE_INTERVAL] = interval_value + if interval_error: + errors[CONF_UPDATE_INTERVAL] = interval_error return errors, None latlon = None From 8b14031710600d3804a6d94f057a24cb30f1acfa Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Tue, 16 Dec 2025 23:34:51 +0100 Subject: [PATCH 057/200] Refine update interval parsing --- README.md | 6 +++--- custom_components/pollenlevels/config_flow.py | 19 +++++++++++++------ 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 678e0581..670740ef 100644 --- a/README.md +++ b/README.md @@ -59,9 +59,9 @@ You can change: - **API response language code** - **Forecast days** (`1–5`) for pollen TYPES - **Per-day TYPE sensors** via `create_forecast_sensors`: - - `none` → no extra sensors - - `D+1` → sensors for each TYPE with suffix `(D+1)` - - `D+1+2` → sensors for `(D+1)` and `(D+2)` + - `none` → no extra sensors + - `D+1` → sensors for each TYPE with suffix `(D+1)` + - `D+1+2` → sensors for `(D+1)` and `(D+2)` > **Validation rules:** > - `D+1` requires `forecast_days ≥ 2` diff --git a/custom_components/pollenlevels/config_flow.py b/custom_components/pollenlevels/config_flow.py index 88d2a8e9..3ab22506 100644 --- a/custom_components/pollenlevels/config_flow.py +++ b/custom_components/pollenlevels/config_flow.py @@ -204,6 +204,17 @@ def _parse_int_option( return parsed, None +def _parse_update_interval(value: Any, default: int) -> tuple[int, str | None]: + """Parse and validate the update interval in hours.""" + + return _parse_int_option( + value, + default=default, + min_value=1, + error_key="invalid_update_interval", + ) + + class PollenLevelsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Config flow for Pollen Levels.""" @@ -248,11 +259,9 @@ async def _async_validate_input( else: normalized.pop(CONF_HTTP_REFERER, None) - interval_value, interval_error = _parse_int_option( + interval_value, interval_error = _parse_update_interval( normalized.get(CONF_UPDATE_INTERVAL), default=DEFAULT_UPDATE_INTERVAL, - min_value=1, - error_key="invalid_update_interval", ) normalized[CONF_UPDATE_INTERVAL] = interval_value if interval_error: @@ -599,11 +608,9 @@ async def async_step_init(self, user_input=None): if user_input is not None: normalized_input: dict[str, Any] = {**self.entry.options, **user_input} - interval_value, interval_error = _parse_int_option( + interval_value, interval_error = _parse_update_interval( normalized_input.get(CONF_UPDATE_INTERVAL, current_interval), current_interval, - min_value=1, - error_key="invalid_update_interval", ) normalized_input[CONF_UPDATE_INTERVAL] = interval_value if interval_error: From 02954038c40801443e10a5e235f32a5dc195807d Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Tue, 16 Dec 2025 23:34:56 +0100 Subject: [PATCH 058/200] Document linting commands --- README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.md b/README.md index 670740ef..b20ba3b8 100644 --- a/README.md +++ b/README.md @@ -233,6 +233,18 @@ curl -X GET "https://pollen.googleapis.com/v1/forecast:lookup?key=YOUR_KEY&locat --- +## 🛠️ Development + +Run the project formatters and linters before opening a pull request: + +```bash +black . +ruff check --fix --select I +ruff check +``` + +--- + ## ❤️ Support the project If this integration helps you, consider supporting development: From fa04b0fa0326b1d1ae4e391d3d83dca591f61c0a Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Tue, 16 Dec 2025 23:41:13 +0100 Subject: [PATCH 059/200] Update README.md --- README.md | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/README.md b/README.md index b20ba3b8..670740ef 100644 --- a/README.md +++ b/README.md @@ -233,18 +233,6 @@ curl -X GET "https://pollen.googleapis.com/v1/forecast:lookup?key=YOUR_KEY&locat --- -## 🛠️ Development - -Run the project formatters and linters before opening a pull request: - -```bash -black . -ruff check --fix --select I -ruff check -``` - ---- - ## ❤️ Support the project If this integration helps you, consider supporting development: From 85b2753fcf13450a728024e6e113cf085d503c39 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Tue, 16 Dec 2025 23:59:15 +0100 Subject: [PATCH 060/200] chore(translations): align HTTP Referer strings and add help link --- custom_components/pollenlevels/translations/ca.json | 5 ++--- custom_components/pollenlevels/translations/cs.json | 5 ++--- custom_components/pollenlevels/translations/da.json | 7 +++---- custom_components/pollenlevels/translations/de.json | 7 +++---- custom_components/pollenlevels/translations/en.json | 7 +++---- custom_components/pollenlevels/translations/es.json | 5 ++--- custom_components/pollenlevels/translations/fi.json | 7 +++---- custom_components/pollenlevels/translations/fr.json | 7 +++---- custom_components/pollenlevels/translations/hu.json | 7 +++---- custom_components/pollenlevels/translations/it.json | 7 +++---- custom_components/pollenlevels/translations/nb.json | 7 +++---- custom_components/pollenlevels/translations/nl.json | 7 +++---- custom_components/pollenlevels/translations/pl.json | 7 +++---- custom_components/pollenlevels/translations/pt-BR.json | 7 +++---- custom_components/pollenlevels/translations/pt-PT.json | 7 +++---- custom_components/pollenlevels/translations/ro.json | 7 +++---- custom_components/pollenlevels/translations/ru.json | 7 +++---- custom_components/pollenlevels/translations/sv.json | 7 +++---- custom_components/pollenlevels/translations/uk.json | 7 +++---- custom_components/pollenlevels/translations/zh-Hans.json | 9 ++++----- custom_components/pollenlevels/translations/zh-Hant.json | 9 ++++----- 21 files changed, 62 insertions(+), 83 deletions(-) diff --git a/custom_components/pollenlevels/translations/ca.json b/custom_components/pollenlevels/translations/ca.json index 5b949d1b..ee033828 100644 --- a/custom_components/pollenlevels/translations/ca.json +++ b/custom_components/pollenlevels/translations/ca.json @@ -18,7 +18,7 @@ } }, "data_description": { - "http_referer": "Només cal si la teva clau API està restringida per Referers HTTP (llocs web)." + "http_referer": "Especifica això només si la teva clau API té una [restricció d’aplicació del lloc web]({restricting_api_keys_url}) (HTTP Referer)." } }, "reauth_confirm": { @@ -70,8 +70,7 @@ "empty": "Aquest camp no pot estar buit", "invalid_option_combo": "Augmenta els 'Dies de previsió' per cobrir els sensors per dia seleccionats.", "unknown": "Error desconegut", - "invalid_update_interval": "L’interval d’actualització ha de ser d’almenys 1 hora.", - "invalid_forecast_days": "Introduïu un nombre vàlid de dies de previsió entre 1 i 5." + "invalid_update_interval": "L’interval d’actualització ha de ser d’almenys 1 hora." } }, "device": { diff --git a/custom_components/pollenlevels/translations/cs.json b/custom_components/pollenlevels/translations/cs.json index 188ef598..9f258516 100644 --- a/custom_components/pollenlevels/translations/cs.json +++ b/custom_components/pollenlevels/translations/cs.json @@ -43,7 +43,7 @@ } }, "data_description": { - "http_referer": "Potřebné pouze pokud je váš API klíč omezen podle HTTP Refererů (webových stránek)." + "http_referer": "Potřebné pouze pokud má váš API klíč [omezení webu]({restricting_api_keys_url}) (HTTP Referer)." } } } @@ -82,8 +82,7 @@ "invalid_option_combo": "Zvyšte „Dny předpovědi“, aby pokryly vybrané senzory po dnech.", "quota_exceeded": "Překročena kvóta\n\n{error_message}", "unknown": "Neznámá chyba", - "invalid_update_interval": "Interval aktualizace musí být alespoň 1 hodina.", - "invalid_forecast_days": "Zadejte platný počet dní předpovědi v rozmezí 1 až 5." + "invalid_update_interval": "Interval aktualizace musí být alespoň 1 hodina." }, "step": { "init": { diff --git a/custom_components/pollenlevels/translations/da.json b/custom_components/pollenlevels/translations/da.json index 442f1877..667db075 100644 --- a/custom_components/pollenlevels/translations/da.json +++ b/custom_components/pollenlevels/translations/da.json @@ -10,7 +10,7 @@ "empty": "Dette felt må ikke være tomt", "invalid_auth": "Ugyldig API-nøgle\n\n{error_message}", "invalid_coordinates": "Vælg en gyldig placering på kortet.", - "invalid_http_referrer": "Ugyldig HTTP Referer-værdi. Den må ikke indeholde linjeskift.", + "invalid_http_referrer": "Ugyldig værdi for HTTP Referer. Den må ikke indeholde linjeskift.", "invalid_language": "Ugyldig sprogkode", "invalid_language_format": "Brug en kanonisk BCP-47-kode som \"en\" eller \"es-ES\".", "invalid_option_combo": "Forøg \"Prognosedage\" for at dække valgte sensorer pr. dag.", @@ -43,7 +43,7 @@ } }, "data_description": { - "http_referer": "Kun nødvendig hvis din API-nøgle er begrænset via HTTP Referers (websteder)." + "http_referer": "Kun nødvendig hvis din API-nøgle har en [webstedsbegrænsning]({restricting_api_keys_url}) (HTTP Referer)." } } } @@ -82,8 +82,7 @@ "invalid_option_combo": "Forøg \"Prognosedage\" for at dække valgte sensorer pr. dag.", "quota_exceeded": "Kvote overskredet\n\n{error_message}", "unknown": "Ukendt fejl", - "invalid_update_interval": "Opdateringsintervallet skal være mindst 1 time.", - "invalid_forecast_days": "Angiv et gyldigt antal prognosedage mellem 1 og 5." + "invalid_update_interval": "Opdateringsintervallet skal være mindst 1 time." }, "step": { "init": { diff --git a/custom_components/pollenlevels/translations/de.json b/custom_components/pollenlevels/translations/de.json index 341ce5f3..5541291b 100644 --- a/custom_components/pollenlevels/translations/de.json +++ b/custom_components/pollenlevels/translations/de.json @@ -10,7 +10,7 @@ "empty": "Dieses Feld darf nicht leer sein", "invalid_auth": "Ungültiger API-Schlüssel\n\n{error_message}", "invalid_coordinates": "Wähle einen gültigen Standort auf der Karte aus.", - "invalid_http_referrer": "Ungültiger HTTP Referer-Wert. Er darf keine Zeilenumbrüche enthalten.", + "invalid_http_referrer": "Ungültiger Wert für HTTP Referer. Er darf keine Zeilenumbrüche enthalten.", "invalid_language": "Ungültiger Sprachcode", "invalid_language_format": "Verwenden Sie einen kanonischen BCP-47-Code wie \"en\" oder \"es-ES\".", "invalid_option_combo": "Erhöhe 'Vorhersagetage', um die gewählten Tagessensoren abzudecken.", @@ -43,7 +43,7 @@ } }, "data_description": { - "http_referer": "Nur erforderlich, wenn dein API-Schlüssel durch HTTP-Referer (Websites) eingeschränkt ist." + "http_referer": "Nur erforderlich, wenn dein API-Schlüssel eine [Website-Beschränkung]({restricting_api_keys_url}) (HTTP Referer) verwendet." } } } @@ -82,8 +82,7 @@ "invalid_option_combo": "Erhöhe 'Vorhersagetage', um die gewählten Tagessensoren abzudecken.", "quota_exceeded": "Kontingent überschritten\n\n{error_message}", "unknown": "Unbekannter Fehler", - "invalid_update_interval": "Das Aktualisierungsintervall muss mindestens 1 Stunde betragen.", - "invalid_forecast_days": "Geben Sie eine gültige Anzahl an Prognosetagen zwischen 1 und 5 ein." + "invalid_update_interval": "Das Aktualisierungsintervall muss mindestens 1 Stunde betragen." }, "step": { "init": { diff --git a/custom_components/pollenlevels/translations/en.json b/custom_components/pollenlevels/translations/en.json index de55be00..5c2b379e 100644 --- a/custom_components/pollenlevels/translations/en.json +++ b/custom_components/pollenlevels/translations/en.json @@ -18,7 +18,7 @@ } }, "data_description": { - "http_referer": "Only needed if your API key is restricted by HTTP Referers (websites)." + "http_referer": "Only needed if your API key uses a [website restriction]({restricting_api_keys_url}) (HTTP Referer)." } }, "reauth_confirm": { @@ -39,7 +39,7 @@ "invalid_option_combo": "Increase 'Forecast days' to cover selected per-day sensors.", "invalid_coordinates": "Please select a valid location on the map.", "unknown": "Unknown error", - "invalid_http_referrer": "Invalid HTTP Referer value. It must not contain newline characters.", + "invalid_http_referrer": "Invalid value for HTTP Referer. It must not contain newline characters.", "invalid_update_interval": "Update interval must be at least 1 hour." }, "abort": { @@ -70,8 +70,7 @@ "empty": "This field cannot be empty", "invalid_option_combo": "Increase 'Forecast days' to cover selected per-day sensors.", "unknown": "Unknown error", - "invalid_update_interval": "Update interval must be at least 1 hour.", - "invalid_forecast_days": "Enter a valid number of forecast days between 1 and 5." + "invalid_update_interval": "Update interval must be at least 1 hour." } }, "device": { diff --git a/custom_components/pollenlevels/translations/es.json b/custom_components/pollenlevels/translations/es.json index a5784ab5..749d2c40 100644 --- a/custom_components/pollenlevels/translations/es.json +++ b/custom_components/pollenlevels/translations/es.json @@ -18,7 +18,7 @@ } }, "data_description": { - "http_referer": "Solo es necesario si tu clave API está restringida por Referers HTTP (sitios web)." + "http_referer": "Especifica esto solo si tu clave API tiene una [restricción de aplicación del sitio web]({restricting_api_keys_url}) (HTTP Referer)." } }, "reauth_confirm": { @@ -70,8 +70,7 @@ "empty": "Este campo no puede estar vacío", "invalid_option_combo": "Aumenta 'Días de previsión' para cubrir los sensores por día seleccionados.", "unknown": "Error desconocido", - "invalid_update_interval": "El intervalo de actualización debe ser de al menos 1 hora.", - "invalid_forecast_days": "Introduce un número válido de días de previsión entre 1 y 5." + "invalid_update_interval": "El intervalo de actualización debe ser de al menos 1 hora." } }, "device": { diff --git a/custom_components/pollenlevels/translations/fi.json b/custom_components/pollenlevels/translations/fi.json index 7009807e..afe2f93a 100644 --- a/custom_components/pollenlevels/translations/fi.json +++ b/custom_components/pollenlevels/translations/fi.json @@ -10,7 +10,7 @@ "empty": "Tämä kenttä ei voi olla tyhjä", "invalid_auth": "Virheellinen API-avain\n\n{error_message}", "invalid_coordinates": "Valitse kartalta kelvollinen sijainti.", - "invalid_http_referrer": "Virheellinen HTTP Referer -arvo. Se ei saa sisältää rivinvaihtomerkkejä.", + "invalid_http_referrer": "Virheellinen arvo kohteelle HTTP Referer. Se ei saa sisältää rivinvaihtomerkkejä.", "invalid_language": "Virheellinen kielikoodi", "invalid_language_format": "Käytä kanonista BCP-47-koodia, esimerkiksi \"en\" tai \"es-ES\".", "invalid_option_combo": "Lisää \"Ennustepäiviä\", jotta valitut päiväsensorit katetaan.", @@ -43,7 +43,7 @@ } }, "data_description": { - "http_referer": "Tarvitaan vain, jos API-avaimesi on rajoitettu HTTP Refererien (verkkosivustojen) perusteella." + "http_referer": "Tarvitaan vain, jos API-avaimesi käyttää [verkkosivustorajoitusta]({restricting_api_keys_url}) (HTTP Referer)." } } } @@ -82,8 +82,7 @@ "invalid_option_combo": "Lisää \"Ennustepäiviä\", jotta valitut päiväsensorit katetaan.", "quota_exceeded": "Kiintiö ylitetty\n\n{error_message}", "unknown": "Tuntematon virhe", - "invalid_update_interval": "Päivitysvälin on oltava vähintään 1 tunti.", - "invalid_forecast_days": "Anna kelvollinen ennustepäivien määrä väliltä 1–5." + "invalid_update_interval": "Päivitysvälin on oltava vähintään 1 tunti." }, "step": { "init": { diff --git a/custom_components/pollenlevels/translations/fr.json b/custom_components/pollenlevels/translations/fr.json index 7c172de9..65246059 100644 --- a/custom_components/pollenlevels/translations/fr.json +++ b/custom_components/pollenlevels/translations/fr.json @@ -10,7 +10,7 @@ "empty": "Ce champ ne peut pas être vide", "invalid_auth": "Clé API invalide\n\n{error_message}", "invalid_coordinates": "Sélectionnez un emplacement valide sur la carte.", - "invalid_http_referrer": "Valeur de HTTP Referer invalide. Elle ne doit pas contenir de retours à la ligne.", + "invalid_http_referrer": "Valeur pour HTTP Referer invalide. Elle ne doit pas contenir de retours à la ligne.", "invalid_language": "Code de langue invalide", "invalid_language_format": "Utilisez un code BCP-47 canonique tel que \"en\" ou \"es-ES\".", "invalid_option_combo": "Augmentez le nombre de « Jours de prévision » afin de couvrir les capteurs par jour sélectionnés.", @@ -43,7 +43,7 @@ } }, "data_description": { - "http_referer": "Nécessaire uniquement si votre clé API est limitée par des HTTP Referers (sites web)." + "http_referer": "Renseignez ceci uniquement si votre clé API a une [restriction de site web]({restricting_api_keys_url}) (HTTP Referer)." } } } @@ -82,8 +82,7 @@ "invalid_option_combo": "Augmentez le nombre de « Jours de prévision » afin de couvrir les capteurs par jour sélectionnés.", "quota_exceeded": "Quota dépassé\n\n{error_message}", "unknown": "Erreur inconnue", - "invalid_update_interval": "L’intervalle de mise à jour doit être d’au moins 1 heure.", - "invalid_forecast_days": "Saisissez un nombre de jours de prévision valide entre 1 et 5." + "invalid_update_interval": "L’intervalle de mise à jour doit être d’au moins 1 heure." }, "step": { "init": { diff --git a/custom_components/pollenlevels/translations/hu.json b/custom_components/pollenlevels/translations/hu.json index e7e6230f..13286fd1 100644 --- a/custom_components/pollenlevels/translations/hu.json +++ b/custom_components/pollenlevels/translations/hu.json @@ -10,7 +10,7 @@ "empty": "A mező nem lehet üres", "invalid_auth": "Érvénytelen API-kulcs\n\n{error_message}", "invalid_coordinates": "Válassz érvényes helyet a térképen.", - "invalid_http_referrer": "Érvénytelen HTTP Referer érték. Nem tartalmazhat sortörés karaktereket.", + "invalid_http_referrer": "Érvénytelen érték a HTTP Referer mezőhöz. Nem tartalmazhat sortörés karaktereket.", "invalid_language": "Érvénytelen nyelvi kód", "invalid_language_format": "Használjon kanonikus BCP-47 kódot, például \"en\" vagy \"es-ES\".", "invalid_option_combo": "Növeld a \"Előrejelzési napok\" értéket a kiválasztott napi szenzorok lefedéséhez.", @@ -43,7 +43,7 @@ } }, "data_description": { - "http_referer": "Csak akkor szükséges, ha az API-kulcsod HTTP Refererek (weboldalak) alapján van korlátozva." + "http_referer": "Csak akkor szükséges, ha az API-kulcsod [webhely-korlátozást]({restricting_api_keys_url}) használ (HTTP Referer)." } } } @@ -82,8 +82,7 @@ "invalid_option_combo": "Növeld a \"Előrejelzési napok\" értéket a kiválasztott napi szenzorok lefedéséhez.", "quota_exceeded": "Kvóta túllépve\n\n{error_message}", "unknown": "Ismeretlen hiba", - "invalid_update_interval": "A frissítési időköznek legalább 1 órának kell lennie.", - "invalid_forecast_days": "Adjon meg érvényes előrejelzési napok számát 1 és 5 között." + "invalid_update_interval": "A frissítési időköznek legalább 1 órának kell lennie." }, "step": { "init": { diff --git a/custom_components/pollenlevels/translations/it.json b/custom_components/pollenlevels/translations/it.json index ff250ff8..176ba218 100644 --- a/custom_components/pollenlevels/translations/it.json +++ b/custom_components/pollenlevels/translations/it.json @@ -10,7 +10,7 @@ "empty": "Questo campo non può essere vuoto", "invalid_auth": "Chiave API non valida\n\n{error_message}", "invalid_coordinates": "Seleziona una posizione valida sulla mappa.", - "invalid_http_referrer": "Valore HTTP Referer non valido. Non deve contenere caratteri di nuova riga.", + "invalid_http_referrer": "Valore per HTTP Referer non valido. Non deve contenere caratteri di nuova riga.", "invalid_language": "Codice lingua non valido", "invalid_language_format": "Usa un codice BCP-47 canonico come \"en\" o \"es-ES\".", "invalid_option_combo": "Aumenta 'Giorni di previsione' per coprire i sensori giornalieri selezionati.", @@ -43,7 +43,7 @@ } }, "data_description": { - "http_referer": "Necessario solo se la tua chiave API è limitata da HTTP Referer (siti web)." + "http_referer": "Necessario solo se la tua chiave API ha una [restrizione per siti web]({restricting_api_keys_url}) (HTTP Referer)." } } } @@ -82,8 +82,7 @@ "invalid_option_combo": "Aumenta 'Giorni di previsione' per coprire i sensori giornalieri selezionati.", "quota_exceeded": "Quota superata\n\n{error_message}", "unknown": "Errore sconosciuto", - "invalid_update_interval": "L’intervallo di aggiornamento deve essere di almeno 1 ora.", - "invalid_forecast_days": "Inserisci un numero valido di giorni di previsione tra 1 e 5." + "invalid_update_interval": "L’intervallo di aggiornamento deve essere di almeno 1 ora." }, "step": { "init": { diff --git a/custom_components/pollenlevels/translations/nb.json b/custom_components/pollenlevels/translations/nb.json index eae98b75..45b5f6ec 100644 --- a/custom_components/pollenlevels/translations/nb.json +++ b/custom_components/pollenlevels/translations/nb.json @@ -10,7 +10,7 @@ "empty": "Dette feltet kan ikke være tomt", "invalid_auth": "Ugyldig API-nøkkel\n\n{error_message}", "invalid_coordinates": "Velg en gyldig posisjon på kartet.", - "invalid_http_referrer": "Ugyldig HTTP Referer-verdi. Den kan ikke inneholde linjeskift.", + "invalid_http_referrer": "Ugyldig verdi for HTTP Referer. Den kan ikke inneholde linjeskift.", "invalid_language": "Ugyldig språkkode", "invalid_language_format": "Bruk en kanonisk BCP-47-kode som \"en\" eller \"es-ES\".", "invalid_option_combo": "Øk «Prognosedager» for å dekke valgte sensorer per dag.", @@ -43,7 +43,7 @@ } }, "data_description": { - "http_referer": "Bare nødvendig hvis API-nøkkelen din er begrenset av HTTP Referer (nettsteder)." + "http_referer": "Bare nødvendig hvis API-nøkkelen din har en [nettstedbegrensning]({restricting_api_keys_url}) (HTTP Referer)." } } } @@ -82,8 +82,7 @@ "invalid_option_combo": "Øk «Prognosedager» for å dekke valgte sensorer per dag.", "quota_exceeded": "Kvote overskredet\n\n{error_message}", "unknown": "Ukjent feil", - "invalid_update_interval": "Oppdateringsintervallet må være minst 1 time.", - "invalid_forecast_days": "Angi et gyldig antall prognosedager mellom 1 og 5." + "invalid_update_interval": "Oppdateringsintervallet må være minst 1 time." }, "step": { "init": { diff --git a/custom_components/pollenlevels/translations/nl.json b/custom_components/pollenlevels/translations/nl.json index d1743126..394f2cce 100644 --- a/custom_components/pollenlevels/translations/nl.json +++ b/custom_components/pollenlevels/translations/nl.json @@ -10,7 +10,7 @@ "empty": "Dit veld mag niet leeg zijn", "invalid_auth": "Ongeldige API-sleutel\n\n{error_message}", "invalid_coordinates": "Selecteer een geldige locatie op de kaart.", - "invalid_http_referrer": "Ongeldige HTTP Referer-waarde. Deze mag geen regeleinden bevatten.", + "invalid_http_referrer": "Ongeldige waarde voor HTTP Referer. Deze mag geen regeleinden bevatten.", "invalid_language": "Ongeldige taalcode", "invalid_language_format": "Gebruik een canonieke BCP-47-code zoals \"en\" of \"es-ES\".", "invalid_option_combo": "Verhoog 'Voorspellingsdagen' om de geselecteerde per-dag sensoren te dekken.", @@ -43,7 +43,7 @@ } }, "data_description": { - "http_referer": "Alleen nodig als je API-sleutel is beperkt tot HTTP Referer (websites)." + "http_referer": "Alleen nodig als je API-sleutel een [websitebeperking]({restricting_api_keys_url}) gebruikt (HTTP Referer)." } } } @@ -82,8 +82,7 @@ "invalid_option_combo": "Verhoog 'Voorspellingsdagen' om de geselecteerde per-dag sensoren te dekken.", "quota_exceeded": "Limiet overschreden\n\n{error_message}", "unknown": "Onbekende fout", - "invalid_update_interval": "Het update-interval moet minimaal 1 uur zijn.", - "invalid_forecast_days": "Voer een geldig aantal voorspellingsdagen in tussen 1 en 5." + "invalid_update_interval": "Het update-interval moet minimaal 1 uur zijn." }, "step": { "init": { diff --git a/custom_components/pollenlevels/translations/pl.json b/custom_components/pollenlevels/translations/pl.json index 742612f5..c212101c 100644 --- a/custom_components/pollenlevels/translations/pl.json +++ b/custom_components/pollenlevels/translations/pl.json @@ -10,7 +10,7 @@ "empty": "To pole nie może być puste", "invalid_auth": "Nieprawidłowy klucz API\n\n{error_message}", "invalid_coordinates": "Wybierz prawidłową lokalizację na mapie.", - "invalid_http_referrer": "Nieprawidłowa wartość HTTP Referer. Nie może zawierać znaków nowej linii.", + "invalid_http_referrer": "Nieprawidłowa wartość dla HTTP Referer. Nie może zawierać znaków nowej linii.", "invalid_language": "Nieprawidłowy kod języka", "invalid_language_format": "Użyj kanonicznego kodu BCP-47, np. \"en\" lub \"es-ES\".", "invalid_option_combo": "Zwiększ 'Dni prognozy', aby objąć wybrane czujniki dzienne.", @@ -43,7 +43,7 @@ } }, "data_description": { - "http_referer": "Wymagane tylko, jeśli Twój klucz API jest ograniczony przez HTTP Referer (witryny)." + "http_referer": "Wymagane tylko, jeśli Twój klucz API ma [ograniczenie witryny]({restricting_api_keys_url}) (HTTP Referer)." } } } @@ -82,8 +82,7 @@ "invalid_option_combo": "Zwiększ 'Dni prognozy', aby objąć wybrane czujniki dzienne.", "quota_exceeded": "Przekroczono limit\n\n{error_message}", "unknown": "Nieznany błąd", - "invalid_update_interval": "Interwał aktualizacji musi wynosić co najmniej 1 godzinę.", - "invalid_forecast_days": "Wprowadź prawidłową liczbę dni prognozy od 1 do 5." + "invalid_update_interval": "Interwał aktualizacji musi wynosić co najmniej 1 godzinę." }, "step": { "init": { diff --git a/custom_components/pollenlevels/translations/pt-BR.json b/custom_components/pollenlevels/translations/pt-BR.json index 551b3664..ab07bf19 100644 --- a/custom_components/pollenlevels/translations/pt-BR.json +++ b/custom_components/pollenlevels/translations/pt-BR.json @@ -10,7 +10,7 @@ "empty": "Este campo não pode ficar vazio", "invalid_auth": "Chave de API inválida\n\n{error_message}", "invalid_coordinates": "Selecione um local válido no mapa.", - "invalid_http_referrer": "Valor de HTTP Referer inválido. Não deve conter quebras de linha.", + "invalid_http_referrer": "Valor inválido para HTTP Referer. Não deve conter quebras de linha.", "invalid_language": "Código de idioma inválido", "invalid_language_format": "Use um código BCP-47 canônico, como \"en\" ou \"es-ES\".", "invalid_option_combo": "Aumente \"Dias de previsão\" para cobrir os sensores por dia selecionados.", @@ -43,7 +43,7 @@ } }, "data_description": { - "http_referer": "Necessário apenas se sua chave de API estiver restrita por HTTP Referer (sites)." + "http_referer": "Necessário apenas se sua chave de API tiver uma [restrição de site]({restricting_api_keys_url}) (HTTP Referer)." } } } @@ -82,8 +82,7 @@ "invalid_option_combo": "Aumente \"Dias de previsão\" para cobrir os sensores por dia selecionados.", "quota_exceeded": "Cota excedida\n\n{error_message}", "unknown": "Erro desconhecido", - "invalid_update_interval": "O intervalo de atualização deve ser de pelo menos 1 hora.", - "invalid_forecast_days": "Insira um número válido de dias de previsão entre 1 e 5." + "invalid_update_interval": "O intervalo de atualização deve ser de pelo menos 1 hora." }, "step": { "init": { diff --git a/custom_components/pollenlevels/translations/pt-PT.json b/custom_components/pollenlevels/translations/pt-PT.json index 0a767634..b9b47beb 100644 --- a/custom_components/pollenlevels/translations/pt-PT.json +++ b/custom_components/pollenlevels/translations/pt-PT.json @@ -10,7 +10,7 @@ "empty": "Este campo não pode estar vazio", "invalid_auth": "Chave da API inválida\n\n{error_message}", "invalid_coordinates": "Selecione uma localização válida no mapa.", - "invalid_http_referrer": "Valor de HTTP Referer inválido. Não pode conter quebras de linha.", + "invalid_http_referrer": "Valor inválido para HTTP Referer. Não pode conter quebras de linha.", "invalid_language": "Código de idioma inválido", "invalid_language_format": "Use um código BCP-47 canónico, como \"en\" ou \"es-ES\".", "invalid_option_combo": "Aumente \"Dias de previsão\" para cobrir os sensores por dia selecionados.", @@ -43,7 +43,7 @@ } }, "data_description": { - "http_referer": "Apenas necessário se a sua chave de API estiver limitada por HTTP Referer (sites)." + "http_referer": "Apenas necessário se a sua chave de API tiver uma [restrição de site]({restricting_api_keys_url}) (HTTP Referer)." } } } @@ -82,8 +82,7 @@ "invalid_option_combo": "Aumente \"Dias de previsão\" para cobrir os sensores por dia selecionados.", "quota_exceeded": "Quota excedida\n\n{error_message}", "unknown": "Erro desconhecido", - "invalid_update_interval": "O intervalo de atualização deve ser de pelo menos 1 hora.", - "invalid_forecast_days": "Introduza um número válido de dias de previsão entre 1 e 5." + "invalid_update_interval": "O intervalo de atualização deve ser de pelo menos 1 hora." }, "step": { "init": { diff --git a/custom_components/pollenlevels/translations/ro.json b/custom_components/pollenlevels/translations/ro.json index ab9410ed..4511f4b9 100644 --- a/custom_components/pollenlevels/translations/ro.json +++ b/custom_components/pollenlevels/translations/ro.json @@ -10,7 +10,7 @@ "empty": "Acest câmp nu poate fi gol", "invalid_auth": "Cheie API nevalidă\n\n{error_message}", "invalid_coordinates": "Selectează o locație validă pe hartă.", - "invalid_http_referrer": "Valoare HTTP Referer invalidă. Nu trebuie să conțină caractere de linie nouă.", + "invalid_http_referrer": "Valoare invalidă pentru HTTP Referer. Nu trebuie să conțină caractere de linie nouă.", "invalid_language": "Cod de limbă nevalid", "invalid_language_format": "Folosiți un cod BCP-47 canonic, de exemplu \"en\" sau \"es-ES\".", "invalid_option_combo": "Măriți \"Zilele de prognoză\" pentru a acoperi senzorii selectați pe zile.", @@ -43,7 +43,7 @@ } }, "data_description": { - "http_referer": "Necesar doar dacă cheia API este restricționată după HTTP Referer (site-uri web)." + "http_referer": "Necesar doar dacă cheia ta API are o [restricție de site web]({restricting_api_keys_url}) (HTTP Referer)." } } } @@ -82,8 +82,7 @@ "invalid_option_combo": "Măriți \"Zilele de prognoză\" pentru a acoperi senzorii selectați pe zile.", "quota_exceeded": "Cota depășită\n\n{error_message}", "unknown": "Eroare necunoscută", - "invalid_update_interval": "Intervalul de actualizare trebuie să fie de cel puțin 1 oră.", - "invalid_forecast_days": "Introduceți un număr valid de zile de prognoză între 1 și 5." + "invalid_update_interval": "Intervalul de actualizare trebuie să fie de cel puțin 1 oră." }, "step": { "init": { diff --git a/custom_components/pollenlevels/translations/ru.json b/custom_components/pollenlevels/translations/ru.json index 2540ca9f..25b38824 100644 --- a/custom_components/pollenlevels/translations/ru.json +++ b/custom_components/pollenlevels/translations/ru.json @@ -10,7 +10,7 @@ "empty": "Это поле не может быть пустым", "invalid_auth": "Неверный ключ API\n\n{error_message}", "invalid_coordinates": "Выберите корректное местоположение на карте.", - "invalid_http_referrer": "Неверное значение HTTP Referer. Оно не должно содержать символы новой строки.", + "invalid_http_referrer": "Неверное значение для HTTP Referer. Оно не должно содержать символы новой строки.", "invalid_language": "Неверный код языка", "invalid_language_format": "Используйте канонический код BCP-47, например \"en\" или \"es-ES\".", "invalid_option_combo": "Увеличьте «Дни прогноза», чтобы охватить выбранные датчики по дням.", @@ -43,7 +43,7 @@ } }, "data_description": { - "http_referer": "Нужно только если ваш ключ API ограничен HTTP Referer (веб-сайты)." + "http_referer": "Нужно только если ваш ключ API имеет [ограничение по веб‑сайту]({restricting_api_keys_url}) (HTTP Referer)." } } } @@ -82,8 +82,7 @@ "invalid_option_combo": "Увеличьте «Дни прогноза», чтобы охватить выбранные датчики по дням.", "quota_exceeded": "Превышен лимит запросов\n\n{error_message}", "unknown": "Неизвестная ошибка", - "invalid_update_interval": "Интервал обновления должен быть не менее 1 часа.", - "invalid_forecast_days": "Введите допустимое количество дней прогноза от 1 до 5." + "invalid_update_interval": "Интервал обновления должен быть не менее 1 часа." }, "step": { "init": { diff --git a/custom_components/pollenlevels/translations/sv.json b/custom_components/pollenlevels/translations/sv.json index 4130a279..7825bc56 100644 --- a/custom_components/pollenlevels/translations/sv.json +++ b/custom_components/pollenlevels/translations/sv.json @@ -10,7 +10,7 @@ "empty": "Detta fält får inte vara tomt", "invalid_auth": "Ogiltig API-nyckel\n\n{error_message}", "invalid_coordinates": "Välj en giltig plats på kartan.", - "invalid_http_referrer": "Ogiltigt HTTP Referer-värde. Det får inte innehålla radbrytningar.", + "invalid_http_referrer": "Ogiltigt värde för HTTP Referer. Det får inte innehålla radbrytningar.", "invalid_language": "Ogiltig språkkod", "invalid_language_format": "Använd en kanonisk BCP-47-kod som \"en\" eller \"es-ES\".", "invalid_option_combo": "Öka \"Prognosdagar\" för att täcka valda sensorer per dag.", @@ -43,7 +43,7 @@ } }, "data_description": { - "http_referer": "Endast nödvändigt om din API-nyckel är begränsad av HTTP Referer (webbplatser)." + "http_referer": "Endast nödvändigt om din API-nyckel har en [webbplatsbegränsning]({restricting_api_keys_url}) (HTTP Referer)." } } } @@ -82,8 +82,7 @@ "invalid_option_combo": "Öka \"Prognosdagar\" för att täcka valda sensorer per dag.", "quota_exceeded": "Kvoten har överskridits\n\n{error_message}", "unknown": "Okänt fel", - "invalid_update_interval": "Uppdateringsintervallet måste vara minst 1 timme.", - "invalid_forecast_days": "Ange ett giltigt antal prognosdagar mellan 1 och 5." + "invalid_update_interval": "Uppdateringsintervallet måste vara minst 1 timme." }, "step": { "init": { diff --git a/custom_components/pollenlevels/translations/uk.json b/custom_components/pollenlevels/translations/uk.json index c34d1a8a..a14166dc 100644 --- a/custom_components/pollenlevels/translations/uk.json +++ b/custom_components/pollenlevels/translations/uk.json @@ -10,7 +10,7 @@ "empty": "Це поле не може бути порожнім", "invalid_auth": "Невірний ключ API\n\n{error_message}", "invalid_coordinates": "Виберіть дійсне місце на карті.", - "invalid_http_referrer": "Неприпустиме значення HTTP Referer. Воно не повинно містити символів нового рядка.", + "invalid_http_referrer": "Неприпустиме значення для HTTP Referer. Воно не повинно містити символів нового рядка.", "invalid_language": "Невірний код мови", "invalid_language_format": "Використовуйте канонічний код BCP-47, наприклад \"en\" або \"es-ES\".", "invalid_option_combo": "Збільшіть «Дні прогнозу», щоб охопити вибрані денні датчики.", @@ -43,7 +43,7 @@ } }, "data_description": { - "http_referer": "Потрібно лише якщо ваш ключ API обмежено через HTTP Referer (вебсайти)." + "http_referer": "Потрібно лише якщо ваш ключ API має [обмеження веб‑сайту]({restricting_api_keys_url}) (HTTP Referer)." } } } @@ -82,8 +82,7 @@ "invalid_option_combo": "Збільшіть «Дні прогнозу», щоб охопити вибрані денні датчики.", "quota_exceeded": "Перевищено ліміт запитів\n\n{error_message}", "unknown": "Невідома помилка", - "invalid_update_interval": "Інтервал оновлення має бути щонайменше 1 година.", - "invalid_forecast_days": "Вкажіть дійсну кількість днів прогнозу від 1 до 5." + "invalid_update_interval": "Інтервал оновлення має бути щонайменше 1 година." }, "step": { "init": { diff --git a/custom_components/pollenlevels/translations/zh-Hans.json b/custom_components/pollenlevels/translations/zh-Hans.json index f99818bd..0ee82bd9 100644 --- a/custom_components/pollenlevels/translations/zh-Hans.json +++ b/custom_components/pollenlevels/translations/zh-Hans.json @@ -10,7 +10,7 @@ "empty": "此字段不能为空", "invalid_auth": "无效的 API 密钥\n\n{error_message}", "invalid_coordinates": "请在地图上选择有效的位置。", - "invalid_http_referrer": "无效的 HTTP Referer 值。它不能包含换行符。", + "invalid_http_referrer": "HTTP Referer 的值无效。它不能包含换行符。", "invalid_language": "无效的语言代码", "invalid_language_format": "请使用规范的 BCP-47 代码,例如 \"en\" 或 \"es-ES\"。", "invalid_option_combo": "请增加“预测天数”,以覆盖所选的逐日类型传感器。", @@ -35,7 +35,7 @@ "update_interval": "更新间隔(小时)", "http_referer": "HTTP Referer" }, - "description": "输入你的 Google API 密钥([在此获取]({api_key_url}))并查看最佳实践([最佳实践]({restricting_api_keys_url}))。在地图上选择位置、更新间隔(小时)以及 API 响应的语言代码。", + "description": "输入你的 Google API 密钥([在此获取]({api_key_url}))并查看最佳实践([最佳实践]({restricting_api_keys_url})). 在地图上选择位置、更新间隔(小时)以及 API 响应的语言代码。", "title": "花粉水平配置", "sections": { "api_key_options": { @@ -43,7 +43,7 @@ } }, "data_description": { - "http_referer": "仅在你的 API 密钥受 HTTP Referer(网站)限制时需要。" + "http_referer": "仅在你的 API 密钥启用了[网站限制]({restricting_api_keys_url})(HTTP Referer)时需要。" } } } @@ -82,8 +82,7 @@ "invalid_option_combo": "请增加“预测天数”,以覆盖所选的逐日类型传感器。", "quota_exceeded": "配额已用尽\n\n{error_message}", "unknown": "未知错误", - "invalid_update_interval": "更新间隔必须至少为 1 小时。", - "invalid_forecast_days": "请输入 1 到 5 之间有效的预测天数。" + "invalid_update_interval": "更新间隔必须至少为 1 小时。" }, "step": { "init": { diff --git a/custom_components/pollenlevels/translations/zh-Hant.json b/custom_components/pollenlevels/translations/zh-Hant.json index d7c8c3fb..feb67943 100644 --- a/custom_components/pollenlevels/translations/zh-Hant.json +++ b/custom_components/pollenlevels/translations/zh-Hant.json @@ -10,7 +10,7 @@ "empty": "此欄位不得為空", "invalid_auth": "無效的 API 金鑰\n\n{error_message}", "invalid_coordinates": "請在地圖上選擇有效的位置。", - "invalid_http_referrer": "無效的 HTTP Referer 值。不得包含換行符。", + "invalid_http_referrer": "HTTP Referer 的值無效。不得包含換行符。", "invalid_language": "無效的語言代碼", "invalid_language_format": "請使用標準的 BCP-47 代碼,例如 \"en\" 或 \"es-ES\"。", "invalid_option_combo": "請增加「預測天數」以涵蓋所選的逐日類型感測器。", @@ -35,7 +35,7 @@ "update_interval": "更新間隔(小時)", "http_referer": "HTTP Referer" }, - "description": "輸入你的 Google API 金鑰([在此取得]({api_key_url}))並查看最佳實務([最佳實務]({restricting_api_keys_url}))。在地圖上選擇位置、更新間隔(小時)以及 API 回應的語言代碼。", + "description": "輸入你的 Google API 金鑰([在此取得]({api_key_url})) 並查看最佳實務([最佳實務]({restricting_api_keys_url})). 在地圖上選擇位置、更新間隔(小時)以及 API 回應的語言代碼。", "title": "花粉水平設定", "sections": { "api_key_options": { @@ -43,7 +43,7 @@ } }, "data_description": { - "http_referer": "僅在你的 API 金鑰受 HTTP Referer(網站)限制時需要。" + "http_referer": "僅在你的 API 金鑰啟用了[網站限制]({restricting_api_keys_url})(HTTP Referer)時需要。" } } } @@ -82,8 +82,7 @@ "invalid_option_combo": "請增加「預測天數」以涵蓋所選的逐日類型感測器。", "quota_exceeded": "超出配額\n\n{error_message}", "unknown": "未知錯誤", - "invalid_update_interval": "更新間隔必須至少為 1 小時。", - "invalid_forecast_days": "請輸入 1 到 5 之間的有效預測天數。" + "invalid_update_interval": "更新間隔必須至少為 1 小時。" }, "step": { "init": { From 90ba22f771ca9b6daa579efb4527a91cb3e33615 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Wed, 17 Dec 2025 00:25:34 +0100 Subject: [PATCH 061/200] Fix README forecast sensor list indentation --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 670740ef..678e0581 100644 --- a/README.md +++ b/README.md @@ -59,9 +59,9 @@ You can change: - **API response language code** - **Forecast days** (`1–5`) for pollen TYPES - **Per-day TYPE sensors** via `create_forecast_sensors`: - - `none` → no extra sensors - - `D+1` → sensors for each TYPE with suffix `(D+1)` - - `D+1+2` → sensors for `(D+1)` and `(D+2)` + - `none` → no extra sensors + - `D+1` → sensors for each TYPE with suffix `(D+1)` + - `D+1+2` → sensors for `(D+1)` and `(D+2)` > **Validation rules:** > - `D+1` requires `forecast_days ≥ 2` From 06aab9d1e307d5748b52a7ef98e96dd9cd71d82b Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Wed, 17 Dec 2025 00:25:41 +0100 Subject: [PATCH 062/200] refactor(config_flow): use dropdown for forecast days --- custom_components/pollenlevels/config_flow.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/custom_components/pollenlevels/config_flow.py b/custom_components/pollenlevels/config_flow.py index 3ab22506..d9bb1de5 100644 --- a/custom_components/pollenlevels/config_flow.py +++ b/custom_components/pollenlevels/config_flow.py @@ -572,6 +572,11 @@ async def async_step_init(self, user_input=None): ) current_mode = self.entry.options.get(CONF_CREATE_FORECAST_SENSORS, "none") + # UI: Forecast days as dropdown to prevent invalid values (keep stored type as int). + forecast_days_options = [ + str(i) for i in range(MIN_FORECAST_DAYS, MAX_FORECAST_DAYS + 1) + ] + options_schema = vol.Schema( { vol.Optional( @@ -587,12 +592,12 @@ async def async_step_init(self, user_input=None): vol.Optional(CONF_LANGUAGE_CODE, default=current_lang): TextSelector( TextSelectorConfig(type=TextSelectorType.TEXT) ), - vol.Optional(CONF_FORECAST_DAYS, default=current_days): NumberSelector( - NumberSelectorConfig( - min=MIN_FORECAST_DAYS, - max=MAX_FORECAST_DAYS, - step=1, - mode=NumberSelectorMode.BOX, + vol.Optional( + CONF_FORECAST_DAYS, default=str(current_days) + ): SelectSelector( + SelectSelectorConfig( + mode=SelectSelectorMode.DROPDOWN, + options=forecast_days_options, ) ), vol.Optional( @@ -624,6 +629,7 @@ async def async_step_init(self, user_input=None): description_placeholders=placeholders, ) + # Persist forecast_days as int, even though UI returns str. forecast_days, days_error = _parse_int_option( normalized_input.get(CONF_FORECAST_DAYS, current_days), current_days, From 9cd067fb90b0cb18d5d56c19a4395c5f48bd407d Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Wed, 17 Dec 2025 00:25:48 +0100 Subject: [PATCH 063/200] feat(config_flow): configure forecast options on setup --- custom_components/pollenlevels/config_flow.py | 51 ++++++++++++++++--- 1 file changed, 45 insertions(+), 6 deletions(-) diff --git a/custom_components/pollenlevels/config_flow.py b/custom_components/pollenlevels/config_flow.py index d9bb1de5..fedcdf6a 100644 --- a/custom_components/pollenlevels/config_flow.py +++ b/custom_components/pollenlevels/config_flow.py @@ -61,6 +61,10 @@ _LOGGER = logging.getLogger(__name__) +FORECAST_DAYS_OPTIONS = [ + str(i) for i in range(MIN_FORECAST_DAYS, MAX_FORECAST_DAYS + 1) +] + # BCP-47-ish regex (common patterns, not full grammar). LANGUAGE_CODE_REGEX = re.compile( r"^[A-Za-z]{2,3}" @@ -87,6 +91,22 @@ vol.Optional(CONF_LANGUAGE_CODE): TextSelector( TextSelectorConfig(type=TextSelectorType.TEXT) ), + vol.Optional( + CONF_FORECAST_DAYS, default=str(DEFAULT_FORECAST_DAYS) + ): SelectSelector( + SelectSelectorConfig( + mode=SelectSelectorMode.DROPDOWN, + options=FORECAST_DAYS_OPTIONS, + ) + ), + vol.Optional( + CONF_CREATE_FORECAST_SENSORS, default=FORECAST_SENSORS_CHOICES[0] + ): SelectSelector( + SelectSelectorConfig( + mode=SelectSelectorMode.DROPDOWN, + options=FORECAST_SENSORS_CHOICES, + ) + ), section(SECTION_API_KEY_OPTIONS, SectionConfig(collapsed=True)): { vol.Optional(CONF_HTTP_REFERER, default=""): TextSelector( TextSelectorConfig(type=TextSelectorType.TEXT) @@ -268,6 +288,28 @@ async def _async_validate_input( errors[CONF_UPDATE_INTERVAL] = interval_error return errors, None + forecast_days, days_error = _parse_int_option( + normalized.get(CONF_FORECAST_DAYS), + DEFAULT_FORECAST_DAYS, + min_value=MIN_FORECAST_DAYS, + max_value=MAX_FORECAST_DAYS, + error_key="invalid_forecast_days", + ) + normalized[CONF_FORECAST_DAYS] = forecast_days + if days_error: + errors[CONF_FORECAST_DAYS] = days_error + return errors, None + + mode = normalized.get(CONF_CREATE_FORECAST_SENSORS, FORECAST_SENSORS_CHOICES[0]) + if mode not in FORECAST_SENSORS_CHOICES: + mode = FORECAST_SENSORS_CHOICES[0] + normalized[CONF_CREATE_FORECAST_SENSORS] = mode + needed = {"D+1": 2, "D+1+2": 3}.get(mode, 1) + if forecast_days < needed: + errors[CONF_CREATE_FORECAST_SENSORS] = "invalid_option_combo" + return errors, None + normalized[CONF_CREATE_FORECAST_SENSORS] = mode + latlon = None if CONF_LOCATION in user_input: latlon = _validate_location_dict(user_input.get(CONF_LOCATION)) @@ -471,6 +513,8 @@ async def async_step_user(self, user_input=None): CONF_LANGUAGE_CODE: self.hass.config.language, CONF_NAME: getattr(self.hass.config, "location_name", "") or DEFAULT_ENTRY_TITLE, + CONF_FORECAST_DAYS: str(DEFAULT_FORECAST_DAYS), + CONF_CREATE_FORECAST_SENSORS: FORECAST_SENSORS_CHOICES[0], } lat = _safe_coord(getattr(self.hass.config, "latitude", None), lat=True) @@ -572,11 +616,6 @@ async def async_step_init(self, user_input=None): ) current_mode = self.entry.options.get(CONF_CREATE_FORECAST_SENSORS, "none") - # UI: Forecast days as dropdown to prevent invalid values (keep stored type as int). - forecast_days_options = [ - str(i) for i in range(MIN_FORECAST_DAYS, MAX_FORECAST_DAYS + 1) - ] - options_schema = vol.Schema( { vol.Optional( @@ -597,7 +636,7 @@ async def async_step_init(self, user_input=None): ): SelectSelector( SelectSelectorConfig( mode=SelectSelectorMode.DROPDOWN, - options=forecast_days_options, + options=FORECAST_DAYS_OPTIONS, ) ), vol.Optional( From e47749a88b93ea54a1cb9e8a0d5ca3cd681b387a Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Wed, 17 Dec 2025 00:25:54 +0100 Subject: [PATCH 064/200] docs(changelog): note forecast options in setup --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 056272f6..391fc1bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,9 @@ # Changelog ## [1.9.0-alpha2] - 2025-12-16 ### Changed +- Enabled forecast day count and per-day sensor mode selection during initial + setup using dropdown selectors shared with the options flow to keep + validation consistent. - Added stable constants for HTTP referrer support and API key helper URLs to support upcoming flow updates. - Modernized the config flow with selectors, API key guidance links, and a From efb89889db385d4e0e4a991c382908cfa67e1082 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Wed, 17 Dec 2025 08:01:22 +0100 Subject: [PATCH 065/200] fix: respect config-flow sensor mode --- custom_components/pollenlevels/__init__.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/custom_components/pollenlevels/__init__.py b/custom_components/pollenlevels/__init__.py index ccef9945..12eda0b0 100644 --- a/custom_components/pollenlevels/__init__.py +++ b/custom_components/pollenlevels/__init__.py @@ -108,7 +108,14 @@ async def async_setup_entry( ) ) language = options.get(CONF_LANGUAGE_CODE, entry.data.get(CONF_LANGUAGE_CODE)) - mode = options.get(CONF_CREATE_FORECAST_SENSORS, ForecastSensorMode.NONE) + raw_mode = options.get( + CONF_CREATE_FORECAST_SENSORS, + entry.data.get(CONF_CREATE_FORECAST_SENSORS, ForecastSensorMode.NONE), + ) + try: + mode = ForecastSensorMode(raw_mode) + except (ValueError, TypeError): + mode = ForecastSensorMode.NONE create_d1 = mode in (ForecastSensorMode.D1, ForecastSensorMode.D1_D2) create_d2 = mode == ForecastSensorMode.D1_D2 From 1367bed732ade8e8bedbae74d8d305d628e55fbe Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Wed, 17 Dec 2025 08:01:28 +0100 Subject: [PATCH 066/200] chore: centralize retries and tighten typing --- CHANGELOG.md | 4 ++++ custom_components/pollenlevels/client.py | 4 ++-- custom_components/pollenlevels/const.py | 1 + custom_components/pollenlevels/util.py | 4 +++- 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 391fc1bb..996cf854 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,10 @@ `invalid_forecast_days` error code for out-of-range values. - Consolidated numeric options validation in the options flow through a shared helper to reduce duplication for interval and forecast day checks. +- Centralized the pollen client retry count into a shared `MAX_RETRIES` + constant to simplify future tuning without touching request logic. +- Tightened error extraction typing to expect `aiohttp.ClientResponse` for + clearer static analysis and editor support. ## [1.9.0-alpha1] - 2025-12-11 ### Changed diff --git a/custom_components/pollenlevels/client.py b/custom_components/pollenlevels/client.py index c45ff41f..9d0cbe4a 100644 --- a/custom_components/pollenlevels/client.py +++ b/custom_components/pollenlevels/client.py @@ -10,7 +10,7 @@ from homeassistant.helpers.update_coordinator import UpdateFailed from homeassistant.util import dt as dt_util -from .const import POLLEN_API_TIMEOUT, is_invalid_api_key_message +from .const import MAX_RETRIES, POLLEN_API_TIMEOUT, is_invalid_api_key_message from .util import extract_error_message, redact_api_key _LOGGER = logging.getLogger(__name__) @@ -86,7 +86,7 @@ async def async_fetch_pollen_data( "Fetching forecast (days=%s, lang_set=%s)", days, bool(language_code) ) - max_retries = 1 + max_retries = MAX_RETRIES for attempt in range(0, max_retries + 1): try: headers: dict[str, str] | None = None diff --git a/custom_components/pollenlevels/const.py b/custom_components/pollenlevels/const.py index d4ff84bd..3823b339 100644 --- a/custom_components/pollenlevels/const.py +++ b/custom_components/pollenlevels/const.py @@ -25,6 +25,7 @@ MAX_FORECAST_DAYS = 5 MIN_FORECAST_DAYS = 1 POLLEN_API_TIMEOUT = 10 +MAX_RETRIES = 1 POLLEN_API_KEY_URL = ( "https://developers.google.com/maps/documentation/pollen/get-api-key" ) diff --git a/custom_components/pollenlevels/util.py b/custom_components/pollenlevels/util.py index ad1747af..a88391c2 100644 --- a/custom_components/pollenlevels/util.py +++ b/custom_components/pollenlevels/util.py @@ -2,8 +2,10 @@ from __future__ import annotations +from aiohttp import ClientResponse -async def extract_error_message(resp: object, default: str = "") -> str: + +async def extract_error_message(resp: ClientResponse, default: str = "") -> str: """Extract and normalize an HTTP error message without secrets.""" message: str | None = None From d0239d138e333562bf5621e64c939adc25d19341 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Wed, 17 Dec 2025 08:07:09 +0100 Subject: [PATCH 067/200] fix: guard aiohttp typing import --- CHANGELOG.md | 4 ++-- custom_components/pollenlevels/util.py | 7 ++++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 996cf854..1f07151b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,8 +31,8 @@ helper to reduce duplication for interval and forecast day checks. - Centralized the pollen client retry count into a shared `MAX_RETRIES` constant to simplify future tuning without touching request logic. -- Tightened error extraction typing to expect `aiohttp.ClientResponse` for - clearer static analysis and editor support. +- Tightened error extraction typing to expect `aiohttp.ClientResponse` while + guarding the import so environments without aiohttp can still run tests. ## [1.9.0-alpha1] - 2025-12-11 ### Changed diff --git a/custom_components/pollenlevels/util.py b/custom_components/pollenlevels/util.py index a88391c2..7ce8a414 100644 --- a/custom_components/pollenlevels/util.py +++ b/custom_components/pollenlevels/util.py @@ -2,7 +2,12 @@ from __future__ import annotations -from aiohttp import ClientResponse +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: # pragma: no cover - typing-only import + from aiohttp import ClientResponse +else: # pragma: no cover - runtime fallback for test environments without aiohttp + ClientResponse = Any async def extract_error_message(resp: ClientResponse, default: str = "") -> str: From 3cd8cb23a8161846448ff495c6024c2908a8cc82 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Wed, 17 Dec 2025 15:02:34 +0100 Subject: [PATCH 068/200] Add files via upload --- .../pollenlevels/translations/ca.json | 17 ++++++++--------- .../pollenlevels/translations/cs.json | 17 ++++++++--------- .../pollenlevels/translations/da.json | 17 ++++++++--------- .../pollenlevels/translations/de.json | 17 ++++++++--------- .../pollenlevels/translations/en.json | 17 ++++++++--------- .../pollenlevels/translations/es.json | 19 +++++++++---------- .../pollenlevels/translations/fi.json | 17 ++++++++--------- .../pollenlevels/translations/fr.json | 17 ++++++++--------- .../pollenlevels/translations/hu.json | 17 ++++++++--------- .../pollenlevels/translations/it.json | 17 ++++++++--------- .../pollenlevels/translations/nb.json | 17 ++++++++--------- .../pollenlevels/translations/nl.json | 17 ++++++++--------- .../pollenlevels/translations/pl.json | 17 ++++++++--------- .../pollenlevels/translations/pt-BR.json | 17 ++++++++--------- .../pollenlevels/translations/pt-PT.json | 17 ++++++++--------- .../pollenlevels/translations/ro.json | 17 ++++++++--------- .../pollenlevels/translations/ru.json | 17 ++++++++--------- .../pollenlevels/translations/sv.json | 17 ++++++++--------- .../pollenlevels/translations/uk.json | 17 ++++++++--------- .../pollenlevels/translations/zh-Hans.json | 17 ++++++++--------- .../pollenlevels/translations/zh-Hant.json | 17 ++++++++--------- 21 files changed, 169 insertions(+), 190 deletions(-) diff --git a/custom_components/pollenlevels/translations/ca.json b/custom_components/pollenlevels/translations/ca.json index ee033828..efb41fa0 100644 --- a/custom_components/pollenlevels/translations/ca.json +++ b/custom_components/pollenlevels/translations/ca.json @@ -10,7 +10,9 @@ "location": "Ubicació", "update_interval": "Interval d’actualització (hores)", "language_code": "Codi d’idioma de la resposta de l’API", - "http_referer": "HTTP Referer" + "http_referer": "HTTP Referer", + "forecast_days": "Dies de previsió (1–5)", + "create_forecast_sensors": "Abast dels sensors per dia (TIPUS)" }, "sections": { "api_key_options": { @@ -33,14 +35,14 @@ "invalid_auth": "Clau API no vàlida\n\n{error_message}", "cannot_connect": "No es pot connectar al servei de pol·len.\n\n{error_message}", "quota_exceeded": "Quota excedida\n\n{error_message}", - "invalid_language": "Codi d’idioma no vàlid", "invalid_language_format": "Utilitza un codi BCP-47 canònic com \"en\" o \"es-ES\".", "empty": "Aquest camp no pot estar buit", "invalid_option_combo": "Augmenta els 'Dies de previsió' per cobrir els sensors per dia seleccionats.", "invalid_coordinates": "Selecciona una ubicació vàlida al mapa.", "unknown": "Error desconegut", "invalid_http_referrer": "Valor de HTTP Referer no vàlid. No pot contenir salts de línia.", - "invalid_update_interval": "L’interval d’actualització ha de ser d’almenys 1 hora." + "invalid_update_interval": "L’interval d’actualització ha de ser d’almenys 1 hora.", + "invalid_forecast_days": "Els dies de previsió han d’estar entre 1 i 5." }, "abort": { "already_configured": "Aquesta ubicació ja està configurada.", @@ -52,7 +54,7 @@ "step": { "init": { "title": "Pollen Levels – Opcions", - "description": "Canvia l’interval d’actualització, l’idioma de resposta de l’API, els dies de previsió i els sensors per dia per a {title}.\nOpcions de sensors per dia (TIPUS): Només avui (none), Fins demà (D+1), Fins demà passat (D+2).", + "description": "Canvia l’interval d’actualització, l’idioma de resposta de l’API, els dies de previsió i els sensors per dia per a {title}.\nOpcions de sensors per dia (TIPUS): Només avui (none), Fins demà (D+1), Fins demà passat (D+1+2).", "data": { "update_interval": "Interval d’actualització (hores)", "language_code": "Codi d’idioma de la resposta de l’API", @@ -62,15 +64,12 @@ } }, "error": { - "invalid_auth": "Clau API no vàlida\n\n{error_message}", - "cannot_connect": "No es pot connectar al servei de pol·len.\n\n{error_message}", - "quota_exceeded": "Quota excedida\n\n{error_message}", - "invalid_language": "Codi d’idioma no vàlid", "invalid_language_format": "Utilitza un codi BCP-47 canònic com \"en\" o \"es-ES\".", "empty": "Aquest camp no pot estar buit", "invalid_option_combo": "Augmenta els 'Dies de previsió' per cobrir els sensors per dia seleccionats.", "unknown": "Error desconegut", - "invalid_update_interval": "L’interval d’actualització ha de ser d’almenys 1 hora." + "invalid_update_interval": "L’interval d’actualització ha de ser d’almenys 1 hora.", + "invalid_forecast_days": "Els dies de previsió han d’estar entre 1 i 5." } }, "device": { diff --git a/custom_components/pollenlevels/translations/cs.json b/custom_components/pollenlevels/translations/cs.json index 9f258516..abfd0d5a 100644 --- a/custom_components/pollenlevels/translations/cs.json +++ b/custom_components/pollenlevels/translations/cs.json @@ -11,12 +11,12 @@ "invalid_auth": "Neplatný klíč API\n\n{error_message}", "invalid_coordinates": "Vyberte platné umístění na mapě.", "invalid_http_referrer": "Neplatná hodnota HTTP Referer. Nesmí obsahovat znaky nového řádku.", - "invalid_language": "Neplatný kód jazyka", "invalid_language_format": "Použijte kanonický kód BCP-47, například \"en\" nebo \"es-ES\".", "invalid_option_combo": "Zvyšte „Dny předpovědi“, aby pokryly vybrané senzory po dnech.", "quota_exceeded": "Překročena kvóta\n\n{error_message}", "unknown": "Neznámá chyba", - "invalid_update_interval": "Interval aktualizace musí být alespoň 1 hodina." + "invalid_update_interval": "Interval aktualizace musí být alespoň 1 hodina.", + "invalid_forecast_days": "Dny předpovědi musí být v rozmezí 1–5." }, "step": { "reauth_confirm": { @@ -33,7 +33,9 @@ "location": "Poloha", "name": "Název", "update_interval": "Interval aktualizace (hodiny)", - "http_referer": "HTTP Referer" + "http_referer": "HTTP Referer", + "forecast_days": "Dny předpovědi (1–5)", + "create_forecast_sensors": "Rozsah senzorů po dnech (TYPY)" }, "description": "Zadejte svůj Google API klíč ([získejte jej zde]({api_key_url})) a přečtěte si doporučené postupy ([doporučené postupy]({restricting_api_keys_url})). Vyberte polohu na mapě, interval aktualizace (v hodinách) a jazykový kód odpovědi API.", "title": "Konfigurace úrovní pylu", @@ -74,15 +76,12 @@ }, "options": { "error": { - "cannot_connect": "Nelze se připojit ke službě\n\n{error_message}", "empty": "Toto pole nemůže být prázdné", - "invalid_auth": "Neplatný klíč API\n\n{error_message}", - "invalid_language": "Neplatný kód jazyka", "invalid_language_format": "Použijte kanonický kód BCP-47, například \"en\" nebo \"es-ES\".", "invalid_option_combo": "Zvyšte „Dny předpovědi“, aby pokryly vybrané senzory po dnech.", - "quota_exceeded": "Překročena kvóta\n\n{error_message}", "unknown": "Neznámá chyba", - "invalid_update_interval": "Interval aktualizace musí být alespoň 1 hodina." + "invalid_update_interval": "Interval aktualizace musí být alespoň 1 hodina.", + "invalid_forecast_days": "Dny předpovědi musí být v rozmezí 1–5." }, "step": { "init": { @@ -92,7 +91,7 @@ "language_code": "Kód jazyka odpovědi API", "update_interval": "Interval aktualizace (hodiny)" }, - "description": "Změňte interval aktualizace, jazyk API, dny předpovědi a senzory po dnech pro {title}.\nMožnosti senzorů po dnech (TYPY): Pouze dnes (none), Do zítra (D+1), Do pozítří (D+2).", + "description": "Změňte interval aktualizace, jazyk API, dny předpovědi a senzory po dnech pro {title}.\nMožnosti senzorů po dnech (TYPY): Pouze dnes (none), Do zítra (D+1), Do pozítří (D+1+2).", "title": "Pollen Levels – Možnosti" } } diff --git a/custom_components/pollenlevels/translations/da.json b/custom_components/pollenlevels/translations/da.json index 667db075..ef953c6a 100644 --- a/custom_components/pollenlevels/translations/da.json +++ b/custom_components/pollenlevels/translations/da.json @@ -11,12 +11,12 @@ "invalid_auth": "Ugyldig API-nøgle\n\n{error_message}", "invalid_coordinates": "Vælg en gyldig placering på kortet.", "invalid_http_referrer": "Ugyldig værdi for HTTP Referer. Den må ikke indeholde linjeskift.", - "invalid_language": "Ugyldig sprogkode", "invalid_language_format": "Brug en kanonisk BCP-47-kode som \"en\" eller \"es-ES\".", "invalid_option_combo": "Forøg \"Prognosedage\" for at dække valgte sensorer pr. dag.", "quota_exceeded": "Kvote overskredet\n\n{error_message}", "unknown": "Ukendt fejl", - "invalid_update_interval": "Opdateringsintervallet skal være mindst 1 time." + "invalid_update_interval": "Opdateringsintervallet skal være mindst 1 time.", + "invalid_forecast_days": "Prognosedage skal være mellem 1 og 5." }, "step": { "reauth_confirm": { @@ -33,7 +33,9 @@ "location": "Placering", "name": "Navn", "update_interval": "Opdateringsinterval (timer)", - "http_referer": "HTTP Referer" + "http_referer": "HTTP Referer", + "forecast_days": "Prognosedage (1–5)", + "create_forecast_sensors": "Omfang af sensorer pr. dag (TYPER)" }, "description": "Indtast din Google API-nøgle ([hent den her]({api_key_url})) og læs bedste praksis ([bedste praksis]({restricting_api_keys_url})). Vælg din placering på kortet, opdateringsinterval (timer) og sprogkode for API-svaret.", "title": "Konfiguration af pollenniveauer", @@ -74,15 +76,12 @@ }, "options": { "error": { - "cannot_connect": "Kan ikke oprette forbindelse til tjenesten\n\n{error_message}", "empty": "Dette felt må ikke være tomt", - "invalid_auth": "Ugyldig API-nøgle\n\n{error_message}", - "invalid_language": "Ugyldig sprogkode", "invalid_language_format": "Brug en kanonisk BCP-47-kode som \"en\" eller \"es-ES\".", "invalid_option_combo": "Forøg \"Prognosedage\" for at dække valgte sensorer pr. dag.", - "quota_exceeded": "Kvote overskredet\n\n{error_message}", "unknown": "Ukendt fejl", - "invalid_update_interval": "Opdateringsintervallet skal være mindst 1 time." + "invalid_update_interval": "Opdateringsintervallet skal være mindst 1 time.", + "invalid_forecast_days": "Prognosedage skal være mellem 1 og 5." }, "step": { "init": { @@ -92,7 +91,7 @@ "language_code": "Sprogkode for API-svar", "update_interval": "Opdateringsinterval (timer)" }, - "description": "Skift opdateringsinterval, API-sprog, prognosedage og sensorer pr. dag for {title}.\nIndstillinger for sensorer pr. dag (TYPER): Kun i dag (none), Til og med i morgen (D+1), Til og med overmorgen (D+2).", + "description": "Skift opdateringsinterval, API-sprog, prognosedage og sensorer pr. dag for {title}.\nIndstillinger for sensorer pr. dag (TYPER): Kun i dag (none), Til og med i morgen (D+1), Til og med overmorgen (D+1+2).", "title": "Pollen Levels – Indstillinger" } } diff --git a/custom_components/pollenlevels/translations/de.json b/custom_components/pollenlevels/translations/de.json index 5541291b..11c1ecff 100644 --- a/custom_components/pollenlevels/translations/de.json +++ b/custom_components/pollenlevels/translations/de.json @@ -11,12 +11,12 @@ "invalid_auth": "Ungültiger API-Schlüssel\n\n{error_message}", "invalid_coordinates": "Wähle einen gültigen Standort auf der Karte aus.", "invalid_http_referrer": "Ungültiger Wert für HTTP Referer. Er darf keine Zeilenumbrüche enthalten.", - "invalid_language": "Ungültiger Sprachcode", "invalid_language_format": "Verwenden Sie einen kanonischen BCP-47-Code wie \"en\" oder \"es-ES\".", "invalid_option_combo": "Erhöhe 'Vorhersagetage', um die gewählten Tagessensoren abzudecken.", "quota_exceeded": "Kontingent überschritten\n\n{error_message}", "unknown": "Unbekannter Fehler", - "invalid_update_interval": "Das Aktualisierungsintervall muss mindestens 1 Stunde betragen." + "invalid_update_interval": "Das Aktualisierungsintervall muss mindestens 1 Stunde betragen.", + "invalid_forecast_days": "Vorhersagetage müssen zwischen 1 und 5 liegen." }, "step": { "reauth_confirm": { @@ -33,7 +33,9 @@ "location": "Standort", "name": "Name", "update_interval": "Aktualisierungsintervall (Stunden)", - "http_referer": "HTTP Referer" + "http_referer": "HTTP Referer", + "forecast_days": "Vorhersagetage (1–5)", + "create_forecast_sensors": "Bereich der Tagessensoren (TYPEN)" }, "description": "Gib deinen Google API-Schlüssel ein ([hier abrufen]({api_key_url})) und lies die Best Practices ([Best Practices]({restricting_api_keys_url})). Wähle deinen Standort auf der Karte, das Aktualisierungsintervall (Stunden) und den Sprachcode der API-Antwort.", "title": "Pollen Levels – Konfiguration", @@ -74,15 +76,12 @@ }, "options": { "error": { - "cannot_connect": "Verbindung zum Dienst fehlgeschlagen\n\n{error_message}", "empty": "Dieses Feld darf nicht leer sein", - "invalid_auth": "Ungültiger API-Schlüssel\n\n{error_message}", - "invalid_language": "Ungültiger Sprachcode", "invalid_language_format": "Verwenden Sie einen kanonischen BCP-47-Code wie \"en\" oder \"es-ES\".", "invalid_option_combo": "Erhöhe 'Vorhersagetage', um die gewählten Tagessensoren abzudecken.", - "quota_exceeded": "Kontingent überschritten\n\n{error_message}", "unknown": "Unbekannter Fehler", - "invalid_update_interval": "Das Aktualisierungsintervall muss mindestens 1 Stunde betragen." + "invalid_update_interval": "Das Aktualisierungsintervall muss mindestens 1 Stunde betragen.", + "invalid_forecast_days": "Vorhersagetage müssen zwischen 1 und 5 liegen." }, "step": { "init": { @@ -92,7 +91,7 @@ "language_code": "Sprachcode für die API-Antwort", "update_interval": "Aktualisierungsintervall (Stunden)" }, - "description": "Ändere Aktualisierungsintervall, API-Sprache, Vorhersagetage und Tagessensoren für {title}.\nOptionen für Tagessensoren (TYPEN): Nur heute (none), Bis morgen (D+1), Bis übermorgen (D+2).", + "description": "Ändere Aktualisierungsintervall, API-Sprache, Vorhersagetage und Tagessensoren für {title}.\nOptionen für Tagessensoren (TYPEN): Nur heute (none), Bis morgen (D+1), Bis übermorgen (D+1+2).", "title": "Pollen Levels – Optionen" } } diff --git a/custom_components/pollenlevels/translations/en.json b/custom_components/pollenlevels/translations/en.json index 5c2b379e..fe08f245 100644 --- a/custom_components/pollenlevels/translations/en.json +++ b/custom_components/pollenlevels/translations/en.json @@ -10,7 +10,9 @@ "location": "Location", "update_interval": "Update interval (hours)", "language_code": "API response language code", - "http_referer": "HTTP Referer" + "http_referer": "HTTP Referer", + "forecast_days": "Forecast days (1–5)", + "create_forecast_sensors": "Per-day TYPE sensors range" }, "sections": { "api_key_options": { @@ -33,14 +35,14 @@ "invalid_auth": "Invalid API key\n\n{error_message}", "cannot_connect": "Unable to connect to the pollen service.\n\n{error_message}", "quota_exceeded": "Quota exceeded\n\n{error_message}", - "invalid_language": "Invalid language code", "invalid_language_format": "Use a canonical BCP-47 code such as \"en\" or \"es-ES\".", "empty": "This field cannot be empty", "invalid_option_combo": "Increase 'Forecast days' to cover selected per-day sensors.", "invalid_coordinates": "Please select a valid location on the map.", "unknown": "Unknown error", "invalid_http_referrer": "Invalid value for HTTP Referer. It must not contain newline characters.", - "invalid_update_interval": "Update interval must be at least 1 hour." + "invalid_update_interval": "Update interval must be at least 1 hour.", + "invalid_forecast_days": "Forecast days must be between 1 and 5." }, "abort": { "already_configured": "This location is already configured.", @@ -52,7 +54,7 @@ "step": { "init": { "title": "Pollen Levels – Options", - "description": "Change the update interval, API language, forecast days and per-day TYPE sensors for {title}.\nOptions for per-day TYPE sensors: Only today (none), Through tomorrow (D+1), Through day after tomorrow (D+2).", + "description": "Change the update interval, API language, forecast days and per-day TYPE sensors for {title}.\nOptions for per-day TYPE sensors: Only today (none), Through tomorrow (D+1), Through day after tomorrow (D+1+2).", "data": { "update_interval": "Update interval (hours)", "language_code": "API response language code", @@ -62,15 +64,12 @@ } }, "error": { - "invalid_auth": "Invalid API key\n\n{error_message}", - "cannot_connect": "Unable to connect to the pollen service.\n\n{error_message}", - "quota_exceeded": "Quota exceeded\n\n{error_message}", - "invalid_language": "Invalid language code", "invalid_language_format": "Use a canonical BCP-47 code such as \"en\" or \"es-ES\".", "empty": "This field cannot be empty", "invalid_option_combo": "Increase 'Forecast days' to cover selected per-day sensors.", "unknown": "Unknown error", - "invalid_update_interval": "Update interval must be at least 1 hour." + "invalid_update_interval": "Update interval must be at least 1 hour.", + "invalid_forecast_days": "Forecast days must be between 1 and 5." } }, "device": { diff --git a/custom_components/pollenlevels/translations/es.json b/custom_components/pollenlevels/translations/es.json index 749d2c40..3d7b76f3 100644 --- a/custom_components/pollenlevels/translations/es.json +++ b/custom_components/pollenlevels/translations/es.json @@ -10,7 +10,9 @@ "location": "Ubicación", "update_interval": "Intervalo de actualización (horas)", "language_code": "Código de idioma de la respuesta de la API", - "http_referer": "HTTP Referer" + "http_referer": "HTTP Referer", + "forecast_days": "Días de previsión (1–5)", + "create_forecast_sensors": "Alcance de sensores por día (TIPOS)" }, "sections": { "api_key_options": { @@ -18,7 +20,7 @@ } }, "data_description": { - "http_referer": "Especifica esto solo si tu clave API tiene una [restricción de aplicación del sitio web]({restricting_api_keys_url}) (HTTP Referer)." + "http_referer": "Solo es necesario si tu clave API tiene una restricción de tipo \"HTTP Referer\" (sitios web). Consulta [cómo restringir claves API]({restricting_api_keys_url})." } }, "reauth_confirm": { @@ -33,14 +35,14 @@ "invalid_auth": "Clave API inválida\n\n{error_message}", "cannot_connect": "No se puede conectar al servicio de polen.\n\n{error_message}", "quota_exceeded": "Cuota excedida\n\n{error_message}", - "invalid_language": "Código de idioma no válido", "invalid_language_format": "Usa un código BCP-47 canónico como \"en\" o \"es-ES\".", "empty": "Este campo no puede estar vacío", "invalid_option_combo": "Aumenta 'Días de previsión' para cubrir los sensores por día seleccionados.", "invalid_coordinates": "Selecciona una ubicación válida en el mapa.", "unknown": "Error desconocido", "invalid_http_referrer": "Valor de HTTP Referer no válido. No debe contener saltos de línea.", - "invalid_update_interval": "El intervalo de actualización debe ser de al menos 1 hora." + "invalid_update_interval": "El intervalo de actualización debe ser de al menos 1 hora.", + "invalid_forecast_days": "Los días de previsión deben estar entre 1 y 5." }, "abort": { "already_configured": "Esta ubicación ya está configurada.", @@ -52,7 +54,7 @@ "step": { "init": { "title": "Pollen Levels – Opciones", - "description": "Cambia el intervalo de actualización, el idioma de respuesta de la API, los días de previsión y los sensores por día para {title}.\nOpciones de sensores por día (TIPOS): Solo hoy (none), Hasta mañana (D+1), Hasta pasado mañana (D+2).", + "description": "Cambia el intervalo de actualización, el idioma de respuesta de la API, los días de previsión y los sensores por día para {title}.\nOpciones de sensores por día (TIPOS): Solo hoy (none), Hasta mañana (D+1), Hasta pasado mañana (D+1+2).", "data": { "update_interval": "Intervalo de actualización (horas)", "language_code": "Código de idioma de la respuesta de la API", @@ -62,15 +64,12 @@ } }, "error": { - "invalid_auth": "Clave API inválida\n\n{error_message}", - "cannot_connect": "No se puede conectar al servicio de polen.\n\n{error_message}", - "quota_exceeded": "Cuota excedida\n\n{error_message}", - "invalid_language": "Código de idioma no válido", "invalid_language_format": "Usa un código BCP-47 canónico como \"en\" o \"es-ES\".", "empty": "Este campo no puede estar vacío", "invalid_option_combo": "Aumenta 'Días de previsión' para cubrir los sensores por día seleccionados.", "unknown": "Error desconocido", - "invalid_update_interval": "El intervalo de actualización debe ser de al menos 1 hora." + "invalid_update_interval": "El intervalo de actualización debe ser de al menos 1 hora.", + "invalid_forecast_days": "Los días de previsión deben estar entre 1 y 5." } }, "device": { diff --git a/custom_components/pollenlevels/translations/fi.json b/custom_components/pollenlevels/translations/fi.json index afe2f93a..9dc7bd25 100644 --- a/custom_components/pollenlevels/translations/fi.json +++ b/custom_components/pollenlevels/translations/fi.json @@ -11,12 +11,12 @@ "invalid_auth": "Virheellinen API-avain\n\n{error_message}", "invalid_coordinates": "Valitse kartalta kelvollinen sijainti.", "invalid_http_referrer": "Virheellinen arvo kohteelle HTTP Referer. Se ei saa sisältää rivinvaihtomerkkejä.", - "invalid_language": "Virheellinen kielikoodi", "invalid_language_format": "Käytä kanonista BCP-47-koodia, esimerkiksi \"en\" tai \"es-ES\".", "invalid_option_combo": "Lisää \"Ennustepäiviä\", jotta valitut päiväsensorit katetaan.", "quota_exceeded": "Kiintiö ylitetty\n\n{error_message}", "unknown": "Tuntematon virhe", - "invalid_update_interval": "Päivitysvälin on oltava vähintään 1 tunti." + "invalid_update_interval": "Päivitysvälin on oltava vähintään 1 tunti.", + "invalid_forecast_days": "Ennustepäivien on oltava välillä 1–5." }, "step": { "reauth_confirm": { @@ -33,7 +33,9 @@ "location": "Sijainti", "name": "Nimi", "update_interval": "Päivitysväli (tunnit)", - "http_referer": "HTTP Referer" + "http_referer": "HTTP Referer", + "forecast_days": "Ennustepäivät (1–5)", + "create_forecast_sensors": "Päiväsensorien laajuus (TYYPIT)" }, "description": "Syötä Google API -avaimesi ([hanki se täältä]({api_key_url})) ja tutustu parhaisiin käytäntöihin ([parhaat käytännöt]({restricting_api_keys_url})). Valitse sijainti kartalta, päivitysväli (tunteina) ja API-vastauksen kielikoodi.", "title": "Siitepölytason asetukset", @@ -74,15 +76,12 @@ }, "options": { "error": { - "cannot_connect": "Palveluun ei saada yhteyttä\n\n{error_message}", "empty": "Tämä kenttä ei voi olla tyhjä", - "invalid_auth": "Virheellinen API-avain\n\n{error_message}", - "invalid_language": "Virheellinen kielikoodi", "invalid_language_format": "Käytä kanonista BCP-47-koodia, esimerkiksi \"en\" tai \"es-ES\".", "invalid_option_combo": "Lisää \"Ennustepäiviä\", jotta valitut päiväsensorit katetaan.", - "quota_exceeded": "Kiintiö ylitetty\n\n{error_message}", "unknown": "Tuntematon virhe", - "invalid_update_interval": "Päivitysvälin on oltava vähintään 1 tunti." + "invalid_update_interval": "Päivitysvälin on oltava vähintään 1 tunti.", + "invalid_forecast_days": "Ennustepäivien on oltava välillä 1–5." }, "step": { "init": { @@ -92,7 +91,7 @@ "language_code": "API-vastauksen kielikoodi", "update_interval": "Päivitysväli (tunnit)" }, - "description": "Muuta päivitysväliä, API-kieltä, ennustepäiviä ja päiväsensoreita TYYPEILLE kohteelle {title}.\nPäiväsensorien vaihtoehdot (TYYPIT): Vain tänään (none), Huomiseen asti (D+1), Ylihuomiseen asti (D+2).", + "description": "Muuta päivitysväliä, API-kieltä, ennustepäiviä ja päiväsensoreita TYYPEILLE kohteelle {title}.\nPäiväsensorien vaihtoehdot (TYYPIT): Vain tänään (none), Huomiseen asti (D+1), Ylihuomiseen asti (D+1+2).", "title": "Pollen Levels – Asetukset" } } diff --git a/custom_components/pollenlevels/translations/fr.json b/custom_components/pollenlevels/translations/fr.json index 65246059..e1f3eb77 100644 --- a/custom_components/pollenlevels/translations/fr.json +++ b/custom_components/pollenlevels/translations/fr.json @@ -11,12 +11,12 @@ "invalid_auth": "Clé API invalide\n\n{error_message}", "invalid_coordinates": "Sélectionnez un emplacement valide sur la carte.", "invalid_http_referrer": "Valeur pour HTTP Referer invalide. Elle ne doit pas contenir de retours à la ligne.", - "invalid_language": "Code de langue invalide", "invalid_language_format": "Utilisez un code BCP-47 canonique tel que \"en\" ou \"es-ES\".", "invalid_option_combo": "Augmentez le nombre de « Jours de prévision » afin de couvrir les capteurs par jour sélectionnés.", "quota_exceeded": "Quota dépassé\n\n{error_message}", "unknown": "Erreur inconnue", - "invalid_update_interval": "L’intervalle de mise à jour doit être d’au moins 1 heure." + "invalid_update_interval": "L’intervalle de mise à jour doit être d’au moins 1 heure.", + "invalid_forecast_days": "Les jours de prévision doivent être compris entre 1 et 5." }, "step": { "reauth_confirm": { @@ -33,7 +33,9 @@ "location": "Emplacement", "name": "Nom", "update_interval": "Intervalle de mise à jour (heures)", - "http_referer": "HTTP Referer" + "http_referer": "HTTP Referer", + "forecast_days": "Jours de prévision (1–5)", + "create_forecast_sensors": "Portée des capteurs par jour (TYPES)" }, "description": "Saisissez votre clé API Google ([l’obtenir ici]({api_key_url})) et consultez les bonnes pratiques ([bonnes pratiques]({restricting_api_keys_url})). Sélectionnez votre emplacement sur la carte, l’intervalle de mise à jour (heures) et le code de langue de la réponse de l’API.", "title": "Pollen Levels – Configuration", @@ -74,15 +76,12 @@ }, "options": { "error": { - "cannot_connect": "Impossible de se connecter au service\n\n{error_message}", "empty": "Ce champ ne peut pas être vide", - "invalid_auth": "Clé API invalide\n\n{error_message}", - "invalid_language": "Code de langue invalide", "invalid_language_format": "Utilisez un code BCP-47 canonique tel que \"en\" ou \"es-ES\".", "invalid_option_combo": "Augmentez le nombre de « Jours de prévision » afin de couvrir les capteurs par jour sélectionnés.", - "quota_exceeded": "Quota dépassé\n\n{error_message}", "unknown": "Erreur inconnue", - "invalid_update_interval": "L’intervalle de mise à jour doit être d’au moins 1 heure." + "invalid_update_interval": "L’intervalle de mise à jour doit être d’au moins 1 heure.", + "invalid_forecast_days": "Les jours de prévision doivent être compris entre 1 et 5." }, "step": { "init": { @@ -92,7 +91,7 @@ "language_code": "Code de langue pour la réponse de l’API", "update_interval": "Intervalle de mise à jour (heures)" }, - "description": "Modifiez l’intervalle de mise à jour, la langue de l’API, les jours de prévision et les capteurs par jour pour {title}.\nOptions des capteurs par jour (TYPES) : Aujourd’hui uniquement (none), Jusqu’à demain (D+1), Jusqu’au surlendemain (D+2).", + "description": "Modifiez l’intervalle de mise à jour, la langue de l’API, les jours de prévision et les capteurs par jour pour {title}.\nOptions des capteurs par jour (TYPES) : Aujourd’hui uniquement (none), Jusqu’à demain (D+1), Jusqu’au surlendemain (D+1+2).", "title": "Pollen Levels – Options" } } diff --git a/custom_components/pollenlevels/translations/hu.json b/custom_components/pollenlevels/translations/hu.json index 13286fd1..762a9f39 100644 --- a/custom_components/pollenlevels/translations/hu.json +++ b/custom_components/pollenlevels/translations/hu.json @@ -11,12 +11,12 @@ "invalid_auth": "Érvénytelen API-kulcs\n\n{error_message}", "invalid_coordinates": "Válassz érvényes helyet a térképen.", "invalid_http_referrer": "Érvénytelen érték a HTTP Referer mezőhöz. Nem tartalmazhat sortörés karaktereket.", - "invalid_language": "Érvénytelen nyelvi kód", "invalid_language_format": "Használjon kanonikus BCP-47 kódot, például \"en\" vagy \"es-ES\".", "invalid_option_combo": "Növeld a \"Előrejelzési napok\" értéket a kiválasztott napi szenzorok lefedéséhez.", "quota_exceeded": "Kvóta túllépve\n\n{error_message}", "unknown": "Ismeretlen hiba", - "invalid_update_interval": "A frissítési időköznek legalább 1 órának kell lennie." + "invalid_update_interval": "A frissítési időköznek legalább 1 órának kell lennie.", + "invalid_forecast_days": "Az előrejelzési napoknak 1 és 5 között kell lenniük." }, "step": { "reauth_confirm": { @@ -33,7 +33,9 @@ "location": "Helyszín", "name": "Név", "update_interval": "Frissítési időköz (óra)", - "http_referer": "HTTP Referer" + "http_referer": "HTTP Referer", + "forecast_days": "Előrejelzési napok (1–5)", + "create_forecast_sensors": "Napi TÍPUS szenzorok tartománya" }, "description": "Add meg a Google API-kulcsodat ([itt szerezhető be]({api_key_url})) és nézd át a bevált gyakorlatokat ([bevált gyakorlatok]({restricting_api_keys_url})). Válaszd ki a helyszínt a térképen, a frissítési időközt (órában) és az API-válasz nyelvi kódját.", "title": "Pollen szintek – beállítás", @@ -74,15 +76,12 @@ }, "options": { "error": { - "cannot_connect": "Nem lehet csatlakozni a szolgáltatáshoz\n\n{error_message}", "empty": "A mező nem lehet üres", - "invalid_auth": "Érvénytelen API-kulcs\n\n{error_message}", - "invalid_language": "Érvénytelen nyelvi kód", "invalid_language_format": "Használjon kanonikus BCP-47 kódot, például \"en\" vagy \"es-ES\".", "invalid_option_combo": "Növeld a \"Előrejelzési napok\" értéket a kiválasztott napi szenzorok lefedéséhez.", - "quota_exceeded": "Kvóta túllépve\n\n{error_message}", "unknown": "Ismeretlen hiba", - "invalid_update_interval": "A frissítési időköznek legalább 1 órának kell lennie." + "invalid_update_interval": "A frissítési időköznek legalább 1 órának kell lennie.", + "invalid_forecast_days": "Az előrejelzési napoknak 1 és 5 között kell lenniük." }, "step": { "init": { @@ -92,7 +91,7 @@ "language_code": "API-válasz nyelvi kódja", "update_interval": "Frissítési időköz (óra)" }, - "description": "Módosítsd a frissítési időközt, az API nyelvét, az előrejelzési napokat és a napi TÍPUS szenzorokat a(z) {title} bejegyzéshez.\nNapi TÍPUS szenzorok: Csak ma (none), Holnapig (D+1), Holnaputánig (D+2).", + "description": "Módosítsd a frissítési időközt, az API nyelvét, az előrejelzési napokat és a napi TÍPUS szenzorokat a(z) {title} bejegyzéshez.\nNapi TÍPUS szenzorok: Csak ma (none), Holnapig (D+1), Holnaputánig (D+1+2).", "title": "Pollen Levels – Beállítások" } } diff --git a/custom_components/pollenlevels/translations/it.json b/custom_components/pollenlevels/translations/it.json index 176ba218..25c31b64 100644 --- a/custom_components/pollenlevels/translations/it.json +++ b/custom_components/pollenlevels/translations/it.json @@ -11,12 +11,12 @@ "invalid_auth": "Chiave API non valida\n\n{error_message}", "invalid_coordinates": "Seleziona una posizione valida sulla mappa.", "invalid_http_referrer": "Valore per HTTP Referer non valido. Non deve contenere caratteri di nuova riga.", - "invalid_language": "Codice lingua non valido", "invalid_language_format": "Usa un codice BCP-47 canonico come \"en\" o \"es-ES\".", "invalid_option_combo": "Aumenta 'Giorni di previsione' per coprire i sensori giornalieri selezionati.", "quota_exceeded": "Quota superata\n\n{error_message}", "unknown": "Errore sconosciuto", - "invalid_update_interval": "L’intervallo di aggiornamento deve essere di almeno 1 ora." + "invalid_update_interval": "L’intervallo di aggiornamento deve essere di almeno 1 ora.", + "invalid_forecast_days": "I giorni di previsione devono essere compresi tra 1 e 5." }, "step": { "reauth_confirm": { @@ -33,7 +33,9 @@ "location": "Posizione", "name": "Nome", "update_interval": "Intervallo di aggiornamento (ore)", - "http_referer": "HTTP Referer" + "http_referer": "HTTP Referer", + "forecast_days": "Giorni di previsione (1–5)", + "create_forecast_sensors": "Ambito dei sensori per giorno (TIPI)" }, "description": "Inserisci la tua chiave API di Google ([ottienila qui]({api_key_url})) e consulta le best practice ([best practice]({restricting_api_keys_url})). Seleziona la posizione sulla mappa, l’intervallo di aggiornamento (ore) e il codice lingua della risposta dell’API.", "title": "Configurazione Livelli di polline", @@ -74,15 +76,12 @@ }, "options": { "error": { - "cannot_connect": "Impossibile connettersi al servizio\n\n{error_message}", "empty": "Questo campo non può essere vuoto", - "invalid_auth": "Chiave API non valida\n\n{error_message}", - "invalid_language": "Codice lingua non valido", "invalid_language_format": "Usa un codice BCP-47 canonico come \"en\" o \"es-ES\".", "invalid_option_combo": "Aumenta 'Giorni di previsione' per coprire i sensori giornalieri selezionati.", - "quota_exceeded": "Quota superata\n\n{error_message}", "unknown": "Errore sconosciuto", - "invalid_update_interval": "L’intervallo di aggiornamento deve essere di almeno 1 ora." + "invalid_update_interval": "L’intervallo di aggiornamento deve essere di almeno 1 ora.", + "invalid_forecast_days": "I giorni di previsione devono essere compresi tra 1 e 5." }, "step": { "init": { @@ -92,7 +91,7 @@ "language_code": "Codice lingua per la risposta dell'API", "update_interval": "Intervallo di aggiornamento (ore)" }, - "description": "Modifica l’intervallo di aggiornamento, la lingua della risposta dell’API, i giorni di previsione e i sensori giornalieri per i TIPI per {title}.\nOpzioni dei sensori giornalieri (TIPI): Solo oggi (none), Fino a domani (D+1), Fino a dopodomani (D+2).", + "description": "Modifica l’intervallo di aggiornamento, la lingua della risposta dell’API, i giorni di previsione e i sensori giornalieri per i TIPI per {title}.\nOpzioni dei sensori giornalieri (TIPI): Solo oggi (none), Fino a domani (D+1), Fino a dopodomani (D+1+2).", "title": "Pollen Levels – Opzioni" } } diff --git a/custom_components/pollenlevels/translations/nb.json b/custom_components/pollenlevels/translations/nb.json index 45b5f6ec..03788ae7 100644 --- a/custom_components/pollenlevels/translations/nb.json +++ b/custom_components/pollenlevels/translations/nb.json @@ -11,12 +11,12 @@ "invalid_auth": "Ugyldig API-nøkkel\n\n{error_message}", "invalid_coordinates": "Velg en gyldig posisjon på kartet.", "invalid_http_referrer": "Ugyldig verdi for HTTP Referer. Den kan ikke inneholde linjeskift.", - "invalid_language": "Ugyldig språkkode", "invalid_language_format": "Bruk en kanonisk BCP-47-kode som \"en\" eller \"es-ES\".", "invalid_option_combo": "Øk «Prognosedager» for å dekke valgte sensorer per dag.", "quota_exceeded": "Kvote overskredet\n\n{error_message}", "unknown": "Ukjent feil", - "invalid_update_interval": "Oppdateringsintervallet må være minst 1 time." + "invalid_update_interval": "Oppdateringsintervallet må være minst 1 time.", + "invalid_forecast_days": "Prognosedager må være mellom 1 og 5." }, "step": { "reauth_confirm": { @@ -33,7 +33,9 @@ "location": "Posisjon", "name": "Navn", "update_interval": "Oppdateringsintervall (timer)", - "http_referer": "HTTP Referer" + "http_referer": "HTTP Referer", + "forecast_days": "Prognosedager (1–5)", + "create_forecast_sensors": "Omfang av sensorer per dag (TYPER)" }, "description": "Oppgi Google API-nøkkelen din ([få den her]({api_key_url})) og les beste praksis ([beste praksis]({restricting_api_keys_url})). Velg posisjonen din på kartet, oppdateringsintervallet (timer) og språkkoden for API-svaret.", "title": "Konfigurasjon av pollennivåer", @@ -74,15 +76,12 @@ }, "options": { "error": { - "cannot_connect": "Kan ikke koble til tjenesten\n\n{error_message}", "empty": "Dette feltet kan ikke være tomt", - "invalid_auth": "Ugyldig API-nøkkel\n\n{error_message}", - "invalid_language": "Ugyldig språkkode", "invalid_language_format": "Bruk en kanonisk BCP-47-kode som \"en\" eller \"es-ES\".", "invalid_option_combo": "Øk «Prognosedager» for å dekke valgte sensorer per dag.", - "quota_exceeded": "Kvote overskredet\n\n{error_message}", "unknown": "Ukjent feil", - "invalid_update_interval": "Oppdateringsintervallet må være minst 1 time." + "invalid_update_interval": "Oppdateringsintervallet må være minst 1 time.", + "invalid_forecast_days": "Prognosedager må være mellom 1 og 5." }, "step": { "init": { @@ -92,7 +91,7 @@ "language_code": "Språkkode for API-svar", "update_interval": "Oppdateringsintervall (timer)" }, - "description": "Endre oppdateringsintervall, API-språk, prognosedager og sensorer per dag for {title}.\nAlternativer for sensorer per dag (TYPER): Kun i dag (none), Til og med i morgen (D+1), Til og med i overmorgen (D+2).", + "description": "Endre oppdateringsintervall, API-språk, prognosedager og sensorer per dag for {title}.\nAlternativer for sensorer per dag (TYPER): Kun i dag (none), Til og med i morgen (D+1), Til og med i overmorgen (D+1+2).", "title": "Pollen Levels – Innstillinger" } } diff --git a/custom_components/pollenlevels/translations/nl.json b/custom_components/pollenlevels/translations/nl.json index 394f2cce..4df9a0d2 100644 --- a/custom_components/pollenlevels/translations/nl.json +++ b/custom_components/pollenlevels/translations/nl.json @@ -11,12 +11,12 @@ "invalid_auth": "Ongeldige API-sleutel\n\n{error_message}", "invalid_coordinates": "Selecteer een geldige locatie op de kaart.", "invalid_http_referrer": "Ongeldige waarde voor HTTP Referer. Deze mag geen regeleinden bevatten.", - "invalid_language": "Ongeldige taalcode", "invalid_language_format": "Gebruik een canonieke BCP-47-code zoals \"en\" of \"es-ES\".", "invalid_option_combo": "Verhoog 'Voorspellingsdagen' om de geselecteerde per-dag sensoren te dekken.", "quota_exceeded": "Limiet overschreden\n\n{error_message}", "unknown": "Onbekende fout", - "invalid_update_interval": "Het update-interval moet minimaal 1 uur zijn." + "invalid_update_interval": "Het update-interval moet minimaal 1 uur zijn.", + "invalid_forecast_days": "Voorspellingsdagen moeten tussen 1 en 5 liggen." }, "step": { "reauth_confirm": { @@ -33,7 +33,9 @@ "location": "Locatie", "name": "Naam", "update_interval": "Update-interval (uren)", - "http_referer": "HTTP Referer" + "http_referer": "HTTP Referer", + "forecast_days": "Voorspellingsdagen (1–5)", + "create_forecast_sensors": "Bereik van per-dag TYPE-sensoren" }, "description": "Voer je Google API-sleutel in ([haal hem hier]({api_key_url})) en bekijk de best practices ([best practices]({restricting_api_keys_url})). Selecteer je locatie op de kaart, het update-interval (uren) en de taalcode van de API-respons.", "title": "Pollen Levels – Configuratie", @@ -74,15 +76,12 @@ }, "options": { "error": { - "cannot_connect": "Kan geen verbinding maken met de service\n\n{error_message}", "empty": "Dit veld mag niet leeg zijn", - "invalid_auth": "Ongeldige API-sleutel\n\n{error_message}", - "invalid_language": "Ongeldige taalcode", "invalid_language_format": "Gebruik een canonieke BCP-47-code zoals \"en\" of \"es-ES\".", "invalid_option_combo": "Verhoog 'Voorspellingsdagen' om de geselecteerde per-dag sensoren te dekken.", - "quota_exceeded": "Limiet overschreden\n\n{error_message}", "unknown": "Onbekende fout", - "invalid_update_interval": "Het update-interval moet minimaal 1 uur zijn." + "invalid_update_interval": "Het update-interval moet minimaal 1 uur zijn.", + "invalid_forecast_days": "Voorspellingsdagen moeten tussen 1 en 5 liggen." }, "step": { "init": { @@ -92,7 +91,7 @@ "language_code": "Taalcode voor API-respons", "update_interval": "Update-interval (uren)" }, - "description": "Wijzig het update-interval, de API-taal, het aantal voorspellingsdagen en de per-dag TYPE-sensoren voor {title}.\nOpties voor per-dag TYPE-sensoren: Alleen vandaag (none), Tot en met morgen (D+1), Tot en met overmorgen (D+2).", + "description": "Wijzig het update-interval, de API-taal, het aantal voorspellingsdagen en de per-dag TYPE-sensoren voor {title}.\nOpties voor per-dag TYPE-sensoren: Alleen vandaag (none), Tot en met morgen (D+1), Tot en met overmorgen (D+1+2).", "title": "Pollen Levels – Opties" } } diff --git a/custom_components/pollenlevels/translations/pl.json b/custom_components/pollenlevels/translations/pl.json index c212101c..a7863501 100644 --- a/custom_components/pollenlevels/translations/pl.json +++ b/custom_components/pollenlevels/translations/pl.json @@ -11,12 +11,12 @@ "invalid_auth": "Nieprawidłowy klucz API\n\n{error_message}", "invalid_coordinates": "Wybierz prawidłową lokalizację na mapie.", "invalid_http_referrer": "Nieprawidłowa wartość dla HTTP Referer. Nie może zawierać znaków nowej linii.", - "invalid_language": "Nieprawidłowy kod języka", "invalid_language_format": "Użyj kanonicznego kodu BCP-47, np. \"en\" lub \"es-ES\".", "invalid_option_combo": "Zwiększ 'Dni prognozy', aby objąć wybrane czujniki dzienne.", "quota_exceeded": "Przekroczono limit\n\n{error_message}", "unknown": "Nieznany błąd", - "invalid_update_interval": "Interwał aktualizacji musi wynosić co najmniej 1 godzinę." + "invalid_update_interval": "Interwał aktualizacji musi wynosić co najmniej 1 godzinę.", + "invalid_forecast_days": "Dni prognozy muszą mieścić się w zakresie 1–5." }, "step": { "reauth_confirm": { @@ -33,7 +33,9 @@ "location": "Lokalizacja", "name": "Nazwa", "update_interval": "Interwał aktualizacji (godziny)", - "http_referer": "HTTP Referer" + "http_referer": "HTTP Referer", + "forecast_days": "Dni prognozy (1–5)", + "create_forecast_sensors": "Zakres czujników dziennych (TYPY)" }, "description": "Wprowadź swój klucz Google API ([uzyskaj go tutaj]({api_key_url})) i zapoznaj się z dobrymi praktykami ([dobre praktyki]({restricting_api_keys_url})). Wybierz lokalizację na mapie, interwał aktualizacji (godziny) oraz kod języka odpowiedzi API.", "title": "Konfiguracja poziomów pyłku", @@ -74,15 +76,12 @@ }, "options": { "error": { - "cannot_connect": "Brak połączenia z usługą\n\n{error_message}", "empty": "To pole nie może być puste", - "invalid_auth": "Nieprawidłowy klucz API\n\n{error_message}", - "invalid_language": "Nieprawidłowy kod języka", "invalid_language_format": "Użyj kanonicznego kodu BCP-47, np. \"en\" lub \"es-ES\".", "invalid_option_combo": "Zwiększ 'Dni prognozy', aby objąć wybrane czujniki dzienne.", - "quota_exceeded": "Przekroczono limit\n\n{error_message}", "unknown": "Nieznany błąd", - "invalid_update_interval": "Interwał aktualizacji musi wynosić co najmniej 1 godzinę." + "invalid_update_interval": "Interwał aktualizacji musi wynosić co najmniej 1 godzinę.", + "invalid_forecast_days": "Dni prognozy muszą mieścić się w zakresie 1–5." }, "step": { "init": { @@ -92,7 +91,7 @@ "language_code": "Kod języka odpowiedzi API", "update_interval": "Interwał aktualizacji (godziny)" }, - "description": "Zmień interwał aktualizacji, język odpowiedzi API, liczbę dni prognozy oraz czujniki dzienne dla TYPÓW dla {title}.\nOpcje czujników dziennych (TYPY): Tylko dziś (none), Do jutra (D+1), Do pojutrza (D+2).", + "description": "Zmień interwał aktualizacji, język odpowiedzi API, liczbę dni prognozy oraz czujniki dzienne dla TYPÓW dla {title}.\nOpcje czujników dziennych (TYPY): Tylko dziś (none), Do jutra (D+1), Do pojutrza (D+1+2).", "title": "Pollen Levels – Opcje" } } diff --git a/custom_components/pollenlevels/translations/pt-BR.json b/custom_components/pollenlevels/translations/pt-BR.json index ab07bf19..ed68c7f5 100644 --- a/custom_components/pollenlevels/translations/pt-BR.json +++ b/custom_components/pollenlevels/translations/pt-BR.json @@ -11,12 +11,12 @@ "invalid_auth": "Chave de API inválida\n\n{error_message}", "invalid_coordinates": "Selecione um local válido no mapa.", "invalid_http_referrer": "Valor inválido para HTTP Referer. Não deve conter quebras de linha.", - "invalid_language": "Código de idioma inválido", "invalid_language_format": "Use um código BCP-47 canônico, como \"en\" ou \"es-ES\".", "invalid_option_combo": "Aumente \"Dias de previsão\" para cobrir os sensores por dia selecionados.", "quota_exceeded": "Cota excedida\n\n{error_message}", "unknown": "Erro desconhecido", - "invalid_update_interval": "O intervalo de atualização deve ser de pelo menos 1 hora." + "invalid_update_interval": "O intervalo de atualização deve ser de pelo menos 1 hora.", + "invalid_forecast_days": "Os dias de previsão devem estar entre 1 e 5." }, "step": { "reauth_confirm": { @@ -33,7 +33,9 @@ "location": "Localização", "name": "Nome", "update_interval": "Intervalo de atualização (horas)", - "http_referer": "HTTP Referer" + "http_referer": "HTTP Referer", + "forecast_days": "Dias de previsão (1–5)", + "create_forecast_sensors": "Escopo dos sensores por dia (TIPOS)" }, "description": "Insira sua chave de API do Google ([obtenha aqui]({api_key_url})) e consulte as melhores práticas ([melhores práticas]({restricting_api_keys_url})). Selecione sua localização no mapa, o intervalo de atualização (horas) e o código de idioma da resposta da API.", "title": "Configuração dos Níveis de Pólen", @@ -74,15 +76,12 @@ }, "options": { "error": { - "cannot_connect": "Não foi possível conectar ao serviço\n\n{error_message}", "empty": "Este campo não pode ficar vazio", - "invalid_auth": "Chave de API inválida\n\n{error_message}", - "invalid_language": "Código de idioma inválido", "invalid_language_format": "Use um código BCP-47 canônico, como \"en\" ou \"es-ES\".", "invalid_option_combo": "Aumente \"Dias de previsão\" para cobrir os sensores por dia selecionados.", - "quota_exceeded": "Cota excedida\n\n{error_message}", "unknown": "Erro desconhecido", - "invalid_update_interval": "O intervalo de atualização deve ser de pelo menos 1 hora." + "invalid_update_interval": "O intervalo de atualização deve ser de pelo menos 1 hora.", + "invalid_forecast_days": "Os dias de previsão devem estar entre 1 e 5." }, "step": { "init": { @@ -92,7 +91,7 @@ "language_code": "Código de idioma da resposta da API", "update_interval": "Intervalo de atualização (horas)" }, - "description": "Altere o intervalo de atualização, o idioma da API, os dias de previsão e os sensores por dia para {title}.\nOpções de sensores por dia (TIPOS): Apenas hoje (none), Até amanhã (D+1), Até depois de amanhã (D+2).", + "description": "Altere o intervalo de atualização, o idioma da API, os dias de previsão e os sensores por dia para {title}.\nOpções de sensores por dia (TIPOS): Apenas hoje (none), Até amanhã (D+1), Até depois de amanhã (D+1+2).", "title": "Pollen Levels – Opções" } } diff --git a/custom_components/pollenlevels/translations/pt-PT.json b/custom_components/pollenlevels/translations/pt-PT.json index b9b47beb..1b3c44a4 100644 --- a/custom_components/pollenlevels/translations/pt-PT.json +++ b/custom_components/pollenlevels/translations/pt-PT.json @@ -11,12 +11,12 @@ "invalid_auth": "Chave da API inválida\n\n{error_message}", "invalid_coordinates": "Selecione uma localização válida no mapa.", "invalid_http_referrer": "Valor inválido para HTTP Referer. Não pode conter quebras de linha.", - "invalid_language": "Código de idioma inválido", "invalid_language_format": "Use um código BCP-47 canónico, como \"en\" ou \"es-ES\".", "invalid_option_combo": "Aumente \"Dias de previsão\" para cobrir os sensores por dia selecionados.", "quota_exceeded": "Quota excedida\n\n{error_message}", "unknown": "Erro desconhecido", - "invalid_update_interval": "O intervalo de atualização deve ser de pelo menos 1 hora." + "invalid_update_interval": "O intervalo de atualização deve ser de pelo menos 1 hora.", + "invalid_forecast_days": "Os dias de previsão devem estar entre 1 e 5." }, "step": { "reauth_confirm": { @@ -33,7 +33,9 @@ "location": "Localização", "name": "Nome", "update_interval": "Intervalo de atualização (horas)", - "http_referer": "HTTP Referer" + "http_referer": "HTTP Referer", + "forecast_days": "Dias de previsão (1–5)", + "create_forecast_sensors": "Âmbito dos sensores por dia (TIPOS)" }, "description": "Introduza a sua chave de API do Google ([obtenha-a aqui]({api_key_url})) e reveja as melhores práticas ([melhores práticas]({restricting_api_keys_url})). Selecione a sua localização no mapa, o intervalo de atualização (horas) e o código de idioma da resposta da API.", "title": "Configuração dos Níveis de Pólen", @@ -74,15 +76,12 @@ }, "options": { "error": { - "cannot_connect": "Não é possível ligar ao serviço\n\n{error_message}", "empty": "Este campo não pode estar vazio", - "invalid_auth": "Chave da API inválida\n\n{error_message}", - "invalid_language": "Código de idioma inválido", "invalid_language_format": "Use um código BCP-47 canónico, como \"en\" ou \"es-ES\".", "invalid_option_combo": "Aumente \"Dias de previsão\" para cobrir os sensores por dia selecionados.", - "quota_exceeded": "Quota excedida\n\n{error_message}", "unknown": "Erro desconhecido", - "invalid_update_interval": "O intervalo de atualização deve ser de pelo menos 1 hora." + "invalid_update_interval": "O intervalo de atualização deve ser de pelo menos 1 hora.", + "invalid_forecast_days": "Os dias de previsão devem estar entre 1 e 5." }, "step": { "init": { @@ -92,7 +91,7 @@ "language_code": "Código de idioma da resposta da API", "update_interval": "Intervalo de atualização (horas)" }, - "description": "Altere o intervalo de atualização, o idioma da API, os dias de previsão e os sensores por dia para {title}.\nOpções de sensores por dia (TIPOS): Apenas hoje (none), Até amanhã (D+1), Até depois de amanhã (D+2).", + "description": "Altere o intervalo de atualização, o idioma da API, os dias de previsão e os sensores por dia para {title}.\nOpções de sensores por dia (TIPOS): Apenas hoje (none), Até amanhã (D+1), Até depois de amanhã (D+1+2).", "title": "Pollen Levels – Opções" } } diff --git a/custom_components/pollenlevels/translations/ro.json b/custom_components/pollenlevels/translations/ro.json index 4511f4b9..64ca142f 100644 --- a/custom_components/pollenlevels/translations/ro.json +++ b/custom_components/pollenlevels/translations/ro.json @@ -11,12 +11,12 @@ "invalid_auth": "Cheie API nevalidă\n\n{error_message}", "invalid_coordinates": "Selectează o locație validă pe hartă.", "invalid_http_referrer": "Valoare invalidă pentru HTTP Referer. Nu trebuie să conțină caractere de linie nouă.", - "invalid_language": "Cod de limbă nevalid", "invalid_language_format": "Folosiți un cod BCP-47 canonic, de exemplu \"en\" sau \"es-ES\".", "invalid_option_combo": "Măriți \"Zilele de prognoză\" pentru a acoperi senzorii selectați pe zile.", "quota_exceeded": "Cota depășită\n\n{error_message}", "unknown": "Eroare necunoscută", - "invalid_update_interval": "Intervalul de actualizare trebuie să fie de cel puțin 1 oră." + "invalid_update_interval": "Intervalul de actualizare trebuie să fie de cel puțin 1 oră.", + "invalid_forecast_days": "Zilele de prognoză trebuie să fie între 1 și 5." }, "step": { "reauth_confirm": { @@ -33,7 +33,9 @@ "location": "Locație", "name": "Nume", "update_interval": "Interval de actualizare (ore)", - "http_referer": "HTTP Referer" + "http_referer": "HTTP Referer", + "forecast_days": "Zile de prognoză (1–5)", + "create_forecast_sensors": "Domeniul senzorilor pe zile (TIPURI)" }, "description": "Introdu cheia ta API Google ([obține-o aici]({api_key_url})) și consultă cele mai bune practici ([cele mai bune practici]({restricting_api_keys_url})). Selectează locația pe hartă, intervalul de actualizare (ore) și codul de limbă al răspunsului API.", "title": "Configurare Niveluri de Polen", @@ -74,15 +76,12 @@ }, "options": { "error": { - "cannot_connect": "Nu se poate conecta la serviciu\n\n{error_message}", "empty": "Acest câmp nu poate fi gol", - "invalid_auth": "Cheie API nevalidă\n\n{error_message}", - "invalid_language": "Cod de limbă nevalid", "invalid_language_format": "Folosiți un cod BCP-47 canonic, de exemplu \"en\" sau \"es-ES\".", "invalid_option_combo": "Măriți \"Zilele de prognoză\" pentru a acoperi senzorii selectați pe zile.", - "quota_exceeded": "Cota depășită\n\n{error_message}", "unknown": "Eroare necunoscută", - "invalid_update_interval": "Intervalul de actualizare trebuie să fie de cel puțin 1 oră." + "invalid_update_interval": "Intervalul de actualizare trebuie să fie de cel puțin 1 oră.", + "invalid_forecast_days": "Zilele de prognoză trebuie să fie între 1 și 5." }, "step": { "init": { @@ -92,7 +91,7 @@ "language_code": "Codul limbii pentru răspunsul API", "update_interval": "Interval de actualizare (ore)" }, - "description": "Modificați intervalul de actualizare, limba API, zilele de prognoză și senzorii pe zile pentru {title}.\nOpțiuni pentru senzorii pe zile (TIPURI): Doar azi (none), Până mâine (D+1), Până poimâine (D+2).", + "description": "Modificați intervalul de actualizare, limba API, zilele de prognoză și senzorii pe zile pentru {title}.\nOpțiuni pentru senzorii pe zile (TIPURI): Doar azi (none), Până mâine (D+1), Până poimâine (D+1+2).", "title": "Pollen Levels – Opțiuni" } } diff --git a/custom_components/pollenlevels/translations/ru.json b/custom_components/pollenlevels/translations/ru.json index 25b38824..efe5cb26 100644 --- a/custom_components/pollenlevels/translations/ru.json +++ b/custom_components/pollenlevels/translations/ru.json @@ -11,12 +11,12 @@ "invalid_auth": "Неверный ключ API\n\n{error_message}", "invalid_coordinates": "Выберите корректное местоположение на карте.", "invalid_http_referrer": "Неверное значение для HTTP Referer. Оно не должно содержать символы новой строки.", - "invalid_language": "Неверный код языка", "invalid_language_format": "Используйте канонический код BCP-47, например \"en\" или \"es-ES\".", "invalid_option_combo": "Увеличьте «Дни прогноза», чтобы охватить выбранные датчики по дням.", "quota_exceeded": "Превышен лимит запросов\n\n{error_message}", "unknown": "Неизвестная ошибка", - "invalid_update_interval": "Интервал обновления должен быть не менее 1 часа." + "invalid_update_interval": "Интервал обновления должен быть не менее 1 часа.", + "invalid_forecast_days": "Дни прогноза должны быть от 1 до 5." }, "step": { "reauth_confirm": { @@ -33,7 +33,9 @@ "location": "Местоположение", "name": "Имя", "update_interval": "Интервал обновления (в часах)", - "http_referer": "HTTP Referer" + "http_referer": "HTTP Referer", + "forecast_days": "Дни прогноза (1–5)", + "create_forecast_sensors": "Диапазон дневных датчиков (ТИПЫ)" }, "description": "Введите ключ Google API ([получите его здесь]({api_key_url})) и изучите рекомендации ([лучшие практики]({restricting_api_keys_url})). Выберите местоположение на карте, интервал обновления (часы) и языковой код ответа API.", "title": "Настройка уровней пыльцы", @@ -74,15 +76,12 @@ }, "options": { "error": { - "cannot_connect": "Не удаётся подключиться к сервису\n\n{error_message}", "empty": "Это поле не может быть пустым", - "invalid_auth": "Неверный ключ API\n\n{error_message}", - "invalid_language": "Неверный код языка", "invalid_language_format": "Используйте канонический код BCP-47, например \"en\" или \"es-ES\".", "invalid_option_combo": "Увеличьте «Дни прогноза», чтобы охватить выбранные датчики по дням.", - "quota_exceeded": "Превышен лимит запросов\n\n{error_message}", "unknown": "Неизвестная ошибка", - "invalid_update_interval": "Интервал обновления должен быть не менее 1 часа." + "invalid_update_interval": "Интервал обновления должен быть не менее 1 часа.", + "invalid_forecast_days": "Дни прогноза должны быть от 1 до 5." }, "step": { "init": { @@ -92,7 +91,7 @@ "language_code": "Код языка ответа API", "update_interval": "Интервал обновления (в часах)" }, - "description": "Измените интервал обновления, язык ответа API, дни прогноза и дневные датчики для ТИПОВ для {title}.\nВарианты дневных датчиков (ТИПЫ): Только сегодня (none), До завтра (D+1), До послезавтра (D+2).", + "description": "Измените интервал обновления, язык ответа API, дни прогноза и дневные датчики для ТИПОВ для {title}.\nВарианты дневных датчиков (ТИПЫ): Только сегодня (none), До завтра (D+1), До послезавтра (D+1+2).", "title": "Pollen Levels – Параметры" } } diff --git a/custom_components/pollenlevels/translations/sv.json b/custom_components/pollenlevels/translations/sv.json index 7825bc56..e8690eac 100644 --- a/custom_components/pollenlevels/translations/sv.json +++ b/custom_components/pollenlevels/translations/sv.json @@ -11,12 +11,12 @@ "invalid_auth": "Ogiltig API-nyckel\n\n{error_message}", "invalid_coordinates": "Välj en giltig plats på kartan.", "invalid_http_referrer": "Ogiltigt värde för HTTP Referer. Det får inte innehålla radbrytningar.", - "invalid_language": "Ogiltig språkkod", "invalid_language_format": "Använd en kanonisk BCP-47-kod som \"en\" eller \"es-ES\".", "invalid_option_combo": "Öka \"Prognosdagar\" för att täcka valda sensorer per dag.", "quota_exceeded": "Kvoten har överskridits\n\n{error_message}", "unknown": "Okänt fel", - "invalid_update_interval": "Uppdateringsintervallet måste vara minst 1 timme." + "invalid_update_interval": "Uppdateringsintervallet måste vara minst 1 timme.", + "invalid_forecast_days": "Prognosdagar måste vara mellan 1 och 5." }, "step": { "reauth_confirm": { @@ -33,7 +33,9 @@ "location": "Plats", "name": "Namn", "update_interval": "Uppdateringsintervall (timmar)", - "http_referer": "HTTP Referer" + "http_referer": "HTTP Referer", + "forecast_days": "Prognosdagar (1–5)", + "create_forecast_sensors": "Omfång för sensorer per dag (TYPER)" }, "description": "Ange din Google API-nyckel ([hämta den här]({api_key_url})) och läs bästa praxis ([bästa praxis]({restricting_api_keys_url})). Välj din plats på kartan, uppdateringsintervallet (timmar) och språkkoden för API-svaret.", "title": "Konfiguration av pollennivåer", @@ -74,15 +76,12 @@ }, "options": { "error": { - "cannot_connect": "Kan inte ansluta till tjänsten\n\n{error_message}", "empty": "Detta fält får inte vara tomt", - "invalid_auth": "Ogiltig API-nyckel\n\n{error_message}", - "invalid_language": "Ogiltig språkkod", "invalid_language_format": "Använd en kanonisk BCP-47-kod som \"en\" eller \"es-ES\".", "invalid_option_combo": "Öka \"Prognosdagar\" för att täcka valda sensorer per dag.", - "quota_exceeded": "Kvoten har överskridits\n\n{error_message}", "unknown": "Okänt fel", - "invalid_update_interval": "Uppdateringsintervallet måste vara minst 1 timme." + "invalid_update_interval": "Uppdateringsintervallet måste vara minst 1 timme.", + "invalid_forecast_days": "Prognosdagar måste vara mellan 1 och 5." }, "step": { "init": { @@ -92,7 +91,7 @@ "language_code": "Språkkod för API-svar", "update_interval": "Uppdateringsintervall (timmar)" }, - "description": "Ändra uppdateringsintervall, API-språk, prognosdagar och sensorer per dag för {title}.\nAlternativ för sensorer per dag (TYPER): Endast idag (none), Till och med i morgon (D+1), Till och med i övermorgon (D+2).", + "description": "Ändra uppdateringsintervall, API-språk, prognosdagar och sensorer per dag för {title}.\nAlternativ för sensorer per dag (TYPER): Endast idag (none), Till och med i morgon (D+1), Till och med i övermorgon (D+1+2).", "title": "Pollen Levels – Alternativ" } } diff --git a/custom_components/pollenlevels/translations/uk.json b/custom_components/pollenlevels/translations/uk.json index a14166dc..5c257a0b 100644 --- a/custom_components/pollenlevels/translations/uk.json +++ b/custom_components/pollenlevels/translations/uk.json @@ -11,12 +11,12 @@ "invalid_auth": "Невірний ключ API\n\n{error_message}", "invalid_coordinates": "Виберіть дійсне місце на карті.", "invalid_http_referrer": "Неприпустиме значення для HTTP Referer. Воно не повинно містити символів нового рядка.", - "invalid_language": "Невірний код мови", "invalid_language_format": "Використовуйте канонічний код BCP-47, наприклад \"en\" або \"es-ES\".", "invalid_option_combo": "Збільшіть «Дні прогнозу», щоб охопити вибрані денні датчики.", "quota_exceeded": "Перевищено ліміт запитів\n\n{error_message}", "unknown": "Невідома помилка", - "invalid_update_interval": "Інтервал оновлення має бути щонайменше 1 година." + "invalid_update_interval": "Інтервал оновлення має бути щонайменше 1 година.", + "invalid_forecast_days": "Дні прогнозу мають бути від 1 до 5." }, "step": { "reauth_confirm": { @@ -33,7 +33,9 @@ "location": "Місцезнаходження", "name": "Ім'я", "update_interval": "Інтервал оновлення (у годинах)", - "http_referer": "HTTP Referer" + "http_referer": "HTTP Referer", + "forecast_days": "Дні прогнозу (1–5)", + "create_forecast_sensors": "Діапазон денних датчиків (ТИПИ)" }, "description": "Введіть свій ключ Google API ([отримайте його тут]({api_key_url})) та ознайомтеся з найкращими практиками ([найкращі практики]({restricting_api_keys_url})). Виберіть місце на карті, інтервал оновлення (години) і код мови відповіді API.", "title": "Налаштування рівнів пилку", @@ -74,15 +76,12 @@ }, "options": { "error": { - "cannot_connect": "Не вдається підключитися до сервісу\n\n{error_message}", "empty": "Це поле не може бути порожнім", - "invalid_auth": "Невірний ключ API\n\n{error_message}", - "invalid_language": "Невірний код мови", "invalid_language_format": "Використовуйте канонічний код BCP-47, наприклад \"en\" або \"es-ES\".", "invalid_option_combo": "Збільшіть «Дні прогнозу», щоб охопити вибрані денні датчики.", - "quota_exceeded": "Перевищено ліміт запитів\n\n{error_message}", "unknown": "Невідома помилка", - "invalid_update_interval": "Інтервал оновлення має бути щонайменше 1 година." + "invalid_update_interval": "Інтервал оновлення має бути щонайменше 1 година.", + "invalid_forecast_days": "Дні прогнозу мають бути від 1 до 5." }, "step": { "init": { @@ -92,7 +91,7 @@ "language_code": "Код мови відповіді API", "update_interval": "Інтервал оновлення (у годинах)" }, - "description": "Змініть інтервал оновлення, мову відповіді API, кількість днів прогнозу та денні датчики для ТИПІВ для {title}.\nПараметри денних датчиків (ТИПИ): Лише сьогодні (none), До завтра (D+1), До післязавтра (D+2).", + "description": "Змініть інтервал оновлення, мову відповіді API, кількість днів прогнозу та денні датчики для ТИПІВ для {title}.\nПараметри денних датчиків (ТИПИ): Лише сьогодні (none), До завтра (D+1), До післязавтра (D+1+2).", "title": "Pollen Levels – Параметри" } } diff --git a/custom_components/pollenlevels/translations/zh-Hans.json b/custom_components/pollenlevels/translations/zh-Hans.json index 0ee82bd9..1675b851 100644 --- a/custom_components/pollenlevels/translations/zh-Hans.json +++ b/custom_components/pollenlevels/translations/zh-Hans.json @@ -11,12 +11,12 @@ "invalid_auth": "无效的 API 密钥\n\n{error_message}", "invalid_coordinates": "请在地图上选择有效的位置。", "invalid_http_referrer": "HTTP Referer 的值无效。它不能包含换行符。", - "invalid_language": "无效的语言代码", "invalid_language_format": "请使用规范的 BCP-47 代码,例如 \"en\" 或 \"es-ES\"。", "invalid_option_combo": "请增加“预测天数”,以覆盖所选的逐日类型传感器。", "quota_exceeded": "配额已用尽\n\n{error_message}", "unknown": "未知错误", - "invalid_update_interval": "更新间隔必须至少为 1 小时。" + "invalid_update_interval": "更新间隔必须至少为 1 小时。", + "invalid_forecast_days": "预测天数必须在 1 到 5 之间。" }, "step": { "reauth_confirm": { @@ -33,7 +33,9 @@ "location": "位置", "name": "名称", "update_interval": "更新间隔(小时)", - "http_referer": "HTTP Referer" + "http_referer": "HTTP Referer", + "forecast_days": "预测天数(1–5)", + "create_forecast_sensors": "逐日类型传感器范围" }, "description": "输入你的 Google API 密钥([在此获取]({api_key_url}))并查看最佳实践([最佳实践]({restricting_api_keys_url})). 在地图上选择位置、更新间隔(小时)以及 API 响应的语言代码。", "title": "花粉水平配置", @@ -74,15 +76,12 @@ }, "options": { "error": { - "cannot_connect": "无法连接到服务\n\n{error_message}", "empty": "此字段不能为空", - "invalid_auth": "无效的 API 密钥\n\n{error_message}", - "invalid_language": "无效的语言代码", "invalid_language_format": "请使用规范的 BCP-47 代码,例如 \"en\" 或 \"es-ES\"。", "invalid_option_combo": "请增加“预测天数”,以覆盖所选的逐日类型传感器。", - "quota_exceeded": "配额已用尽\n\n{error_message}", "unknown": "未知错误", - "invalid_update_interval": "更新间隔必须至少为 1 小时。" + "invalid_update_interval": "更新间隔必须至少为 1 小时。", + "invalid_forecast_days": "预测天数必须在 1 到 5 之间。" }, "step": { "init": { @@ -92,7 +91,7 @@ "language_code": "API 响应语言代码", "update_interval": "更新间隔(小时)" }, - "description": "修改更新间隔、API 语言、预测天以及逐日类型传感器,适用于 {title}。\n逐日类型传感器选项:仅今日(none)、至明日(D+1)、至后日(D+2)。", + "description": "修改更新间隔、API 语言、预测天以及逐日类型传感器,适用于 {title}。\n逐日类型传感器选项:仅今日(none)、至明日(D+1)、至后日(D+1+2)。", "title": "Pollen Levels – 选项" } } diff --git a/custom_components/pollenlevels/translations/zh-Hant.json b/custom_components/pollenlevels/translations/zh-Hant.json index feb67943..273e41d2 100644 --- a/custom_components/pollenlevels/translations/zh-Hant.json +++ b/custom_components/pollenlevels/translations/zh-Hant.json @@ -11,12 +11,12 @@ "invalid_auth": "無效的 API 金鑰\n\n{error_message}", "invalid_coordinates": "請在地圖上選擇有效的位置。", "invalid_http_referrer": "HTTP Referer 的值無效。不得包含換行符。", - "invalid_language": "無效的語言代碼", "invalid_language_format": "請使用標準的 BCP-47 代碼,例如 \"en\" 或 \"es-ES\"。", "invalid_option_combo": "請增加「預測天數」以涵蓋所選的逐日類型感測器。", "quota_exceeded": "超出配額\n\n{error_message}", "unknown": "未知錯誤", - "invalid_update_interval": "更新間隔必須至少為 1 小時。" + "invalid_update_interval": "更新間隔必須至少為 1 小時。", + "invalid_forecast_days": "預測天數必須在 1 到 5 之間。" }, "step": { "reauth_confirm": { @@ -33,7 +33,9 @@ "location": "位置", "name": "名稱", "update_interval": "更新間隔(小時)", - "http_referer": "HTTP Referer" + "http_referer": "HTTP Referer", + "forecast_days": "預測天數(1–5)", + "create_forecast_sensors": "逐日類型感測器範圍" }, "description": "輸入你的 Google API 金鑰([在此取得]({api_key_url})) 並查看最佳實務([最佳實務]({restricting_api_keys_url})). 在地圖上選擇位置、更新間隔(小時)以及 API 回應的語言代碼。", "title": "花粉水平設定", @@ -74,15 +76,12 @@ }, "options": { "error": { - "cannot_connect": "無法連線到服務\n\n{error_message}", "empty": "此欄位不得為空", - "invalid_auth": "無效的 API 金鑰\n\n{error_message}", - "invalid_language": "無效的語言代碼", "invalid_language_format": "請使用標準的 BCP-47 代碼,例如 \"en\" 或 \"es-ES\"。", "invalid_option_combo": "請增加「預測天數」以涵蓋所選的逐日類型感測器。", - "quota_exceeded": "超出配額\n\n{error_message}", "unknown": "未知錯誤", - "invalid_update_interval": "更新間隔必須至少為 1 小時。" + "invalid_update_interval": "更新間隔必須至少為 1 小時。", + "invalid_forecast_days": "預測天數必須在 1 到 5 之間。" }, "step": { "init": { @@ -92,7 +91,7 @@ "language_code": "API 回應語言代碼", "update_interval": "更新間隔(小時)" }, - "description": "修改更新間隔、API 語言、預測天數與逐日類型感測器,適用於 {title}。\n逐日類型感測器選項:僅今日(none)、至明日(D+1)、至後日(D+2)。", + "description": "修改更新間隔、API 語言、預測天數與逐日類型感測器,適用於 {title}。\n逐日類型感測器選項:僅今日(none)、至明日(D+1)、至後日(D+1+2)。", "title": "Pollen Levels – 選項" } } From e158be0f66a4e0a486b15ad17312fc9090a2f202 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Wed, 17 Dec 2025 20:29:35 +0100 Subject: [PATCH 069/200] Document translation source of truth --- AGENTS.md | 1 + CONTRIBUTING.md | 4 ++++ 2 files changed, 5 insertions(+) create mode 100644 CONTRIBUTING.md diff --git a/AGENTS.md b/AGENTS.md index 6f90a2bb..37f14e41 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,7 @@ ## Style and Documentation - All code comments, README entries, and changelog notes **must be written in English**. - Keep imports tidy—remove unused symbols and respect the Ruff isort grouping so the Home Assistant package stays first-party under `custom_components/pollenlevels`. +- Translation source of truth is `custom_components/pollenlevels/translations/en.json`. Keep all other locale files in sync with it and do not add or rely on a `strings.json` file. Note: When Home Assistant raises its Python floor to 3.14, this guidance will be updated; until then, treat Python 3.13 as the compatibility target for integration code. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..32d01f9c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,4 @@ +# Contributing + +- The translation source of truth is `custom_components/pollenlevels/translations/en.json`. Keep every other locale file in sync with it. +- Do not add or rely on a `strings.json` file; translation updates should flow from `en.json` to the other language files. From 77bd42d1f20bd8b2ff6c80c6688daf7595a2105b Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Wed, 17 Dec 2025 20:29:41 +0100 Subject: [PATCH 070/200] Expand contributing guidelines --- CONTRIBUTING.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 32d01f9c..c02090e7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,4 +1,10 @@ # Contributing -- The translation source of truth is `custom_components/pollenlevels/translations/en.json`. Keep every other locale file in sync with it. +- Follow Home Assistant's current Python floor for integration code (Python 3.13). Tooling is pinned to Python 3.14, but + integration logic must stay compatible with 3.13 syntax and standard library features. +- Format code with Black (line length 88, target-version `py314`) and sort/lint imports with Ruff (`ruff check --fix --select I` followed + by `ruff check`). +- The translation source of truth is `custom_components/pollenlevels/translations/en.json`. Keep every other locale file in + sync with it. - Do not add or rely on a `strings.json` file; translation updates should flow from `en.json` to the other language files. +- Preserve the existing coordinator-driven architecture and avoid introducing blocking I/O in the event loop. From 3d58a5a4213d1f5e3dd5291c70133b2bb7b95e77 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Wed, 17 Dec 2025 20:29:47 +0100 Subject: [PATCH 071/200] Clarify per-day sensor selector options --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 678e0581..9d6059fb 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,8 @@ Get sensors for **grass**, **tree**, **weed** pollen, plus individual plants lik - `forecast` list with `{offset, date, has_index, value, category, description, color_*}` - Convenience: `tomorrow_*` and `d2_*` - Derived: `trend` and `expected_peak` - - **Per-day sensors:** remain **TYPES-only** (optional `D+1` / `D+2`). + - **Per-day sensors:** remain **TYPES-only** with selector options `none`, `D+1`, + or `D+1+2` (creates both `(D+1)` and `(D+2)` sensors). **PLANTS** expose forecast **as attributes only** (no extra entities). - **Smart grouping** — Organizes sensors into: - **Pollen Types** (Grass / Tree / Weed) From 9c9e628ebb0a59668c7c7de19c3665243e9c4ae5 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Wed, 17 Dec 2025 20:29:57 +0100 Subject: [PATCH 072/200] Harden pollen API client error handling --- custom_components/pollenlevels/client.py | 73 ++++++++++++++++++------ custom_components/pollenlevels/util.py | 18 +++--- 2 files changed, 66 insertions(+), 25 deletions(-) diff --git a/custom_components/pollenlevels/client.py b/custom_components/pollenlevels/client.py index 9d0cbe4a..dde3e4b8 100644 --- a/custom_components/pollenlevels/client.py +++ b/custom_components/pollenlevels/client.py @@ -5,12 +5,17 @@ import random from typing import Any -from aiohttp import ClientError, ClientSession, ClientTimeout +from aiohttp import ClientError, ClientSession, ClientTimeout, ContentTypeError from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import UpdateFailed from homeassistant.util import dt as dt_util -from .const import MAX_RETRIES, POLLEN_API_TIMEOUT, is_invalid_api_key_message +from .const import ( + MAX_RETRIES, + POLLEN_API_TIMEOUT, + is_invalid_api_key_message, + normalize_http_referer, +) from .util import extract_error_message, redact_api_key _LOGGER = logging.getLogger(__name__) @@ -87,11 +92,17 @@ async def async_fetch_pollen_data( ) max_retries = MAX_RETRIES + headers: dict[str, str] | None = None + if self._http_referer: + try: + referer = normalize_http_referer(self._http_referer) + if referer: + headers = {"Referer": referer} + except ValueError: + _LOGGER.warning("Ignoring http_referer containing newline characters") + for attempt in range(0, max_retries + 1): try: - headers: dict[str, str] | None = None - if self._http_referer: - headers = {"Referer": self._http_referer} async with self._session.get( url, params=params, @@ -99,13 +110,17 @@ async def async_fetch_pollen_data( headers=headers, ) as resp: if resp.status == 401: - raw_message = await extract_error_message(resp) - message = _format_http_message(resp.status, raw_message) + raw_message = redact_api_key( + await extract_error_message(resp, default=""), self._api_key + ) + message = _format_http_message(resp.status, raw_message or None) raise ConfigEntryAuthFailed(message) if resp.status == 403: - raw_message = await extract_error_message(resp) - message = _format_http_message(resp.status, raw_message) + raw_message = redact_api_key( + await extract_error_message(resp, default=""), self._api_key + ) + message = _format_http_message(resp.status, raw_message or None) if is_invalid_api_key_message(raw_message): raise ConfigEntryAuthFailed(message) raise UpdateFailed(message) @@ -125,8 +140,10 @@ async def async_fetch_pollen_data( ) await asyncio.sleep(delay) continue - raw_message = await extract_error_message(resp) - message = _format_http_message(resp.status, raw_message) + raw_message = redact_api_key( + await extract_error_message(resp, default=""), self._api_key + ) + message = _format_http_message(resp.status, raw_message or None) raise UpdateFailed(message) if 500 <= resp.status <= 599: @@ -141,21 +158,39 @@ async def async_fetch_pollen_data( base_args=(resp.status,), ) continue - raw_message = await extract_error_message(resp) - message = _format_http_message(resp.status, raw_message) + raw_message = redact_api_key( + await extract_error_message(resp, default=""), self._api_key + ) + message = _format_http_message(resp.status, raw_message or None) raise UpdateFailed(message) if 400 <= resp.status < 500 and resp.status not in (403, 429): - raw_message = await extract_error_message(resp) - message = _format_http_message(resp.status, raw_message) + raw_message = redact_api_key( + await extract_error_message(resp, default=""), self._api_key + ) + message = _format_http_message(resp.status, raw_message or None) raise UpdateFailed(message) if resp.status != 200: - raw_message = await extract_error_message(resp) - message = _format_http_message(resp.status, raw_message) + raw_message = redact_api_key( + await extract_error_message(resp, default=""), self._api_key + ) + message = _format_http_message(resp.status, raw_message or None) raise UpdateFailed(message) - return await resp.json() + try: + payload = await resp.json(content_type=None) + except (ContentTypeError, TypeError, ValueError) as err: + raise UpdateFailed( + "Unexpected API response: invalid JSON" + ) from err + + if not isinstance(payload, dict): + raise UpdateFailed( + "Unexpected API response: expected JSON object" + ) + + return payload except ConfigEntryAuthFailed: raise @@ -189,6 +224,8 @@ async def async_fetch_pollen_data( "Network error while calling the Google Pollen API" ) raise UpdateFailed(msg) from err + except UpdateFailed: + raise except Exception as err: # noqa: BLE001 msg = redact_api_key(err, self._api_key) _LOGGER.error("Pollen API error: %s", msg) diff --git a/custom_components/pollenlevels/util.py b/custom_components/pollenlevels/util.py index 7ce8a414..570fa86f 100644 --- a/custom_components/pollenlevels/util.py +++ b/custom_components/pollenlevels/util.py @@ -15,13 +15,13 @@ async def extract_error_message(resp: ClientResponse, default: str = "") -> str: message: str | None = None try: - json_obj = await resp.json() + json_obj = await resp.json(content_type=None) if isinstance(json_obj, dict): error = json_obj.get("error") if isinstance(error, dict): raw_msg = error.get("message") if isinstance(raw_msg, str): - message = raw_msg.strip() + message = raw_msg except Exception: # noqa: BLE001 message = None @@ -29,14 +29,18 @@ async def extract_error_message(resp: ClientResponse, default: str = "") -> str: try: text = await resp.text() if isinstance(text, str): - message = text.strip() + message = text except Exception: # noqa: BLE001 message = None - message = (message or "").strip() or default - if len(message) > 300: - message = message[:300] - return message + normalized = " ".join( + (message or "").replace("\r", " ").replace("\n", " ").split() + ).strip() + + if len(normalized) > 300: + normalized = normalized[:300] + + return normalized or default def redact_api_key(text: object, api_key: str | None) -> str: From 3d5490dc4dc1369dfe3d117df6a448d7b2f85aac Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Wed, 17 Dec 2025 20:30:02 +0100 Subject: [PATCH 073/200] Update changelog for client hardening --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f07151b..634bfc14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,9 @@ constant to simplify future tuning without touching request logic. - Tightened error extraction typing to expect `aiohttp.ClientResponse` while guarding the import so environments without aiohttp can still run tests. +- Hardened the runtime pollen client with sanitized `Referer` headers, + normalized error parsing, and strict JSON validation to avoid leaking secrets + while surfacing consistent failures. ## [1.9.0-alpha1] - 2025-12-11 ### Changed From c325e79c4f0772defd7acc70db49b129599c17f2 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Wed, 17 Dec 2025 20:39:06 +0100 Subject: [PATCH 074/200] Handle stubbed aiohttp JSON parsing --- custom_components/pollenlevels/client.py | 12 ++++++++++-- custom_components/pollenlevels/util.py | 5 ++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/custom_components/pollenlevels/client.py b/custom_components/pollenlevels/client.py index dde3e4b8..302049bd 100644 --- a/custom_components/pollenlevels/client.py +++ b/custom_components/pollenlevels/client.py @@ -5,7 +5,12 @@ import random from typing import Any -from aiohttp import ClientError, ClientSession, ClientTimeout, ContentTypeError +from aiohttp import ClientError, ClientSession, ClientTimeout + +try: # pragma: no cover - fallback for environments with stubbed aiohttp + from aiohttp import ContentTypeError +except ImportError: # pragma: no cover - tests stub aiohttp without ContentTypeError + ContentTypeError = ValueError # type: ignore[misc,assignment] from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import UpdateFailed from homeassistant.util import dt as dt_util @@ -179,7 +184,10 @@ async def async_fetch_pollen_data( raise UpdateFailed(message) try: - payload = await resp.json(content_type=None) + try: + payload = await resp.json(content_type=None) + except TypeError: + payload = await resp.json() except (ContentTypeError, TypeError, ValueError) as err: raise UpdateFailed( "Unexpected API response: invalid JSON" diff --git a/custom_components/pollenlevels/util.py b/custom_components/pollenlevels/util.py index 570fa86f..30f827b3 100644 --- a/custom_components/pollenlevels/util.py +++ b/custom_components/pollenlevels/util.py @@ -15,7 +15,10 @@ async def extract_error_message(resp: ClientResponse, default: str = "") -> str: message: str | None = None try: - json_obj = await resp.json(content_type=None) + try: + json_obj = await resp.json(content_type=None) + except TypeError: + json_obj = await resp.json() if isinstance(json_obj, dict): error = json_obj.get("error") if isinstance(error, dict): From 33fc5024e4b879c0ae9754e53f7aa440f58571e3 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Thu, 18 Dec 2025 09:42:48 +0100 Subject: [PATCH 075/200] Reject empty API keys in config flow --- custom_components/pollenlevels/config_flow.py | 25 +++++++++++----- tests/test_config_flow.py | 30 +++++++++++++++++++ 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/custom_components/pollenlevels/config_flow.py b/custom_components/pollenlevels/config_flow.py index fedcdf6a..4c45b0d7 100644 --- a/custom_components/pollenlevels/config_flow.py +++ b/custom_components/pollenlevels/config_flow.py @@ -266,6 +266,13 @@ async def _async_validate_input( normalized.pop(CONF_NAME, None) normalized.pop(CONF_LOCATION, None) + api_key = str(user_input.get(CONF_API_KEY, "")) if user_input else "" + api_key = api_key.strip() + + if not api_key: + errors[CONF_API_KEY] = "empty" + return errors, None + headers: dict[str, str] | None = None try: http_referer = normalize_http_referer(normalized.get(CONF_HTTP_REFERER)) @@ -344,10 +351,12 @@ async def _async_validate_input( except Exception as err: # defensive _LOGGER.exception( "Unique ID setup failed for coordinates (values redacted): %s", - redact_api_key(err, user_input.get(CONF_API_KEY)), + redact_api_key(err, api_key), ) raise + normalized[CONF_API_KEY] = api_key + try: # Allow blank language; if present, validate & normalize raw_lang = user_input.get(CONF_LANGUAGE_CODE, "") @@ -357,7 +366,7 @@ async def _async_validate_input( session = async_get_clientsession(self.hass) params = { - "key": user_input[CONF_API_KEY], + "key": api_key, "location.latitude": f"{lat:.6f}", "location.longitude": f"{lon:.6f}", "days": 1, @@ -413,7 +422,7 @@ async def _async_validate_input( _LOGGER.debug( "Validation HTTP %s — %s", status, - redact_api_key(body_str, user_input.get(CONF_API_KEY)), + redact_api_key(body_str, api_key), ) try: data = json.loads(body_str) if body_str else {} @@ -445,11 +454,11 @@ async def _async_validate_input( _LOGGER.warning( "Validation timeout (%ss): %s", POLLEN_API_TIMEOUT, - redact_api_key(err, user_input.get(CONF_API_KEY)), + redact_api_key(err, api_key), ) errors["base"] = "cannot_connect" if placeholders is not None: - redacted = redact_api_key(err, user_input.get(CONF_API_KEY)) + redacted = redact_api_key(err, api_key) placeholders["error_message"] = ( redacted or f"Validation request timed out ({POLLEN_API_TIMEOUT} seconds)." @@ -457,18 +466,18 @@ async def _async_validate_input( except aiohttp.ClientError as err: _LOGGER.error( "Connection error: %s", - redact_api_key(err, user_input.get(CONF_API_KEY)), + redact_api_key(err, api_key), ) errors["base"] = "cannot_connect" if placeholders is not None: - redacted = redact_api_key(err, user_input.get(CONF_API_KEY)) + redacted = redact_api_key(err, api_key) placeholders["error_message"] = ( redacted or "Network error while connecting to the pollen service." ) except Exception as err: # defensive _LOGGER.exception( "Unexpected error in Pollen Levels config flow while validating input: %s", - redact_api_key(err, user_input.get(CONF_API_KEY)), + redact_api_key(err, api_key), ) errors["base"] = "unknown" if placeholders is not None: diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 3589e950..a9ecb617 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -435,6 +435,36 @@ def test_validate_input_invalid_language_key_mapping() -> None: assert normalized is None +def test_validate_input_empty_api_key(monkeypatch: pytest.MonkeyPatch) -> None: + """Blank or whitespace API keys should be rejected without HTTP calls.""" + + flow = PollenLevelsConfigFlow() + flow.hass = SimpleNamespace() + + session_called = False + + def _raise_session(hass): + nonlocal session_called + session_called = True + raise AssertionError("async_get_clientsession should not be called") + + monkeypatch.setattr(cf, "async_get_clientsession", _raise_session) + + errors, normalized = asyncio.run( + flow._async_validate_input( + { + CONF_API_KEY: " ", + CONF_LOCATION: {CONF_LATITUDE: 1.0, CONF_LONGITUDE: 2.0}, + }, + check_unique_id=False, + ) + ) + + assert errors == {CONF_API_KEY: "empty"} + assert normalized is None + assert session_called is False + + def test_language_error_to_form_key_mapping() -> None: """voluptuous error messages map to localized form keys.""" From 243c5a6970a85a7f3f01e3f06a1bb4d5df97cc47 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Thu, 18 Dec 2025 10:14:52 +0100 Subject: [PATCH 076/200] Expand translation coverage tests --- tests/test_translations.py | 216 +++++++++++++++++++++++++++++++++++-- 1 file changed, 207 insertions(+), 9 deletions(-) diff --git a/tests/test_translations.py b/tests/test_translations.py index 904a3c3a..f7aa4f0f 100644 --- a/tests/test_translations.py +++ b/tests/test_translations.py @@ -1,9 +1,12 @@ """Translation coverage tests for the Pollen Levels integration. -These tests parse ``config_flow.py`` with a simple AST walker to ensure every -translation key used in the config/options flows exists in each locale file. -If the flow code changes structure, update the helper below rather than -changing the assertions to keep the guarantees intact. +These tests ensure: +- All locale files have the exact same keyset as en.json (en.json is the source of truth). +- Translation keys referenced by config_flow.py (config + options flows) exist in en.json. +- Translation keys referenced by sensor.py via entity/device translation_key exist in en.json. + +The config_flow extraction uses an AST walker. If config_flow.py changes structure in +unexpected ways, the helpers should fail loudly so we don't silently lose coverage. """ from __future__ import annotations @@ -23,6 +26,7 @@ TRANSLATIONS_DIR = COMPONENT_DIR / "translations" CONFIG_FLOW_PATH = COMPONENT_DIR / "config_flow.py" CONST_PATH = COMPONENT_DIR / "const.py" +SENSOR_PATH = COMPONENT_DIR / "sensor.py" def _fail_unexpected_ast(context: str) -> None: @@ -56,6 +60,104 @@ def _load_translation(path: Path) -> dict[str, Any]: return json.load(file) +def _extract_sensor_translation_key_usage() -> tuple[set[str], set[str]]: + """Extract translation keys referenced by sensor entities and devices. + + Entity keys: + - _attr_translation_key = "" -> entity.sensor..name + + Device keys: + - "translation_key": "" in a device_info dict literal + - values in a mapping like: translation_keys = {"type": "types", ...} + - default used in translation_keys.get(..., "") + + This stays intentionally narrow; unsupported AST changes should fail loudly. + """ + + if not SENSOR_PATH.is_file(): + raise AssertionError(f"Missing sensor.py at {SENSOR_PATH}") + + tree = ast.parse(SENSOR_PATH.read_text(encoding="utf-8")) + + entity_keys: set[str] = set() + device_keys: set[str] = set() + + # 1) Entity translation keys: _attr_translation_key = "" + for node in ast.walk(tree): + if not isinstance(node, (ast.Assign, ast.AnnAssign)): + continue + + if isinstance(node, ast.Assign): + if len(node.targets) != 1: + continue + target = node.targets[0] + value = node.value + else: + target = node.target + value = node.value + + if ( + isinstance(target, ast.Name) + and target.id == "_attr_translation_key" + and isinstance(value, ast.Constant) + and isinstance(value.value, str) + ): + entity_keys.add(value.value) + + # 2) Device translation keys from explicit dict literals: {"translation_key": ""} + for node in ast.walk(tree): + if not isinstance(node, ast.Dict): + continue + for k, v in zip(node.keys, node.values, strict=False): + if ( + isinstance(k, ast.Constant) + and k.value == "translation_key" + and isinstance(v, ast.Constant) + and isinstance(v.value, str) + ): + device_keys.add(v.value) + + # 3) Device translation keys from a mapping: translation_keys = {...} + for node in ast.walk(tree): + if not isinstance(node, ast.Assign) or len(node.targets) != 1: + continue + if not ( + isinstance(node.targets[0], ast.Name) + and node.targets[0].id == "translation_keys" + ): + continue + if not isinstance(node.value, ast.Dict): + _fail_unexpected_ast("sensor.py translation_keys assignment is not a dict") + for v in node.value.values: + if not (isinstance(v, ast.Constant) and isinstance(v.value, str)): + _fail_unexpected_ast( + "sensor.py translation_keys dict contains non-string values" + ) + device_keys.add(v.value) + + # 4) Default device translation key: translation_keys.get(..., "") + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + if not (isinstance(node.func, ast.Attribute) and node.func.attr == "get"): + continue + if not ( + isinstance(node.func.value, ast.Name) + and node.func.value.id == "translation_keys" + ): + continue + if len(node.args) >= 2: + default = node.args[1] + if isinstance(default, ast.Constant) and isinstance(default.value, str): + device_keys.add(default.value) + else: + _fail_unexpected_ast( + "sensor.py translation_keys.get default is not a string literal" + ) + + return entity_keys, device_keys + + def test_translations_match_english_keyset() -> None: """Verify all locale files mirror the English translation keyset.""" @@ -71,9 +173,9 @@ def test_translations_match_english_keyset() -> None: extra = locale_keys - english_keys if missing or extra: problems.append( - f"{translation_path.name}: " - f"missing {sorted(missing)} extra {sorted(extra)}" + f"{translation_path.name}: missing {sorted(missing)} extra {sorted(extra)}" ) + assert not problems, "Translation keys mismatch: " + "; ".join(problems) @@ -86,6 +188,41 @@ def test_config_flow_translation_keys_present() -> None: assert not missing, f"Missing config_flow translation keys: {sorted(missing)}" +def test_config_flow_extractor_includes_helper_error_keys() -> None: + """Regression: helper-propagated errors must be detected by AST extraction.""" + + keys = _extract_config_flow_keys() + assert "config.error.invalid_update_interval" in keys + assert "options.error.invalid_update_interval" in keys + assert "config.error.invalid_forecast_days" in keys + assert "options.error.invalid_forecast_days" in keys + + +def test_sensor_translation_keys_present() -> None: + """Ensure entity/device translation keys referenced by sensor.py exist in en.json.""" + + english = _flatten_keys(_load_translation(TRANSLATIONS_DIR / "en.json")) + entity_keys, device_keys = _extract_sensor_translation_key_usage() + + assert entity_keys, "No _attr_translation_key values found in sensor.py" + assert device_keys, "No device translation_key values found in sensor.py" + + missing: list[str] = [] + for key in sorted(entity_keys): + tkey = f"entity.sensor.{key}.name" + if tkey not in english: + missing.append(tkey) + + for key in sorted(device_keys): + tkey = f"device.{key}.name" + if tkey not in english: + missing.append(tkey) + + assert not missing, "Missing sensor/device translation keys in en.json: " + ", ".join( + missing + ) + + def _extract_constant_assignments(tree: ast.AST) -> dict[str, str]: """Collect string literal assignments from an AST. @@ -143,6 +280,7 @@ def _fields_from_schema_dict( for key_node, value_node in zip(schema_dict.keys, schema_dict.values, strict=False): if not isinstance(key_node, ast.Call): _fail_unexpected_ast("schema key wrapper") + if isinstance(key_node.func, ast.Attribute) and key_node.func.attr in { "Required", "Optional", @@ -196,14 +334,13 @@ def _extract_schema_fields( "_user_schema", "_options_schema", }: - returns = [ - child for child in ast.walk(node) if isinstance(child, ast.Return) - ] + returns = [child for child in ast.walk(node) if isinstance(child, ast.Return)] for ret in returns: if isinstance(ret.value, ast.Call): fields.setdefault(node.name, set()).update( _fields_from_schema_call(ret.value, mapping) ) + if isinstance(node, ast.Assign): if ( isinstance(node.targets[0], ast.Name) @@ -218,6 +355,34 @@ def _extract_schema_fields( return fields +def _extract_helper_error_keys(tree: ast.AST) -> dict[str, set[str]]: + """Discover module-level helper functions that emit error keys via _parse_int_option(..., error_key=...).""" + + helpers: dict[str, set[str]] = {} + for node in getattr(tree, "body", []): + if not isinstance(node, ast.FunctionDef): + continue + + emitted: set[str] = set() + for call in ast.walk(node): + if not (isinstance(call, ast.Call) and isinstance(call.func, ast.Name)): + continue + if call.func.id != "_parse_int_option": + continue + for kw in call.keywords: + if kw.arg != "error_key": + continue + if isinstance(kw.value, ast.Constant) and isinstance(kw.value.value, str): + emitted.add(kw.value.value) + else: + _fail_unexpected_ast( + f"error_key in {node.name} is not a string literal" + ) + if emitted: + helpers[node.name] = emitted + return helpers + + def _is_options_flow_class(name: str) -> bool: """Heuristic to decide if a class represents an options flow. @@ -268,6 +433,9 @@ def _extract_config_flow_keys() -> set[str]: schema_fields = _extract_schema_fields(config_tree, mapping) + # Helper functions can return error keys indirectly (e.g., interval_error/days_error). + helper_error_keys = _extract_helper_error_keys(config_tree) + language_error_returns: set[str] = set() class _LanguageErrorVisitor(ast.NodeVisitor): @@ -296,6 +464,15 @@ def _extract_error_values(value: ast.AST) -> set[str]: values.update(language_error_returns) return values + def _extract_error_key_kw(call: ast.Call) -> str | None: + for kw in call.keywords: + if kw.arg != "error_key": + continue + if isinstance(kw.value, ast.Constant) and isinstance(kw.value.value, str): + return kw.value.value + _fail_unexpected_ast("error_key kwarg is not a string literal") + return None + class _ScopedErrorsVisitor(ast.NodeVisitor): def __init__(self) -> None: self.class_stack: list[str | None] = [] @@ -315,6 +492,26 @@ def visit_AnnAssign(self, node: ast.AnnAssign) -> None: # noqa: N802 self._record_errors(node.target, node.value) self.generic_visit(node) + def visit_Call(self, node: ast.Call) -> None: # noqa: N802 + # Collect helper-propagated errors used in a class scope, e.g.: + # interval_value, interval_error = _parse_update_interval(...); errors[...] = interval_error + class_name = self.class_stack[-1] if self.class_stack else None + if class_name is None: + self.generic_visit(node) + return + + if isinstance(node.func, ast.Name): + if node.func.id == "_parse_int_option": + err = _extract_error_key_kw(node) + if err: + self.by_class.setdefault(class_name, set()).add(err) + elif node.func.id in helper_error_keys: + self.by_class.setdefault(class_name, set()).update( + helper_error_keys[node.func.id] + ) + + self.generic_visit(node) + def _record_errors(self, target: ast.AST, value: ast.AST | None) -> None: if ( isinstance(target, ast.Subscript) @@ -441,3 +638,4 @@ def _handle_abort(self, node: ast.Call, prefix: str) -> None: FlowVisitor().visit(config_tree) return keys + From bfc0751f885a31b7bab687bcd4c8d1a031c10c17 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Thu, 18 Dec 2025 10:19:43 +0100 Subject: [PATCH 077/200] Run black and ruff --- tests/test_translations.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/tests/test_translations.py b/tests/test_translations.py index f7aa4f0f..e7f8d53c 100644 --- a/tests/test_translations.py +++ b/tests/test_translations.py @@ -218,9 +218,9 @@ def test_sensor_translation_keys_present() -> None: if tkey not in english: missing.append(tkey) - assert not missing, "Missing sensor/device translation keys in en.json: " + ", ".join( - missing - ) + assert ( + not missing + ), "Missing sensor/device translation keys in en.json: " + ", ".join(missing) def _extract_constant_assignments(tree: ast.AST) -> dict[str, str]: @@ -334,7 +334,9 @@ def _extract_schema_fields( "_user_schema", "_options_schema", }: - returns = [child for child in ast.walk(node) if isinstance(child, ast.Return)] + returns = [ + child for child in ast.walk(node) if isinstance(child, ast.Return) + ] for ret in returns: if isinstance(ret.value, ast.Call): fields.setdefault(node.name, set()).update( @@ -372,7 +374,9 @@ def _extract_helper_error_keys(tree: ast.AST) -> dict[str, set[str]]: for kw in call.keywords: if kw.arg != "error_key": continue - if isinstance(kw.value, ast.Constant) and isinstance(kw.value.value, str): + if isinstance(kw.value, ast.Constant) and isinstance( + kw.value.value, str + ): emitted.add(kw.value.value) else: _fail_unexpected_ast( @@ -638,4 +642,3 @@ def _handle_abort(self, node: ast.Call, prefix: str) -> None: FlowVisitor().visit(config_tree) return keys - From 4a704122a3b5d0b663c389de7dab39b781cb99df Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Thu, 18 Dec 2025 13:50:13 +0100 Subject: [PATCH 078/200] Update changelog with formatting note --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 634bfc14..a48ae28d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,8 @@ - Hardened the runtime pollen client with sanitized `Referer` headers, normalized error parsing, and strict JSON validation to avoid leaking secrets while surfacing consistent failures. +- Reformatted the codebase with Black and Ruff to keep imports and styling + consistent with repository standards. ## [1.9.0-alpha1] - 2025-12-11 ### Changed From 943382046751815d5c9dc3d008304692c08f76bc Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Thu, 18 Dec 2025 13:50:17 +0100 Subject: [PATCH 079/200] Expand translation coverage tests for sections and services --- tests/test_translations.py | 146 ++++++++++++++++++++++++++++--------- 1 file changed, 113 insertions(+), 33 deletions(-) diff --git a/tests/test_translations.py b/tests/test_translations.py index e7f8d53c..f63d58da 100644 --- a/tests/test_translations.py +++ b/tests/test_translations.py @@ -4,6 +4,8 @@ - All locale files have the exact same keyset as en.json (en.json is the source of truth). - Translation keys referenced by config_flow.py (config + options flows) exist in en.json. - Translation keys referenced by sensor.py via entity/device translation_key exist in en.json. +- Translation keys for sections (step.*.sections.*) are present if schema uses ``section(...)``. +- Translation keys for services declared in services.yaml exist in en.json. The config_flow extraction uses an AST walker. If config_flow.py changes structure in unexpected ways, the helpers should fail loudly so we don't silently lose coverage. @@ -13,6 +15,7 @@ import ast import json +import re from pathlib import Path from typing import Any @@ -27,6 +30,7 @@ CONFIG_FLOW_PATH = COMPONENT_DIR / "config_flow.py" CONST_PATH = COMPONENT_DIR / "const.py" SENSOR_PATH = COMPONENT_DIR / "sensor.py" +SERVICES_YAML_PATH = COMPONENT_DIR / "services.yaml" def _fail_unexpected_ast(context: str) -> None: @@ -60,6 +64,26 @@ def _load_translation(path: Path) -> dict[str, Any]: return json.load(file) +def _extract_services_from_services_yaml() -> set[str]: + """Extract top-level service names from services.yaml without requiring PyYAML.""" + + if not SERVICES_YAML_PATH.is_file(): + return set() + + services: set[str] = set() + for line in SERVICES_YAML_PATH.read_text(encoding="utf-8").splitlines(): + raw = line.rstrip("\n") + if not raw or raw.lstrip().startswith("#"): + continue + if raw.startswith(" "): + continue + match = re.match(r"^([a-zA-Z0-9_]+):\s*$", raw) + if match: + services.add(match.group(1)) + + return services + + def _extract_sensor_translation_key_usage() -> tuple[set[str], set[str]]: """Extract translation keys referenced by sensor entities and devices. @@ -223,6 +247,26 @@ def test_sensor_translation_keys_present() -> None: ), "Missing sensor/device translation keys in en.json: " + ", ".join(missing) +def test_services_translation_keys_present() -> None: + """Ensure services declared in services.yaml have translations in en.json.""" + + english = _flatten_keys(_load_translation(TRANSLATIONS_DIR / "en.json")) + service_names = _extract_services_from_services_yaml() + if not service_names: + return + + missing: list[str] = [] + for service in sorted(service_names): + for suffix in ("name", "description"): + key = f"services.{service}.{suffix}" + if key not in english: + missing.append(key) + + assert not missing, "Missing service translation keys in en.json: " + ", ".join( + missing + ) + + def _extract_constant_assignments(tree: ast.AST) -> dict[str, str]: """Collect string literal assignments from an AST. @@ -259,8 +303,10 @@ def _resolve_name(name: str, mapping: dict[str, str]) -> str | None: return mapping.get(name) -def _fields_from_section_value(value: ast.AST, mapping: dict[str, str]) -> set[str]: - """Extract fields from a section(...) value.""" +def _fields_from_section_value( + value: ast.AST, mapping: dict[str, str] +) -> tuple[set[str], set[str]]: + """Extract fields and nested section IDs from a section(...) value.""" if isinstance(value, ast.Dict): return _fields_from_schema_dict(value, mapping) @@ -268,15 +314,16 @@ def _fields_from_section_value(value: ast.AST, mapping: dict[str, str]) -> set[s if isinstance(value.func, ast.Attribute) and value.func.attr == "Schema": return _fields_from_schema_call(value, mapping) _fail_unexpected_ast("unexpected section value AST") - return set() + return set(), set() def _fields_from_schema_dict( schema_dict: ast.Dict, mapping: dict[str, str] -) -> set[str]: - """Extract field keys from an AST dict representing a schema.""" +) -> tuple[set[str], set[str]]: + """Extract field keys and section IDs from an AST dict representing a schema.""" fields: set[str] = set() + sections: set[str] = set() for key_node, value_node in zip(schema_dict.keys, schema_dict.values, strict=False): if not isinstance(key_node, ast.Call): _fail_unexpected_ast("schema key wrapper") @@ -298,19 +345,40 @@ def _fields_from_schema_dict( _fail_unexpected_ast(f"unmapped selector {selector.id}") else: _fail_unexpected_ast("selector type") - elif isinstance(key_node.func, ast.Name) and key_node.func.id == "section": - fields.update(_fields_from_section_value(value_node, mapping)) - else: - _fail_unexpected_ast("unexpected schema call wrapper") - return fields + continue + + if isinstance(key_node.func, ast.Name) and key_node.func.id == "section": + if not key_node.args: + _fail_unexpected_ast("section() missing section id") + section_id_node = key_node.args[0] + if isinstance(section_id_node, ast.Constant) and isinstance( + section_id_node.value, str + ): + sections.add(section_id_node.value) + elif isinstance(section_id_node, ast.Name): + resolved = _resolve_name(section_id_node.id, mapping) + if resolved: + sections.add(resolved) + else: + _fail_unexpected_ast(f"unmapped section id {section_id_node.id}") + else: + _fail_unexpected_ast("section id type") + section_fields, nested_sections = _fields_from_section_value( + value_node, mapping + ) + fields.update(section_fields) + sections.update(nested_sections) + continue -def _fields_from_schema_call(call: ast.Call, mapping: dict[str, str]) -> set[str]: - """Extract field keys from a vol.Schema(...) call. + _fail_unexpected_ast("unexpected schema call wrapper") + return fields, sections - Looks for patterns like: - vol.Schema({vol.Required(CONF_USERNAME): str, ...}) - """ + +def _fields_from_schema_call( + call: ast.Call, mapping: dict[str, str] +) -> tuple[set[str], set[str]]: + """Extract field keys and section IDs from a vol.Schema(...) call.""" if not call.args or not isinstance(call.args[0], ast.Dict): _fail_unexpected_ast("schema call arguments") @@ -320,15 +388,10 @@ def _fields_from_schema_call(call: ast.Call, mapping: dict[str, str]) -> set[str def _extract_schema_fields( tree: ast.AST, mapping: dict[str, str] -) -> dict[str, set[str]]: - """Map schema helper names to their field keys. +) -> dict[str, tuple[set[str], set[str]]]: + """Map schema helper names to (fields, sections).""" - Collects: - - Functions like _user_schema / _options_schema returning vol.Schema(...) - - Top-level assignments like USER_SCHEMA = vol.Schema(...) - """ - - fields: dict[str, set[str]] = {} + schemas: dict[str, tuple[set[str], set[str]]] = {} for node in ast.walk(tree): if isinstance(node, ast.FunctionDef) and node.name in { "_user_schema", @@ -339,8 +402,11 @@ def _extract_schema_fields( ] for ret in returns: if isinstance(ret.value, ast.Call): - fields.setdefault(node.name, set()).update( - _fields_from_schema_call(ret.value, mapping) + fields, sections = _fields_from_schema_call(ret.value, mapping) + prev_fields, prev_sections = schemas.get(node.name, (set(), set())) + schemas[node.name] = ( + prev_fields | fields, + prev_sections | sections, ) if isinstance(node, ast.Assign): @@ -351,10 +417,13 @@ def _extract_schema_fields( and node.value.func.attr == "Schema" ): name = node.targets[0].id - fields.setdefault(name, set()).update( - _fields_from_schema_call(node.value, mapping) + fields, sections = _fields_from_schema_call(node.value, mapping) + prev_fields, prev_sections = schemas.get(name, (set(), set())) + schemas[name] = ( + prev_fields | fields, + prev_sections | sections, ) - return fields + return schemas def _extract_helper_error_keys(tree: ast.AST) -> dict[str, set[str]]: @@ -407,6 +476,7 @@ def _extract_config_flow_keys() -> set[str]: - config.step..title - config.step..description - config.step..data. + - config.step..sections. - config.error. - config.abort. And the equivalent options.* keys for options flows. @@ -435,7 +505,7 @@ def _extract_config_flow_keys() -> set[str]: mapping.update(_extract_constant_assignments(const_tree)) mapping.update(_extract_constant_assignments(config_tree)) - schema_fields = _extract_schema_fields(config_tree, mapping) + schema_info = _extract_schema_fields(config_tree, mapping) # Helper functions can return error keys indirectly (e.g., interval_error/days_error). helper_error_keys = _extract_helper_error_keys(config_tree) @@ -536,7 +606,9 @@ def _record_errors(self, target: ast.AST, value: ast.AST | None) -> None: class FlowVisitor(ast.NodeVisitor): def __init__(self) -> None: self.class_stack: list[str] = [] - self.local_schema_vars: dict[str, set[str]] = dict(schema_fields) + self.local_schema_vars: dict[str, tuple[set[str], set[str]]] = dict( + schema_info + ) def visit_ClassDef(self, node: ast.ClassDef) -> None: # noqa: N802 self.class_stack.append(node.name) @@ -576,6 +648,7 @@ def _handle_show_form(self, node: ast.Call, prefix: str) -> None: step_id: str | None = None schema_name: str | None = None inline_schema_fields: set[str] = set() + inline_sections: set[str] = set() for kw in node.keywords: if kw.arg == "step_id" and isinstance(kw.value, ast.Constant): @@ -588,9 +661,11 @@ def _handle_show_form(self, node: ast.Call, prefix: str) -> None: isinstance(kw.value.func, ast.Attribute) and kw.value.func.attr == "Schema" ): - inline_schema_fields.update( - _fields_from_schema_call(kw.value, mapping) + fields, sections = _fields_from_schema_call( + kw.value, mapping ) + inline_schema_fields.update(fields) + inline_sections.update(sections) if kw.arg == "errors": if isinstance(kw.value, ast.Dict): for err_value in kw.value.values: @@ -624,11 +699,16 @@ def _handle_show_form(self, node: ast.Call, prefix: str) -> None: keys.add(f"{prefix}.step.{step_id}.description") if schema_name and schema_name in self.local_schema_vars: - for field in self.local_schema_vars[schema_name]: + fields, sections = self.local_schema_vars[schema_name] + for field in fields: keys.add(f"{prefix}.step.{step_id}.data.{field}") + for section_id in sections: + keys.add(f"{prefix}.step.{step_id}.sections.{section_id}") for field in inline_schema_fields: keys.add(f"{prefix}.step.{step_id}.data.{field}") + for section_id in inline_sections: + keys.add(f"{prefix}.step.{step_id}.sections.{section_id}") def _handle_abort(self, node: ast.Call, prefix: str) -> None: for kw in node.keywords: From ba84b1fa381a3eece602e7333f24c2f9872fa443 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Thu, 18 Dec 2025 13:50:34 +0100 Subject: [PATCH 080/200] Update changelog for translation coverage --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a48ae28d..3537f588 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,8 @@ while surfacing consistent failures. - Reformatted the codebase with Black and Ruff to keep imports and styling consistent with repository standards. +- Expanded translation coverage tests to include section titles and service + metadata keys, ensuring locales stay aligned with `en.json`. ## [1.9.0-alpha1] - 2025-12-11 ### Changed From f5d8747155f7b73ed8d212a3b4ed91a9209cc50b Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Fri, 19 Dec 2025 08:20:30 +0100 Subject: [PATCH 081/200] Update const.py --- custom_components/pollenlevels/const.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/custom_components/pollenlevels/const.py b/custom_components/pollenlevels/const.py index 3823b339..b62bd9d8 100644 --- a/custom_components/pollenlevels/const.py +++ b/custom_components/pollenlevels/const.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import Any # Define constants for Pollen Levels integration @@ -34,7 +36,7 @@ ) # Allowed values for create_forecast_sensors selector -FORECAST_SENSORS_CHOICES = ["none", "D+1", "D+1+2"] +FORECAST_SENSORS_CHOICES: list[str] = ["none", "D+1", "D+1+2"] def is_invalid_api_key_message(message: str | None) -> bool: From 5fe6d5b6643de0274b083dc1d1592b3c553f16db Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Fri, 19 Dec 2025 08:21:01 +0100 Subject: [PATCH 082/200] Update pyproject.toml --- pyproject.toml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index d0a80fad..cfcf2508 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,12 @@ ignore = [ known-first-party = ["custom_components.pollenlevels"] combine-as-imports = true +[tool.pytest.ini_options] +# Use importlib mode to avoid test module name collisions in environments +# where unrelated packages (or plugins) ship a top-level "tests" package. +addopts = ["--import-mode=importlib"] +testpaths = ["tests"] + # --- Optional hardening (uncomment if needed) --- # [tool.ruff] # force-exclude = true From 27d1e409e8856426942fa13fc0a0ac36b50cea1c Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Fri, 19 Dec 2025 08:21:37 +0100 Subject: [PATCH 083/200] Add files via upload --- tests/test_config_flow.py | 39 +++++++++++++++++++++++++++------------ 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index a9ecb617..e27c41be 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -16,19 +16,34 @@ ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) + +def _force_module(name: str, module: ModuleType) -> None: + """Force a module into sys.modules. + + Tests in this repository are designed to run without Home Assistant installed. + In some developer environments, other pytest plugins or pre-imports may have + already inserted modules like `custom_components` or `homeassistant`. + + Using `setdefault()` can then silently keep the pre-existing module, which + may not match the lightweight stubs expected by these tests. + """ + + sys.modules[name] = module + + # --------------------------------------------------------------------------- # Minimal package and dependency stubs so the config flow can be imported. # --------------------------------------------------------------------------- custom_components_pkg = ModuleType("custom_components") custom_components_pkg.__path__ = [str(ROOT / "custom_components")] -sys.modules.setdefault("custom_components", custom_components_pkg) +_force_module("custom_components", custom_components_pkg) pollenlevels_pkg = ModuleType("custom_components.pollenlevels") pollenlevels_pkg.__path__ = [str(ROOT / "custom_components" / "pollenlevels")] -sys.modules.setdefault("custom_components.pollenlevels", pollenlevels_pkg) +_force_module("custom_components.pollenlevels", pollenlevels_pkg) ha_mod = ModuleType("homeassistant") -sys.modules.setdefault("homeassistant", ha_mod) +_force_module("homeassistant", ha_mod) config_entries_mod = ModuleType("homeassistant.config_entries") @@ -46,7 +61,7 @@ def section(key: str, config: _SectionConfig): # noqa: ARG001 data_entry_flow_mod.SectionConfig = _SectionConfig data_entry_flow_mod.section = section -sys.modules.setdefault("homeassistant.data_entry_flow", data_entry_flow_mod) +_force_module("homeassistant.data_entry_flow", data_entry_flow_mod) class _StubConfigFlow: @@ -88,17 +103,17 @@ def __init__(self, data=None, options=None, entry_id="stub-entry"): config_entries_mod.ConfigFlow = _StubConfigFlow config_entries_mod.OptionsFlow = _StubOptionsFlow config_entries_mod.ConfigEntry = _StubConfigEntry -sys.modules.setdefault("homeassistant.config_entries", config_entries_mod) +_force_module("homeassistant.config_entries", config_entries_mod) const_mod = ModuleType("homeassistant.const") const_mod.CONF_LATITUDE = "latitude" const_mod.CONF_LOCATION = "location" const_mod.CONF_LONGITUDE = "longitude" const_mod.CONF_NAME = "name" -sys.modules.setdefault("homeassistant.const", const_mod) +_force_module("homeassistant.const", const_mod) helpers_mod = ModuleType("homeassistant.helpers") -sys.modules.setdefault("homeassistant.helpers", helpers_mod) +_force_module("homeassistant.helpers", helpers_mod) config_validation_mod = ModuleType("homeassistant.helpers.config_validation") @@ -130,7 +145,7 @@ def _longitude(value=None): config_validation_mod.latitude = _latitude config_validation_mod.longitude = _longitude config_validation_mod.string = lambda value=None: value -sys.modules.setdefault("homeassistant.helpers.config_validation", config_validation_mod) +_force_module("homeassistant.helpers.config_validation", config_validation_mod) aiohttp_client_mod = ModuleType("homeassistant.helpers.aiohttp_client") @@ -172,7 +187,7 @@ def get(self, *args, **kwargs) -> _StubResponse: aiohttp_client_mod.async_get_clientsession = lambda hass: _StubSession() -sys.modules.setdefault("homeassistant.helpers.aiohttp_client", aiohttp_client_mod) +_force_module("homeassistant.helpers.aiohttp_client", aiohttp_client_mod) selector_mod = ModuleType("homeassistant.helpers.selector") @@ -253,7 +268,7 @@ def __init__(self, config: _SelectSelectorConfig): selector_mod.SelectSelector = _SelectSelector selector_mod.SelectSelectorConfig = _SelectSelectorConfig selector_mod.SelectSelectorMode = _SelectSelectorMode -sys.modules.setdefault("homeassistant.helpers.selector", selector_mod) +_force_module("homeassistant.helpers.selector", selector_mod) ha_mod.helpers = helpers_mod ha_mod.config_entries = config_entries_mod @@ -273,7 +288,7 @@ def __init__(self, *, total: float | int): aiohttp_mod.ClientError = _StubClientError aiohttp_mod.ClientTimeout = _StubClientTimeout -sys.modules.setdefault("aiohttp", aiohttp_mod) +_force_module("aiohttp", aiohttp_mod) vol_mod = ModuleType("voluptuous") @@ -297,7 +312,7 @@ def __init__(self, schema): vol_mod.Coerce = lambda *args, **kwargs: None vol_mod.Range = lambda *args, **kwargs: None vol_mod.In = lambda *args, **kwargs: None -sys.modules.setdefault("voluptuous", vol_mod) +_force_module("voluptuous", vol_mod) from homeassistant.const import ( CONF_LATITUDE, From 8d2af9a4d97ad0d3a555272cc2ce9b85b93da402 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Fri, 19 Dec 2025 16:24:23 +0100 Subject: [PATCH 084/200] Add files via upload --- tests/__init__.py | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 tests/__init__.py diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..ae4307a2 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,6 @@ +"""Test package marker. + +This file ensures that imports like `import tests.test_config_flow` resolve to the +repository's local `tests` package instead of an unrelated third-party package +named `tests` that may be present in site-packages. +""" From 434231f1a2f7ad92da090c04b910b211e2f7a4bd Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Fri, 19 Dec 2025 22:49:16 +0100 Subject: [PATCH 085/200] Reduce debug logging noise --- CHANGELOG.md | 2 + custom_components/pollenlevels/__init__.py | 3 +- custom_components/pollenlevels/coordinator.py | 515 ++++++++++++++++++ custom_components/pollenlevels/runtime.py | 2 +- custom_components/pollenlevels/sensor.py | 488 +---------------- 5 files changed, 534 insertions(+), 476 deletions(-) create mode 100644 custom_components/pollenlevels/coordinator.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3537f588..6d90057a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,8 @@ - Hardened the runtime pollen client with sanitized `Referer` headers, normalized error parsing, and strict JSON validation to avoid leaking secrets while surfacing consistent failures. +- Reduced debug log volume by summarizing coordinator refreshes and sensor + creation details instead of logging full payloads. - Reformatted the codebase with Black and Ruff to keep imports and styling consistent with repository standards. - Expanded translation coverage tests to include section titles and service diff --git a/custom_components/pollenlevels/__init__.py b/custom_components/pollenlevels/__init__.py index 12eda0b0..c6d6f1e3 100644 --- a/custom_components/pollenlevels/__init__.py +++ b/custom_components/pollenlevels/__init__.py @@ -36,8 +36,9 @@ DOMAIN, normalize_http_referer, ) +from .coordinator import PollenDataUpdateCoordinator from .runtime import PollenLevelsConfigEntry, PollenLevelsRuntimeData -from .sensor import ForecastSensorMode, PollenDataUpdateCoordinator +from .sensor import ForecastSensorMode # Ensure YAML config is entry-only for this domain (no YAML schema). CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) diff --git a/custom_components/pollenlevels/coordinator.py b/custom_components/pollenlevels/coordinator.py new file mode 100644 index 00000000..060ba41a --- /dev/null +++ b/custom_components/pollenlevels/coordinator.py @@ -0,0 +1,515 @@ +"""Pollen data update coordinator.""" + +from __future__ import annotations + +import logging +from datetime import timedelta +from typing import TYPE_CHECKING, Any + +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util + +from .client import GooglePollenApiClient +from .const import ( + DEFAULT_ENTRY_TITLE, + DOMAIN, + MAX_FORECAST_DAYS, + MIN_FORECAST_DAYS, +) +from .util import redact_api_key + +if TYPE_CHECKING: + from homeassistant.core import HomeAssistant + + +_LOGGER = logging.getLogger(__name__) + + +def _normalize_channel(v: Any) -> int | None: + """Normalize a single channel to 0..255 (accept 0..1 or 0..255 inputs). + + Returns None if the value cannot be interpreted as a number. + """ + try: + f = float(v) + except (TypeError, ValueError): + return None + if 0.0 <= f <= 1.0: + f *= 255.0 + return max(0, min(255, int(round(f)))) + + +def _rgb_from_api(color: dict[str, Any] | None) -> tuple[int, int, int] | None: + """Build an (R, G, B) tuple from API color dict. + + Rules: + - If color is not a dict, or an empty dict, or has no numeric channels at all, + return None (meaning "no color provided by API"). + - If only some channels are present, missing ones are treated as 0 (black baseline) + but ONLY when at least one channel exists. This preserves partial colors like + {green, blue} without inventing a color for {}. + """ + if not isinstance(color, dict) or not color: + return None + + # Check if any of the channels is actually provided as numeric + has_any_channel = any( + isinstance(color.get(k), (int, float)) for k in ("red", "green", "blue") + ) + if not has_any_channel: + return None + + r = _normalize_channel(color.get("red")) + g = _normalize_channel(color.get("green")) + b = _normalize_channel(color.get("blue")) + + # If all channels are None, treat as no color + if r is None and g is None and b is None: + return None + + # Replace missing channels with 0 (only when at least one exists) + return (r or 0, g or 0, b or 0) + + +def _rgb_to_hex_triplet(rgb: tuple[int, int, int] | None) -> str | None: + """Convert (R,G,B) 0..255 to #RRGGBB.""" + if rgb is None: + return None + r, g, b = rgb + return f"#{r:02X}{g:02X}{b:02X}" + + +class PollenDataUpdateCoordinator(DataUpdateCoordinator): + """Coordinate pollen data fetch with forecast support for TYPES and PLANTS.""" + + def __init__( + self, + hass: HomeAssistant, + api_key: str, + lat: float, + lon: float, + hours: int, + language: str | None, + entry_id: str, + forecast_days: int, + create_d1: bool, + create_d2: bool, + client: GooglePollenApiClient, + entry_title: str = DEFAULT_ENTRY_TITLE, + ): + """Initialize coordinator with configuration and interval.""" + super().__init__( + hass, + _LOGGER, + name=f"{DOMAIN}_{entry_id}", + update_interval=timedelta(hours=hours), + ) + self.api_key = api_key + self.lat = lat + self.lon = lon + + # Normalize language once at runtime: + # - Trim whitespace + # - Use None if empty after normalization (skip sending languageCode) + if isinstance(language, str): + language = language.strip() + self.language = language if language else None + + self.entry_id = entry_id + self.entry_title = entry_title or DEFAULT_ENTRY_TITLE + # Clamp defensively for legacy/manual entries to supported range. + self.forecast_days = max( + MIN_FORECAST_DAYS, min(MAX_FORECAST_DAYS, int(forecast_days)) + ) + self.create_d1 = create_d1 + self.create_d2 = create_d2 + self._client = client + + self.data: dict[str, dict] = {} + self.last_updated = None + + # ------------------------------ + # DRY helper for forecast attrs + # ------------------------------ + def _process_forecast_attributes( + self, base: dict[str, Any], forecast_list: list[dict[str, Any]] + ) -> dict[str, Any]: + """Attach common forecast attributes to a base sensor dict. + + This keeps TYPE and PLANT processing consistent without duplicating code. + + Adds: + - 'forecast' list + - Convenience: tomorrow_* / d2_* + - Derived: trend, expected_peak + + Does NOT touch per-day TYPE sensor creation (kept elsewhere). + """ + base["forecast"] = forecast_list + + def _set_convenience(prefix: str, off: int) -> None: + f = next((d for d in forecast_list if d["offset"] == off), None) + base[f"{prefix}_has_index"] = f.get("has_index") if f else False + base[f"{prefix}_value"] = ( + f.get("value") if f and f.get("has_index") else None + ) + base[f"{prefix}_category"] = ( + f.get("category") if f and f.get("has_index") else None + ) + base[f"{prefix}_description"] = ( + f.get("description") if f and f.get("has_index") else None + ) + base[f"{prefix}_color_hex"] = ( + f.get("color_hex") if f and f.get("has_index") else None + ) + + _set_convenience("tomorrow", 1) + _set_convenience("d2", 2) + + # Trend (today vs tomorrow) + now_val = base.get("value") + tomorrow_val = base.get("tomorrow_value") + if isinstance(now_val, (int, float)) and isinstance(tomorrow_val, (int, float)): + if tomorrow_val > now_val: + base["trend"] = "up" + elif tomorrow_val < now_val: + base["trend"] = "down" + else: + base["trend"] = "flat" + else: + base["trend"] = None + + # Expected peak (excluding today) + peak = None + for f in forecast_list: + if f.get("has_index") and isinstance(f.get("value"), (int, float)): + if peak is None or f["value"] > peak["value"]: + peak = f + base["expected_peak"] = ( + { + "offset": peak["offset"], + "date": peak["date"], + "value": peak["value"], + "category": peak["category"], + } + if peak + else None + ) + return base + + async def _async_update_data(self): + """Fetch pollen data and extract sensors for current day and forecast.""" + try: + payload = await self._client.async_fetch_pollen_data( + latitude=self.lat, + longitude=self.lon, + days=self.forecast_days, + language_code=self.language, + ) + except ConfigEntryAuthFailed: + raise + except UpdateFailed: + raise + except Exception as err: # Keep previous behavior for unexpected errors + msg = redact_api_key(err, self.api_key) + _LOGGER.error("Pollen API error: %s", msg) + raise UpdateFailed(msg) from err + + new_data: dict[str, dict] = {} + + # region + if region := payload.get("regionCode"): + new_data["region"] = {"source": "meta", "value": region} + + daily: list[dict] = payload.get("dailyInfo") or [] + if not daily: + self.data = new_data + self.last_updated = dt_util.utcnow() + return self.data + + # date (today) + first_day = daily[0] + date_obj = first_day.get("date", {}) or {} + if all(k in date_obj for k in ("year", "month", "day")): + new_data["date"] = { + "source": "meta", + "value": f"{date_obj['year']:04d}-{date_obj['month']:02d}-{date_obj['day']:02d}", + } + + # collect type codes found in any day + type_codes: set[str] = set() + for day in daily: + for item in day.get("pollenTypeInfo", []) or []: + code = (item.get("code") or "").upper() + if code: + type_codes.add(code) + + def _find_type(day: dict, code: str) -> dict | None: + """Find a pollen TYPE entry by code inside a day's 'pollenTypeInfo'.""" + for item in day.get("pollenTypeInfo", []) or []: + if (item.get("code") or "").upper() == code: + return item + return None + + def _find_plant(day: dict, code: str) -> dict | None: + """Find a PLANT entry by code inside a day's 'plantInfo'.""" + for item in day.get("plantInfo", []) or []: + if (item.get("code") or "") == code: + return item + return None + + # Current-day TYPES + for tcode in type_codes: + titem = _find_type(first_day, tcode) or {} + idx = (titem.get("indexInfo") or {}) if isinstance(titem, dict) else {} + rgb = _rgb_from_api(idx.get("color")) + key = f"type_{tcode.lower()}" + new_data[key] = { + "source": "type", + "value": idx.get("value"), + "category": idx.get("category"), + "displayName": titem.get("displayName", tcode), + "inSeason": titem.get("inSeason"), + "description": idx.get("indexDescription"), + "advice": titem.get("healthRecommendations"), + "color_hex": _rgb_to_hex_triplet(rgb), + "color_rgb": list(rgb) if rgb is not None else None, + "color_raw": ( + idx.get("color") if isinstance(idx.get("color"), dict) else None + ), + } + + # Current-day PLANTS + for pitem in first_day.get("plantInfo", []) or []: + code = pitem.get("code") + # Safety: skip plants without a stable 'code' to avoid duplicate 'plants_' keys + # and silent overwrites. This is robust and avoids creating unstable entities. + if not code: + continue + idx = pitem.get("indexInfo", {}) or {} + desc = pitem.get("plantDescription", {}) or {} + rgb = _rgb_from_api(idx.get("color")) + key = f"plants_{(code or '').lower()}" + new_data[key] = { + "source": "plant", + "value": idx.get("value"), + "category": idx.get("category"), + "displayName": pitem.get("displayName", code), + "code": code, + "inSeason": pitem.get("inSeason"), + "type": desc.get("type"), + "family": desc.get("family"), + "season": desc.get("season"), + "cross_reaction": desc.get("crossReaction"), + "description": idx.get("indexDescription"), + "advice": pitem.get("healthRecommendations"), + "color_hex": _rgb_to_hex_triplet(rgb), + "color_rgb": list(rgb) if rgb is not None else None, + "color_raw": ( + idx.get("color") if isinstance(idx.get("color"), dict) else None + ), + "picture": desc.get("picture"), + "picture_closeup": desc.get("pictureCloseup"), + } + + # Forecast for TYPES + def _extract_day_info(day: dict) -> tuple[str | None, dict | None]: + d = day.get("date") or {} + if not all(k in d for k in ("year", "month", "day")): + return None, None + return f"{d['year']:04d}-{d['month']:02d}-{d['day']:02d}", d + + for tcode in type_codes: + type_key = f"type_{tcode.lower()}" + existing = new_data.get(type_key) + needs_skeleton = not existing or ( + existing.get("source") == "type" + and existing.get("value") is None + and existing.get("category") is None + and existing.get("description") is None + ) + base = existing or {} + if needs_skeleton: + base = { + "source": "type", + "displayName": tcode, + "inSeason": None, + "advice": None, + "value": None, + "category": None, + "description": None, + "color_hex": None, + "color_rgb": None, + "color_raw": None, + } + + candidate = None + for day_data in daily: + candidate = _find_type(day_data, tcode) + if isinstance(candidate, dict): + base["displayName"] = candidate.get("displayName", tcode) + base["inSeason"] = candidate.get("inSeason") + base["advice"] = candidate.get("healthRecommendations") + break + forecast_list: list[dict[str, Any]] = [] + for offset, day in enumerate(daily[1:], start=1): + if offset >= self.forecast_days: + break + date_str, _ = _extract_day_info(day) + item = _find_type(day, tcode) or {} + idx = item.get("indexInfo") if isinstance(item, dict) else None + has_index = isinstance(idx, dict) + rgb = _rgb_from_api(idx.get("color")) if has_index else None + forecast_list.append( + { + "offset": offset, + "date": date_str, + "has_index": has_index, + "value": idx.get("value") if has_index else None, + "category": idx.get("category") if has_index else None, + "description": ( + idx.get("indexDescription") if has_index else None + ), + "color_hex": _rgb_to_hex_triplet(rgb) if has_index else None, + "color_rgb": ( + list(rgb) if (has_index and rgb is not None) else None + ), + "color_raw": ( + idx.get("color") + if has_index and isinstance(idx.get("color"), dict) + else None + ), + } + ) + # Attach common forecast attributes (convenience, trend, expected_peak) + base = self._process_forecast_attributes(base, forecast_list) + new_data[type_key] = base + + # Optional per-day sensors (only if requested and day exists) + def _add_day_sensor( + off: int, + *, + _forecast_list=forecast_list, + _base=base, + _tcode=tcode, + _type_key=type_key, + ) -> None: + """Create a per-day type sensor for a given offset.""" + f = next((d for d in _forecast_list if d["offset"] == off), None) + if not f: + return + + # Use day-specific 'inSeason' and 'advice' from the forecast day. + try: + day_obj = daily[off] + except (IndexError, TypeError): + day_obj = None + day_item = _find_type(day_obj, _tcode) if day_obj else None + day_in_season = ( + day_item.get("inSeason") if isinstance(day_item, dict) else None + ) + day_advice = ( + day_item.get("healthRecommendations") + if isinstance(day_item, dict) + else None + ) + + dname = f"{_base.get('displayName', _tcode)} (D+{off})" + new_data[f"{_type_key}_d{off}"] = { + "source": "type", + "displayName": dname, + "value": f.get("value") if f.get("has_index") else None, + "category": f.get("category") if f.get("has_index") else None, + "description": f.get("description") if f.get("has_index") else None, + "inSeason": day_in_season, + "advice": day_advice, + "color_hex": f.get("color_hex"), + "color_rgb": f.get("color_rgb"), + "color_raw": f.get("color_raw"), + "date": f.get("date"), + "has_index": f.get("has_index"), + } + + if self.create_d1: + _add_day_sensor(1) + if self.create_d2: + _add_day_sensor(2) + + # Forecast for PLANTS (attributes only; no per-day plant sensors) + for key, base in list(new_data.items()): + if base.get("source") != "plant": + continue + pcode = base.get("code") + if not pcode: + # Safety: skip if for some reason code is missing + continue + + forecast_list: list[dict[str, Any]] = [] + for offset, day in enumerate(daily[1:], start=1): + if offset >= self.forecast_days: + break + date_str, _ = _extract_day_info(day) + item = _find_plant(day, pcode) or {} + idx = item.get("indexInfo") if isinstance(item, dict) else None + has_index = isinstance(idx, dict) + rgb = _rgb_from_api(idx.get("color")) if has_index else None + forecast_list.append( + { + "offset": offset, + "date": date_str, + "has_index": has_index, + "value": idx.get("value") if has_index else None, + "category": idx.get("category") if has_index else None, + "description": ( + idx.get("indexDescription") if has_index else None + ), + "color_hex": _rgb_to_hex_triplet(rgb) if has_index else None, + "color_rgb": ( + list(rgb) if (has_index and rgb is not None) else None + ), + "color_raw": ( + idx.get("color") + if has_index and isinstance(idx.get("color"), dict) + else None + ), + } + ) + + # Attach common forecast attributes (convenience, trend, expected_peak) + base = self._process_forecast_attributes(base, forecast_list) + new_data[key] = base + + self.data = new_data + self.last_updated = dt_util.utcnow() + if _LOGGER.isEnabledFor(logging.DEBUG): + total = len(new_data) + types = 0 + plants = 0 + meta = 0 + per_day = 0 + for key, value in new_data.items(): + source = value.get("source") + if source == "type": + types += 1 + elif source == "plant": + plants += 1 + else: + meta += 1 + if key.endswith(("_d1", "_d2")): + per_day += 1 + updated = self.last_updated.isoformat() if self.last_updated else "unknown" + _LOGGER.debug( + "Update complete: entries=%d types=%d plants=%d meta=%d per_day=%d " + "forecast_days=%d d1=%s d2=%s updated=%s", + total, + types, + plants, + meta, + per_day, + self.forecast_days, + self.create_d1, + self.create_d2, + updated, + ) + return self.data diff --git a/custom_components/pollenlevels/runtime.py b/custom_components/pollenlevels/runtime.py index 13082e4a..fbca54c6 100644 --- a/custom_components/pollenlevels/runtime.py +++ b/custom_components/pollenlevels/runtime.py @@ -7,7 +7,7 @@ if TYPE_CHECKING: from .client import GooglePollenApiClient - from .sensor import PollenDataUpdateCoordinator + from .coordinator import PollenDataUpdateCoordinator @dataclass(slots=True) diff --git a/custom_components/pollenlevels/sensor.py b/custom_components/pollenlevels/sensor.py index dd862433..33690391 100644 --- a/custom_components/pollenlevels/sensor.py +++ b/custom_components/pollenlevels/sensor.py @@ -14,7 +14,7 @@ import asyncio import logging from collections.abc import Awaitable -from datetime import date, timedelta # Added `date` for DATE device class native_value +from datetime import date # Added `date` for DATE device class native_value from enum import StrEnum from typing import TYPE_CHECKING, Any, cast @@ -25,21 +25,17 @@ SensorStateClass, ) from homeassistant.const import ATTR_ATTRIBUTION -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import entity_registry as er # entity-registry cleanup from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, ) -from homeassistant.util import dt as dt_util if TYPE_CHECKING: from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .client import GooglePollenApiClient from .const import ( CONF_API_KEY, CONF_FORECAST_DAYS, @@ -50,11 +46,9 @@ DEFAULT_FORECAST_DAYS, DEFAULT_UPDATE_INTERVAL, DOMAIN, - MAX_FORECAST_DAYS, - MIN_FORECAST_DAYS, ) +from .coordinator import PollenDataUpdateCoordinator from .runtime import PollenLevelsConfigEntry, PollenLevelsRuntimeData -from .util import redact_api_key _LOGGER = logging.getLogger(__name__) @@ -87,60 +81,6 @@ class ForecastSensorMode(StrEnum): D1_D2 = "D+1+2" -def _normalize_channel(v: Any) -> int | None: - """Normalize a single channel to 0..255 (accept 0..1 or 0..255 inputs). - - Returns None if the value cannot be interpreted as a number. - """ - try: - f = float(v) - except (TypeError, ValueError): - return None - if 0.0 <= f <= 1.0: - f *= 255.0 - return max(0, min(255, int(round(f)))) - - -def _rgb_from_api(color: dict[str, Any] | None) -> tuple[int, int, int] | None: - """Build an (R, G, B) tuple from API color dict. - - Rules: - - If color is not a dict, or an empty dict, or has no numeric channels at all, - return None (meaning "no color provided by API"). - - If only some channels are present, missing ones are treated as 0 (black baseline) - but ONLY when at least one channel exists. This preserves partial colors like - {green, blue} without inventing a color for {}. - """ - if not isinstance(color, dict) or not color: - return None - - # Check if any of the channels is actually provided as numeric - has_any_channel = any( - isinstance(color.get(k), (int, float)) for k in ("red", "green", "blue") - ) - if not has_any_channel: - return None - - r = _normalize_channel(color.get("red")) - g = _normalize_channel(color.get("green")) - b = _normalize_channel(color.get("blue")) - - # If all channels are None, treat as no color - if r is None and g is None and b is None: - return None - - # Replace missing channels with 0 (only when at least one exists) - return (r or 0, g or 0, b or 0) - - -def _rgb_to_hex_triplet(rgb: tuple[int, int, int] | None) -> str | None: - """Convert (R,G,B) 0..255 to #RRGGBB.""" - if rgb is None: - return None - r, g, b = rgb - return f"#{r:02X}{g:02X}{b:02X}" - - async def _cleanup_per_day_entities( hass: HomeAssistant, entry_id: str, allow_d1: bool, allow_d2: bool ) -> int: @@ -248,418 +188,18 @@ async def async_setup_entry( ] ) - _LOGGER.debug( - "Creating %d sensors: %s", - len(sensors), - [getattr(s, "unique_id", None) for s in sensors], - ) - async_add_entities(sensors, True) - - -class PollenDataUpdateCoordinator(DataUpdateCoordinator): - """Coordinate pollen data fetch with forecast support for TYPES and PLANTS.""" - - def __init__( - self, - hass: HomeAssistant, - api_key: str, - lat: float, - lon: float, - hours: int, - language: str | None, - entry_id: str, - forecast_days: int, - create_d1: bool, - create_d2: bool, - client: GooglePollenApiClient, - entry_title: str = DEFAULT_ENTRY_TITLE, - ): - """Initialize coordinator with configuration and interval.""" - super().__init__( - hass, - _LOGGER, - name=f"{DOMAIN}_{entry_id}", - update_interval=timedelta(hours=hours), - ) - self.api_key = api_key - self.lat = lat - self.lon = lon - - # Normalize language once at runtime: - # - Trim whitespace - # - Use None if empty after normalization (skip sending languageCode) - if isinstance(language, str): - language = language.strip() - self.language = language if language else None - - self.entry_id = entry_id - self.entry_title = entry_title or DEFAULT_ENTRY_TITLE - # Clamp defensively for legacy/manual entries to supported range. - self.forecast_days = max( - MIN_FORECAST_DAYS, min(MAX_FORECAST_DAYS, int(forecast_days)) + if _LOGGER.isEnabledFor(logging.DEBUG): + ids = [getattr(s, "unique_id", None) for s in sensors] + preview = ids[:10] + extra = max(0, len(ids) - len(preview)) + suffix = f", +{extra} more" if extra else "" + _LOGGER.debug( + "Creating %d sensors (preview=%s%s)", + len(ids), + preview, + suffix, ) - self.create_d1 = create_d1 - self.create_d2 = create_d2 - self._client = client - - self.data: dict[str, dict] = {} - self.last_updated = None - - # ------------------------------ - # DRY helper for forecast attrs - # ------------------------------ - def _process_forecast_attributes( - self, base: dict[str, Any], forecast_list: list[dict[str, Any]] - ) -> dict[str, Any]: - """Attach common forecast attributes to a base sensor dict. - - This keeps TYPE and PLANT processing consistent without duplicating code. - - Adds: - - 'forecast' list - - Convenience: tomorrow_* / d2_* - - Derived: trend, expected_peak - - Does NOT touch per-day TYPE sensor creation (kept elsewhere). - """ - base["forecast"] = forecast_list - - def _set_convenience(prefix: str, off: int) -> None: - f = next((d for d in forecast_list if d["offset"] == off), None) - base[f"{prefix}_has_index"] = f.get("has_index") if f else False - base[f"{prefix}_value"] = ( - f.get("value") if f and f.get("has_index") else None - ) - base[f"{prefix}_category"] = ( - f.get("category") if f and f.get("has_index") else None - ) - base[f"{prefix}_description"] = ( - f.get("description") if f and f.get("has_index") else None - ) - base[f"{prefix}_color_hex"] = ( - f.get("color_hex") if f and f.get("has_index") else None - ) - - _set_convenience("tomorrow", 1) - _set_convenience("d2", 2) - - # Trend (today vs tomorrow) - now_val = base.get("value") - tomorrow_val = base.get("tomorrow_value") - if isinstance(now_val, (int, float)) and isinstance(tomorrow_val, (int, float)): - if tomorrow_val > now_val: - base["trend"] = "up" - elif tomorrow_val < now_val: - base["trend"] = "down" - else: - base["trend"] = "flat" - else: - base["trend"] = None - - # Expected peak (excluding today) - peak = None - for f in forecast_list: - if f.get("has_index") and isinstance(f.get("value"), (int, float)): - if peak is None or f["value"] > peak["value"]: - peak = f - base["expected_peak"] = ( - { - "offset": peak["offset"], - "date": peak["date"], - "value": peak["value"], - "category": peak["category"], - } - if peak - else None - ) - return base - - async def _async_update_data(self): - """Fetch pollen data and extract sensors for current day and forecast.""" - try: - payload = await self._client.async_fetch_pollen_data( - latitude=self.lat, - longitude=self.lon, - days=self.forecast_days, - language_code=self.language, - ) - except ConfigEntryAuthFailed: - raise - except UpdateFailed: - raise - except Exception as err: # Keep previous behavior for unexpected errors - msg = redact_api_key(err, self.api_key) - _LOGGER.error("Pollen API error: %s", msg) - raise UpdateFailed(msg) from err - - new_data: dict[str, dict] = {} - - # region - if region := payload.get("regionCode"): - new_data["region"] = {"source": "meta", "value": region} - - daily: list[dict] = payload.get("dailyInfo") or [] - if not daily: - self.data = new_data - self.last_updated = dt_util.utcnow() - return self.data - - # date (today) - first_day = daily[0] - date_obj = first_day.get("date", {}) or {} - if all(k in date_obj for k in ("year", "month", "day")): - new_data["date"] = { - "source": "meta", - "value": f"{date_obj['year']:04d}-{date_obj['month']:02d}-{date_obj['day']:02d}", - } - - # collect type codes found in any day - type_codes: set[str] = set() - for day in daily: - for item in day.get("pollenTypeInfo", []) or []: - code = (item.get("code") or "").upper() - if code: - type_codes.add(code) - - def _find_type(day: dict, code: str) -> dict | None: - """Find a pollen TYPE entry by code inside a day's 'pollenTypeInfo'.""" - for item in day.get("pollenTypeInfo", []) or []: - if (item.get("code") or "").upper() == code: - return item - return None - - def _find_plant(day: dict, code: str) -> dict | None: - """Find a PLANT entry by code inside a day's 'plantInfo'.""" - for item in day.get("plantInfo", []) or []: - if (item.get("code") or "") == code: - return item - return None - - # Current-day TYPES - for tcode in type_codes: - titem = _find_type(first_day, tcode) or {} - idx = (titem.get("indexInfo") or {}) if isinstance(titem, dict) else {} - rgb = _rgb_from_api(idx.get("color")) - key = f"type_{tcode.lower()}" - new_data[key] = { - "source": "type", - "value": idx.get("value"), - "category": idx.get("category"), - "displayName": titem.get("displayName", tcode), - "inSeason": titem.get("inSeason"), - "description": idx.get("indexDescription"), - "advice": titem.get("healthRecommendations"), - "color_hex": _rgb_to_hex_triplet(rgb), - "color_rgb": list(rgb) if rgb is not None else None, - "color_raw": ( - idx.get("color") if isinstance(idx.get("color"), dict) else None - ), - } - - # Current-day PLANTS - for pitem in first_day.get("plantInfo", []) or []: - code = pitem.get("code") - # Safety: skip plants without a stable 'code' to avoid duplicate 'plants_' keys - # and silent overwrites. This is robust and avoids creating unstable entities. - if not code: - continue - idx = pitem.get("indexInfo", {}) or {} - desc = pitem.get("plantDescription", {}) or {} - rgb = _rgb_from_api(idx.get("color")) - key = f"plants_{(code or '').lower()}" - new_data[key] = { - "source": "plant", - "value": idx.get("value"), - "category": idx.get("category"), - "displayName": pitem.get("displayName", code), - "code": code, - "inSeason": pitem.get("inSeason"), - "type": desc.get("type"), - "family": desc.get("family"), - "season": desc.get("season"), - "cross_reaction": desc.get("crossReaction"), - "description": idx.get("indexDescription"), - "advice": pitem.get("healthRecommendations"), - "color_hex": _rgb_to_hex_triplet(rgb), - "color_rgb": list(rgb) if rgb is not None else None, - "color_raw": ( - idx.get("color") if isinstance(idx.get("color"), dict) else None - ), - "picture": desc.get("picture"), - "picture_closeup": desc.get("pictureCloseup"), - } - - # Forecast for TYPES - def _extract_day_info(day: dict) -> tuple[str | None, dict | None]: - d = day.get("date") or {} - if not all(k in d for k in ("year", "month", "day")): - return None, None - return f"{d['year']:04d}-{d['month']:02d}-{d['day']:02d}", d - - for tcode in type_codes: - type_key = f"type_{tcode.lower()}" - existing = new_data.get(type_key) - needs_skeleton = not existing or ( - existing.get("source") == "type" - and existing.get("value") is None - and existing.get("category") is None - and existing.get("description") is None - ) - base = existing or {} - if needs_skeleton: - base = { - "source": "type", - "displayName": tcode, - "inSeason": None, - "advice": None, - "value": None, - "category": None, - "description": None, - "color_hex": None, - "color_rgb": None, - "color_raw": None, - } - - candidate = None - for day_data in daily: - candidate = _find_type(day_data, tcode) - if isinstance(candidate, dict): - base["displayName"] = candidate.get("displayName", tcode) - base["inSeason"] = candidate.get("inSeason") - base["advice"] = candidate.get("healthRecommendations") - break - forecast_list: list[dict[str, Any]] = [] - for offset, day in enumerate(daily[1:], start=1): - if offset >= self.forecast_days: - break - date_str, _ = _extract_day_info(day) - item = _find_type(day, tcode) or {} - idx = item.get("indexInfo") if isinstance(item, dict) else None - has_index = isinstance(idx, dict) - rgb = _rgb_from_api(idx.get("color")) if has_index else None - forecast_list.append( - { - "offset": offset, - "date": date_str, - "has_index": has_index, - "value": idx.get("value") if has_index else None, - "category": idx.get("category") if has_index else None, - "description": ( - idx.get("indexDescription") if has_index else None - ), - "color_hex": _rgb_to_hex_triplet(rgb) if has_index else None, - "color_rgb": ( - list(rgb) if (has_index and rgb is not None) else None - ), - "color_raw": ( - idx.get("color") - if has_index and isinstance(idx.get("color"), dict) - else None - ), - } - ) - # Attach common forecast attributes (convenience, trend, expected_peak) - base = self._process_forecast_attributes(base, forecast_list) - new_data[type_key] = base - - # Optional per-day sensors (only if requested and day exists) - def _add_day_sensor( - off: int, - *, - _forecast_list=forecast_list, - _base=base, - _tcode=tcode, - _type_key=type_key, - ) -> None: - """Create a per-day type sensor for a given offset.""" - f = next((d for d in _forecast_list if d["offset"] == off), None) - if not f: - return - - # Use day-specific 'inSeason' and 'advice' from the forecast day. - try: - day_obj = daily[off] - except (IndexError, TypeError): - day_obj = None - day_item = _find_type(day_obj, _tcode) if day_obj else None - day_in_season = ( - day_item.get("inSeason") if isinstance(day_item, dict) else None - ) - day_advice = ( - day_item.get("healthRecommendations") - if isinstance(day_item, dict) - else None - ) - - dname = f"{_base.get('displayName', _tcode)} (D+{off})" - new_data[f"{_type_key}_d{off}"] = { - "source": "type", - "displayName": dname, - "value": f.get("value") if f.get("has_index") else None, - "category": f.get("category") if f.get("has_index") else None, - "description": f.get("description") if f.get("has_index") else None, - "inSeason": day_in_season, - "advice": day_advice, - "color_hex": f.get("color_hex"), - "color_rgb": f.get("color_rgb"), - "color_raw": f.get("color_raw"), - "date": f.get("date"), - "has_index": f.get("has_index"), - } - - if self.create_d1: - _add_day_sensor(1) - if self.create_d2: - _add_day_sensor(2) - - # Forecast for PLANTS (attributes only; no per-day plant sensors) - for key, base in list(new_data.items()): - if base.get("source") != "plant": - continue - pcode = base.get("code") - if not pcode: - # Safety: skip if for some reason code is missing - continue - - forecast_list: list[dict[str, Any]] = [] - for offset, day in enumerate(daily[1:], start=1): - if offset >= self.forecast_days: - break - date_str, _ = _extract_day_info(day) - item = _find_plant(day, pcode) or {} - idx = item.get("indexInfo") if isinstance(item, dict) else None - has_index = isinstance(idx, dict) - rgb = _rgb_from_api(idx.get("color")) if has_index else None - forecast_list.append( - { - "offset": offset, - "date": date_str, - "has_index": has_index, - "value": idx.get("value") if has_index else None, - "category": idx.get("category") if has_index else None, - "description": ( - idx.get("indexDescription") if has_index else None - ), - "color_hex": _rgb_to_hex_triplet(rgb) if has_index else None, - "color_rgb": ( - list(rgb) if (has_index and rgb is not None) else None - ), - "color_raw": ( - idx.get("color") - if has_index and isinstance(idx.get("color"), dict) - else None - ), - } - ) - - # Attach common forecast attributes (convenience, trend, expected_peak) - base = self._process_forecast_attributes(base, forecast_list) - new_data[key] = base - - self.data = new_data - self.last_updated = dt_util.utcnow() - _LOGGER.debug("Updated data: %s", self.data) - return self.data + async_add_entities(sensors, True) class PollenSensor(CoordinatorEntity, SensorEntity): From 7a93fb7c9f7c406863c28eaa66ce28c6f2660d3e Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Fri, 19 Dec 2025 23:06:37 +0100 Subject: [PATCH 086/200] Update custom_components/pollenlevels/coordinator.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- custom_components/pollenlevels/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/pollenlevels/coordinator.py b/custom_components/pollenlevels/coordinator.py index 060ba41a..bc5cf6ad 100644 --- a/custom_components/pollenlevels/coordinator.py +++ b/custom_components/pollenlevels/coordinator.py @@ -290,7 +290,7 @@ def _find_plant(day: dict, code: str) -> dict | None: idx = pitem.get("indexInfo", {}) or {} desc = pitem.get("plantDescription", {}) or {} rgb = _rgb_from_api(idx.get("color")) - key = f"plants_{(code or '').lower()}" + key = f"plants_{code.lower()}" new_data[key] = { "source": "plant", "value": idx.get("value"), From 42373b6a6f16018a8a11b7438164041758e52222 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Fri, 19 Dec 2025 23:12:58 +0100 Subject: [PATCH 087/200] Update test_sensor.py --- tests/test_sensor.py | 109 ++++++++++++++++++++++--------------------- 1 file changed, 57 insertions(+), 52 deletions(-) diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 6bc6d394..b84ba2d3 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -261,7 +261,7 @@ def _load_module(module_name: str, relative_path: str): return module -_load_module("custom_components.pollenlevels.const", "const.py") +const = _load_module("custom_components.pollenlevels.const", "const.py") sensor = _load_module("custom_components.pollenlevels.sensor", "sensor.py") client_mod = importlib.import_module("custom_components.pollenlevels.client") @@ -346,39 +346,41 @@ def __init__(self, sequence: list[ResponseSpec | Exception]): self.calls = 0 def get(self, *_args, **_kwargs): + """Return the next fake response in the sequence.""" + if self.calls >= len(self.sequence): raise AssertionError( - "SequenceSession exhausted; no more responses " - f"(calls={self.calls}, sequence_len={len(self.sequence)})." + "SequenceSession exhausted; no more responses configured" ) + item = self.sequence[self.calls] self.calls += 1 if isinstance(item, Exception): raise item - return FakeResponse( - item.payload, status=item.status, headers=item.headers or {} - ) + return FakeResponse(item.payload, status=item.status, headers=item.headers) -class RegistryEntry: - """Simple stub representing an Entity Registry entry.""" +class RegistryEntry(NamedTuple): + """Entity registry entry stub.""" - def __init__(self, unique_id: str, entity_id: str) -> None: - self.unique_id = unique_id - self.entity_id = entity_id - self.domain = "sensor" - self.platform = sensor.DOMAIN + entry_id: str + entity_id: str class RegistryStub: - """Minimal async Entity Registry stub capturing removals.""" + """Stubbed entity registry that records removals.""" - def __init__(self, entries: list[RegistryEntry]) -> None: - self.entries = entries + def __init__(self, entries: list[RegistryEntry], entry_id: str) -> None: + self._entries = entries + self._entry_id = entry_id self.removals: list[str] = [] + def async_entries_for_config_entry(self, _registry, entry_id: str): + assert entry_id == self._entry_id + return [types.SimpleNamespace(entity_id=e.entity_id) for e in self._entries] + async def async_remove(self, entity_id: str) -> None: self.removals.append(entity_id) @@ -389,14 +391,17 @@ def _setup_registry_stub( *, entry_id: str, ) -> RegistryStub: - registry = RegistryStub(entries) + """Patch the sensor module's entity registry helpers for cleanup tests.""" + + registry = RegistryStub(entries, entry_id=entry_id) - monkeypatch.setattr(sensor.er, "async_get", lambda _hass: registry) + monkeypatch.setattr(sensor.er, "async_get", lambda hass: registry) monkeypatch.setattr( sensor.er, "async_entries_for_config_entry", - lambda reg, eid: entries if reg is registry and eid == entry_id else [], + registry.async_entries_for_config_entry, ) + monkeypatch.setattr(sensor.er, "async_remove", registry.async_remove) return registry @@ -430,7 +435,7 @@ def test_type_sensor_preserves_source_with_single_day( } fake_session = FakeSession(payload) - client = sensor.GooglePollenApiClient(fake_session, "test") + client = client_mod.GooglePollenApiClient(fake_session, "test") loop = asyncio.new_event_loop() hass = DummyHass(loop) @@ -467,7 +472,7 @@ def test_coordinator_clamps_forecast_days_low() -> None: loop = asyncio.new_event_loop() hass = DummyHass(loop) - client = sensor.GooglePollenApiClient(FakeSession({}), "test") + client = client_mod.GooglePollenApiClient(FakeSession({}), "test") try: coordinator = sensor.PollenDataUpdateCoordinator( @@ -486,7 +491,7 @@ def test_coordinator_clamps_forecast_days_low() -> None: finally: loop.close() - assert coordinator.forecast_days == sensor.MIN_FORECAST_DAYS + assert coordinator.forecast_days == const.MIN_FORECAST_DAYS def test_coordinator_clamps_forecast_days_negative() -> None: @@ -494,7 +499,7 @@ def test_coordinator_clamps_forecast_days_negative() -> None: loop = asyncio.new_event_loop() hass = DummyHass(loop) - client = sensor.GooglePollenApiClient(FakeSession({}), "test") + client = client_mod.GooglePollenApiClient(FakeSession({}), "test") try: coordinator = sensor.PollenDataUpdateCoordinator( @@ -513,7 +518,7 @@ def test_coordinator_clamps_forecast_days_negative() -> None: finally: loop.close() - assert coordinator.forecast_days == sensor.MIN_FORECAST_DAYS + assert coordinator.forecast_days == const.MIN_FORECAST_DAYS def test_coordinator_clamps_forecast_days_high() -> None: @@ -521,7 +526,7 @@ def test_coordinator_clamps_forecast_days_high() -> None: loop = asyncio.new_event_loop() hass = DummyHass(loop) - client = sensor.GooglePollenApiClient(FakeSession({}), "test") + client = client_mod.GooglePollenApiClient(FakeSession({}), "test") try: coordinator = sensor.PollenDataUpdateCoordinator( @@ -540,7 +545,7 @@ def test_coordinator_clamps_forecast_days_high() -> None: finally: loop.close() - assert coordinator.forecast_days == sensor.MAX_FORECAST_DAYS + assert coordinator.forecast_days == const.MAX_FORECAST_DAYS def test_coordinator_keeps_forecast_days_within_range() -> None: @@ -548,7 +553,7 @@ def test_coordinator_keeps_forecast_days_within_range() -> None: loop = asyncio.new_event_loop() hass = DummyHass(loop) - client = sensor.GooglePollenApiClient(FakeSession({}), "test") + client = client_mod.GooglePollenApiClient(FakeSession({}), "test") try: coordinator = sensor.PollenDataUpdateCoordinator( @@ -620,7 +625,7 @@ def test_type_sensor_uses_forecast_metadata_when_today_missing( } fake_session = FakeSession(payload) - client = sensor.GooglePollenApiClient(fake_session, "test") + client = client_mod.GooglePollenApiClient(fake_session, "test") loop = asyncio.new_event_loop() hass = DummyHass(loop) @@ -730,7 +735,7 @@ def test_plant_sensor_includes_forecast_attributes( } fake_session = FakeSession(payload) - client = sensor.GooglePollenApiClient(fake_session, "test") + client = client_mod.GooglePollenApiClient(fake_session, "test") loop = asyncio.new_event_loop() hass = DummyHass(loop) @@ -820,7 +825,7 @@ def test_coordinator_raises_auth_failed() -> None: """401 responses trigger ConfigEntryAuthFailed for re-auth flows.""" fake_session = FakeSession({}, status=401) - client = sensor.GooglePollenApiClient(fake_session, "bad") + client = client_mod.GooglePollenApiClient(fake_session, "bad") loop = asyncio.new_event_loop() hass = DummyHass(loop) @@ -839,7 +844,7 @@ def test_coordinator_raises_auth_failed() -> None: ) try: - with pytest.raises(sensor.ConfigEntryAuthFailed): + with pytest.raises(client_mod.ConfigEntryAuthFailed): loop.run_until_complete(coordinator._async_update_data()) finally: loop.close() @@ -849,7 +854,7 @@ def test_coordinator_handles_forbidden() -> None: """403 responses raise UpdateFailed without triggering re-auth.""" fake_session = FakeSession({"error": {"message": "Forbidden"}}, status=403) - client = sensor.GooglePollenApiClient(fake_session, "bad") + client = client_mod.GooglePollenApiClient(fake_session, "bad") loop = asyncio.new_event_loop() hass = DummyHass(loop) @@ -868,7 +873,7 @@ def test_coordinator_handles_forbidden() -> None: ) try: - with pytest.raises(sensor.UpdateFailed): + with pytest.raises(client_mod.UpdateFailed): loop.run_until_complete(coordinator._async_update_data()) finally: loop.close() @@ -879,7 +884,7 @@ def test_coordinator_invalid_key_message_triggers_reauth() -> None: payload = {"error": {"message": "API key not valid. Please pass a valid API key."}} fake_session = FakeSession(payload, status=403) - client = sensor.GooglePollenApiClient(fake_session, "bad") + client = client_mod.GooglePollenApiClient(fake_session, "bad") loop = asyncio.new_event_loop() hass = DummyHass(loop) @@ -898,7 +903,7 @@ def test_coordinator_invalid_key_message_triggers_reauth() -> None: ) try: - with pytest.raises(sensor.ConfigEntryAuthFailed): + with pytest.raises(client_mod.ConfigEntryAuthFailed): loop.run_until_complete(coordinator._async_update_data()) finally: loop.close() @@ -928,10 +933,10 @@ def test_coordinator_retries_then_raises_on_rate_limit( async def _fast_sleep(delay: float) -> None: delays.append(delay) - monkeypatch.setattr(sensor.asyncio, "sleep", _fast_sleep) + monkeypatch.setattr(client_mod.asyncio, "sleep", _fast_sleep) monkeypatch.setattr(client_mod.random, "uniform", lambda *_args, **_kwargs: 0.0) - client = sensor.GooglePollenApiClient(session, "test") + client = client_mod.GooglePollenApiClient(session, "test") loop = asyncio.new_event_loop() hass = DummyHass(loop) @@ -950,7 +955,7 @@ async def _fast_sleep(delay: float) -> None: ) try: - with pytest.raises(sensor.UpdateFailed, match="Quota exceeded"): + with pytest.raises(client_mod.UpdateFailed, match="Quota exceeded"): loop.run_until_complete(coordinator._async_update_data()) finally: loop.close() @@ -982,7 +987,7 @@ def test_coordinator_retry_after_http_date(monkeypatch: pytest.MonkeyPatch) -> N async def _fast_sleep(delay: float) -> None: delays.append(delay) - monkeypatch.setattr(sensor.asyncio, "sleep", _fast_sleep) + monkeypatch.setattr(client_mod.asyncio, "sleep", _fast_sleep) monkeypatch.setattr(client_mod.random, "uniform", lambda *_args, **_kwargs: 0.0) monkeypatch.setattr( client_mod.dt_util, @@ -990,7 +995,7 @@ async def _fast_sleep(delay: float) -> None: lambda: datetime.datetime(2025, 12, 10, 12, 0, 0, tzinfo=datetime.UTC), ) - client = sensor.GooglePollenApiClient(session, "test") + client = client_mod.GooglePollenApiClient(session, "test") loop = asyncio.new_event_loop() hass = DummyHass(loop) @@ -1009,7 +1014,7 @@ async def _fast_sleep(delay: float) -> None: ) try: - with pytest.raises(sensor.UpdateFailed, match="Quota exceeded"): + with pytest.raises(client_mod.UpdateFailed, match="Quota exceeded"): loop.run_until_complete(coordinator._async_update_data()) finally: loop.close() @@ -1031,10 +1036,10 @@ def test_coordinator_retries_then_raises_on_server_errors( async def _fast_sleep(delay: float) -> None: delays.append(delay) - monkeypatch.setattr(sensor.asyncio, "sleep", _fast_sleep) + monkeypatch.setattr(client_mod.asyncio, "sleep", _fast_sleep) monkeypatch.setattr(client_mod.random, "uniform", lambda *_args, **_kwargs: 0.0) - client = sensor.GooglePollenApiClient(session, "test") + client = client_mod.GooglePollenApiClient(session, "test") loop = asyncio.new_event_loop() hass = DummyHass(loop) @@ -1053,7 +1058,7 @@ async def _fast_sleep(delay: float) -> None: ) try: - with pytest.raises(sensor.UpdateFailed, match="HTTP 502"): + with pytest.raises(client_mod.UpdateFailed, match="HTTP 502"): loop.run_until_complete(coordinator._async_update_data()) finally: loop.close() @@ -1073,10 +1078,10 @@ def test_coordinator_retries_then_wraps_timeout( async def _fast_sleep(delay: float) -> None: delays.append(delay) - monkeypatch.setattr(sensor.asyncio, "sleep", _fast_sleep) + monkeypatch.setattr(client_mod.asyncio, "sleep", _fast_sleep) monkeypatch.setattr(client_mod.random, "uniform", lambda *_args, **_kwargs: 0.0) - client = sensor.GooglePollenApiClient(session, "test") + client = client_mod.GooglePollenApiClient(session, "test") loop = asyncio.new_event_loop() hass = DummyHass(loop) @@ -1095,7 +1100,7 @@ async def _fast_sleep(delay: float) -> None: ) try: - with pytest.raises(sensor.UpdateFailed, match="Timeout"): + with pytest.raises(client_mod.UpdateFailed, match="Timeout"): loop.run_until_complete(coordinator._async_update_data()) finally: loop.close() @@ -1120,10 +1125,10 @@ def test_coordinator_retries_then_wraps_client_error( async def _fast_sleep(delay: float) -> None: delays.append(delay) - monkeypatch.setattr(sensor.asyncio, "sleep", _fast_sleep) + monkeypatch.setattr(client_mod.asyncio, "sleep", _fast_sleep) monkeypatch.setattr(client_mod.random, "uniform", lambda *_args, **_kwargs: 0.0) - client = sensor.GooglePollenApiClient(session, "secret") + client = client_mod.GooglePollenApiClient(session, "secret") loop = asyncio.new_event_loop() hass = DummyHass(loop) @@ -1142,7 +1147,7 @@ async def _fast_sleep(delay: float) -> None: ) try: - with pytest.raises(sensor.UpdateFailed, match="net down"): + with pytest.raises(client_mod.UpdateFailed, match="net down"): loop.run_until_complete(coordinator._async_update_data()) finally: loop.close() @@ -1196,7 +1201,7 @@ async def test_device_info_uses_default_title_when_blank( ) config_entry.title = " " - client = sensor.GooglePollenApiClient(FakeSession({}), "key") + client = client_mod.GooglePollenApiClient(FakeSession({}), "key") clean_title = sensor.DEFAULT_ENTRY_TITLE coordinator = sensor.PollenDataUpdateCoordinator( hass=hass, @@ -1251,7 +1256,7 @@ async def test_device_info_trims_custom_title( ) config_entry.title = " My Location " - client = sensor.GooglePollenApiClient(FakeSession({}), "key") + client = client_mod.GooglePollenApiClient(FakeSession({}), "key") clean_title = config_entry.title.strip() coordinator = sensor.PollenDataUpdateCoordinator( hass=hass, From 0d245a28205e5cad7a94fd94b955d4b81950ed03 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Fri, 19 Dec 2025 23:16:39 +0100 Subject: [PATCH 088/200] Update test_sensor.py --- tests/test_sensor.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_sensor.py b/tests/test_sensor.py index b84ba2d3..46249d13 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -395,13 +395,14 @@ def _setup_registry_stub( registry = RegistryStub(entries, entry_id=entry_id) + # In Home Assistant, `async_remove()` is a method of the registry object returned by + # `entity_registry.async_get(hass)`, not a module-level function. monkeypatch.setattr(sensor.er, "async_get", lambda hass: registry) monkeypatch.setattr( sensor.er, "async_entries_for_config_entry", registry.async_entries_for_config_entry, ) - monkeypatch.setattr(sensor.er, "async_remove", registry.async_remove) return registry From 1053d27aefdb09acbd66a27d532424e42ec81fbf Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Fri, 19 Dec 2025 23:25:40 +0100 Subject: [PATCH 089/200] Update tests for coordinator module --- tests/test_sensor.py | 37 ++++++++++++++++++++----------------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 46249d13..0aaaf27d 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -262,6 +262,9 @@ def _load_module(module_name: str, relative_path: str): const = _load_module("custom_components.pollenlevels.const", "const.py") +coordinator_mod = _load_module( + "custom_components.pollenlevels.coordinator", "coordinator.py" +) sensor = _load_module("custom_components.pollenlevels.sensor", "sensor.py") client_mod = importlib.import_module("custom_components.pollenlevels.client") @@ -440,7 +443,7 @@ def test_type_sensor_preserves_source_with_single_day( loop = asyncio.new_event_loop() hass = DummyHass(loop) - coordinator = sensor.PollenDataUpdateCoordinator( + coordinator = coordinator_mod.PollenDataUpdateCoordinator( hass=hass, api_key="test", lat=1.0, @@ -476,7 +479,7 @@ def test_coordinator_clamps_forecast_days_low() -> None: client = client_mod.GooglePollenApiClient(FakeSession({}), "test") try: - coordinator = sensor.PollenDataUpdateCoordinator( + coordinator = coordinator_mod.PollenDataUpdateCoordinator( hass=hass, api_key="test", lat=1.0, @@ -503,7 +506,7 @@ def test_coordinator_clamps_forecast_days_negative() -> None: client = client_mod.GooglePollenApiClient(FakeSession({}), "test") try: - coordinator = sensor.PollenDataUpdateCoordinator( + coordinator = coordinator_mod.PollenDataUpdateCoordinator( hass=hass, api_key="test", lat=1.0, @@ -530,7 +533,7 @@ def test_coordinator_clamps_forecast_days_high() -> None: client = client_mod.GooglePollenApiClient(FakeSession({}), "test") try: - coordinator = sensor.PollenDataUpdateCoordinator( + coordinator = coordinator_mod.PollenDataUpdateCoordinator( hass=hass, api_key="test", lat=1.0, @@ -557,7 +560,7 @@ def test_coordinator_keeps_forecast_days_within_range() -> None: client = client_mod.GooglePollenApiClient(FakeSession({}), "test") try: - coordinator = sensor.PollenDataUpdateCoordinator( + coordinator = coordinator_mod.PollenDataUpdateCoordinator( hass=hass, api_key="test", lat=1.0, @@ -630,7 +633,7 @@ def test_type_sensor_uses_forecast_metadata_when_today_missing( loop = asyncio.new_event_loop() hass = DummyHass(loop) - coordinator = sensor.PollenDataUpdateCoordinator( + coordinator = coordinator_mod.PollenDataUpdateCoordinator( hass=hass, api_key="test", lat=1.0, @@ -740,7 +743,7 @@ def test_plant_sensor_includes_forecast_attributes( loop = asyncio.new_event_loop() hass = DummyHass(loop) - coordinator = sensor.PollenDataUpdateCoordinator( + coordinator = coordinator_mod.PollenDataUpdateCoordinator( hass=hass, api_key="test", lat=1.0, @@ -830,7 +833,7 @@ def test_coordinator_raises_auth_failed() -> None: loop = asyncio.new_event_loop() hass = DummyHass(loop) - coordinator = sensor.PollenDataUpdateCoordinator( + coordinator = coordinator_mod.PollenDataUpdateCoordinator( hass=hass, api_key="bad", lat=1.0, @@ -859,7 +862,7 @@ def test_coordinator_handles_forbidden() -> None: loop = asyncio.new_event_loop() hass = DummyHass(loop) - coordinator = sensor.PollenDataUpdateCoordinator( + coordinator = coordinator_mod.PollenDataUpdateCoordinator( hass=hass, api_key="bad", lat=1.0, @@ -889,7 +892,7 @@ def test_coordinator_invalid_key_message_triggers_reauth() -> None: loop = asyncio.new_event_loop() hass = DummyHass(loop) - coordinator = sensor.PollenDataUpdateCoordinator( + coordinator = coordinator_mod.PollenDataUpdateCoordinator( hass=hass, api_key="bad", lat=1.0, @@ -941,7 +944,7 @@ async def _fast_sleep(delay: float) -> None: loop = asyncio.new_event_loop() hass = DummyHass(loop) - coordinator = sensor.PollenDataUpdateCoordinator( + coordinator = coordinator_mod.PollenDataUpdateCoordinator( hass=hass, api_key="test", lat=1.0, @@ -1000,7 +1003,7 @@ async def _fast_sleep(delay: float) -> None: loop = asyncio.new_event_loop() hass = DummyHass(loop) - coordinator = sensor.PollenDataUpdateCoordinator( + coordinator = coordinator_mod.PollenDataUpdateCoordinator( hass=hass, api_key="test", lat=1.0, @@ -1044,7 +1047,7 @@ async def _fast_sleep(delay: float) -> None: loop = asyncio.new_event_loop() hass = DummyHass(loop) - coordinator = sensor.PollenDataUpdateCoordinator( + coordinator = coordinator_mod.PollenDataUpdateCoordinator( hass=hass, api_key="test", lat=1.0, @@ -1086,7 +1089,7 @@ async def _fast_sleep(delay: float) -> None: loop = asyncio.new_event_loop() hass = DummyHass(loop) - coordinator = sensor.PollenDataUpdateCoordinator( + coordinator = coordinator_mod.PollenDataUpdateCoordinator( hass=hass, api_key="test", lat=1.0, @@ -1133,7 +1136,7 @@ async def _fast_sleep(delay: float) -> None: loop = asyncio.new_event_loop() hass = DummyHass(loop) - coordinator = sensor.PollenDataUpdateCoordinator( + coordinator = coordinator_mod.PollenDataUpdateCoordinator( hass=hass, api_key="secret", lat=1.0, @@ -1204,7 +1207,7 @@ async def test_device_info_uses_default_title_when_blank( client = client_mod.GooglePollenApiClient(FakeSession({}), "key") clean_title = sensor.DEFAULT_ENTRY_TITLE - coordinator = sensor.PollenDataUpdateCoordinator( + coordinator = coordinator_mod.PollenDataUpdateCoordinator( hass=hass, api_key="key", lat=1.0, @@ -1259,7 +1262,7 @@ async def test_device_info_trims_custom_title( client = client_mod.GooglePollenApiClient(FakeSession({}), "key") clean_title = config_entry.title.strip() - coordinator = sensor.PollenDataUpdateCoordinator( + coordinator = coordinator_mod.PollenDataUpdateCoordinator( hass=hass, api_key="key", lat=1.0, From 3ec0653ef6182ec96a91b1b36dcd85a1f85001ea Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Fri, 19 Dec 2025 23:30:00 +0100 Subject: [PATCH 090/200] Fix registry stubs for cleanup tests --- tests/test_sensor.py | 74 +++++++++++++++++++++++++++++++------------- 1 file changed, 53 insertions(+), 21 deletions(-) diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 46249d13..ce8d3ed5 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -262,6 +262,9 @@ def _load_module(module_name: str, relative_path: str): const = _load_module("custom_components.pollenlevels.const", "const.py") +coordinator_mod = _load_module( + "custom_components.pollenlevels.coordinator", "coordinator.py" +) sensor = _load_module("custom_components.pollenlevels.sensor", "sensor.py") client_mod = importlib.import_module("custom_components.pollenlevels.client") @@ -367,6 +370,9 @@ class RegistryEntry(NamedTuple): entry_id: str entity_id: str + unique_id: str + domain: str + platform: str class RegistryStub: @@ -379,7 +385,15 @@ def __init__(self, entries: list[RegistryEntry], entry_id: str) -> None: def async_entries_for_config_entry(self, _registry, entry_id: str): assert entry_id == self._entry_id - return [types.SimpleNamespace(entity_id=e.entity_id) for e in self._entries] + return [ + types.SimpleNamespace( + entity_id=e.entity_id, + unique_id=e.unique_id, + domain=e.domain, + platform=e.platform, + ) + for e in self._entries + ] async def async_remove(self, entity_id: str) -> None: self.removals.append(entity_id) @@ -440,7 +454,7 @@ def test_type_sensor_preserves_source_with_single_day( loop = asyncio.new_event_loop() hass = DummyHass(loop) - coordinator = sensor.PollenDataUpdateCoordinator( + coordinator = coordinator_mod.PollenDataUpdateCoordinator( hass=hass, api_key="test", lat=1.0, @@ -476,7 +490,7 @@ def test_coordinator_clamps_forecast_days_low() -> None: client = client_mod.GooglePollenApiClient(FakeSession({}), "test") try: - coordinator = sensor.PollenDataUpdateCoordinator( + coordinator = coordinator_mod.PollenDataUpdateCoordinator( hass=hass, api_key="test", lat=1.0, @@ -503,7 +517,7 @@ def test_coordinator_clamps_forecast_days_negative() -> None: client = client_mod.GooglePollenApiClient(FakeSession({}), "test") try: - coordinator = sensor.PollenDataUpdateCoordinator( + coordinator = coordinator_mod.PollenDataUpdateCoordinator( hass=hass, api_key="test", lat=1.0, @@ -530,7 +544,7 @@ def test_coordinator_clamps_forecast_days_high() -> None: client = client_mod.GooglePollenApiClient(FakeSession({}), "test") try: - coordinator = sensor.PollenDataUpdateCoordinator( + coordinator = coordinator_mod.PollenDataUpdateCoordinator( hass=hass, api_key="test", lat=1.0, @@ -557,7 +571,7 @@ def test_coordinator_keeps_forecast_days_within_range() -> None: client = client_mod.GooglePollenApiClient(FakeSession({}), "test") try: - coordinator = sensor.PollenDataUpdateCoordinator( + coordinator = coordinator_mod.PollenDataUpdateCoordinator( hass=hass, api_key="test", lat=1.0, @@ -630,7 +644,7 @@ def test_type_sensor_uses_forecast_metadata_when_today_missing( loop = asyncio.new_event_loop() hass = DummyHass(loop) - coordinator = sensor.PollenDataUpdateCoordinator( + coordinator = coordinator_mod.PollenDataUpdateCoordinator( hass=hass, api_key="test", lat=1.0, @@ -740,7 +754,7 @@ def test_plant_sensor_includes_forecast_attributes( loop = asyncio.new_event_loop() hass = DummyHass(loop) - coordinator = sensor.PollenDataUpdateCoordinator( + coordinator = coordinator_mod.PollenDataUpdateCoordinator( hass=hass, api_key="test", lat=1.0, @@ -801,9 +815,27 @@ def test_cleanup_per_day_entities_removes_disabled_days( """D+1/D+2 entities are awaited and removed when disabled.""" entries = [ - RegistryEntry("entry_type_grass", "sensor.pollen_type_grass"), - RegistryEntry("entry_type_grass_d1", "sensor.pollen_type_grass_d1"), - RegistryEntry("entry_type_grass_d2", "sensor.pollen_type_grass_d2"), + RegistryEntry( + "entry_type_grass", + "sensor.pollen_type_grass", + "entry_type_grass", + "sensor", + sensor.DOMAIN, + ), + RegistryEntry( + "entry_type_grass_d1", + "sensor.pollen_type_grass_d1", + "entry_type_grass_d1", + "sensor", + sensor.DOMAIN, + ), + RegistryEntry( + "entry_type_grass_d2", + "sensor.pollen_type_grass_d2", + "entry_type_grass_d2", + "sensor", + sensor.DOMAIN, + ), ] registry = _setup_registry_stub(monkeypatch, entries, entry_id="entry") @@ -830,7 +862,7 @@ def test_coordinator_raises_auth_failed() -> None: loop = asyncio.new_event_loop() hass = DummyHass(loop) - coordinator = sensor.PollenDataUpdateCoordinator( + coordinator = coordinator_mod.PollenDataUpdateCoordinator( hass=hass, api_key="bad", lat=1.0, @@ -859,7 +891,7 @@ def test_coordinator_handles_forbidden() -> None: loop = asyncio.new_event_loop() hass = DummyHass(loop) - coordinator = sensor.PollenDataUpdateCoordinator( + coordinator = coordinator_mod.PollenDataUpdateCoordinator( hass=hass, api_key="bad", lat=1.0, @@ -889,7 +921,7 @@ def test_coordinator_invalid_key_message_triggers_reauth() -> None: loop = asyncio.new_event_loop() hass = DummyHass(loop) - coordinator = sensor.PollenDataUpdateCoordinator( + coordinator = coordinator_mod.PollenDataUpdateCoordinator( hass=hass, api_key="bad", lat=1.0, @@ -941,7 +973,7 @@ async def _fast_sleep(delay: float) -> None: loop = asyncio.new_event_loop() hass = DummyHass(loop) - coordinator = sensor.PollenDataUpdateCoordinator( + coordinator = coordinator_mod.PollenDataUpdateCoordinator( hass=hass, api_key="test", lat=1.0, @@ -1000,7 +1032,7 @@ async def _fast_sleep(delay: float) -> None: loop = asyncio.new_event_loop() hass = DummyHass(loop) - coordinator = sensor.PollenDataUpdateCoordinator( + coordinator = coordinator_mod.PollenDataUpdateCoordinator( hass=hass, api_key="test", lat=1.0, @@ -1044,7 +1076,7 @@ async def _fast_sleep(delay: float) -> None: loop = asyncio.new_event_loop() hass = DummyHass(loop) - coordinator = sensor.PollenDataUpdateCoordinator( + coordinator = coordinator_mod.PollenDataUpdateCoordinator( hass=hass, api_key="test", lat=1.0, @@ -1086,7 +1118,7 @@ async def _fast_sleep(delay: float) -> None: loop = asyncio.new_event_loop() hass = DummyHass(loop) - coordinator = sensor.PollenDataUpdateCoordinator( + coordinator = coordinator_mod.PollenDataUpdateCoordinator( hass=hass, api_key="test", lat=1.0, @@ -1133,7 +1165,7 @@ async def _fast_sleep(delay: float) -> None: loop = asyncio.new_event_loop() hass = DummyHass(loop) - coordinator = sensor.PollenDataUpdateCoordinator( + coordinator = coordinator_mod.PollenDataUpdateCoordinator( hass=hass, api_key="secret", lat=1.0, @@ -1204,7 +1236,7 @@ async def test_device_info_uses_default_title_when_blank( client = client_mod.GooglePollenApiClient(FakeSession({}), "key") clean_title = sensor.DEFAULT_ENTRY_TITLE - coordinator = sensor.PollenDataUpdateCoordinator( + coordinator = coordinator_mod.PollenDataUpdateCoordinator( hass=hass, api_key="key", lat=1.0, @@ -1259,7 +1291,7 @@ async def test_device_info_trims_custom_title( client = client_mod.GooglePollenApiClient(FakeSession({}), "key") clean_title = config_entry.title.strip() - coordinator = sensor.PollenDataUpdateCoordinator( + coordinator = coordinator_mod.PollenDataUpdateCoordinator( hass=hass, api_key="key", lat=1.0, From e55a400ca6975876c7d9133f7c49f3f1db756cfe Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Fri, 19 Dec 2025 23:51:49 +0100 Subject: [PATCH 091/200] Update coordinator.py --- custom_components/pollenlevels/coordinator.py | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/custom_components/pollenlevels/coordinator.py b/custom_components/pollenlevels/coordinator.py index bc5cf6ad..f6f942b2 100644 --- a/custom_components/pollenlevels/coordinator.py +++ b/custom_components/pollenlevels/coordinator.py @@ -241,6 +241,8 @@ async def _async_update_data(self): type_codes: set[str] = set() for day in daily: for item in day.get("pollenTypeInfo", []) or []: + if not isinstance(item, dict): + continue code = (item.get("code") or "").upper() if code: type_codes.add(code) @@ -248,6 +250,8 @@ async def _async_update_data(self): def _find_type(day: dict, code: str) -> dict | None: """Find a pollen TYPE entry by code inside a day's 'pollenTypeInfo'.""" for item in day.get("pollenTypeInfo", []) or []: + if not isinstance(item, dict): + continue if (item.get("code") or "").upper() == code: return item return None @@ -255,6 +259,8 @@ def _find_type(day: dict, code: str) -> dict | None: def _find_plant(day: dict, code: str) -> dict | None: """Find a PLANT entry by code inside a day's 'plantInfo'.""" for item in day.get("plantInfo", []) or []: + if not isinstance(item, dict): + continue if (item.get("code") or "") == code: return item return None @@ -262,7 +268,8 @@ def _find_plant(day: dict, code: str) -> dict | None: # Current-day TYPES for tcode in type_codes: titem = _find_type(first_day, tcode) or {} - idx = (titem.get("indexInfo") or {}) if isinstance(titem, dict) else {} + idx_raw = titem.get("indexInfo") + idx = idx_raw if isinstance(idx_raw, dict) else {} rgb = _rgb_from_api(idx.get("color")) key = f"type_{tcode.lower()}" new_data[key] = { @@ -282,13 +289,17 @@ def _find_plant(day: dict, code: str) -> dict | None: # Current-day PLANTS for pitem in first_day.get("plantInfo", []) or []: + if not isinstance(pitem, dict): + continue code = pitem.get("code") # Safety: skip plants without a stable 'code' to avoid duplicate 'plants_' keys # and silent overwrites. This is robust and avoids creating unstable entities. if not code: continue - idx = pitem.get("indexInfo", {}) or {} - desc = pitem.get("plantDescription", {}) or {} + idx_raw = pitem.get("indexInfo") + idx = idx_raw if isinstance(idx_raw, dict) else {} + desc_raw = pitem.get("plantDescription") + desc = desc_raw if isinstance(desc_raw, dict) else {} rgb = _rgb_from_api(idx.get("color")) key = f"plants_{code.lower()}" new_data[key] = { @@ -358,8 +369,9 @@ def _extract_day_info(day: dict) -> tuple[str | None, dict | None]: break date_str, _ = _extract_day_info(day) item = _find_type(day, tcode) or {} - idx = item.get("indexInfo") if isinstance(item, dict) else None - has_index = isinstance(idx, dict) + idx_raw = item.get("indexInfo") + idx = idx_raw if isinstance(idx_raw, dict) else None + has_index = idx is not None rgb = _rgb_from_api(idx.get("color")) if has_index else None forecast_list.append( { @@ -451,8 +463,9 @@ def _add_day_sensor( break date_str, _ = _extract_day_info(day) item = _find_plant(day, pcode) or {} - idx = item.get("indexInfo") if isinstance(item, dict) else None - has_index = isinstance(idx, dict) + idx_raw = item.get("indexInfo") + idx = idx_raw if isinstance(idx_raw, dict) else None + has_index = idx is not None rgb = _rgb_from_api(idx.get("color")) if has_index else None forecast_list.append( { From 85d70539eb6e5f85eb4cb4cbd977c7166ff2228d Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Fri, 19 Dec 2025 23:58:52 +0100 Subject: [PATCH 092/200] Add files via upload --- tests/test_sensor.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/test_sensor.py b/tests/test_sensor.py index ce8d3ed5..1b6c13fa 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -368,7 +368,6 @@ def get(self, *_args, **_kwargs): class RegistryEntry(NamedTuple): """Entity registry entry stub.""" - entry_id: str entity_id: str unique_id: str domain: str @@ -816,21 +815,18 @@ def test_cleanup_per_day_entities_removes_disabled_days( entries = [ RegistryEntry( - "entry_type_grass", "sensor.pollen_type_grass", "entry_type_grass", "sensor", sensor.DOMAIN, ), RegistryEntry( - "entry_type_grass_d1", "sensor.pollen_type_grass_d1", "entry_type_grass_d1", "sensor", sensor.DOMAIN, ), RegistryEntry( - "entry_type_grass_d2", "sensor.pollen_type_grass_d2", "entry_type_grass_d2", "sensor", From 5d77ed89933976c8dd9528ee0c7cad3856d50d68 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sat, 20 Dec 2025 00:34:23 +0100 Subject: [PATCH 093/200] Update CHANGELOG.md --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d90057a..3e7e489f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,8 @@ consistent with repository standards. - Expanded translation coverage tests to include section titles and service metadata keys, ensuring locales stay aligned with `en.json`. +- Coordinator Module Extraction: The PollenDataUpdateCoordinator class and its associated helper functions have been moved from sensor.py to a new, dedicated coordinator.py module. This significantly improves modularity and separation of concerns within the component. +- Reduced Debug Logging: Debug log volume has been reduced by summarizing coordinator refreshes and sensor creation details, rather than logging full payloads. This makes logs more concise and easier to review. ## [1.9.0-alpha1] - 2025-12-11 ### Changed From 37ef9267374ef7f0bd18f66f97b3864975b9ac5b Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sat, 20 Dec 2025 00:57:47 +0100 Subject: [PATCH 094/200] fix(config_flow): wrap section schema to avoid nested mapping serialization crash --- custom_components/pollenlevels/config_flow.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/custom_components/pollenlevels/config_flow.py b/custom_components/pollenlevels/config_flow.py index 4c45b0d7..d3e5981e 100644 --- a/custom_components/pollenlevels/config_flow.py +++ b/custom_components/pollenlevels/config_flow.py @@ -107,11 +107,18 @@ options=FORECAST_SENSORS_CHOICES, ) ), - section(SECTION_API_KEY_OPTIONS, SectionConfig(collapsed=True)): { - vol.Optional(CONF_HTTP_REFERER, default=""): TextSelector( - TextSelectorConfig(type=TextSelectorType.TEXT) - ), - }, + # NOTE: HA's voluptuous serializer can't convert nested dict mappings directly. + # Wrap the section body in vol.Schema(...) to make it serializable and valid. + section( + SECTION_API_KEY_OPTIONS, + SectionConfig(collapsed=True), + ): vol.Schema( + { + vol.Optional(CONF_HTTP_REFERER, default=""): TextSelector( + TextSelectorConfig(type=TextSelectorType.TEXT) + ), + } + ), } ) From f137523a17d7bb5f80b717440731a5be22247a4b Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sat, 20 Dec 2025 01:09:42 +0100 Subject: [PATCH 095/200] fix(config_flow): avoid section nested schema to prevent 500 on flow render --- custom_components/pollenlevels/config_flow.py | 74 ++++++------------- 1 file changed, 23 insertions(+), 51 deletions(-) diff --git a/custom_components/pollenlevels/config_flow.py b/custom_components/pollenlevels/config_flow.py index d3e5981e..dc9febde 100644 --- a/custom_components/pollenlevels/config_flow.py +++ b/custom_components/pollenlevels/config_flow.py @@ -6,6 +6,10 @@ - Redacts API keys in debug logs. - Timeout handling: on Python 3.14, built-in `TimeoutError` also covers `asyncio.TimeoutError`, so catching `TimeoutError` is sufficient and preferred. + +IMPORTANT: +- Some HA versions cannot serialize nested mapping schemas (e.g. sections) via voluptuous_serialize. + Keep http_referer as a flat field to avoid 500 errors when rendering the config flow form. """ from __future__ import annotations @@ -20,7 +24,6 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, CONF_NAME -from homeassistant.data_entry_flow import SectionConfig, section from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import ( LocationSelector, @@ -107,17 +110,10 @@ options=FORECAST_SENSORS_CHOICES, ) ), - # NOTE: HA's voluptuous serializer can't convert nested dict mappings directly. - # Wrap the section body in vol.Schema(...) to make it serializable and valid. - section( - SECTION_API_KEY_OPTIONS, - SectionConfig(collapsed=True), - ): vol.Schema( - { - vol.Optional(CONF_HTTP_REFERER, default=""): TextSelector( - TextSelectorConfig(type=TextSelectorType.TEXT) - ), - } + # NOTE: Keep this flat (not inside a section) to avoid nested mapping schema + # serialization errors in some HA versions. + vol.Optional(CONF_HTTP_REFERER, default=""): TextSelector( + TextSelectorConfig(type=TextSelectorType.TEXT) ), } ) @@ -138,7 +134,6 @@ def is_valid_language_code(value: str) -> str: def _language_error_to_form_key(error: vol.Invalid) -> str: """Convert voluptuous validation errors into form error keys.""" - message = getattr(error, "error_message", "") if message == "empty": return "empty" @@ -149,7 +144,6 @@ def _language_error_to_form_key(error: vol.Invalid) -> str: def _safe_coord(value: float | None, *, lat: bool) -> float | None: """Return a validated latitude/longitude or None if unset/invalid.""" - try: if lat: return cv.latitude(value) @@ -160,7 +154,6 @@ def _safe_coord(value: float | None, *, lat: bool) -> float | None: def _get_location_schema(hass: Any) -> vol.Schema: """Return schema for name + location with defaults from HA config.""" - default_name = getattr(hass.config, "location_name", "") or DEFAULT_ENTRY_TITLE default_lat = _safe_coord(getattr(hass.config, "latitude", None), lat=True) default_lon = _safe_coord(getattr(hass.config, "longitude", None), lat=False) @@ -188,7 +181,6 @@ def _validate_location_dict( location: dict[str, Any] | None, ) -> tuple[float, float] | None: """Validate location dict and return (lat, lon) or None on error.""" - if not isinstance(location, dict): return None @@ -216,7 +208,6 @@ def _parse_int_option( error_key: str | None = None, ) -> tuple[int, str | None]: """Parse a numeric option to int and enforce bounds.""" - try: parsed = int(float(value if value is not None else default)) except (TypeError, ValueError): @@ -233,7 +224,6 @@ def _parse_int_option( def _parse_update_interval(value: Any, default: int) -> tuple[int, str | None]: """Parse and validate the update interval in hours.""" - return _parse_int_option( value, default=default, @@ -264,7 +254,6 @@ async def _async_validate_input( description_placeholders: dict[str, Any] | None = None, ) -> tuple[dict[str, str], dict[str, Any] | None]: """Validate user or reauth input and return normalized data.""" - placeholders = ( description_placeholders if description_placeholders is not None else {} ) @@ -342,7 +331,6 @@ async def _async_validate_input( _LOGGER.debug( "Invalid coordinates provided (values redacted): parsing failed" ) - # Legacy lat/lon path (e.g., reauth) has no CONF_LOCATION field on the form errors["base"] = "invalid_coordinates" return errors, None @@ -365,7 +353,6 @@ async def _async_validate_input( normalized[CONF_API_KEY] = api_key try: - # Allow blank language; if present, validate & normalize raw_lang = user_input.get(CONF_LANGUAGE_CODE, "") lang = raw_lang.strip() if isinstance(raw_lang, str) else "" if lang: @@ -383,10 +370,8 @@ async def _async_validate_input( url = "https://pollen.googleapis.com/v1/forecast:lookup" - # SECURITY: Avoid logging URL+params (contains coordinates/key) _LOGGER.debug("Validating Pollen API (days=%s, lang_set=%s)", 1, bool(lang)) - # Add explicit timeout to prevent UI hangs on provider issues async with session.get( url, params=params, @@ -438,10 +423,9 @@ async def _async_validate_input( if not data.get("dailyInfo"): _LOGGER.warning("Validation: 'dailyInfo' missing") errors["base"] = "cannot_connect" - if placeholders is not None: - placeholders["error_message"] = ( - "API response missing expected pollen forecast information." - ) + placeholders["error_message"] = ( + "API response missing expected pollen forecast information." + ) if errors: return errors, None @@ -457,38 +441,33 @@ async def _async_validate_input( ) errors[CONF_LANGUAGE_CODE] = _language_error_to_form_key(ve) except TimeoutError as err: - # Catch built-in TimeoutError; on Python 3.14 this also covers asyncio.TimeoutError. _LOGGER.warning( "Validation timeout (%ss): %s", POLLEN_API_TIMEOUT, redact_api_key(err, api_key), ) errors["base"] = "cannot_connect" - if placeholders is not None: - redacted = redact_api_key(err, api_key) - placeholders["error_message"] = ( - redacted - or f"Validation request timed out ({POLLEN_API_TIMEOUT} seconds)." - ) + redacted = redact_api_key(err, api_key) + placeholders["error_message"] = ( + redacted or f"Validation request timed out ({POLLEN_API_TIMEOUT} seconds)." + ) except aiohttp.ClientError as err: _LOGGER.error( "Connection error: %s", redact_api_key(err, api_key), ) errors["base"] = "cannot_connect" - if placeholders is not None: - redacted = redact_api_key(err, api_key) - placeholders["error_message"] = ( - redacted or "Network error while connecting to the pollen service." - ) + redacted = redact_api_key(err, api_key) + placeholders["error_message"] = ( + redacted or "Network error while connecting to the pollen service." + ) except Exception as err: # defensive _LOGGER.exception( "Unexpected error in Pollen Levels config flow while validating input: %s", redact_api_key(err, api_key), ) errors["base"] = "unknown" - if placeholders is not None: - placeholders.pop("error_message", None) + placeholders.pop("error_message", None) return errors, None @@ -502,6 +481,8 @@ async def async_step_user(self, user_input=None): if user_input: sanitized_input: dict[str, Any] = dict(user_input) + + # Backward/forward compatible extraction if the UI ever posts a section payload. section_values = sanitized_input.get(SECTION_API_KEY_OPTIONS) raw_http_referer = sanitized_input.get(CONF_HTTP_REFERER) if raw_http_referer is None and isinstance(section_values, dict): @@ -553,7 +534,6 @@ async def async_step_user(self, user_input=None): async def async_step_reauth(self, entry_data: dict[str, Any]): """Handle re-authentication when credentials become invalid.""" - entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) if entry is None: return self.async_abort(reason="reauth_failed") @@ -563,7 +543,6 @@ async def async_step_reauth(self, entry_data: dict[str, Any]): async def async_step_reauth_confirm(self, user_input: dict[str, Any] | None = None): """Prompt for a refreshed API key and validate it.""" - assert self._reauth_entry is not None errors: dict[str, str] = {} @@ -597,7 +576,6 @@ async def async_step_reauth_confirm(self, user_input: dict[str, Any] | None = No } ) - # Ensure the form posts back to this handler. return self.async_show_form( step_id="reauth_confirm", data_schema=schema, @@ -617,7 +595,6 @@ async def async_step_init(self, user_input=None): errors: dict[str, str] = {} placeholders = {"title": self.entry.title or DEFAULT_ENTRY_TITLE} - # Defaults: prefer options, fallback to data/HA config current_interval = self.entry.options.get( CONF_UPDATE_INTERVAL, self.entry.data.get(CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL), @@ -684,7 +661,6 @@ async def async_step_init(self, user_input=None): description_placeholders=placeholders, ) - # Persist forecast_days as int, even though UI returns str. forecast_days, days_error = _parse_int_option( normalized_input.get(CONF_FORECAST_DAYS, current_days), current_days, @@ -697,7 +673,6 @@ async def async_step_init(self, user_input=None): errors[CONF_FORECAST_DAYS] = days_error try: - # Language: allow empty; if provided, validate & normalize. raw_lang = normalized_input.get( CONF_LANGUAGE_CODE, self.entry.options.get( @@ -708,12 +683,9 @@ async def async_step_init(self, user_input=None): lang = raw_lang.strip() if isinstance(raw_lang, str) else "" if lang: lang = is_valid_language_code(lang) - normalized_input[CONF_LANGUAGE_CODE] = lang # persist normalized + normalized_input[CONF_LANGUAGE_CODE] = lang - # forecast_days within 1..5 days = normalized_input[CONF_FORECAST_DAYS] - - # per-day sensors vs number of days mode = normalized_input.get( CONF_CREATE_FORECAST_SENSORS, self.entry.options.get(CONF_CREATE_FORECAST_SENSORS, "none"), From 2a0ccc66d7dd4b7e62d58545d05a1d43e8b69536 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 20 Dec 2025 00:11:01 +0000 Subject: [PATCH 096/200] style: apply Ruff fixes (py314) and Black formatting --- custom_components/pollenlevels/config_flow.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/custom_components/pollenlevels/config_flow.py b/custom_components/pollenlevels/config_flow.py index dc9febde..9025f14e 100644 --- a/custom_components/pollenlevels/config_flow.py +++ b/custom_components/pollenlevels/config_flow.py @@ -449,7 +449,8 @@ async def _async_validate_input( errors["base"] = "cannot_connect" redacted = redact_api_key(err, api_key) placeholders["error_message"] = ( - redacted or f"Validation request timed out ({POLLEN_API_TIMEOUT} seconds)." + redacted + or f"Validation request timed out ({POLLEN_API_TIMEOUT} seconds)." ) except aiohttp.ClientError as err: _LOGGER.error( From 00865f41f57fe5091f020b33efbc0bde854a569e Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sat, 20 Dec 2025 08:36:17 +0100 Subject: [PATCH 097/200] Fix changelog wrapping and duplicates --- CHANGELOG.md | 35 +++++++++++++++++++++-------------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e7e489f..bf34e25b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,8 +42,10 @@ consistent with repository standards. - Expanded translation coverage tests to include section titles and service metadata keys, ensuring locales stay aligned with `en.json`. -- Coordinator Module Extraction: The PollenDataUpdateCoordinator class and its associated helper functions have been moved from sensor.py to a new, dedicated coordinator.py module. This significantly improves modularity and separation of concerns within the component. -- Reduced Debug Logging: Debug log volume has been reduced by summarizing coordinator refreshes and sensor creation details, rather than logging full payloads. This makes logs more concise and easier to review. +- Coordinator Module Extraction: The PollenDataUpdateCoordinator class and its + associated helper functions have been moved from sensor.py to a new, + dedicated coordinator.py module. This significantly improves modularity and + separation of concerns within the component. ## [1.9.0-alpha1] - 2025-12-11 ### Changed @@ -318,23 +320,25 @@ ## [1.7.9] - 2025-09-06 ### Fixed -- **Date sensor**: Return a `datetime.date` object for `device_class: date` (was a string). Ensures - correct UI formatting and automation compatibility. +- **Date sensor**: Return a `datetime.date` object for `device_class: date` (was + a string). Ensures correct UI formatting and automation compatibility. ## [1.7.8] - 2025-09-05 ### Changed -- **Date sensor**: Set `device_class: date` so Home Assistant treats the value as a calendar date - (UI semantics/formatting). No functional impact. -- > Note: 1.7.8 set `device_class: date` but still returned a string. This was corrected in 1.7.9 to - return a proper `date` object. +- **Date sensor**: Set `device_class: date` so Home Assistant treats the value + as a calendar date (UI semantics/formatting). No functional impact. +- > Note: 1.7.8 set `device_class: date` but still returned a string. This was + corrected in 1.7.9 to return a proper `date` object. ## [1.7.7] - 2025-09-05 ### Changed - **Performance/cleanup**: Precompute static attributes for metadata sensors: - - Set `_attr_unique_id` and `_attr_icon` in `RegionSensor`, `DateSensor`, and `LastUpdatedSensor`. + - Set `_attr_unique_id` and `_attr_icon` in `RegionSensor`, `DateSensor`, and + `LastUpdatedSensor`. - Set `_attr_device_info` once in `_BaseMetaSensor`. - Also set `_attr_unique_id` in `PollenSensor` for consistency. - These changes avoid repeated property calls and align with modern HA entity patterns. No functional impact. + These changes avoid repeated property calls and align with modern HA entity + patterns. No functional impact. ## [1.7.6] - 2025-09-05 ### Changed @@ -388,8 +392,9 @@ ## [1.6.5] - 2025-08-26 ### Fixed -- Timeouts: catch built-in **`TimeoutError`** in Config Flow and Coordinator. - On Python 3.14 this also covers `asyncio.TimeoutError`, so listing both is unnecessary (and auto-removed by ruff/pyupgrade). +- Timeouts: catch built-in **`TimeoutError`** in Config Flow and Coordinator. + On Python 3.14 this also covers `asyncio.TimeoutError`, so listing both is + unnecessary (and auto-removed by ruff/pyupgrade). - Added missing `options.error` translations across all locales so **Options Flow** errors display localized. - **Security**: Config Flow now sanitizes exception messages (including connection/timeout errors) @@ -405,11 +410,13 @@ - Improved wording for `create_forecast_sensors` across all locales: - Field label now clarifies it’s the **range** for per-day TYPE sensors. - Step description explains each choice with plain language: - - **Only today (none)**, **Through tomorrow (D+1)**, **Through day after tomorrow (D+2)** (and local equivalents). + - **Only today (none)**, **Through tomorrow (D+1)**, + **Through day after tomorrow (D+2)** (and local equivalents). ### Changed - Minimal safe backoff in coordinator: single retry on transient failures (**TimeoutError**, `aiohttp.ClientError`, `5xx`). - For **429**, honor numeric `Retry-After` seconds (capped at **5s**) or fall back to ~**2s** plus small jitter. + For **429**, honor numeric `Retry-After` seconds (capped at **5s**) or fall + back to ~**2s** plus small jitter. ## [1.6.4] - 2025-08-22 ### Fixed From a5cfb30138f6bfbf1babf836d8fa9c4581115691 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sat, 20 Dec 2025 08:55:34 +0100 Subject: [PATCH 098/200] Fix config flow section and bump alpha version --- CHANGELOG.md | 5 + custom_components/pollenlevels/config_flow.py | 148 ++++++++---------- custom_components/pollenlevels/manifest.json | 2 +- pyproject.toml | 2 +- 4 files changed, 76 insertions(+), 81 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf34e25b..331fdf3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,9 @@ # Changelog +## [1.9.0-alpha3] - 2025-12-20 +### Fixed +- Restored the optional API key options section in the config flow so the HTTP + Referer field stays collapsed and avoids nested schema serialization errors. + ## [1.9.0-alpha2] - 2025-12-16 ### Changed - Enabled forecast day count and per-day sensor mode selection during initial diff --git a/custom_components/pollenlevels/config_flow.py b/custom_components/pollenlevels/config_flow.py index 9025f14e..ac48dd56 100644 --- a/custom_components/pollenlevels/config_flow.py +++ b/custom_components/pollenlevels/config_flow.py @@ -8,8 +8,9 @@ so catching `TimeoutError` is sufficient and preferred. IMPORTANT: -- Some HA versions cannot serialize nested mapping schemas (e.g. sections) via voluptuous_serialize. - Keep http_referer as a flat field to avoid 500 errors when rendering the config flow form. +- Some HA versions cannot serialize nested mapping schemas (e.g. sections) via + voluptuous_serialize when schemas are flattened and rebuilt. Construct the schema in + one pass so the section marker stays intact. """ from __future__ import annotations @@ -37,6 +38,7 @@ TextSelector, TextSelectorConfig, TextSelectorType, + section, ) from .const import ( @@ -78,47 +80,6 @@ ) -STEP_USER_DATA_SCHEMA = vol.Schema( - { - vol.Required(CONF_API_KEY): str, - vol.Optional( - CONF_UPDATE_INTERVAL, default=DEFAULT_UPDATE_INTERVAL - ): NumberSelector( - NumberSelectorConfig( - min=1, - step=1, - mode=NumberSelectorMode.BOX, - unit_of_measurement="h", - ) - ), - vol.Optional(CONF_LANGUAGE_CODE): TextSelector( - TextSelectorConfig(type=TextSelectorType.TEXT) - ), - vol.Optional( - CONF_FORECAST_DAYS, default=str(DEFAULT_FORECAST_DAYS) - ): SelectSelector( - SelectSelectorConfig( - mode=SelectSelectorMode.DROPDOWN, - options=FORECAST_DAYS_OPTIONS, - ) - ), - vol.Optional( - CONF_CREATE_FORECAST_SENSORS, default=FORECAST_SENSORS_CHOICES[0] - ): SelectSelector( - SelectSelectorConfig( - mode=SelectSelectorMode.DROPDOWN, - options=FORECAST_SENSORS_CHOICES, - ) - ), - # NOTE: Keep this flat (not inside a section) to avoid nested mapping schema - # serialization errors in some HA versions. - vol.Optional(CONF_HTTP_REFERER, default=""): TextSelector( - TextSelectorConfig(type=TextSelectorType.TEXT) - ), - } -) - - def is_valid_language_code(value: str) -> str: """Validate language code format; return normalized (trimmed) value.""" if not isinstance(value, str): @@ -152,27 +113,78 @@ def _safe_coord(value: float | None, *, lat: bool) -> float | None: return None -def _get_location_schema(hass: Any) -> vol.Schema: - """Return schema for name + location with defaults from HA config.""" - default_name = getattr(hass.config, "location_name", "") or DEFAULT_ENTRY_TITLE - default_lat = _safe_coord(getattr(hass.config, "latitude", None), lat=True) - default_lon = _safe_coord(getattr(hass.config, "longitude", None), lat=False) +def _build_step_user_schema(hass: Any, user_input: dict[str, Any] | None) -> vol.Schema: + """Build the full step user schema without flattening nested sections.""" + user_input = user_input or {} - if default_lat is not None and default_lon is not None: - location_field = vol.Required( - CONF_LOCATION, - default={ - CONF_LATITUDE: default_lat, - CONF_LONGITUDE: default_lon, - }, - ) + default_name = str( + user_input.get(CONF_NAME) + or getattr(hass.config, "location_name", "") + or DEFAULT_ENTRY_TITLE + ) + + location_default = None + if isinstance(user_input.get(CONF_LOCATION), dict): + location_default = user_input[CONF_LOCATION] + else: + lat = _safe_coord(getattr(hass.config, "latitude", None), lat=True) + lon = _safe_coord(getattr(hass.config, "longitude", None), lat=False) + if lat is not None and lon is not None: + location_default = {CONF_LATITUDE: lat, CONF_LONGITUDE: lon} + + if location_default is not None: + location_field = vol.Required(CONF_LOCATION, default=location_default) else: location_field = vol.Required(CONF_LOCATION) return vol.Schema( { + vol.Required(CONF_API_KEY, default=user_input.get(CONF_API_KEY, "")): str, vol.Required(CONF_NAME, default=default_name): str, location_field: LocationSelector(LocationSelectorConfig(radius=False)), + vol.Optional( + CONF_UPDATE_INTERVAL, + default=user_input.get(CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL), + ): NumberSelector( + NumberSelectorConfig( + min=1, + step=1, + mode=NumberSelectorMode.BOX, + unit_of_measurement="h", + ) + ), + vol.Optional( + CONF_LANGUAGE_CODE, + default=user_input.get( + CONF_LANGUAGE_CODE, getattr(hass.config, "language", "") + ), + ): TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT)), + vol.Optional( + CONF_FORECAST_DAYS, + default=user_input.get(CONF_FORECAST_DAYS, str(DEFAULT_FORECAST_DAYS)), + ): SelectSelector( + SelectSelectorConfig( + mode=SelectSelectorMode.DROPDOWN, + options=FORECAST_DAYS_OPTIONS, + ) + ), + vol.Optional( + CONF_CREATE_FORECAST_SENSORS, + default=user_input.get( + CONF_CREATE_FORECAST_SENSORS, FORECAST_SENSORS_CHOICES[0] + ), + ): SelectSelector( + SelectSelectorConfig( + mode=SelectSelectorMode.DROPDOWN, + options=FORECAST_SENSORS_CHOICES, + ) + ), + section(SECTION_API_KEY_OPTIONS): { + vol.Optional( + CONF_HTTP_REFERER, + default=user_input.get(CONF_HTTP_REFERER, ""), + ): TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT)) + }, } ) @@ -504,31 +516,9 @@ async def async_step_user(self, user_input=None): title = entry_name or DEFAULT_ENTRY_TITLE return self.async_create_entry(title=title, data=normalized) - base_schema = STEP_USER_DATA_SCHEMA.schema.copy() - base_schema.update(_get_location_schema(self.hass).schema) - - suggested_values = { - CONF_LANGUAGE_CODE: self.hass.config.language, - CONF_NAME: getattr(self.hass.config, "location_name", "") - or DEFAULT_ENTRY_TITLE, - CONF_FORECAST_DAYS: str(DEFAULT_FORECAST_DAYS), - CONF_CREATE_FORECAST_SENSORS: FORECAST_SENSORS_CHOICES[0], - } - - lat = _safe_coord(getattr(self.hass.config, "latitude", None), lat=True) - lon = _safe_coord(getattr(self.hass.config, "longitude", None), lat=False) - if lat is not None and lon is not None: - suggested_values[CONF_LOCATION] = { - CONF_LATITUDE: lat, - CONF_LONGITUDE: lon, - } - return self.async_show_form( step_id="user", - data_schema=self.add_suggested_values_to_schema( - vol.Schema(base_schema), - {**suggested_values, **(user_input or {})}, - ), + data_schema=_build_step_user_schema(self.hass, user_input), errors=errors, description_placeholders=description_placeholders, ) diff --git a/custom_components/pollenlevels/manifest.json b/custom_components/pollenlevels/manifest.json index bc3f3065..f4fcb68b 100644 --- a/custom_components/pollenlevels/manifest.json +++ b/custom_components/pollenlevels/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "issue_tracker": "https://github.com/eXPerience83/pollenlevels/issues", - "version": "1.9.0-alpha2" + "version": "1.9.0-alpha3" } diff --git a/pyproject.toml b/pyproject.toml index cfcf2508..6170497e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ [project] name = "pollenlevels" -version = "1.9.0-alpha2" +version = "1.9.0-alpha3" # Enforce the runtime floor aligned with upcoming HA Python 3.14 images. requires-python = ">=3.14" From 1c2cd2bf09fbe3df8998618fd8ce8233f12f659f Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sat, 20 Dec 2025 09:02:20 +0100 Subject: [PATCH 099/200] Fix selector section stub for config flow tests --- tests/test_config_flow.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index e27c41be..bd25d909 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -268,6 +268,7 @@ def __init__(self, config: _SelectSelectorConfig): selector_mod.SelectSelector = _SelectSelector selector_mod.SelectSelectorConfig = _SelectSelectorConfig selector_mod.SelectSelectorMode = _SelectSelectorMode +selector_mod.section = lambda key: key _force_module("homeassistant.helpers.selector", selector_mod) ha_mod.helpers = helpers_mod From f506fe2c7c09b888454cf1d2359289e51bf5b683 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sat, 20 Dec 2025 09:09:08 +0100 Subject: [PATCH 100/200] Collapse API key section and preserve referer --- custom_components/pollenlevels/config_flow.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/custom_components/pollenlevels/config_flow.py b/custom_components/pollenlevels/config_flow.py index ac48dd56..38ae84f7 100644 --- a/custom_components/pollenlevels/config_flow.py +++ b/custom_components/pollenlevels/config_flow.py @@ -25,6 +25,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, CONF_NAME +from homeassistant.data_entry_flow import SectionConfig, section from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import ( LocationSelector, @@ -38,7 +39,6 @@ TextSelector, TextSelectorConfig, TextSelectorType, - section, ) from .const import ( @@ -117,6 +117,12 @@ def _build_step_user_schema(hass: Any, user_input: dict[str, Any] | None) -> vol """Build the full step user schema without flattening nested sections.""" user_input = user_input or {} + http_referer_default = user_input.get(CONF_HTTP_REFERER, "") + if not http_referer_default: + section_values = user_input.get(SECTION_API_KEY_OPTIONS) + if isinstance(section_values, dict): + http_referer_default = section_values.get(CONF_HTTP_REFERER, "") + default_name = str( user_input.get(CONF_NAME) or getattr(hass.config, "location_name", "") @@ -179,10 +185,10 @@ def _build_step_user_schema(hass: Any, user_input: dict[str, Any] | None) -> vol options=FORECAST_SENSORS_CHOICES, ) ), - section(SECTION_API_KEY_OPTIONS): { + section(SECTION_API_KEY_OPTIONS, SectionConfig(collapsed=True)): { vol.Optional( CONF_HTTP_REFERER, - default=user_input.get(CONF_HTTP_REFERER, ""), + default=http_referer_default, ): TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT)) }, } From 5abf60cfb9b521cb23e6bf5ca52ca118b65cb152 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sat, 20 Dec 2025 09:09:15 +0100 Subject: [PATCH 101/200] Add fallback message for client errors --- CHANGELOG.md | 2 ++ custom_components/pollenlevels/client.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 331fdf3b..fd1574ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ ### Fixed - Restored the optional API key options section in the config flow so the HTTP Referer field stays collapsed and avoids nested schema serialization errors. +- Added a fallback error message when unexpected client exceptions are raised to + avoid empty UpdateFailed errors in the UI. ## [1.9.0-alpha2] - 2025-12-16 ### Changed diff --git a/custom_components/pollenlevels/client.py b/custom_components/pollenlevels/client.py index 302049bd..bbf68924 100644 --- a/custom_components/pollenlevels/client.py +++ b/custom_components/pollenlevels/client.py @@ -236,5 +236,7 @@ async def async_fetch_pollen_data( raise except Exception as err: # noqa: BLE001 msg = redact_api_key(err, self._api_key) + if not msg: + msg = "Unexpected error while calling the Google Pollen API" _LOGGER.error("Pollen API error: %s", msg) raise UpdateFailed(msg) from err From 4e6bd149a17687be375c0fb437f254070f5ad9fe Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sat, 20 Dec 2025 09:17:18 +0100 Subject: [PATCH 102/200] Ensure forecast days default is a string --- custom_components/pollenlevels/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/pollenlevels/config_flow.py b/custom_components/pollenlevels/config_flow.py index 38ae84f7..2553a2fa 100644 --- a/custom_components/pollenlevels/config_flow.py +++ b/custom_components/pollenlevels/config_flow.py @@ -167,7 +167,7 @@ def _build_step_user_schema(hass: Any, user_input: dict[str, Any] | None) -> vol ): TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT)), vol.Optional( CONF_FORECAST_DAYS, - default=user_input.get(CONF_FORECAST_DAYS, str(DEFAULT_FORECAST_DAYS)), + default=str(user_input.get(CONF_FORECAST_DAYS, DEFAULT_FORECAST_DAYS)), ): SelectSelector( SelectSelectorConfig( mode=SelectSelectorMode.DROPDOWN, From 1b36bb66345f20e310ff03c1d21f4274e505714e Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sat, 20 Dec 2025 09:44:56 +0100 Subject: [PATCH 103/200] Harden config flow section schema --- CHANGELOG.md | 4 + custom_components/pollenlevels/config_flow.py | 74 +++++++++++++++++-- tests/test_config_flow.py | 5 ++ 3 files changed, 75 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fd1574ab..4c854392 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ### Fixed - Restored the optional API key options section in the config flow so the HTTP Referer field stays collapsed and avoids nested schema serialization errors. +- Fixed a config flow crash (500) caused by nested schema serialization when + rendering the optional API key section. +- Avoid pre-filling the API key field when the form is re-displayed after + validation errors. - Added a fallback error message when unexpected client exceptions are raised to avoid empty UpdateFailed errors in the UI. diff --git a/custom_components/pollenlevels/config_flow.py b/custom_components/pollenlevels/config_flow.py index 2553a2fa..ceb62361 100644 --- a/custom_components/pollenlevels/config_flow.py +++ b/custom_components/pollenlevels/config_flow.py @@ -143,9 +143,9 @@ def _build_step_user_schema(hass: Any, user_input: dict[str, Any] | None) -> vol else: location_field = vol.Required(CONF_LOCATION) - return vol.Schema( + section_schema = vol.Schema( { - vol.Required(CONF_API_KEY, default=user_input.get(CONF_API_KEY, "")): str, + vol.Required(CONF_API_KEY): str, vol.Required(CONF_NAME, default=default_name): str, location_field: LocationSelector(LocationSelectorConfig(radius=False)), vol.Optional( @@ -185,15 +185,73 @@ def _build_step_user_schema(hass: Any, user_input: dict[str, Any] | None) -> vol options=FORECAST_SENSORS_CHOICES, ) ), - section(SECTION_API_KEY_OPTIONS, SectionConfig(collapsed=True)): { - vol.Optional( - CONF_HTTP_REFERER, - default=http_referer_default, - ): TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT)) - }, + section(SECTION_API_KEY_OPTIONS, SectionConfig(collapsed=True)): vol.Schema( + { + vol.Optional( + CONF_HTTP_REFERER, + default=http_referer_default, + ): TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT)) + } + ), } ) + flat_schema = vol.Schema( + { + vol.Required(CONF_API_KEY): str, + vol.Required(CONF_NAME, default=default_name): str, + location_field: LocationSelector(LocationSelectorConfig(radius=False)), + vol.Optional( + CONF_UPDATE_INTERVAL, + default=user_input.get(CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL), + ): NumberSelector( + NumberSelectorConfig( + min=1, + step=1, + mode=NumberSelectorMode.BOX, + unit_of_measurement="h", + ) + ), + vol.Optional( + CONF_LANGUAGE_CODE, + default=user_input.get( + CONF_LANGUAGE_CODE, getattr(hass.config, "language", "") + ), + ): TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT)), + vol.Optional( + CONF_FORECAST_DAYS, + default=str(user_input.get(CONF_FORECAST_DAYS, DEFAULT_FORECAST_DAYS)), + ): SelectSelector( + SelectSelectorConfig( + mode=SelectSelectorMode.DROPDOWN, + options=FORECAST_DAYS_OPTIONS, + ) + ), + vol.Optional( + CONF_CREATE_FORECAST_SENSORS, + default=user_input.get( + CONF_CREATE_FORECAST_SENSORS, FORECAST_SENSORS_CHOICES[0] + ), + ): SelectSelector( + SelectSelectorConfig( + mode=SelectSelectorMode.DROPDOWN, + options=FORECAST_SENSORS_CHOICES, + ) + ), + vol.Optional(CONF_HTTP_REFERER, default=http_referer_default): TextSelector( + TextSelectorConfig(type=TextSelectorType.TEXT) + ), + } + ) + + try: + from voluptuous_serialize import convert + + convert(section_schema, custom_serializer=cv.custom_serializer) + return section_schema + except Exception: # noqa: BLE001 + return flat_schema + def _validate_location_dict( location: dict[str, Any] | None, diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index bd25d909..9916d124 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -145,6 +145,7 @@ def _longitude(value=None): config_validation_mod.latitude = _latitude config_validation_mod.longitude = _longitude config_validation_mod.string = lambda value=None: value +config_validation_mod.custom_serializer = lambda *args, **kwargs: None _force_module("homeassistant.helpers.config_validation", config_validation_mod) aiohttp_client_mod = ModuleType("homeassistant.helpers.aiohttp_client") @@ -315,6 +316,10 @@ def __init__(self, schema): vol_mod.In = lambda *args, **kwargs: None _force_module("voluptuous", vol_mod) +voluptuous_serialize_mod = ModuleType("voluptuous_serialize") +voluptuous_serialize_mod.convert = lambda *args, **kwargs: {} +_force_module("voluptuous_serialize", voluptuous_serialize_mod) + from homeassistant.const import ( CONF_LATITUDE, CONF_LOCATION, From 3632b6516a5074dc78ee445135778278ede575e4 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sat, 20 Dec 2025 09:51:03 +0100 Subject: [PATCH 104/200] Preserve empty referer defaults --- CHANGELOG.md | 2 ++ custom_components/pollenlevels/config_flow.py | 6 ++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c854392..4d27bc50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ Referer field stays collapsed and avoids nested schema serialization errors. - Fixed a config flow crash (500) caused by nested schema serialization when rendering the optional API key section. +- Preserved empty HTTP Referer values when the form re-renders to avoid + accidentally overriding explicit empty input with section defaults. - Avoid pre-filling the API key field when the form is re-displayed after validation errors. - Added a fallback error message when unexpected client exceptions are raised to diff --git a/custom_components/pollenlevels/config_flow.py b/custom_components/pollenlevels/config_flow.py index ceb62361..90c75d7d 100644 --- a/custom_components/pollenlevels/config_flow.py +++ b/custom_components/pollenlevels/config_flow.py @@ -117,11 +117,13 @@ def _build_step_user_schema(hass: Any, user_input: dict[str, Any] | None) -> vol """Build the full step user schema without flattening nested sections.""" user_input = user_input or {} - http_referer_default = user_input.get(CONF_HTTP_REFERER, "") - if not http_referer_default: + http_referer_default = user_input.get(CONF_HTTP_REFERER) + if http_referer_default is None: section_values = user_input.get(SECTION_API_KEY_OPTIONS) if isinstance(section_values, dict): http_referer_default = section_values.get(CONF_HTTP_REFERER, "") + else: + http_referer_default = "" default_name = str( user_input.get(CONF_NAME) From 234ab9531d0772321a37b33f26756355a062531b Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sat, 20 Dec 2025 09:55:12 +0100 Subject: [PATCH 105/200] Fix translation test mapping for name/location --- tests/test_translations.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_translations.py b/tests/test_translations.py index f63d58da..62441483 100644 --- a/tests/test_translations.py +++ b/tests/test_translations.py @@ -496,6 +496,8 @@ def _extract_config_flow_keys() -> set[str]: "CONF_API_KEY": "api_key", "CONF_LATITUDE": "latitude", "CONF_LONGITUDE": "longitude", + "CONF_LOCATION": "location", + "CONF_NAME": "name", "CONF_LANGUAGE": "language", "CONF_SCAN_INTERVAL": "scan_interval", } From 2ac220f5cd14fd96655102f37272ce00a5a0afb0 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sat, 20 Dec 2025 09:58:28 +0100 Subject: [PATCH 106/200] Handle schema key aliases in translation tests --- tests/test_translations.py | 49 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/tests/test_translations.py b/tests/test_translations.py index 62441483..a69f5a74 100644 --- a/tests/test_translations.py +++ b/tests/test_translations.py @@ -297,6 +297,48 @@ def _extract_constant_assignments(tree: ast.AST) -> dict[str, str]: return constants +def _extract_schema_key_aliases( + tree: ast.AST, mapping: dict[str, str] +) -> dict[str, str]: + """Extract schema key wrapper aliases like location_field = vol.Required(CONF_LOCATION).""" + + aliases: dict[str, str] = {} + for node in ast.walk(tree): + target: ast.AST | None = None + if isinstance(node, ast.Assign): + if len(node.targets) != 1: + continue + target = node.targets[0] + elif isinstance(node, ast.AnnAssign): + target = node.target + + if not isinstance(target, ast.Name): + continue + + value = node.value if hasattr(node, "value") else None + if not isinstance(value, ast.Call): + continue + + if not ( + isinstance(value.func, ast.Attribute) + and value.func.attr in {"Required", "Optional"} + ): + continue + + if not value.args: + continue + + selector = value.args[0] + if isinstance(selector, ast.Constant) and isinstance(selector.value, str): + aliases[target.id] = selector.value + elif isinstance(selector, ast.Name): + resolved = _resolve_name(selector.id, mapping) + if resolved: + aliases[target.id] = resolved + + return aliases + + def _resolve_name(name: str, mapping: dict[str, str]) -> str | None: """Resolve a variable name to its string value if known.""" @@ -325,6 +367,12 @@ def _fields_from_schema_dict( fields: set[str] = set() sections: set[str] = set() for key_node, value_node in zip(schema_dict.keys, schema_dict.values, strict=False): + if isinstance(key_node, ast.Name): + resolved = _resolve_name(key_node.id, mapping) + if resolved: + fields.add(resolved) + continue + _fail_unexpected_ast(f"unmapped schema key {key_node.id}") if not isinstance(key_node, ast.Call): _fail_unexpected_ast("schema key wrapper") @@ -506,6 +554,7 @@ def _extract_config_flow_keys() -> set[str]: if const_tree is not None: mapping.update(_extract_constant_assignments(const_tree)) mapping.update(_extract_constant_assignments(config_tree)) + mapping.update(_extract_schema_key_aliases(config_tree, mapping)) schema_info = _extract_schema_fields(config_tree, mapping) From 2683980c6a999e13d917ae130b3aae675605e26b Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sat, 20 Dec 2025 11:06:29 +0100 Subject: [PATCH 107/200] Fix config flow section schema --- CHANGELOG.md | 7 ++--- custom_components/pollenlevels/config_flow.py | 30 ++++++++++--------- tests/test_translations.py | 29 ++++++++++++++---- 3 files changed, 43 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d27bc50..25751b5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,9 @@ # Changelog ## [1.9.0-alpha3] - 2025-12-20 ### Fixed -- Restored the optional API key options section in the config flow so the HTTP - Referer field stays collapsed and avoids nested schema serialization errors. -- Fixed a config flow crash (500) caused by nested schema serialization when - rendering the optional API key section. +- Fixed config flow crash (500) caused by invalid section schema serialization; + HTTP Referer is now correctly placed inside a collapsed 'API key options' + section. - Preserved empty HTTP Referer values when the form re-renders to avoid accidentally overriding explicit empty input with section defaults. - Avoid pre-filling the API key field when the form is re-displayed after diff --git a/custom_components/pollenlevels/config_flow.py b/custom_components/pollenlevels/config_flow.py index 90c75d7d..92e6fba9 100644 --- a/custom_components/pollenlevels/config_flow.py +++ b/custom_components/pollenlevels/config_flow.py @@ -25,7 +25,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, CONF_NAME -from homeassistant.data_entry_flow import SectionConfig, section +from homeassistant.data_entry_flow import section from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import ( LocationSelector, @@ -187,13 +187,16 @@ def _build_step_user_schema(hass: Any, user_input: dict[str, Any] | None) -> vol options=FORECAST_SENSORS_CHOICES, ) ), - section(SECTION_API_KEY_OPTIONS, SectionConfig(collapsed=True)): vol.Schema( - { - vol.Optional( - CONF_HTTP_REFERER, - default=http_referer_default, - ): TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT)) - } + vol.Optional(SECTION_API_KEY_OPTIONS, default={}): section( + vol.Schema( + { + vol.Optional( + CONF_HTTP_REFERER, + default=http_referer_default, + ): TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT)) + } + ), + {"collapsed": True}, ), } ) @@ -240,9 +243,6 @@ def _build_step_user_schema(hass: Any, user_input: dict[str, Any] | None) -> vol options=FORECAST_SENSORS_CHOICES, ) ), - vol.Optional(CONF_HTTP_REFERER, default=http_referer_default): TextSelector( - TextSelectorConfig(type=TextSelectorType.TEXT) - ), } ) @@ -562,10 +562,12 @@ async def async_step_user(self, user_input=None): sanitized_input: dict[str, Any] = dict(user_input) # Backward/forward compatible extraction if the UI ever posts a section payload. - section_values = sanitized_input.get(SECTION_API_KEY_OPTIONS) - raw_http_referer = sanitized_input.get(CONF_HTTP_REFERER) - if raw_http_referer is None and isinstance(section_values, dict): + section_values = sanitized_input.get(SECTION_API_KEY_OPTIONS, {}) + raw_http_referer = None + if isinstance(section_values, dict): raw_http_referer = section_values.get(CONF_HTTP_REFERER) + if raw_http_referer is None: + raw_http_referer = sanitized_input.get(CONF_HTTP_REFERER) sanitized_input.pop(SECTION_API_KEY_OPTIONS, None) sanitized_input.pop(CONF_HTTP_REFERER, None) diff --git a/tests/test_translations.py b/tests/test_translations.py index a69f5a74..a07c1ca4 100644 --- a/tests/test_translations.py +++ b/tests/test_translations.py @@ -383,16 +383,35 @@ def _fields_from_schema_dict( if not key_node.args: _fail_unexpected_ast("schema key args") selector = key_node.args[0] + selector_value: str | None = None if isinstance(selector, ast.Constant) and isinstance(selector.value, str): - fields.add(selector.value) + selector_value = selector.value elif isinstance(selector, ast.Name): - resolved = _resolve_name(selector.id, mapping) - if resolved: - fields.add(resolved) - else: + selector_value = _resolve_name(selector.id, mapping) + if selector_value is None: _fail_unexpected_ast(f"unmapped selector {selector.id}") else: _fail_unexpected_ast("selector type") + + if ( + isinstance(value_node, ast.Call) + and isinstance(value_node.func, ast.Name) + and value_node.func.id == "section" + ): + if selector_value is None: + _fail_unexpected_ast("section selector missing") + sections.add(selector_value) + if not value_node.args: + _fail_unexpected_ast("section() missing schema value") + section_fields, nested_sections = _fields_from_section_value( + value_node.args[0], mapping + ) + fields.update(section_fields) + sections.update(nested_sections) + continue + + if selector_value is not None: + fields.add(selector_value) continue if isinstance(key_node.func, ast.Name) and key_node.func.id == "section": From 60e4739f8e0735ccb9750f07bf7a4b4e0226a155 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sat, 20 Dec 2025 11:14:29 +0100 Subject: [PATCH 108/200] Update custom_components/pollenlevels/config_flow.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- custom_components/pollenlevels/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/pollenlevels/config_flow.py b/custom_components/pollenlevels/config_flow.py index 92e6fba9..2e520b28 100644 --- a/custom_components/pollenlevels/config_flow.py +++ b/custom_components/pollenlevels/config_flow.py @@ -562,7 +562,7 @@ async def async_step_user(self, user_input=None): sanitized_input: dict[str, Any] = dict(user_input) # Backward/forward compatible extraction if the UI ever posts a section payload. - section_values = sanitized_input.get(SECTION_API_KEY_OPTIONS, {}) + section_values = sanitized_input.get(SECTION_API_KEY_OPTIONS) raw_http_referer = None if isinstance(section_values, dict): raw_http_referer = section_values.get(CONF_HTTP_REFERER) From bc3b3bf5d2b6b2f153187a32b26110e943fffb4c Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sat, 20 Dec 2025 12:38:03 +0100 Subject: [PATCH 109/200] Migrate per-day sensor mode to options --- CHANGELOG.md | 9 ++++ custom_components/pollenlevels/__init__.py | 29 +++++++++++++ custom_components/pollenlevels/config_flow.py | 25 ++++++++++- custom_components/pollenlevels/manifest.json | 2 +- pyproject.toml | 2 +- tests/test_init.py | 41 +++++++++++++++++++ 6 files changed, 104 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25751b5f..8cabba7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,13 @@ # Changelog +## [1.9.0-alpha4] - 2025-12-20 +### Fixed +- Fixed options flow to preserve the stored per-day sensor mode when no override + is set in entry options, preventing unintended resets to "none". +- Normalized invalid stored per-day sensor mode values in the options flow to + avoid persisting unsupported selector choices. +- Migrated per-day sensor mode to entry options when stored in entry data to + prevent option resets after upgrades. + ## [1.9.0-alpha3] - 2025-12-20 ### Fixed - Fixed config flow crash (500) caused by invalid section schema serialization; diff --git a/custom_components/pollenlevels/__init__.py b/custom_components/pollenlevels/__init__.py index c6d6f1e3..a7167b48 100644 --- a/custom_components/pollenlevels/__init__.py +++ b/custom_components/pollenlevels/__init__.py @@ -34,6 +34,7 @@ DEFAULT_FORECAST_DAYS, DEFAULT_UPDATE_INTERVAL, DOMAIN, + FORECAST_SENSORS_CHOICES, normalize_http_referer, ) from .coordinator import PollenDataUpdateCoordinator @@ -48,6 +49,34 @@ # ---- Service ------------------------------------------------------------- +async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Migrate config entry data to options when needed.""" + try: + opt_mode = entry.options.get(CONF_CREATE_FORECAST_SENSORS) + if opt_mode is not None: + return True + + data_mode = entry.data.get(CONF_CREATE_FORECAST_SENSORS) + if data_mode is None: + return True + + normalized_mode = data_mode + if normalized_mode not in FORECAST_SENSORS_CHOICES: + _LOGGER.warning( + "Invalid stored per-day sensor mode '%s'; defaulting to '%s'", + normalized_mode, + FORECAST_SENSORS_CHOICES[0], + ) + normalized_mode = FORECAST_SENSORS_CHOICES[0] + + new_options = {**entry.options, CONF_CREATE_FORECAST_SENSORS: normalized_mode} + hass.config_entries.async_update_entry(entry, options=new_options) + return True + except Exception: # noqa: BLE001 + _LOGGER.exception("Failed to migrate per-day sensor mode to entry options") + return False + + async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool: """Register force_update service.""" _LOGGER.debug("PollenLevels async_setup called") diff --git a/custom_components/pollenlevels/config_flow.py b/custom_components/pollenlevels/config_flow.py index 2e520b28..c9a8c10d 100644 --- a/custom_components/pollenlevels/config_flow.py +++ b/custom_components/pollenlevels/config_flow.py @@ -666,7 +666,17 @@ async def async_step_init(self, user_input=None): CONF_FORECAST_DAYS, self.entry.data.get(CONF_FORECAST_DAYS, DEFAULT_FORECAST_DAYS), ) - current_mode = self.entry.options.get(CONF_CREATE_FORECAST_SENSORS, "none") + current_mode = self.entry.options.get( + CONF_CREATE_FORECAST_SENSORS, + self.entry.data.get(CONF_CREATE_FORECAST_SENSORS, "none"), + ) + if current_mode not in FORECAST_SENSORS_CHOICES: + _LOGGER.warning( + "Invalid stored per-day sensor mode '%s'; defaulting to '%s'", + current_mode, + FORECAST_SENSORS_CHOICES[0], + ) + current_mode = FORECAST_SENSORS_CHOICES[0] options_schema = vol.Schema( { @@ -747,8 +757,19 @@ async def async_step_init(self, user_input=None): days = normalized_input[CONF_FORECAST_DAYS] mode = normalized_input.get( CONF_CREATE_FORECAST_SENSORS, - self.entry.options.get(CONF_CREATE_FORECAST_SENSORS, "none"), + self.entry.options.get( + CONF_CREATE_FORECAST_SENSORS, + self.entry.data.get(CONF_CREATE_FORECAST_SENSORS, "none"), + ), ) + if mode not in FORECAST_SENSORS_CHOICES: + _LOGGER.warning( + "Invalid per-day sensor mode '%s'; defaulting to '%s'", + mode, + FORECAST_SENSORS_CHOICES[0], + ) + mode = FORECAST_SENSORS_CHOICES[0] + normalized_input[CONF_CREATE_FORECAST_SENSORS] = mode needed = {"D+1": 2, "D+1+2": 3}.get(mode, 1) if days < needed: errors[CONF_CREATE_FORECAST_SENSORS] = "invalid_option_combo" diff --git a/custom_components/pollenlevels/manifest.json b/custom_components/pollenlevels/manifest.json index f4fcb68b..5719155c 100644 --- a/custom_components/pollenlevels/manifest.json +++ b/custom_components/pollenlevels/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "issue_tracker": "https://github.com/eXPerience83/pollenlevels/issues", - "version": "1.9.0-alpha3" + "version": "1.9.0-alpha4" } diff --git a/pyproject.toml b/pyproject.toml index 6170497e..3f1bb569 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ [project] name = "pollenlevels" -version = "1.9.0-alpha3" +version = "1.9.0-alpha4" # Enforce the runtime floor aligned with upcoming HA Python 3.14 images. requires-python = ">=3.14" diff --git a/tests/test_init.py b/tests/test_init.py index 527e42f9..56d7accb 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -263,6 +263,10 @@ async def async_unload_platforms(self, entry, platforms): self.unload_calls.append((entry, platforms)) return self._unload_result + def async_update_entry(self, entry, **kwargs): + if "options" in kwargs: + entry.options = kwargs["options"] + async def async_reload(self, entry_id: str): # pragma: no cover - used in tests self.reload_calls.append(entry_id) @@ -453,3 +457,40 @@ def async_request_refresh(self): assert entry1.runtime_data.coordinator.calls == ["refresh"] assert entry2.runtime_data.coordinator.calls == ["refresh"] + + +def test_migrate_entry_moves_mode_to_options() -> None: + """Migration should copy per-day sensor mode from data to options.""" + entry = _FakeEntry( + data={ + integration.CONF_API_KEY: "key", + integration.CONF_LATITUDE: 1.0, + integration.CONF_LONGITUDE: 2.0, + integration.CONF_CREATE_FORECAST_SENSORS: "D+1", + }, + options={}, + ) + hass = _FakeHass(entries=[entry]) + + assert asyncio.run(integration.async_migrate_entry(hass, entry)) is True + assert entry.options[integration.CONF_CREATE_FORECAST_SENSORS] == "D+1" + + +def test_migrate_entry_normalizes_invalid_mode() -> None: + """Migration should normalize invalid per-day sensor mode values.""" + entry = _FakeEntry( + data={ + integration.CONF_API_KEY: "key", + integration.CONF_LATITUDE: 1.0, + integration.CONF_LONGITUDE: 2.0, + integration.CONF_CREATE_FORECAST_SENSORS: "bad-value", + }, + options={}, + ) + hass = _FakeHass(entries=[entry]) + + assert asyncio.run(integration.async_migrate_entry(hass, entry)) is True + assert ( + entry.options[integration.CONF_CREATE_FORECAST_SENSORS] + == integration.FORECAST_SENSORS_CHOICES[0] + ) From 0eae3475566b1a20d03532ea9c8ee8a65104fec5 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sat, 20 Dec 2025 13:11:56 +0100 Subject: [PATCH 110/200] Centralize per-day sensor mode normalization --- CHANGELOG.md | 2 ++ custom_components/pollenlevels/__init__.py | 12 ++------- custom_components/pollenlevels/config_flow.py | 20 +++------------ custom_components/pollenlevels/util.py | 25 ++++++++++++++++++- 4 files changed, 32 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cabba7a..76c1e0fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ avoid persisting unsupported selector choices. - Migrated per-day sensor mode to entry options when stored in entry data to prevent option resets after upgrades. +- Centralized per-day sensor mode normalization to avoid duplicate validation + logic across migration and options handling. ## [1.9.0-alpha3] - 2025-12-20 ### Fixed diff --git a/custom_components/pollenlevels/__init__.py b/custom_components/pollenlevels/__init__.py index a7167b48..2fe1140e 100644 --- a/custom_components/pollenlevels/__init__.py +++ b/custom_components/pollenlevels/__init__.py @@ -34,12 +34,12 @@ DEFAULT_FORECAST_DAYS, DEFAULT_UPDATE_INTERVAL, DOMAIN, - FORECAST_SENSORS_CHOICES, normalize_http_referer, ) from .coordinator import PollenDataUpdateCoordinator from .runtime import PollenLevelsConfigEntry, PollenLevelsRuntimeData from .sensor import ForecastSensorMode +from .util import normalize_sensor_mode # Ensure YAML config is entry-only for this domain (no YAML schema). CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -60,15 +60,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if data_mode is None: return True - normalized_mode = data_mode - if normalized_mode not in FORECAST_SENSORS_CHOICES: - _LOGGER.warning( - "Invalid stored per-day sensor mode '%s'; defaulting to '%s'", - normalized_mode, - FORECAST_SENSORS_CHOICES[0], - ) - normalized_mode = FORECAST_SENSORS_CHOICES[0] - + normalized_mode = normalize_sensor_mode(data_mode, _LOGGER) new_options = {**entry.options, CONF_CREATE_FORECAST_SENSORS: normalized_mode} hass.config_entries.async_update_entry(entry, options=new_options) return True diff --git a/custom_components/pollenlevels/config_flow.py b/custom_components/pollenlevels/config_flow.py index c9a8c10d..496ef00f 100644 --- a/custom_components/pollenlevels/config_flow.py +++ b/custom_components/pollenlevels/config_flow.py @@ -62,7 +62,7 @@ is_invalid_api_key_message, normalize_http_referer, ) -from .util import extract_error_message, redact_api_key +from .util import extract_error_message, normalize_sensor_mode, redact_api_key _LOGGER = logging.getLogger(__name__) @@ -670,13 +670,7 @@ async def async_step_init(self, user_input=None): CONF_CREATE_FORECAST_SENSORS, self.entry.data.get(CONF_CREATE_FORECAST_SENSORS, "none"), ) - if current_mode not in FORECAST_SENSORS_CHOICES: - _LOGGER.warning( - "Invalid stored per-day sensor mode '%s'; defaulting to '%s'", - current_mode, - FORECAST_SENSORS_CHOICES[0], - ) - current_mode = FORECAST_SENSORS_CHOICES[0] + current_mode = normalize_sensor_mode(current_mode, _LOGGER) options_schema = vol.Schema( { @@ -762,14 +756,8 @@ async def async_step_init(self, user_input=None): self.entry.data.get(CONF_CREATE_FORECAST_SENSORS, "none"), ), ) - if mode not in FORECAST_SENSORS_CHOICES: - _LOGGER.warning( - "Invalid per-day sensor mode '%s'; defaulting to '%s'", - mode, - FORECAST_SENSORS_CHOICES[0], - ) - mode = FORECAST_SENSORS_CHOICES[0] - normalized_input[CONF_CREATE_FORECAST_SENSORS] = mode + mode = normalize_sensor_mode(mode, _LOGGER) + normalized_input[CONF_CREATE_FORECAST_SENSORS] = mode needed = {"D+1": 2, "D+1+2": 3}.get(mode, 1) if days < needed: errors[CONF_CREATE_FORECAST_SENSORS] = "invalid_option_combo" diff --git a/custom_components/pollenlevels/util.py b/custom_components/pollenlevels/util.py index 30f827b3..08d8816b 100644 --- a/custom_components/pollenlevels/util.py +++ b/custom_components/pollenlevels/util.py @@ -2,8 +2,11 @@ from __future__ import annotations +import logging from typing import TYPE_CHECKING, Any +from .const import FORECAST_SENSORS_CHOICES + if TYPE_CHECKING: # pragma: no cover - typing-only import from aiohttp import ClientResponse else: # pragma: no cover - runtime fallback for test environments without aiohttp @@ -65,7 +68,27 @@ def redact_api_key(text: object, api_key: str | None) -> str: return s +def normalize_sensor_mode(mode: str | None, logger: logging.Logger) -> str: + """Normalize sensor mode, defaulting and logging a warning if invalid.""" + if mode in FORECAST_SENSORS_CHOICES: + return mode + + default_mode = FORECAST_SENSORS_CHOICES[0] + if mode is not None: + logger.warning( + "Invalid stored per-day sensor mode '%s'; defaulting to '%s'", + mode, + default_mode, + ) + return default_mode + + # Backwards-compatible alias for modules that still import the private helper name. _redact_api_key = redact_api_key -__all__ = ["extract_error_message", "redact_api_key", "_redact_api_key"] +__all__ = [ + "extract_error_message", + "normalize_sensor_mode", + "redact_api_key", + "_redact_api_key", +] From df917ce8d397ea283d59869f125e5714e13e531f Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sat, 20 Dec 2025 13:20:22 +0100 Subject: [PATCH 111/200] Fix migration test constant reference --- tests/test_init.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_init.py b/tests/test_init.py index 56d7accb..1eb149ff 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -238,6 +238,7 @@ def async_request_refresh(self): # pragma: no cover - scheduling helper integration = importlib.import_module( "custom_components.pollenlevels.__init__" ) # noqa: E402 +const = importlib.import_module("custom_components.pollenlevels.const") # noqa: E402 class _FakeConfigEntries: @@ -492,5 +493,5 @@ def test_migrate_entry_normalizes_invalid_mode() -> None: assert asyncio.run(integration.async_migrate_entry(hass, entry)) is True assert ( entry.options[integration.CONF_CREATE_FORECAST_SENSORS] - == integration.FORECAST_SENSORS_CHOICES[0] + == const.FORECAST_SENSORS_CHOICES[0] ) From b970a23304283318eb00988dd8e36d5a9f60582c Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sat, 20 Dec 2025 15:01:04 +0100 Subject: [PATCH 112/200] Normalize stored per-day sensor mode options --- CHANGELOG.md | 2 ++ custom_components/pollenlevels/__init__.py | 7 +++++++ 2 files changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 76c1e0fd..5ce0ce12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ prevent option resets after upgrades. - Centralized per-day sensor mode normalization to avoid duplicate validation logic across migration and options handling. +- Normalized invalid per-day sensor mode values already stored in entry options + during migration to keep options consistent. ## [1.9.0-alpha3] - 2025-12-20 ### Fixed diff --git a/custom_components/pollenlevels/__init__.py b/custom_components/pollenlevels/__init__.py index 2fe1140e..e98da1aa 100644 --- a/custom_components/pollenlevels/__init__.py +++ b/custom_components/pollenlevels/__init__.py @@ -54,6 +54,13 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: opt_mode = entry.options.get(CONF_CREATE_FORECAST_SENSORS) if opt_mode is not None: + normalized_mode = normalize_sensor_mode(opt_mode, _LOGGER) + if normalized_mode != opt_mode: + new_options = { + **entry.options, + CONF_CREATE_FORECAST_SENSORS: normalized_mode, + } + hass.config_entries.async_update_entry(entry, options=new_options) return True data_mode = entry.data.get(CONF_CREATE_FORECAST_SENSORS) From 7605b497eb9b30e377ebbe389215a39bb470d4d5 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sat, 20 Dec 2025 15:14:04 +0100 Subject: [PATCH 113/200] Version entry migration for per-day sensor mode --- CHANGELOG.md | 2 ++ custom_components/pollenlevels/__init__.py | 15 ++++++++++++-- custom_components/pollenlevels/config_flow.py | 2 +- tests/test_init.py | 20 +++++++++++++++++++ 4 files changed, 36 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ce0ce12..fc2d218d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ logic across migration and options handling. - Normalized invalid per-day sensor mode values already stored in entry options during migration to keep options consistent. +- Versioned config entries to ensure the per-day sensor mode migration runs + once and is not repeated on every restart. ## [1.9.0-alpha3] - 2025-12-20 ### Fixed diff --git a/custom_components/pollenlevels/__init__.py b/custom_components/pollenlevels/__init__.py index e98da1aa..3c3e644b 100644 --- a/custom_components/pollenlevels/__init__.py +++ b/custom_components/pollenlevels/__init__.py @@ -52,6 +52,10 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Migrate config entry data to options when needed.""" try: + target_version = 2 + if getattr(entry, "version", target_version) >= target_version: + return True + opt_mode = entry.options.get(CONF_CREATE_FORECAST_SENSORS) if opt_mode is not None: normalized_mode = normalize_sensor_mode(opt_mode, _LOGGER) @@ -60,16 +64,23 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: **entry.options, CONF_CREATE_FORECAST_SENSORS: normalized_mode, } - hass.config_entries.async_update_entry(entry, options=new_options) + hass.config_entries.async_update_entry( + entry, options=new_options, version=target_version + ) + else: + hass.config_entries.async_update_entry(entry, version=target_version) return True data_mode = entry.data.get(CONF_CREATE_FORECAST_SENSORS) if data_mode is None: + hass.config_entries.async_update_entry(entry, version=target_version) return True normalized_mode = normalize_sensor_mode(data_mode, _LOGGER) new_options = {**entry.options, CONF_CREATE_FORECAST_SENSORS: normalized_mode} - hass.config_entries.async_update_entry(entry, options=new_options) + hass.config_entries.async_update_entry( + entry, options=new_options, version=target_version + ) return True except Exception: # noqa: BLE001 _LOGGER.exception("Failed to migrate per-day sensor mode to entry options") diff --git a/custom_components/pollenlevels/config_flow.py b/custom_components/pollenlevels/config_flow.py index 496ef00f..68b9d6a9 100644 --- a/custom_components/pollenlevels/config_flow.py +++ b/custom_components/pollenlevels/config_flow.py @@ -313,7 +313,7 @@ def _parse_update_interval(value: Any, default: int) -> tuple[int, str | None]: class PollenLevelsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Config flow for Pollen Levels.""" - VERSION = 1 + VERSION = 2 def __init__(self) -> None: """Initialize the config flow state.""" diff --git a/tests/test_init.py b/tests/test_init.py index 1eb149ff..a489995b 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -267,6 +267,8 @@ async def async_unload_platforms(self, entry, platforms): def async_update_entry(self, entry, **kwargs): if "options" in kwargs: entry.options = kwargs["options"] + if "version" in kwargs: + entry.version = kwargs["version"] async def async_reload(self, entry_id: str): # pragma: no cover - used in tests self.reload_calls.append(entry_id) @@ -287,6 +289,7 @@ def __init__( title: str = "Pollen Levels", data: dict | None = None, options: dict | None = None, + version: int = 1, ): self.entry_id = entry_id self.title = title @@ -298,6 +301,7 @@ def __init__( integration.CONF_LONGITUDE: 2.0, } self.options = options or {} + self.version = version self.runtime_data = None def add_update_listener(self, listener): @@ -470,11 +474,13 @@ def test_migrate_entry_moves_mode_to_options() -> None: integration.CONF_CREATE_FORECAST_SENSORS: "D+1", }, options={}, + version=1, ) hass = _FakeHass(entries=[entry]) assert asyncio.run(integration.async_migrate_entry(hass, entry)) is True assert entry.options[integration.CONF_CREATE_FORECAST_SENSORS] == "D+1" + assert entry.version == 2 def test_migrate_entry_normalizes_invalid_mode() -> None: @@ -487,6 +493,7 @@ def test_migrate_entry_normalizes_invalid_mode() -> None: integration.CONF_CREATE_FORECAST_SENSORS: "bad-value", }, options={}, + version=1, ) hass = _FakeHass(entries=[entry]) @@ -495,3 +502,16 @@ def test_migrate_entry_normalizes_invalid_mode() -> None: entry.options[integration.CONF_CREATE_FORECAST_SENSORS] == const.FORECAST_SENSORS_CHOICES[0] ) + assert entry.version == 2 + + +def test_migrate_entry_marks_version_when_no_changes() -> None: + """Migration should still bump the version when no changes are needed.""" + entry = _FakeEntry( + options={integration.CONF_CREATE_FORECAST_SENSORS: "D+1"}, + version=1, + ) + hass = _FakeHass(entries=[entry]) + + assert asyncio.run(integration.async_migrate_entry(hass, entry)) is True + assert entry.version == 2 From 2a6f9a8c1bba4d758b30dce56f5fe3e100da620d Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sat, 20 Dec 2025 15:19:16 +0100 Subject: [PATCH 114/200] Simplify mode fallback and expand migration tests --- custom_components/pollenlevels/config_flow.py | 5 +---- tests/test_init.py | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/custom_components/pollenlevels/config_flow.py b/custom_components/pollenlevels/config_flow.py index 68b9d6a9..fa7d03c2 100644 --- a/custom_components/pollenlevels/config_flow.py +++ b/custom_components/pollenlevels/config_flow.py @@ -751,10 +751,7 @@ async def async_step_init(self, user_input=None): days = normalized_input[CONF_FORECAST_DAYS] mode = normalized_input.get( CONF_CREATE_FORECAST_SENSORS, - self.entry.options.get( - CONF_CREATE_FORECAST_SENSORS, - self.entry.data.get(CONF_CREATE_FORECAST_SENSORS, "none"), - ), + self.entry.data.get(CONF_CREATE_FORECAST_SENSORS, "none"), ) mode = normalize_sensor_mode(mode, _LOGGER) normalized_input[CONF_CREATE_FORECAST_SENSORS] = mode diff --git a/tests/test_init.py b/tests/test_init.py index a489995b..7fc41877 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -505,6 +505,23 @@ def test_migrate_entry_normalizes_invalid_mode() -> None: assert entry.version == 2 +def test_migrate_entry_normalizes_invalid_mode_in_options() -> None: + """Migration should normalize invalid per-day sensor mode values in options.""" + entry = _FakeEntry( + data={}, + options={integration.CONF_CREATE_FORECAST_SENSORS: "bad-value"}, + version=1, + ) + hass = _FakeHass(entries=[entry]) + + assert asyncio.run(integration.async_migrate_entry(hass, entry)) is True + assert ( + entry.options[integration.CONF_CREATE_FORECAST_SENSORS] + == const.FORECAST_SENSORS_CHOICES[0] + ) + assert entry.version == 2 + + def test_migrate_entry_marks_version_when_no_changes() -> None: """Migration should still bump the version when no changes are needed.""" entry = _FakeEntry( From 035c1bdadb004b43cfbcf2ecb69d41fdab41c0c4 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sat, 20 Dec 2025 15:26:08 +0100 Subject: [PATCH 115/200] Fix migration version handling --- custom_components/pollenlevels/__init__.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/custom_components/pollenlevels/__init__.py b/custom_components/pollenlevels/__init__.py index 3c3e644b..d79d5846 100644 --- a/custom_components/pollenlevels/__init__.py +++ b/custom_components/pollenlevels/__init__.py @@ -53,11 +53,12 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Migrate config entry data to options when needed.""" try: target_version = 2 - if getattr(entry, "version", target_version) >= target_version: + current_version = getattr(entry, "version", 1) + if current_version >= target_version: return True - opt_mode = entry.options.get(CONF_CREATE_FORECAST_SENSORS) - if opt_mode is not None: + if CONF_CREATE_FORECAST_SENSORS in entry.options: + opt_mode = entry.options.get(CONF_CREATE_FORECAST_SENSORS) normalized_mode = normalize_sensor_mode(opt_mode, _LOGGER) if normalized_mode != opt_mode: new_options = { From 591b704c536d9865c5edd9454a9416c1d3f4c05f Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sat, 20 Dec 2025 15:30:05 +0100 Subject: [PATCH 116/200] Move API key options section near API key --- CHANGELOG.md | 2 ++ custom_components/pollenlevels/config_flow.py | 22 +++++++++---------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc2d218d..2db71043 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ during migration to keep options consistent. - Versioned config entries to ensure the per-day sensor mode migration runs once and is not repeated on every restart. +- Moved the optional API key section directly below the API key field in the + setup flow for improved visibility. ## [1.9.0-alpha3] - 2025-12-20 ### Fixed diff --git a/custom_components/pollenlevels/config_flow.py b/custom_components/pollenlevels/config_flow.py index fa7d03c2..c9b740aa 100644 --- a/custom_components/pollenlevels/config_flow.py +++ b/custom_components/pollenlevels/config_flow.py @@ -148,6 +148,17 @@ def _build_step_user_schema(hass: Any, user_input: dict[str, Any] | None) -> vol section_schema = vol.Schema( { vol.Required(CONF_API_KEY): str, + vol.Optional(SECTION_API_KEY_OPTIONS, default={}): section( + vol.Schema( + { + vol.Optional( + CONF_HTTP_REFERER, + default=http_referer_default, + ): TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT)) + } + ), + {"collapsed": True}, + ), vol.Required(CONF_NAME, default=default_name): str, location_field: LocationSelector(LocationSelectorConfig(radius=False)), vol.Optional( @@ -187,17 +198,6 @@ def _build_step_user_schema(hass: Any, user_input: dict[str, Any] | None) -> vol options=FORECAST_SENSORS_CHOICES, ) ), - vol.Optional(SECTION_API_KEY_OPTIONS, default={}): section( - vol.Schema( - { - vol.Optional( - CONF_HTTP_REFERER, - default=http_referer_default, - ): TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT)) - } - ), - {"collapsed": True}, - ), } ) From ad87252db1fe1e1526e6796939aa829773971067 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sat, 20 Dec 2025 15:42:09 +0100 Subject: [PATCH 117/200] Use current_mode fallback in options flow --- custom_components/pollenlevels/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/pollenlevels/config_flow.py b/custom_components/pollenlevels/config_flow.py index c9b740aa..9b2650f4 100644 --- a/custom_components/pollenlevels/config_flow.py +++ b/custom_components/pollenlevels/config_flow.py @@ -751,7 +751,7 @@ async def async_step_init(self, user_input=None): days = normalized_input[CONF_FORECAST_DAYS] mode = normalized_input.get( CONF_CREATE_FORECAST_SENSORS, - self.entry.data.get(CONF_CREATE_FORECAST_SENSORS, "none"), + current_mode, ) mode = normalize_sensor_mode(mode, _LOGGER) normalized_input[CONF_CREATE_FORECAST_SENSORS] = mode From 2577b955dfa9c5e0ff92b68b09e27ce03baae483 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sat, 20 Dec 2025 15:45:55 +0100 Subject: [PATCH 118/200] Update changelog for migration refinements --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2db71043..9ded51f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ is set in entry options, preventing unintended resets to "none". - Normalized invalid stored per-day sensor mode values in the options flow to avoid persisting unsupported selector choices. +- Simplified the per-day sensor mode fallback during options submission to reuse + the normalized current mode and prevent regressions. - Migrated per-day sensor mode to entry options when stored in entry data to prevent option resets after upgrades. - Centralized per-day sensor mode normalization to avoid duplicate validation @@ -13,6 +15,8 @@ during migration to keep options consistent. - Versioned config entries to ensure the per-day sensor mode migration runs once and is not repeated on every restart. +- Ensured unversioned entries run the per-day sensor mode migration and that + option presence is respected even when the stored value is None. - Moved the optional API key section directly below the API key field in the setup flow for improved visibility. From c610e003b36dd6534e95bca93487a803bef9f115 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sat, 20 Dec 2025 19:46:25 +0100 Subject: [PATCH 119/200] Refactor migration flow --- custom_components/pollenlevels/__init__.py | 41 +++++++++------------- 1 file changed, 17 insertions(+), 24 deletions(-) diff --git a/custom_components/pollenlevels/__init__.py b/custom_components/pollenlevels/__init__.py index d79d5846..8b7cb069 100644 --- a/custom_components/pollenlevels/__init__.py +++ b/custom_components/pollenlevels/__init__.py @@ -57,31 +57,24 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if current_version >= target_version: return True - if CONF_CREATE_FORECAST_SENSORS in entry.options: - opt_mode = entry.options.get(CONF_CREATE_FORECAST_SENSORS) - normalized_mode = normalize_sensor_mode(opt_mode, _LOGGER) - if normalized_mode != opt_mode: - new_options = { - **entry.options, - CONF_CREATE_FORECAST_SENSORS: normalized_mode, - } - hass.config_entries.async_update_entry( - entry, options=new_options, version=target_version - ) - else: - hass.config_entries.async_update_entry(entry, version=target_version) - return True - - data_mode = entry.data.get(CONF_CREATE_FORECAST_SENSORS) - if data_mode is None: + new_options = dict(entry.options) + if CONF_CREATE_FORECAST_SENSORS in new_options: + mode = new_options.get(CONF_CREATE_FORECAST_SENSORS) + normalized_mode = normalize_sensor_mode(mode, _LOGGER) + if normalized_mode != mode: + new_options[CONF_CREATE_FORECAST_SENSORS] = normalized_mode + else: + mode = entry.data.get(CONF_CREATE_FORECAST_SENSORS) + if mode is not None: + normalized_mode = normalize_sensor_mode(mode, _LOGGER) + new_options[CONF_CREATE_FORECAST_SENSORS] = normalized_mode + + if new_options != entry.options: + hass.config_entries.async_update_entry( + entry, options=new_options, version=target_version + ) + else: hass.config_entries.async_update_entry(entry, version=target_version) - return True - - normalized_mode = normalize_sensor_mode(data_mode, _LOGGER) - new_options = {**entry.options, CONF_CREATE_FORECAST_SENSORS: normalized_mode} - hass.config_entries.async_update_entry( - entry, options=new_options, version=target_version - ) return True except Exception: # noqa: BLE001 _LOGGER.exception("Failed to migrate per-day sensor mode to entry options") From d40d3a5a91cd36ab9d0078b6faa753e7f8f0e241 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sat, 20 Dec 2025 19:50:37 +0100 Subject: [PATCH 120/200] Improve migration logging and schema defaults --- custom_components/pollenlevels/__init__.py | 7 ++++++- custom_components/pollenlevels/config_flow.py | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/custom_components/pollenlevels/__init__.py b/custom_components/pollenlevels/__init__.py index 8b7cb069..afa7da1b 100644 --- a/custom_components/pollenlevels/__init__.py +++ b/custom_components/pollenlevels/__init__.py @@ -77,7 +77,12 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.config_entries.async_update_entry(entry, version=target_version) return True except Exception: # noqa: BLE001 - _LOGGER.exception("Failed to migrate per-day sensor mode to entry options") + _LOGGER.exception( + "Failed to migrate per-day sensor mode to entry options for entry %s " + "(version=%s)", + entry.entry_id, + getattr(entry, "version", None), + ) return False diff --git a/custom_components/pollenlevels/config_flow.py b/custom_components/pollenlevels/config_flow.py index 9b2650f4..4620041d 100644 --- a/custom_components/pollenlevels/config_flow.py +++ b/custom_components/pollenlevels/config_flow.py @@ -148,7 +148,7 @@ def _build_step_user_schema(hass: Any, user_input: dict[str, Any] | None) -> vol section_schema = vol.Schema( { vol.Required(CONF_API_KEY): str, - vol.Optional(SECTION_API_KEY_OPTIONS, default={}): section( + vol.Optional(SECTION_API_KEY_OPTIONS): section( vol.Schema( { vol.Optional( From 85e6bf3d771061c0cacf71a3e9e5828bf4983ce4 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sun, 21 Dec 2025 09:09:20 +0100 Subject: [PATCH 121/200] Handle None modes and cancellation in migration --- custom_components/pollenlevels/__init__.py | 11 ++++++++--- custom_components/pollenlevels/config_flow.py | 7 +++---- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/custom_components/pollenlevels/__init__.py b/custom_components/pollenlevels/__init__.py index afa7da1b..36d146af 100644 --- a/custom_components/pollenlevels/__init__.py +++ b/custom_components/pollenlevels/__init__.py @@ -60,9 +60,12 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: new_options = dict(entry.options) if CONF_CREATE_FORECAST_SENSORS in new_options: mode = new_options.get(CONF_CREATE_FORECAST_SENSORS) - normalized_mode = normalize_sensor_mode(mode, _LOGGER) - if normalized_mode != mode: - new_options[CONF_CREATE_FORECAST_SENSORS] = normalized_mode + if mode is None: + new_options.pop(CONF_CREATE_FORECAST_SENSORS, None) + else: + normalized_mode = normalize_sensor_mode(mode, _LOGGER) + if normalized_mode != mode: + new_options[CONF_CREATE_FORECAST_SENSORS] = normalized_mode else: mode = entry.data.get(CONF_CREATE_FORECAST_SENSORS) if mode is not None: @@ -76,6 +79,8 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: else: hass.config_entries.async_update_entry(entry, version=target_version) return True + except asyncio.CancelledError: + raise except Exception: # noqa: BLE001 _LOGGER.exception( "Failed to migrate per-day sensor mode to entry options for entry %s " diff --git a/custom_components/pollenlevels/config_flow.py b/custom_components/pollenlevels/config_flow.py index 4620041d..90771d1b 100644 --- a/custom_components/pollenlevels/config_flow.py +++ b/custom_components/pollenlevels/config_flow.py @@ -666,10 +666,9 @@ async def async_step_init(self, user_input=None): CONF_FORECAST_DAYS, self.entry.data.get(CONF_FORECAST_DAYS, DEFAULT_FORECAST_DAYS), ) - current_mode = self.entry.options.get( - CONF_CREATE_FORECAST_SENSORS, - self.entry.data.get(CONF_CREATE_FORECAST_SENSORS, "none"), - ) + current_mode = self.entry.options.get(CONF_CREATE_FORECAST_SENSORS) + if current_mode is None: + current_mode = self.entry.data.get(CONF_CREATE_FORECAST_SENSORS, "none") current_mode = normalize_sensor_mode(current_mode, _LOGGER) options_schema = vol.Schema( From 1bd61340523bf79e3b10ab9052bab0e2a81a9252 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sun, 21 Dec 2025 09:09:28 +0100 Subject: [PATCH 122/200] Restore http referer field in flat setup --- CHANGELOG.md | 2 ++ custom_components/pollenlevels/config_flow.py | 4 ++++ custom_components/pollenlevels/translations/ca.json | 2 +- custom_components/pollenlevels/translations/cs.json | 2 +- custom_components/pollenlevels/translations/da.json | 2 +- custom_components/pollenlevels/translations/de.json | 2 +- custom_components/pollenlevels/translations/en.json | 2 +- custom_components/pollenlevels/translations/es.json | 2 +- custom_components/pollenlevels/translations/fi.json | 2 +- custom_components/pollenlevels/translations/fr.json | 2 +- custom_components/pollenlevels/translations/hu.json | 2 +- custom_components/pollenlevels/translations/it.json | 2 +- custom_components/pollenlevels/translations/nb.json | 2 +- custom_components/pollenlevels/translations/nl.json | 2 +- custom_components/pollenlevels/translations/pl.json | 2 +- custom_components/pollenlevels/translations/pt-BR.json | 2 +- custom_components/pollenlevels/translations/pt-PT.json | 2 +- custom_components/pollenlevels/translations/ro.json | 2 +- custom_components/pollenlevels/translations/ru.json | 2 +- custom_components/pollenlevels/translations/sv.json | 2 +- custom_components/pollenlevels/translations/uk.json | 2 +- custom_components/pollenlevels/translations/zh-Hans.json | 2 +- custom_components/pollenlevels/translations/zh-Hant.json | 2 +- 23 files changed, 27 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ded51f5..3f2effc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ option presence is respected even when the stored value is None. - Moved the optional API key section directly below the API key field in the setup flow for improved visibility. +- Restored the HTTP Referer field when the setup schema falls back to the flat + layout and updated setup guidance to mention forecast configuration. ## [1.9.0-alpha3] - 2025-12-20 ### Fixed diff --git a/custom_components/pollenlevels/config_flow.py b/custom_components/pollenlevels/config_flow.py index 90771d1b..a95444f5 100644 --- a/custom_components/pollenlevels/config_flow.py +++ b/custom_components/pollenlevels/config_flow.py @@ -243,6 +243,10 @@ def _build_step_user_schema(hass: Any, user_input: dict[str, Any] | None) -> vol options=FORECAST_SENSORS_CHOICES, ) ), + vol.Optional( + CONF_HTTP_REFERER, + default=http_referer_default, + ): TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT)), } ) diff --git a/custom_components/pollenlevels/translations/ca.json b/custom_components/pollenlevels/translations/ca.json index efb41fa0..d5c01fc9 100644 --- a/custom_components/pollenlevels/translations/ca.json +++ b/custom_components/pollenlevels/translations/ca.json @@ -3,7 +3,7 @@ "step": { "user": { "title": "Configuració de Nivells de pol·len", - "description": "Introdueix la teva clau API de Google ([aconsegueix-la aquí]({api_key_url})) i revisa les bones pràctiques ([bones pràctiques]({restricting_api_keys_url})). Selecciona la ubicació al mapa, l’interval d’actualització (hores) i el codi d’idioma de la resposta de l’API.", + "description": "Introdueix la teva clau API de Google ([aconsegueix-la aquí]({api_key_url})) i revisa les bones pràctiques ([bones pràctiques]({restricting_api_keys_url})). Selecciona la ubicació al mapa, l’interval d’actualització (hores) i el codi d’idioma de la resposta de l’API. You can also set forecast days and per-day TYPE sensor range.", "data": { "api_key": "Clau API", "name": "Nom", diff --git a/custom_components/pollenlevels/translations/cs.json b/custom_components/pollenlevels/translations/cs.json index abfd0d5a..acd84426 100644 --- a/custom_components/pollenlevels/translations/cs.json +++ b/custom_components/pollenlevels/translations/cs.json @@ -37,7 +37,7 @@ "forecast_days": "Dny předpovědi (1–5)", "create_forecast_sensors": "Rozsah senzorů po dnech (TYPY)" }, - "description": "Zadejte svůj Google API klíč ([získejte jej zde]({api_key_url})) a přečtěte si doporučené postupy ([doporučené postupy]({restricting_api_keys_url})). Vyberte polohu na mapě, interval aktualizace (v hodinách) a jazykový kód odpovědi API.", + "description": "Zadejte svůj Google API klíč ([získejte jej zde]({api_key_url})) a přečtěte si doporučené postupy ([doporučené postupy]({restricting_api_keys_url})). Vyberte polohu na mapě, interval aktualizace (v hodinách) a jazykový kód odpovědi API. You can also set forecast days and per-day TYPE sensor range.", "title": "Konfigurace úrovní pylu", "sections": { "api_key_options": { diff --git a/custom_components/pollenlevels/translations/da.json b/custom_components/pollenlevels/translations/da.json index ef953c6a..ce215305 100644 --- a/custom_components/pollenlevels/translations/da.json +++ b/custom_components/pollenlevels/translations/da.json @@ -37,7 +37,7 @@ "forecast_days": "Prognosedage (1–5)", "create_forecast_sensors": "Omfang af sensorer pr. dag (TYPER)" }, - "description": "Indtast din Google API-nøgle ([hent den her]({api_key_url})) og læs bedste praksis ([bedste praksis]({restricting_api_keys_url})). Vælg din placering på kortet, opdateringsinterval (timer) og sprogkode for API-svaret.", + "description": "Indtast din Google API-nøgle ([hent den her]({api_key_url})) og læs bedste praksis ([bedste praksis]({restricting_api_keys_url})). Vælg din placering på kortet, opdateringsinterval (timer) og sprogkode for API-svaret. You can also set forecast days and per-day TYPE sensor range.", "title": "Konfiguration af pollenniveauer", "sections": { "api_key_options": { diff --git a/custom_components/pollenlevels/translations/de.json b/custom_components/pollenlevels/translations/de.json index 11c1ecff..65858ef8 100644 --- a/custom_components/pollenlevels/translations/de.json +++ b/custom_components/pollenlevels/translations/de.json @@ -37,7 +37,7 @@ "forecast_days": "Vorhersagetage (1–5)", "create_forecast_sensors": "Bereich der Tagessensoren (TYPEN)" }, - "description": "Gib deinen Google API-Schlüssel ein ([hier abrufen]({api_key_url})) und lies die Best Practices ([Best Practices]({restricting_api_keys_url})). Wähle deinen Standort auf der Karte, das Aktualisierungsintervall (Stunden) und den Sprachcode der API-Antwort.", + "description": "Gib deinen Google API-Schlüssel ein ([hier abrufen]({api_key_url})) und lies die Best Practices ([Best Practices]({restricting_api_keys_url})). Wähle deinen Standort auf der Karte, das Aktualisierungsintervall (Stunden) und den Sprachcode der API-Antwort. You can also set forecast days and per-day TYPE sensor range.", "title": "Pollen Levels – Konfiguration", "sections": { "api_key_options": { diff --git a/custom_components/pollenlevels/translations/en.json b/custom_components/pollenlevels/translations/en.json index fe08f245..220784c8 100644 --- a/custom_components/pollenlevels/translations/en.json +++ b/custom_components/pollenlevels/translations/en.json @@ -3,7 +3,7 @@ "step": { "user": { "title": "Pollen Levels Configuration", - "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code.", + "description": "Enter your Google API Key ([get it here]({api_key_url})) and review best practices ([best practices]({restricting_api_keys_url})). Select your location on the map, update interval (hours) and API response language code. You can also set forecast days and per-day TYPE sensor range.", "data": { "api_key": "API Key", "name": "Name", diff --git a/custom_components/pollenlevels/translations/es.json b/custom_components/pollenlevels/translations/es.json index 3d7b76f3..9fe407b5 100644 --- a/custom_components/pollenlevels/translations/es.json +++ b/custom_components/pollenlevels/translations/es.json @@ -3,7 +3,7 @@ "step": { "user": { "title": "Configuración de Niveles de Polen", - "description": "Introduce tu clave API de Google ([consíguela aquí]({api_key_url})) y revisa las buenas prácticas ([buenas prácticas]({restricting_api_keys_url})). Selecciona tu ubicación en el mapa, el intervalo de actualización (horas) y el código de idioma de la respuesta de la API.", + "description": "Introduce tu clave API de Google ([consíguela aquí]({api_key_url})) y revisa las buenas prácticas ([buenas prácticas]({restricting_api_keys_url})). Selecciona tu ubicación en el mapa, el intervalo de actualización (horas) y el código de idioma de la respuesta de la API. You can also set forecast days and per-day TYPE sensor range.", "data": { "api_key": "Clave API", "name": "Nombre", diff --git a/custom_components/pollenlevels/translations/fi.json b/custom_components/pollenlevels/translations/fi.json index 9dc7bd25..b2de6c87 100644 --- a/custom_components/pollenlevels/translations/fi.json +++ b/custom_components/pollenlevels/translations/fi.json @@ -37,7 +37,7 @@ "forecast_days": "Ennustepäivät (1–5)", "create_forecast_sensors": "Päiväsensorien laajuus (TYYPIT)" }, - "description": "Syötä Google API -avaimesi ([hanki se täältä]({api_key_url})) ja tutustu parhaisiin käytäntöihin ([parhaat käytännöt]({restricting_api_keys_url})). Valitse sijainti kartalta, päivitysväli (tunteina) ja API-vastauksen kielikoodi.", + "description": "Syötä Google API -avaimesi ([hanki se täältä]({api_key_url})) ja tutustu parhaisiin käytäntöihin ([parhaat käytännöt]({restricting_api_keys_url})). Valitse sijainti kartalta, päivitysväli (tunteina) ja API-vastauksen kielikoodi. You can also set forecast days and per-day TYPE sensor range.", "title": "Siitepölytason asetukset", "sections": { "api_key_options": { diff --git a/custom_components/pollenlevels/translations/fr.json b/custom_components/pollenlevels/translations/fr.json index e1f3eb77..091f1e0a 100644 --- a/custom_components/pollenlevels/translations/fr.json +++ b/custom_components/pollenlevels/translations/fr.json @@ -37,7 +37,7 @@ "forecast_days": "Jours de prévision (1–5)", "create_forecast_sensors": "Portée des capteurs par jour (TYPES)" }, - "description": "Saisissez votre clé API Google ([l’obtenir ici]({api_key_url})) et consultez les bonnes pratiques ([bonnes pratiques]({restricting_api_keys_url})). Sélectionnez votre emplacement sur la carte, l’intervalle de mise à jour (heures) et le code de langue de la réponse de l’API.", + "description": "Saisissez votre clé API Google ([l’obtenir ici]({api_key_url})) et consultez les bonnes pratiques ([bonnes pratiques]({restricting_api_keys_url})). Sélectionnez votre emplacement sur la carte, l’intervalle de mise à jour (heures) et le code de langue de la réponse de l’API. You can also set forecast days and per-day TYPE sensor range.", "title": "Pollen Levels – Configuration", "sections": { "api_key_options": { diff --git a/custom_components/pollenlevels/translations/hu.json b/custom_components/pollenlevels/translations/hu.json index 762a9f39..9b5dffd9 100644 --- a/custom_components/pollenlevels/translations/hu.json +++ b/custom_components/pollenlevels/translations/hu.json @@ -37,7 +37,7 @@ "forecast_days": "Előrejelzési napok (1–5)", "create_forecast_sensors": "Napi TÍPUS szenzorok tartománya" }, - "description": "Add meg a Google API-kulcsodat ([itt szerezhető be]({api_key_url})) és nézd át a bevált gyakorlatokat ([bevált gyakorlatok]({restricting_api_keys_url})). Válaszd ki a helyszínt a térképen, a frissítési időközt (órában) és az API-válasz nyelvi kódját.", + "description": "Add meg a Google API-kulcsodat ([itt szerezhető be]({api_key_url})) és nézd át a bevált gyakorlatokat ([bevált gyakorlatok]({restricting_api_keys_url})). Válaszd ki a helyszínt a térképen, a frissítési időközt (órában) és az API-válasz nyelvi kódját. You can also set forecast days and per-day TYPE sensor range.", "title": "Pollen szintek – beállítás", "sections": { "api_key_options": { diff --git a/custom_components/pollenlevels/translations/it.json b/custom_components/pollenlevels/translations/it.json index 25c31b64..d39c200d 100644 --- a/custom_components/pollenlevels/translations/it.json +++ b/custom_components/pollenlevels/translations/it.json @@ -37,7 +37,7 @@ "forecast_days": "Giorni di previsione (1–5)", "create_forecast_sensors": "Ambito dei sensori per giorno (TIPI)" }, - "description": "Inserisci la tua chiave API di Google ([ottienila qui]({api_key_url})) e consulta le best practice ([best practice]({restricting_api_keys_url})). Seleziona la posizione sulla mappa, l’intervallo di aggiornamento (ore) e il codice lingua della risposta dell’API.", + "description": "Inserisci la tua chiave API di Google ([ottienila qui]({api_key_url})) e consulta le best practice ([best practice]({restricting_api_keys_url})). Seleziona la posizione sulla mappa, l’intervallo di aggiornamento (ore) e il codice lingua della risposta dell’API. You can also set forecast days and per-day TYPE sensor range.", "title": "Configurazione Livelli di polline", "sections": { "api_key_options": { diff --git a/custom_components/pollenlevels/translations/nb.json b/custom_components/pollenlevels/translations/nb.json index 03788ae7..52c98a4a 100644 --- a/custom_components/pollenlevels/translations/nb.json +++ b/custom_components/pollenlevels/translations/nb.json @@ -37,7 +37,7 @@ "forecast_days": "Prognosedager (1–5)", "create_forecast_sensors": "Omfang av sensorer per dag (TYPER)" }, - "description": "Oppgi Google API-nøkkelen din ([få den her]({api_key_url})) og les beste praksis ([beste praksis]({restricting_api_keys_url})). Velg posisjonen din på kartet, oppdateringsintervallet (timer) og språkkoden for API-svaret.", + "description": "Oppgi Google API-nøkkelen din ([få den her]({api_key_url})) og les beste praksis ([beste praksis]({restricting_api_keys_url})). Velg posisjonen din på kartet, oppdateringsintervallet (timer) og språkkoden for API-svaret. You can also set forecast days and per-day TYPE sensor range.", "title": "Konfigurasjon av pollennivåer", "sections": { "api_key_options": { diff --git a/custom_components/pollenlevels/translations/nl.json b/custom_components/pollenlevels/translations/nl.json index 4df9a0d2..300f6572 100644 --- a/custom_components/pollenlevels/translations/nl.json +++ b/custom_components/pollenlevels/translations/nl.json @@ -37,7 +37,7 @@ "forecast_days": "Voorspellingsdagen (1–5)", "create_forecast_sensors": "Bereik van per-dag TYPE-sensoren" }, - "description": "Voer je Google API-sleutel in ([haal hem hier]({api_key_url})) en bekijk de best practices ([best practices]({restricting_api_keys_url})). Selecteer je locatie op de kaart, het update-interval (uren) en de taalcode van de API-respons.", + "description": "Voer je Google API-sleutel in ([haal hem hier]({api_key_url})) en bekijk de best practices ([best practices]({restricting_api_keys_url})). Selecteer je locatie op de kaart, het update-interval (uren) en de taalcode van de API-respons. You can also set forecast days and per-day TYPE sensor range.", "title": "Pollen Levels – Configuratie", "sections": { "api_key_options": { diff --git a/custom_components/pollenlevels/translations/pl.json b/custom_components/pollenlevels/translations/pl.json index a7863501..3778c10d 100644 --- a/custom_components/pollenlevels/translations/pl.json +++ b/custom_components/pollenlevels/translations/pl.json @@ -37,7 +37,7 @@ "forecast_days": "Dni prognozy (1–5)", "create_forecast_sensors": "Zakres czujników dziennych (TYPY)" }, - "description": "Wprowadź swój klucz Google API ([uzyskaj go tutaj]({api_key_url})) i zapoznaj się z dobrymi praktykami ([dobre praktyki]({restricting_api_keys_url})). Wybierz lokalizację na mapie, interwał aktualizacji (godziny) oraz kod języka odpowiedzi API.", + "description": "Wprowadź swój klucz Google API ([uzyskaj go tutaj]({api_key_url})) i zapoznaj się z dobrymi praktykami ([dobre praktyki]({restricting_api_keys_url})). Wybierz lokalizację na mapie, interwał aktualizacji (godziny) oraz kod języka odpowiedzi API. You can also set forecast days and per-day TYPE sensor range.", "title": "Konfiguracja poziomów pyłku", "sections": { "api_key_options": { diff --git a/custom_components/pollenlevels/translations/pt-BR.json b/custom_components/pollenlevels/translations/pt-BR.json index ed68c7f5..6c7c10be 100644 --- a/custom_components/pollenlevels/translations/pt-BR.json +++ b/custom_components/pollenlevels/translations/pt-BR.json @@ -37,7 +37,7 @@ "forecast_days": "Dias de previsão (1–5)", "create_forecast_sensors": "Escopo dos sensores por dia (TIPOS)" }, - "description": "Insira sua chave de API do Google ([obtenha aqui]({api_key_url})) e consulte as melhores práticas ([melhores práticas]({restricting_api_keys_url})). Selecione sua localização no mapa, o intervalo de atualização (horas) e o código de idioma da resposta da API.", + "description": "Insira sua chave de API do Google ([obtenha aqui]({api_key_url})) e consulte as melhores práticas ([melhores práticas]({restricting_api_keys_url})). Selecione sua localização no mapa, o intervalo de atualização (horas) e o código de idioma da resposta da API. You can also set forecast days and per-day TYPE sensor range.", "title": "Configuração dos Níveis de Pólen", "sections": { "api_key_options": { diff --git a/custom_components/pollenlevels/translations/pt-PT.json b/custom_components/pollenlevels/translations/pt-PT.json index 1b3c44a4..c5d3b955 100644 --- a/custom_components/pollenlevels/translations/pt-PT.json +++ b/custom_components/pollenlevels/translations/pt-PT.json @@ -37,7 +37,7 @@ "forecast_days": "Dias de previsão (1–5)", "create_forecast_sensors": "Âmbito dos sensores por dia (TIPOS)" }, - "description": "Introduza a sua chave de API do Google ([obtenha-a aqui]({api_key_url})) e reveja as melhores práticas ([melhores práticas]({restricting_api_keys_url})). Selecione a sua localização no mapa, o intervalo de atualização (horas) e o código de idioma da resposta da API.", + "description": "Introduza a sua chave de API do Google ([obtenha-a aqui]({api_key_url})) e reveja as melhores práticas ([melhores práticas]({restricting_api_keys_url})). Selecione a sua localização no mapa, o intervalo de atualização (horas) e o código de idioma da resposta da API. You can also set forecast days and per-day TYPE sensor range.", "title": "Configuração dos Níveis de Pólen", "sections": { "api_key_options": { diff --git a/custom_components/pollenlevels/translations/ro.json b/custom_components/pollenlevels/translations/ro.json index 64ca142f..7cd72c05 100644 --- a/custom_components/pollenlevels/translations/ro.json +++ b/custom_components/pollenlevels/translations/ro.json @@ -37,7 +37,7 @@ "forecast_days": "Zile de prognoză (1–5)", "create_forecast_sensors": "Domeniul senzorilor pe zile (TIPURI)" }, - "description": "Introdu cheia ta API Google ([obține-o aici]({api_key_url})) și consultă cele mai bune practici ([cele mai bune practici]({restricting_api_keys_url})). Selectează locația pe hartă, intervalul de actualizare (ore) și codul de limbă al răspunsului API.", + "description": "Introdu cheia ta API Google ([obține-o aici]({api_key_url})) și consultă cele mai bune practici ([cele mai bune practici]({restricting_api_keys_url})). Selectează locația pe hartă, intervalul de actualizare (ore) și codul de limbă al răspunsului API. You can also set forecast days and per-day TYPE sensor range.", "title": "Configurare Niveluri de Polen", "sections": { "api_key_options": { diff --git a/custom_components/pollenlevels/translations/ru.json b/custom_components/pollenlevels/translations/ru.json index efe5cb26..0b609d98 100644 --- a/custom_components/pollenlevels/translations/ru.json +++ b/custom_components/pollenlevels/translations/ru.json @@ -37,7 +37,7 @@ "forecast_days": "Дни прогноза (1–5)", "create_forecast_sensors": "Диапазон дневных датчиков (ТИПЫ)" }, - "description": "Введите ключ Google API ([получите его здесь]({api_key_url})) и изучите рекомендации ([лучшие практики]({restricting_api_keys_url})). Выберите местоположение на карте, интервал обновления (часы) и языковой код ответа API.", + "description": "Введите ключ Google API ([получите его здесь]({api_key_url})) и изучите рекомендации ([лучшие практики]({restricting_api_keys_url})). Выберите местоположение на карте, интервал обновления (часы) и языковой код ответа API. You can also set forecast days and per-day TYPE sensor range.", "title": "Настройка уровней пыльцы", "sections": { "api_key_options": { diff --git a/custom_components/pollenlevels/translations/sv.json b/custom_components/pollenlevels/translations/sv.json index e8690eac..043ff324 100644 --- a/custom_components/pollenlevels/translations/sv.json +++ b/custom_components/pollenlevels/translations/sv.json @@ -37,7 +37,7 @@ "forecast_days": "Prognosdagar (1–5)", "create_forecast_sensors": "Omfång för sensorer per dag (TYPER)" }, - "description": "Ange din Google API-nyckel ([hämta den här]({api_key_url})) och läs bästa praxis ([bästa praxis]({restricting_api_keys_url})). Välj din plats på kartan, uppdateringsintervallet (timmar) och språkkoden för API-svaret.", + "description": "Ange din Google API-nyckel ([hämta den här]({api_key_url})) och läs bästa praxis ([bästa praxis]({restricting_api_keys_url})). Välj din plats på kartan, uppdateringsintervallet (timmar) och språkkoden för API-svaret. You can also set forecast days and per-day TYPE sensor range.", "title": "Konfiguration av pollennivåer", "sections": { "api_key_options": { diff --git a/custom_components/pollenlevels/translations/uk.json b/custom_components/pollenlevels/translations/uk.json index 5c257a0b..e4e733dc 100644 --- a/custom_components/pollenlevels/translations/uk.json +++ b/custom_components/pollenlevels/translations/uk.json @@ -37,7 +37,7 @@ "forecast_days": "Дні прогнозу (1–5)", "create_forecast_sensors": "Діапазон денних датчиків (ТИПИ)" }, - "description": "Введіть свій ключ Google API ([отримайте його тут]({api_key_url})) та ознайомтеся з найкращими практиками ([найкращі практики]({restricting_api_keys_url})). Виберіть місце на карті, інтервал оновлення (години) і код мови відповіді API.", + "description": "Введіть свій ключ Google API ([отримайте його тут]({api_key_url})) та ознайомтеся з найкращими практиками ([найкращі практики]({restricting_api_keys_url})). Виберіть місце на карті, інтервал оновлення (години) і код мови відповіді API. You can also set forecast days and per-day TYPE sensor range.", "title": "Налаштування рівнів пилку", "sections": { "api_key_options": { diff --git a/custom_components/pollenlevels/translations/zh-Hans.json b/custom_components/pollenlevels/translations/zh-Hans.json index 1675b851..a8d80b72 100644 --- a/custom_components/pollenlevels/translations/zh-Hans.json +++ b/custom_components/pollenlevels/translations/zh-Hans.json @@ -37,7 +37,7 @@ "forecast_days": "预测天数(1–5)", "create_forecast_sensors": "逐日类型传感器范围" }, - "description": "输入你的 Google API 密钥([在此获取]({api_key_url}))并查看最佳实践([最佳实践]({restricting_api_keys_url})). 在地图上选择位置、更新间隔(小时)以及 API 响应的语言代码。", + "description": "输入你的 Google API 密钥([在此获取]({api_key_url}))并查看最佳实践([最佳实践]({restricting_api_keys_url})). 在地图上选择位置、更新间隔(小时)以及 API 响应的语言代码。 You can also set forecast days and per-day TYPE sensor range.", "title": "花粉水平配置", "sections": { "api_key_options": { diff --git a/custom_components/pollenlevels/translations/zh-Hant.json b/custom_components/pollenlevels/translations/zh-Hant.json index 273e41d2..bae7ca87 100644 --- a/custom_components/pollenlevels/translations/zh-Hant.json +++ b/custom_components/pollenlevels/translations/zh-Hant.json @@ -37,7 +37,7 @@ "forecast_days": "預測天數(1–5)", "create_forecast_sensors": "逐日類型感測器範圍" }, - "description": "輸入你的 Google API 金鑰([在此取得]({api_key_url})) 並查看最佳實務([最佳實務]({restricting_api_keys_url})). 在地圖上選擇位置、更新間隔(小時)以及 API 回應的語言代碼。", + "description": "輸入你的 Google API 金鑰([在此取得]({api_key_url})) 並查看最佳實務([最佳實務]({restricting_api_keys_url})). 在地圖上選擇位置、更新間隔(小時)以及 API 回應的語言代碼。 You can also set forecast days and per-day TYPE sensor range.", "title": "花粉水平設定", "sections": { "api_key_options": { From bb64a7c38f39b9a5e601fc99eb238614d76f5ff1 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sun, 21 Dec 2025 09:09:36 +0100 Subject: [PATCH 123/200] Translate forecast guidance in setup description --- custom_components/pollenlevels/translations/ca.json | 2 +- custom_components/pollenlevels/translations/cs.json | 2 +- custom_components/pollenlevels/translations/da.json | 2 +- custom_components/pollenlevels/translations/de.json | 2 +- custom_components/pollenlevels/translations/es.json | 2 +- custom_components/pollenlevels/translations/fi.json | 2 +- custom_components/pollenlevels/translations/fr.json | 2 +- custom_components/pollenlevels/translations/hu.json | 2 +- custom_components/pollenlevels/translations/it.json | 2 +- custom_components/pollenlevels/translations/nb.json | 2 +- custom_components/pollenlevels/translations/nl.json | 2 +- custom_components/pollenlevels/translations/pl.json | 2 +- custom_components/pollenlevels/translations/pt-BR.json | 2 +- custom_components/pollenlevels/translations/pt-PT.json | 2 +- custom_components/pollenlevels/translations/ro.json | 2 +- custom_components/pollenlevels/translations/ru.json | 2 +- custom_components/pollenlevels/translations/sv.json | 2 +- custom_components/pollenlevels/translations/uk.json | 2 +- custom_components/pollenlevels/translations/zh-Hans.json | 2 +- custom_components/pollenlevels/translations/zh-Hant.json | 2 +- 20 files changed, 20 insertions(+), 20 deletions(-) diff --git a/custom_components/pollenlevels/translations/ca.json b/custom_components/pollenlevels/translations/ca.json index d5c01fc9..998ebd8b 100644 --- a/custom_components/pollenlevels/translations/ca.json +++ b/custom_components/pollenlevels/translations/ca.json @@ -3,7 +3,7 @@ "step": { "user": { "title": "Configuració de Nivells de pol·len", - "description": "Introdueix la teva clau API de Google ([aconsegueix-la aquí]({api_key_url})) i revisa les bones pràctiques ([bones pràctiques]({restricting_api_keys_url})). Selecciona la ubicació al mapa, l’interval d’actualització (hores) i el codi d’idioma de la resposta de l’API. You can also set forecast days and per-day TYPE sensor range.", + "description": "Introdueix la teva clau API de Google ([aconsegueix-la aquí]({api_key_url})) i revisa les bones pràctiques ([bones pràctiques]({restricting_api_keys_url})). Selecciona la ubicació al mapa, l’interval d’actualització (hores) i el codi d’idioma de la resposta de l’API. També pots definir els dies de previsió i l'abast dels sensors per dia (TIPUS).", "data": { "api_key": "Clau API", "name": "Nom", diff --git a/custom_components/pollenlevels/translations/cs.json b/custom_components/pollenlevels/translations/cs.json index acd84426..88c9ac6b 100644 --- a/custom_components/pollenlevels/translations/cs.json +++ b/custom_components/pollenlevels/translations/cs.json @@ -37,7 +37,7 @@ "forecast_days": "Dny předpovědi (1–5)", "create_forecast_sensors": "Rozsah senzorů po dnech (TYPY)" }, - "description": "Zadejte svůj Google API klíč ([získejte jej zde]({api_key_url})) a přečtěte si doporučené postupy ([doporučené postupy]({restricting_api_keys_url})). Vyberte polohu na mapě, interval aktualizace (v hodinách) a jazykový kód odpovědi API. You can also set forecast days and per-day TYPE sensor range.", + "description": "Zadejte svůj Google API klíč ([získejte jej zde]({api_key_url})) a přečtěte si doporučené postupy ([doporučené postupy]({restricting_api_keys_url})). Vyberte polohu na mapě, interval aktualizace (v hodinách) a jazykový kód odpovědi API. Můžete také nastavit dny předpovědi a rozsah senzorů po dnech (TYPY).", "title": "Konfigurace úrovní pylu", "sections": { "api_key_options": { diff --git a/custom_components/pollenlevels/translations/da.json b/custom_components/pollenlevels/translations/da.json index ce215305..dff4c79d 100644 --- a/custom_components/pollenlevels/translations/da.json +++ b/custom_components/pollenlevels/translations/da.json @@ -37,7 +37,7 @@ "forecast_days": "Prognosedage (1–5)", "create_forecast_sensors": "Omfang af sensorer pr. dag (TYPER)" }, - "description": "Indtast din Google API-nøgle ([hent den her]({api_key_url})) og læs bedste praksis ([bedste praksis]({restricting_api_keys_url})). Vælg din placering på kortet, opdateringsinterval (timer) og sprogkode for API-svaret. You can also set forecast days and per-day TYPE sensor range.", + "description": "Indtast din Google API-nøgle ([hent den her]({api_key_url})) og læs bedste praksis ([bedste praksis]({restricting_api_keys_url})). Vælg din placering på kortet, opdateringsinterval (timer) og sprogkode for API-svaret. Du kan også angive prognosedage og omfanget af sensorer pr. dag (TYPER).", "title": "Konfiguration af pollenniveauer", "sections": { "api_key_options": { diff --git a/custom_components/pollenlevels/translations/de.json b/custom_components/pollenlevels/translations/de.json index 65858ef8..61b46d8f 100644 --- a/custom_components/pollenlevels/translations/de.json +++ b/custom_components/pollenlevels/translations/de.json @@ -37,7 +37,7 @@ "forecast_days": "Vorhersagetage (1–5)", "create_forecast_sensors": "Bereich der Tagessensoren (TYPEN)" }, - "description": "Gib deinen Google API-Schlüssel ein ([hier abrufen]({api_key_url})) und lies die Best Practices ([Best Practices]({restricting_api_keys_url})). Wähle deinen Standort auf der Karte, das Aktualisierungsintervall (Stunden) und den Sprachcode der API-Antwort. You can also set forecast days and per-day TYPE sensor range.", + "description": "Gib deinen Google API-Schlüssel ein ([hier abrufen]({api_key_url})) und lies die Best Practices ([Best Practices]({restricting_api_keys_url})). Wähle deinen Standort auf der Karte, das Aktualisierungsintervall (Stunden) und den Sprachcode der API-Antwort. Sie können auch Vorhersagetage und den Umfang der Tagessensoren (TYPEN) festlegen.", "title": "Pollen Levels – Konfiguration", "sections": { "api_key_options": { diff --git a/custom_components/pollenlevels/translations/es.json b/custom_components/pollenlevels/translations/es.json index 9fe407b5..0dbe32d0 100644 --- a/custom_components/pollenlevels/translations/es.json +++ b/custom_components/pollenlevels/translations/es.json @@ -3,7 +3,7 @@ "step": { "user": { "title": "Configuración de Niveles de Polen", - "description": "Introduce tu clave API de Google ([consíguela aquí]({api_key_url})) y revisa las buenas prácticas ([buenas prácticas]({restricting_api_keys_url})). Selecciona tu ubicación en el mapa, el intervalo de actualización (horas) y el código de idioma de la respuesta de la API. You can also set forecast days and per-day TYPE sensor range.", + "description": "Introduce tu clave API de Google ([consíguela aquí]({api_key_url})) y revisa las buenas prácticas ([buenas prácticas]({restricting_api_keys_url})). Selecciona tu ubicación en el mapa, el intervalo de actualización (horas) y el código de idioma de la respuesta de la API. También puedes configurar los días de previsión y el alcance de los sensores por día (TIPOS).", "data": { "api_key": "Clave API", "name": "Nombre", diff --git a/custom_components/pollenlevels/translations/fi.json b/custom_components/pollenlevels/translations/fi.json index b2de6c87..635a1d31 100644 --- a/custom_components/pollenlevels/translations/fi.json +++ b/custom_components/pollenlevels/translations/fi.json @@ -37,7 +37,7 @@ "forecast_days": "Ennustepäivät (1–5)", "create_forecast_sensors": "Päiväsensorien laajuus (TYYPIT)" }, - "description": "Syötä Google API -avaimesi ([hanki se täältä]({api_key_url})) ja tutustu parhaisiin käytäntöihin ([parhaat käytännöt]({restricting_api_keys_url})). Valitse sijainti kartalta, päivitysväli (tunteina) ja API-vastauksen kielikoodi. You can also set forecast days and per-day TYPE sensor range.", + "description": "Syötä Google API -avaimesi ([hanki se täältä]({api_key_url})) ja tutustu parhaisiin käytäntöihin ([parhaat käytännöt]({restricting_api_keys_url})). Valitse sijainti kartalta, päivitysväli (tunteina) ja API-vastauksen kielikoodi. Voit myös määrittää ennustepäivät ja päiväsensorien laajuuden (TYYPIT).", "title": "Siitepölytason asetukset", "sections": { "api_key_options": { diff --git a/custom_components/pollenlevels/translations/fr.json b/custom_components/pollenlevels/translations/fr.json index 091f1e0a..304ba713 100644 --- a/custom_components/pollenlevels/translations/fr.json +++ b/custom_components/pollenlevels/translations/fr.json @@ -37,7 +37,7 @@ "forecast_days": "Jours de prévision (1–5)", "create_forecast_sensors": "Portée des capteurs par jour (TYPES)" }, - "description": "Saisissez votre clé API Google ([l’obtenir ici]({api_key_url})) et consultez les bonnes pratiques ([bonnes pratiques]({restricting_api_keys_url})). Sélectionnez votre emplacement sur la carte, l’intervalle de mise à jour (heures) et le code de langue de la réponse de l’API. You can also set forecast days and per-day TYPE sensor range.", + "description": "Saisissez votre clé API Google ([l’obtenir ici]({api_key_url})) et consultez les bonnes pratiques ([bonnes pratiques]({restricting_api_keys_url})). Sélectionnez votre emplacement sur la carte, l’intervalle de mise à jour (heures) et le code de langue de la réponse de l’API. Vous pouvez aussi définir les jours de prévision et la portée des capteurs par jour (TYPES).", "title": "Pollen Levels – Configuration", "sections": { "api_key_options": { diff --git a/custom_components/pollenlevels/translations/hu.json b/custom_components/pollenlevels/translations/hu.json index 9b5dffd9..a87196b2 100644 --- a/custom_components/pollenlevels/translations/hu.json +++ b/custom_components/pollenlevels/translations/hu.json @@ -37,7 +37,7 @@ "forecast_days": "Előrejelzési napok (1–5)", "create_forecast_sensors": "Napi TÍPUS szenzorok tartománya" }, - "description": "Add meg a Google API-kulcsodat ([itt szerezhető be]({api_key_url})) és nézd át a bevált gyakorlatokat ([bevált gyakorlatok]({restricting_api_keys_url})). Válaszd ki a helyszínt a térképen, a frissítési időközt (órában) és az API-válasz nyelvi kódját. You can also set forecast days and per-day TYPE sensor range.", + "description": "Add meg a Google API-kulcsodat ([itt szerezhető be]({api_key_url})) és nézd át a bevált gyakorlatokat ([bevált gyakorlatok]({restricting_api_keys_url})). Válaszd ki a helyszínt a térképen, a frissítési időközt (órában) és az API-válasz nyelvi kódját. Beállíthatod az előrejelzési napokat és a napi szenzorok tartományát (TÍPUSOK).", "title": "Pollen szintek – beállítás", "sections": { "api_key_options": { diff --git a/custom_components/pollenlevels/translations/it.json b/custom_components/pollenlevels/translations/it.json index d39c200d..7fffd25d 100644 --- a/custom_components/pollenlevels/translations/it.json +++ b/custom_components/pollenlevels/translations/it.json @@ -37,7 +37,7 @@ "forecast_days": "Giorni di previsione (1–5)", "create_forecast_sensors": "Ambito dei sensori per giorno (TIPI)" }, - "description": "Inserisci la tua chiave API di Google ([ottienila qui]({api_key_url})) e consulta le best practice ([best practice]({restricting_api_keys_url})). Seleziona la posizione sulla mappa, l’intervallo di aggiornamento (ore) e il codice lingua della risposta dell’API. You can also set forecast days and per-day TYPE sensor range.", + "description": "Inserisci la tua chiave API di Google ([ottienila qui]({api_key_url})) e consulta le best practice ([best practice]({restricting_api_keys_url})). Seleziona la posizione sulla mappa, l’intervallo di aggiornamento (ore) e il codice lingua della risposta dell’API. Puoi anche impostare i giorni di previsione e l'ambito dei sensori per giorno (TIPI).", "title": "Configurazione Livelli di polline", "sections": { "api_key_options": { diff --git a/custom_components/pollenlevels/translations/nb.json b/custom_components/pollenlevels/translations/nb.json index 52c98a4a..8c3153d2 100644 --- a/custom_components/pollenlevels/translations/nb.json +++ b/custom_components/pollenlevels/translations/nb.json @@ -37,7 +37,7 @@ "forecast_days": "Prognosedager (1–5)", "create_forecast_sensors": "Omfang av sensorer per dag (TYPER)" }, - "description": "Oppgi Google API-nøkkelen din ([få den her]({api_key_url})) og les beste praksis ([beste praksis]({restricting_api_keys_url})). Velg posisjonen din på kartet, oppdateringsintervallet (timer) og språkkoden for API-svaret. You can also set forecast days and per-day TYPE sensor range.", + "description": "Oppgi Google API-nøkkelen din ([få den her]({api_key_url})) og les beste praksis ([beste praksis]({restricting_api_keys_url})). Velg posisjonen din på kartet, oppdateringsintervallet (timer) og språkkoden for API-svaret. Du kan også angi prognosedager og omfanget av sensorer per dag (TYPER).", "title": "Konfigurasjon av pollennivåer", "sections": { "api_key_options": { diff --git a/custom_components/pollenlevels/translations/nl.json b/custom_components/pollenlevels/translations/nl.json index 300f6572..2069389a 100644 --- a/custom_components/pollenlevels/translations/nl.json +++ b/custom_components/pollenlevels/translations/nl.json @@ -37,7 +37,7 @@ "forecast_days": "Voorspellingsdagen (1–5)", "create_forecast_sensors": "Bereik van per-dag TYPE-sensoren" }, - "description": "Voer je Google API-sleutel in ([haal hem hier]({api_key_url})) en bekijk de best practices ([best practices]({restricting_api_keys_url})). Selecteer je locatie op de kaart, het update-interval (uren) en de taalcode van de API-respons. You can also set forecast days and per-day TYPE sensor range.", + "description": "Voer je Google API-sleutel in ([haal hem hier]({api_key_url})) en bekijk de best practices ([best practices]({restricting_api_keys_url})). Selecteer je locatie op de kaart, het update-interval (uren) en de taalcode van de API-respons. Je kunt ook voorspellingsdagen en het bereik van per-dag TYPE-sensoren instellen.", "title": "Pollen Levels – Configuratie", "sections": { "api_key_options": { diff --git a/custom_components/pollenlevels/translations/pl.json b/custom_components/pollenlevels/translations/pl.json index 3778c10d..5c06fbe2 100644 --- a/custom_components/pollenlevels/translations/pl.json +++ b/custom_components/pollenlevels/translations/pl.json @@ -37,7 +37,7 @@ "forecast_days": "Dni prognozy (1–5)", "create_forecast_sensors": "Zakres czujników dziennych (TYPY)" }, - "description": "Wprowadź swój klucz Google API ([uzyskaj go tutaj]({api_key_url})) i zapoznaj się z dobrymi praktykami ([dobre praktyki]({restricting_api_keys_url})). Wybierz lokalizację na mapie, interwał aktualizacji (godziny) oraz kod języka odpowiedzi API. You can also set forecast days and per-day TYPE sensor range.", + "description": "Wprowadź swój klucz Google API ([uzyskaj go tutaj]({api_key_url})) i zapoznaj się z dobrymi praktykami ([dobre praktyki]({restricting_api_keys_url})). Wybierz lokalizację na mapie, interwał aktualizacji (godziny) oraz kod języka odpowiedzi API. Możesz także ustawić dni prognozy oraz zakres czujników dziennych (TYPY).", "title": "Konfiguracja poziomów pyłku", "sections": { "api_key_options": { diff --git a/custom_components/pollenlevels/translations/pt-BR.json b/custom_components/pollenlevels/translations/pt-BR.json index 6c7c10be..4bb52a69 100644 --- a/custom_components/pollenlevels/translations/pt-BR.json +++ b/custom_components/pollenlevels/translations/pt-BR.json @@ -37,7 +37,7 @@ "forecast_days": "Dias de previsão (1–5)", "create_forecast_sensors": "Escopo dos sensores por dia (TIPOS)" }, - "description": "Insira sua chave de API do Google ([obtenha aqui]({api_key_url})) e consulte as melhores práticas ([melhores práticas]({restricting_api_keys_url})). Selecione sua localização no mapa, o intervalo de atualização (horas) e o código de idioma da resposta da API. You can also set forecast days and per-day TYPE sensor range.", + "description": "Insira sua chave de API do Google ([obtenha aqui]({api_key_url})) e consulte as melhores práticas ([melhores práticas]({restricting_api_keys_url})). Selecione sua localização no mapa, o intervalo de atualização (horas) e o código de idioma da resposta da API. Você também pode definir os dias de previsão e o escopo dos sensores por dia (TIPOS).", "title": "Configuração dos Níveis de Pólen", "sections": { "api_key_options": { diff --git a/custom_components/pollenlevels/translations/pt-PT.json b/custom_components/pollenlevels/translations/pt-PT.json index c5d3b955..1e961335 100644 --- a/custom_components/pollenlevels/translations/pt-PT.json +++ b/custom_components/pollenlevels/translations/pt-PT.json @@ -37,7 +37,7 @@ "forecast_days": "Dias de previsão (1–5)", "create_forecast_sensors": "Âmbito dos sensores por dia (TIPOS)" }, - "description": "Introduza a sua chave de API do Google ([obtenha-a aqui]({api_key_url})) e reveja as melhores práticas ([melhores práticas]({restricting_api_keys_url})). Selecione a sua localização no mapa, o intervalo de atualização (horas) e o código de idioma da resposta da API. You can also set forecast days and per-day TYPE sensor range.", + "description": "Introduza a sua chave de API do Google ([obtenha-a aqui]({api_key_url})) e reveja as melhores práticas ([melhores práticas]({restricting_api_keys_url})). Selecione a sua localização no mapa, o intervalo de atualização (horas) e o código de idioma da resposta da API. Também pode definir os dias de previsão e o âmbito dos sensores por dia (TIPOS).", "title": "Configuração dos Níveis de Pólen", "sections": { "api_key_options": { diff --git a/custom_components/pollenlevels/translations/ro.json b/custom_components/pollenlevels/translations/ro.json index 7cd72c05..8a1ee25c 100644 --- a/custom_components/pollenlevels/translations/ro.json +++ b/custom_components/pollenlevels/translations/ro.json @@ -37,7 +37,7 @@ "forecast_days": "Zile de prognoză (1–5)", "create_forecast_sensors": "Domeniul senzorilor pe zile (TIPURI)" }, - "description": "Introdu cheia ta API Google ([obține-o aici]({api_key_url})) și consultă cele mai bune practici ([cele mai bune practici]({restricting_api_keys_url})). Selectează locația pe hartă, intervalul de actualizare (ore) și codul de limbă al răspunsului API. You can also set forecast days and per-day TYPE sensor range.", + "description": "Introdu cheia ta API Google ([obține-o aici]({api_key_url})) și consultă cele mai bune practici ([cele mai bune practici]({restricting_api_keys_url})). Selectează locația pe hartă, intervalul de actualizare (ore) și codul de limbă al răspunsului API. Poți seta și zilele de prognoză și domeniul senzorilor pe zile (TIPURI).", "title": "Configurare Niveluri de Polen", "sections": { "api_key_options": { diff --git a/custom_components/pollenlevels/translations/ru.json b/custom_components/pollenlevels/translations/ru.json index 0b609d98..21b71727 100644 --- a/custom_components/pollenlevels/translations/ru.json +++ b/custom_components/pollenlevels/translations/ru.json @@ -37,7 +37,7 @@ "forecast_days": "Дни прогноза (1–5)", "create_forecast_sensors": "Диапазон дневных датчиков (ТИПЫ)" }, - "description": "Введите ключ Google API ([получите его здесь]({api_key_url})) и изучите рекомендации ([лучшие практики]({restricting_api_keys_url})). Выберите местоположение на карте, интервал обновления (часы) и языковой код ответа API. You can also set forecast days and per-day TYPE sensor range.", + "description": "Введите ключ Google API ([получите его здесь]({api_key_url})) и изучите рекомендации ([лучшие практики]({restricting_api_keys_url})). Выберите местоположение на карте, интервал обновления (часы) и языковой код ответа API. Вы также можете настроить дни прогноза и диапазон дневных датчиков (ТИПЫ).", "title": "Настройка уровней пыльцы", "sections": { "api_key_options": { diff --git a/custom_components/pollenlevels/translations/sv.json b/custom_components/pollenlevels/translations/sv.json index 043ff324..b508a975 100644 --- a/custom_components/pollenlevels/translations/sv.json +++ b/custom_components/pollenlevels/translations/sv.json @@ -37,7 +37,7 @@ "forecast_days": "Prognosdagar (1–5)", "create_forecast_sensors": "Omfång för sensorer per dag (TYPER)" }, - "description": "Ange din Google API-nyckel ([hämta den här]({api_key_url})) och läs bästa praxis ([bästa praxis]({restricting_api_keys_url})). Välj din plats på kartan, uppdateringsintervallet (timmar) och språkkoden för API-svaret. You can also set forecast days and per-day TYPE sensor range.", + "description": "Ange din Google API-nyckel ([hämta den här]({api_key_url})) och läs bästa praxis ([bästa praxis]({restricting_api_keys_url})). Välj din plats på kartan, uppdateringsintervallet (timmar) och språkkoden för API-svaret. Du kan också ange prognosdagar och omfånget för sensorer per dag (TYPER).", "title": "Konfiguration av pollennivåer", "sections": { "api_key_options": { diff --git a/custom_components/pollenlevels/translations/uk.json b/custom_components/pollenlevels/translations/uk.json index e4e733dc..cf9ac3d2 100644 --- a/custom_components/pollenlevels/translations/uk.json +++ b/custom_components/pollenlevels/translations/uk.json @@ -37,7 +37,7 @@ "forecast_days": "Дні прогнозу (1–5)", "create_forecast_sensors": "Діапазон денних датчиків (ТИПИ)" }, - "description": "Введіть свій ключ Google API ([отримайте його тут]({api_key_url})) та ознайомтеся з найкращими практиками ([найкращі практики]({restricting_api_keys_url})). Виберіть місце на карті, інтервал оновлення (години) і код мови відповіді API. You can also set forecast days and per-day TYPE sensor range.", + "description": "Введіть свій ключ Google API ([отримайте його тут]({api_key_url})) та ознайомтеся з найкращими практиками ([найкращі практики]({restricting_api_keys_url})). Виберіть місце на карті, інтервал оновлення (години) і код мови відповіді API. Ви також можете налаштувати дні прогнозу та діапазон денних датчиків (ТИПИ).", "title": "Налаштування рівнів пилку", "sections": { "api_key_options": { diff --git a/custom_components/pollenlevels/translations/zh-Hans.json b/custom_components/pollenlevels/translations/zh-Hans.json index a8d80b72..d016ddd9 100644 --- a/custom_components/pollenlevels/translations/zh-Hans.json +++ b/custom_components/pollenlevels/translations/zh-Hans.json @@ -37,7 +37,7 @@ "forecast_days": "预测天数(1–5)", "create_forecast_sensors": "逐日类型传感器范围" }, - "description": "输入你的 Google API 密钥([在此获取]({api_key_url}))并查看最佳实践([最佳实践]({restricting_api_keys_url})). 在地图上选择位置、更新间隔(小时)以及 API 响应的语言代码。 You can also set forecast days and per-day TYPE sensor range.", + "description": "输入你的 Google API 密钥([在此获取]({api_key_url}))并查看最佳实践([最佳实践]({restricting_api_keys_url})). 在地图上选择位置、更新间隔(小时)以及 API 响应的语言代码。 你还可以设置预测天数以及逐日类型传感器的范围。", "title": "花粉水平配置", "sections": { "api_key_options": { diff --git a/custom_components/pollenlevels/translations/zh-Hant.json b/custom_components/pollenlevels/translations/zh-Hant.json index bae7ca87..64365404 100644 --- a/custom_components/pollenlevels/translations/zh-Hant.json +++ b/custom_components/pollenlevels/translations/zh-Hant.json @@ -37,7 +37,7 @@ "forecast_days": "預測天數(1–5)", "create_forecast_sensors": "逐日類型感測器範圍" }, - "description": "輸入你的 Google API 金鑰([在此取得]({api_key_url})) 並查看最佳實務([最佳實務]({restricting_api_keys_url})). 在地圖上選擇位置、更新間隔(小時)以及 API 回應的語言代碼。 You can also set forecast days and per-day TYPE sensor range.", + "description": "輸入你的 Google API 金鑰([在此取得]({api_key_url})) 並查看最佳實務([最佳實務]({restricting_api_keys_url})). 在地圖上選擇位置、更新間隔(小時)以及 API 回應的語言代碼。 你也可以設定預測天數與逐日類型感測器的範圍。", "title": "花粉水平設定", "sections": { "api_key_options": { From 8ba53de5cdb6de7d9451a776a17ffe6ff37e3626 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sun, 21 Dec 2025 09:09:41 +0100 Subject: [PATCH 124/200] Harden timeout handling and mode normalization --- CHANGELOG.md | 2 ++ custom_components/pollenlevels/client.py | 16 ++++++++++++++++ custom_components/pollenlevels/util.py | 14 ++++++++------ 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f2effc5..71351fc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,8 @@ setup flow for improved visibility. - Restored the HTTP Referer field when the setup schema falls back to the flat layout and updated setup guidance to mention forecast configuration. +- Hardened HTTP client timeout handling and normalized non-string per-day sensor + mode values defensively. ## [1.9.0-alpha3] - 2025-12-20 ### Fixed diff --git a/custom_components/pollenlevels/client.py b/custom_components/pollenlevels/client.py index bbf68924..884d94fa 100644 --- a/custom_components/pollenlevels/client.py +++ b/custom_components/pollenlevels/client.py @@ -218,6 +218,22 @@ async def async_fetch_pollen_data( ) raise UpdateFailed(f"Timeout: {msg}") from err except ClientError as err: + if isinstance(err, asyncio.TimeoutError): + if attempt < max_retries: + await self._async_backoff( + attempt=attempt, + max_retries=max_retries, + message=( + "Pollen API timeout — retrying in %.2fs " + "(attempt %d/%d)" + ), + ) + continue + msg = ( + redact_api_key(err, self._api_key) + or "Google Pollen API call timed out" + ) + raise UpdateFailed(f"Timeout: {msg}") from err if attempt < max_retries: await self._async_backoff( attempt=attempt, diff --git a/custom_components/pollenlevels/util.py b/custom_components/pollenlevels/util.py index 08d8816b..e433bd85 100644 --- a/custom_components/pollenlevels/util.py +++ b/custom_components/pollenlevels/util.py @@ -68,16 +68,18 @@ def redact_api_key(text: object, api_key: str | None) -> str: return s -def normalize_sensor_mode(mode: str | None, logger: logging.Logger) -> str: +def normalize_sensor_mode(mode: Any, logger: logging.Logger) -> str: """Normalize sensor mode, defaulting and logging a warning if invalid.""" - if mode in FORECAST_SENSORS_CHOICES: - return mode + raw_mode = getattr(mode, "value", mode) + mode_str = None if raw_mode is None else str(raw_mode) + if mode_str in FORECAST_SENSORS_CHOICES: + return mode_str - default_mode = FORECAST_SENSORS_CHOICES[0] - if mode is not None: + default_mode = FORECAST_SENSORS_CHOICES[0] if FORECAST_SENSORS_CHOICES else "none" + if mode_str is not None: logger.warning( "Invalid stored per-day sensor mode '%s'; defaulting to '%s'", - mode, + mode_str, default_mode, ) return default_mode From 31d736355a7a2a6603936f0188a32022ca504a2a Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sun, 21 Dec 2025 09:09:47 +0100 Subject: [PATCH 125/200] Update changelog for migration robustness --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 71351fc6..bbae3f12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,9 @@ layout and updated setup guidance to mention forecast configuration. - Hardened HTTP client timeout handling and normalized non-string per-day sensor mode values defensively. +- Added entry context to migration failure logs for easier debugging. +- Removed a mutable default from the API key options schema to avoid shared + state across config flow instances. ## [1.9.0-alpha3] - 2025-12-20 ### Fixed From 82de9d033de4aeafaefc16777000ce4c97125c33 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sun, 21 Dec 2025 09:38:20 +0100 Subject: [PATCH 126/200] Harden migration fallback and mode normalization --- CHANGELOG.md | 2 ++ custom_components/pollenlevels/__init__.py | 9 ++++++++- custom_components/pollenlevels/util.py | 11 +++++++++-- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bbae3f12..961168ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,8 @@ - Added entry context to migration failure logs for easier debugging. - Removed a mutable default from the API key options schema to avoid shared state across config flow instances. +- Normalized whitespace-only per-day sensor mode values and preserved fallback + to entry data when options explicitly store None. ## [1.9.0-alpha3] - 2025-12-20 ### Fixed diff --git a/custom_components/pollenlevels/__init__.py b/custom_components/pollenlevels/__init__.py index 36d146af..e62d7bd8 100644 --- a/custom_components/pollenlevels/__init__.py +++ b/custom_components/pollenlevels/__init__.py @@ -53,7 +53,10 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Migrate config entry data to options when needed.""" try: target_version = 2 - current_version = getattr(entry, "version", 1) + current_version_raw = getattr(entry, "version", 1) + current_version = ( + current_version_raw if isinstance(current_version_raw, int) else 1 + ) if current_version >= target_version: return True @@ -62,6 +65,10 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: mode = new_options.get(CONF_CREATE_FORECAST_SENSORS) if mode is None: new_options.pop(CONF_CREATE_FORECAST_SENSORS, None) + mode = entry.data.get(CONF_CREATE_FORECAST_SENSORS) + if mode is not None: + normalized_mode = normalize_sensor_mode(mode, _LOGGER) + new_options[CONF_CREATE_FORECAST_SENSORS] = normalized_mode else: normalized_mode = normalize_sensor_mode(mode, _LOGGER) if normalized_mode != mode: diff --git a/custom_components/pollenlevels/util.py b/custom_components/pollenlevels/util.py index e433bd85..d3763e62 100644 --- a/custom_components/pollenlevels/util.py +++ b/custom_components/pollenlevels/util.py @@ -71,11 +71,18 @@ def redact_api_key(text: object, api_key: str | None) -> str: def normalize_sensor_mode(mode: Any, logger: logging.Logger) -> str: """Normalize sensor mode, defaulting and logging a warning if invalid.""" raw_mode = getattr(mode, "value", mode) - mode_str = None if raw_mode is None else str(raw_mode) + mode_str = None if raw_mode is None else str(raw_mode).strip() + if not mode_str: + mode_str = None if mode_str in FORECAST_SENSORS_CHOICES: return mode_str - default_mode = FORECAST_SENSORS_CHOICES[0] if FORECAST_SENSORS_CHOICES else "none" + if "none" in FORECAST_SENSORS_CHOICES: + default_mode = "none" + else: + default_mode = ( + FORECAST_SENSORS_CHOICES[0] if FORECAST_SENSORS_CHOICES else "none" + ) if mode_str is not None: logger.warning( "Invalid stored per-day sensor mode '%s'; defaulting to '%s'", From 02a854995de525dcf3c26e222822da949a894274 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sun, 21 Dec 2025 09:38:27 +0100 Subject: [PATCH 127/200] Remove redundant timeout handling --- CHANGELOG.md | 1 + custom_components/pollenlevels/client.py | 16 ---------------- 2 files changed, 1 insertion(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 961168ad..9245a669 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ state across config flow instances. - Normalized whitespace-only per-day sensor mode values and preserved fallback to entry data when options explicitly store None. +- Removed redundant timeout handling in the HTTP client error path. ## [1.9.0-alpha3] - 2025-12-20 ### Fixed diff --git a/custom_components/pollenlevels/client.py b/custom_components/pollenlevels/client.py index 884d94fa..bbf68924 100644 --- a/custom_components/pollenlevels/client.py +++ b/custom_components/pollenlevels/client.py @@ -218,22 +218,6 @@ async def async_fetch_pollen_data( ) raise UpdateFailed(f"Timeout: {msg}") from err except ClientError as err: - if isinstance(err, asyncio.TimeoutError): - if attempt < max_retries: - await self._async_backoff( - attempt=attempt, - max_retries=max_retries, - message=( - "Pollen API timeout — retrying in %.2fs " - "(attempt %d/%d)" - ), - ) - continue - msg = ( - redact_api_key(err, self._api_key) - or "Google Pollen API call timed out" - ) - raise UpdateFailed(f"Timeout: {msg}") from err if attempt < max_retries: await self._async_backoff( attempt=attempt, From 4108e233a67b27ba34ab263249c411d303b4ae70 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sun, 21 Dec 2025 21:16:32 +0100 Subject: [PATCH 128/200] Fix force_update refresh await --- CHANGELOG.md | 2 ++ custom_components/pollenlevels/__init__.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9245a669..9c4f5bdb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,8 @@ - Normalized whitespace-only per-day sensor mode values and preserved fallback to entry data when options explicitly store None. - Removed redundant timeout handling in the HTTP client error path. +- Fixed the force_update service to await coordinator refresh coroutines safely + without passing None into asyncio.gather. ## [1.9.0-alpha3] - 2025-12-20 ### Fixed diff --git a/custom_components/pollenlevels/__init__.py b/custom_components/pollenlevels/__init__.py index e62d7bd8..8d0abdf8 100644 --- a/custom_components/pollenlevels/__init__.py +++ b/custom_components/pollenlevels/__init__.py @@ -114,7 +114,7 @@ async def handle_force_update_service(call: ServiceCall) -> None: coordinator = getattr(runtime, "coordinator", None) if coordinator: _LOGGER.info("Trigger manual refresh for entry %s", entry.entry_id) - refresh_coro = coordinator.async_request_refresh() + refresh_coro = coordinator.async_refresh() tasks.append(refresh_coro) task_entries.append(entry) From 14a6c896e33f18f5f8f69729ef525c7639a182ab Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sun, 21 Dec 2025 21:17:01 +0100 Subject: [PATCH 129/200] Harden setup parsing and forecast handling --- CHANGELOG.md | 6 ++++++ custom_components/pollenlevels/__init__.py | 17 +++++++++++++---- custom_components/pollenlevels/coordinator.py | 4 ++-- custom_components/pollenlevels/sensor.py | 10 +++++++++- custom_components/pollenlevels/services.yaml | 2 ++ 5 files changed, 32 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c4f5bdb..aaec3c33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,12 @@ - Removed redundant timeout handling in the HTTP client error path. - Fixed the force_update service to await coordinator refresh coroutines safely without passing None into asyncio.gather. +- Hardened parsing of update interval and forecast days to tolerate malformed + stored values while keeping defaults intact. +- Clamped forecast day handling in sensor setup to the supported 1–5 range for + consistent cleanup decisions. +- Avoided treating empty indexInfo objects as valid forecast indices. +- Added force_update service name/description for better UI discoverability. ## [1.9.0-alpha3] - 2025-12-20 ### Fixed diff --git a/custom_components/pollenlevels/__init__.py b/custom_components/pollenlevels/__init__.py index 8d0abdf8..b70b25e9 100644 --- a/custom_components/pollenlevels/__init__.py +++ b/custom_components/pollenlevels/__init__.py @@ -146,17 +146,26 @@ async def async_setup_entry( ) options = entry.options or {} - hours = int( + + def _safe_int(value: Any, default: int) -> int: + try: + return int(float(value if value is not None else default)) + except (TypeError, ValueError): + return default + + hours = _safe_int( options.get( CONF_UPDATE_INTERVAL, entry.data.get(CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL), - ) + ), + DEFAULT_UPDATE_INTERVAL, ) - forecast_days = int( + forecast_days = _safe_int( options.get( CONF_FORECAST_DAYS, entry.data.get(CONF_FORECAST_DAYS, DEFAULT_FORECAST_DAYS), - ) + ), + DEFAULT_FORECAST_DAYS, ) language = options.get(CONF_LANGUAGE_CODE, entry.data.get(CONF_LANGUAGE_CODE)) raw_mode = options.get( diff --git a/custom_components/pollenlevels/coordinator.py b/custom_components/pollenlevels/coordinator.py index f6f942b2..db464f1c 100644 --- a/custom_components/pollenlevels/coordinator.py +++ b/custom_components/pollenlevels/coordinator.py @@ -371,7 +371,7 @@ def _extract_day_info(day: dict) -> tuple[str | None, dict | None]: item = _find_type(day, tcode) or {} idx_raw = item.get("indexInfo") idx = idx_raw if isinstance(idx_raw, dict) else None - has_index = idx is not None + has_index = isinstance(idx_raw, dict) and bool(idx_raw) rgb = _rgb_from_api(idx.get("color")) if has_index else None forecast_list.append( { @@ -465,7 +465,7 @@ def _add_day_sensor( item = _find_plant(day, pcode) or {} idx_raw = item.get("indexInfo") idx = idx_raw if isinstance(idx_raw, dict) else None - has_index = idx is not None + has_index = isinstance(idx_raw, dict) and bool(idx_raw) rgb = _rgb_from_api(idx.get("color")) if has_index else None forecast_list.append( { diff --git a/custom_components/pollenlevels/sensor.py b/custom_components/pollenlevels/sensor.py index 33690391..c7a13a47 100644 --- a/custom_components/pollenlevels/sensor.py +++ b/custom_components/pollenlevels/sensor.py @@ -46,6 +46,8 @@ DEFAULT_FORECAST_DAYS, DEFAULT_UPDATE_INTERVAL, DOMAIN, + MAX_FORECAST_DAYS, + MIN_FORECAST_DAYS, ) from .coordinator import PollenDataUpdateCoordinator from .runtime import PollenLevelsConfigEntry, PollenLevelsRuntimeData @@ -153,7 +155,13 @@ async def async_setup_entry( coordinator = runtime.coordinator opts = config_entry.options or {} - forecast_days = int(opts.get(CONF_FORECAST_DAYS, coordinator.forecast_days)) + try: + forecast_days = int( + float(opts.get(CONF_FORECAST_DAYS, coordinator.forecast_days)) + ) + except (TypeError, ValueError): + forecast_days = coordinator.forecast_days + forecast_days = max(MIN_FORECAST_DAYS, min(MAX_FORECAST_DAYS, forecast_days)) create_d1 = coordinator.create_d1 create_d2 = coordinator.create_d2 diff --git a/custom_components/pollenlevels/services.yaml b/custom_components/pollenlevels/services.yaml index 0e2b22df..1d590a46 100644 --- a/custom_components/pollenlevels/services.yaml +++ b/custom_components/pollenlevels/services.yaml @@ -3,4 +3,6 @@ # Exposing a name improves discoverability in Developer Tools. force_update: + name: Force Update + description: Manually refresh pollen data for all configured locations. fields: {} From d194d9afbd6554190c3d11ccc70e18b6aa7278ed Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Mon, 22 Dec 2025 06:45:49 +0100 Subject: [PATCH 130/200] Fix force_update stub for async_refresh --- tests/test_init.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_init.py b/tests/test_init.py index 7fc41877..db30b897 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -443,6 +443,9 @@ def __init__(self): async def _mark(self): self.calls.append("refresh") + async def async_refresh(self): + await self._mark() + def async_request_refresh(self): return asyncio.create_task(self._mark()) From 0edd6a32f6b77c6e09d45206b9b75c69d00ad87b Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Mon, 22 Dec 2025 07:17:25 +0100 Subject: [PATCH 131/200] Harden numeric parsing for options --- CHANGELOG.md | 1 + custom_components/pollenlevels/__init__.py | 7 +++++-- custom_components/pollenlevels/sensor.py | 9 +++++---- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aaec3c33..dc3b7cd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ without passing None into asyncio.gather. - Hardened parsing of update interval and forecast days to tolerate malformed stored values while keeping defaults intact. +- Hardened numeric parsing to handle non-finite values without crashing setup. - Clamped forecast day handling in sensor setup to the supported 1–5 range for consistent cleanup decisions. - Avoided treating empty indexInfo objects as valid forecast indices. diff --git a/custom_components/pollenlevels/__init__.py b/custom_components/pollenlevels/__init__.py index b70b25e9..efb972fc 100644 --- a/custom_components/pollenlevels/__init__.py +++ b/custom_components/pollenlevels/__init__.py @@ -149,8 +149,11 @@ async def async_setup_entry( def _safe_int(value: Any, default: int) -> int: try: - return int(float(value if value is not None else default)) - except (TypeError, ValueError): + val = float(value if value is not None else default) + if val != val or val in (float("inf"), float("-inf")): + return default + return int(val) + except (TypeError, ValueError, OverflowError): return default hours = _safe_int( diff --git a/custom_components/pollenlevels/sensor.py b/custom_components/pollenlevels/sensor.py index c7a13a47..af2a4939 100644 --- a/custom_components/pollenlevels/sensor.py +++ b/custom_components/pollenlevels/sensor.py @@ -156,10 +156,11 @@ async def async_setup_entry( opts = config_entry.options or {} try: - forecast_days = int( - float(opts.get(CONF_FORECAST_DAYS, coordinator.forecast_days)) - ) - except (TypeError, ValueError): + val = float(opts.get(CONF_FORECAST_DAYS, coordinator.forecast_days)) + if val != val or val in (float("inf"), float("-inf")): + raise ValueError + forecast_days = int(val) + except (TypeError, ValueError, OverflowError): forecast_days = coordinator.forecast_days forecast_days = max(MIN_FORECAST_DAYS, min(MAX_FORECAST_DAYS, forecast_days)) create_d1 = coordinator.create_d1 From 6eedbeaf3477bb8e71e82bb2a1a0ed9c035dd4a4 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Mon, 22 Dec 2025 07:29:36 +0100 Subject: [PATCH 132/200] Simplify migration mode selection --- custom_components/pollenlevels/__init__.py | 23 ++++++++-------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/custom_components/pollenlevels/__init__.py b/custom_components/pollenlevels/__init__.py index efb972fc..e0724326 100644 --- a/custom_components/pollenlevels/__init__.py +++ b/custom_components/pollenlevels/__init__.py @@ -61,23 +61,16 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True new_options = dict(entry.options) - if CONF_CREATE_FORECAST_SENSORS in new_options: - mode = new_options.get(CONF_CREATE_FORECAST_SENSORS) - if mode is None: - new_options.pop(CONF_CREATE_FORECAST_SENSORS, None) - mode = entry.data.get(CONF_CREATE_FORECAST_SENSORS) - if mode is not None: - normalized_mode = normalize_sensor_mode(mode, _LOGGER) - new_options[CONF_CREATE_FORECAST_SENSORS] = normalized_mode - else: - normalized_mode = normalize_sensor_mode(mode, _LOGGER) - if normalized_mode != mode: - new_options[CONF_CREATE_FORECAST_SENSORS] = normalized_mode - else: + mode = new_options.get(CONF_CREATE_FORECAST_SENSORS) + if mode is None: mode = entry.data.get(CONF_CREATE_FORECAST_SENSORS) - if mode is not None: - normalized_mode = normalize_sensor_mode(mode, _LOGGER) + + if mode is not None: + normalized_mode = normalize_sensor_mode(mode, _LOGGER) + if new_options.get(CONF_CREATE_FORECAST_SENSORS) != normalized_mode: new_options[CONF_CREATE_FORECAST_SENSORS] = normalized_mode + elif CONF_CREATE_FORECAST_SENSORS in new_options: + new_options.pop(CONF_CREATE_FORECAST_SENSORS) if new_options != entry.options: hass.config_entries.async_update_entry( From 9fceb66ca996c6acf205f63a43ddc3e3248ec20b Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Mon, 22 Dec 2025 11:01:12 +0100 Subject: [PATCH 133/200] Clamp parsed options in setup --- CHANGELOG.md | 1 + custom_components/pollenlevels/__init__.py | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc3b7cd3..8c3071da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ - Hardened parsing of update interval and forecast days to tolerate malformed stored values while keeping defaults intact. - Hardened numeric parsing to handle non-finite values without crashing setup. +- Clamped update interval and forecast days in setup to supported ranges. - Clamped forecast day handling in sensor setup to the supported 1–5 range for consistent cleanup decisions. - Avoided treating empty indexInfo objects as valid forecast indices. diff --git a/custom_components/pollenlevels/__init__.py b/custom_components/pollenlevels/__init__.py index e0724326..d7132a68 100644 --- a/custom_components/pollenlevels/__init__.py +++ b/custom_components/pollenlevels/__init__.py @@ -34,6 +34,8 @@ DEFAULT_FORECAST_DAYS, DEFAULT_UPDATE_INTERVAL, DOMAIN, + MAX_FORECAST_DAYS, + MIN_FORECAST_DAYS, normalize_http_referer, ) from .coordinator import PollenDataUpdateCoordinator @@ -156,6 +158,7 @@ def _safe_int(value: Any, default: int) -> int: ), DEFAULT_UPDATE_INTERVAL, ) + hours = max(1, hours) forecast_days = _safe_int( options.get( CONF_FORECAST_DAYS, @@ -163,6 +166,7 @@ def _safe_int(value: Any, default: int) -> int: ), DEFAULT_FORECAST_DAYS, ) + forecast_days = max(MIN_FORECAST_DAYS, min(MAX_FORECAST_DAYS, forecast_days)) language = options.get(CONF_LANGUAGE_CODE, entry.data.get(CONF_LANGUAGE_CODE)) raw_mode = options.get( CONF_CREATE_FORECAST_SENSORS, From 37b8620b52cee1e63dd15eeac655b63b7a8658eb Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Mon, 22 Dec 2025 11:41:50 +0100 Subject: [PATCH 134/200] Limit update interval to 24 hours --- CHANGELOG.md | 1 + custom_components/pollenlevels/__init__.py | 3 ++- custom_components/pollenlevels/config_flow.py | 3 +++ custom_components/pollenlevels/const.py | 1 + 4 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c3071da..d196d978 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ stored values while keeping defaults intact. - Hardened numeric parsing to handle non-finite values without crashing setup. - Clamped update interval and forecast days in setup to supported ranges. +- Limited update interval to a maximum of 24 hours in setup and options. - Clamped forecast day handling in sensor setup to the supported 1–5 range for consistent cleanup decisions. - Avoided treating empty indexInfo objects as valid forecast indices. diff --git a/custom_components/pollenlevels/__init__.py b/custom_components/pollenlevels/__init__.py index d7132a68..38a13922 100644 --- a/custom_components/pollenlevels/__init__.py +++ b/custom_components/pollenlevels/__init__.py @@ -35,6 +35,7 @@ DEFAULT_UPDATE_INTERVAL, DOMAIN, MAX_FORECAST_DAYS, + MAX_UPDATE_INTERVAL_HOURS, MIN_FORECAST_DAYS, normalize_http_referer, ) @@ -158,7 +159,7 @@ def _safe_int(value: Any, default: int) -> int: ), DEFAULT_UPDATE_INTERVAL, ) - hours = max(1, hours) + hours = max(1, min(MAX_UPDATE_INTERVAL_HOURS, hours)) forecast_days = _safe_int( options.get( CONF_FORECAST_DAYS, diff --git a/custom_components/pollenlevels/config_flow.py b/custom_components/pollenlevels/config_flow.py index a95444f5..7da3aaf3 100644 --- a/custom_components/pollenlevels/config_flow.py +++ b/custom_components/pollenlevels/config_flow.py @@ -54,6 +54,7 @@ DOMAIN, FORECAST_SENSORS_CHOICES, MAX_FORECAST_DAYS, + MAX_UPDATE_INTERVAL_HOURS, MIN_FORECAST_DAYS, POLLEN_API_KEY_URL, POLLEN_API_TIMEOUT, @@ -167,6 +168,7 @@ def _build_step_user_schema(hass: Any, user_input: dict[str, Any] | None) -> vol ): NumberSelector( NumberSelectorConfig( min=1, + max=MAX_UPDATE_INTERVAL_HOURS, step=1, mode=NumberSelectorMode.BOX, unit_of_measurement="h", @@ -682,6 +684,7 @@ async def async_step_init(self, user_input=None): ): NumberSelector( NumberSelectorConfig( min=1, + max=MAX_UPDATE_INTERVAL_HOURS, step=1, mode=NumberSelectorMode.BOX, unit_of_measurement="h", diff --git a/custom_components/pollenlevels/const.py b/custom_components/pollenlevels/const.py index b62bd9d8..6f8486a4 100644 --- a/custom_components/pollenlevels/const.py +++ b/custom_components/pollenlevels/const.py @@ -22,6 +22,7 @@ # Defaults DEFAULT_UPDATE_INTERVAL = 6 +MAX_UPDATE_INTERVAL_HOURS = 24 DEFAULT_FORECAST_DAYS = 2 # today + 1 (tomorrow) DEFAULT_ENTRY_TITLE = "Pollen Levels" MAX_FORECAST_DAYS = 5 From 92f3aca83321dc6d35b0785b8e4a566c4a7cd5cd Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Mon, 22 Dec 2025 13:50:54 +0100 Subject: [PATCH 135/200] Add max update interval to config flow --- custom_components/pollenlevels/config_flow.py | 1 + 1 file changed, 1 insertion(+) diff --git a/custom_components/pollenlevels/config_flow.py b/custom_components/pollenlevels/config_flow.py index 7da3aaf3..46648f14 100644 --- a/custom_components/pollenlevels/config_flow.py +++ b/custom_components/pollenlevels/config_flow.py @@ -214,6 +214,7 @@ def _build_step_user_schema(hass: Any, user_input: dict[str, Any] | None) -> vol ): NumberSelector( NumberSelectorConfig( min=1, + max=MAX_UPDATE_INTERVAL_HOURS, step=1, mode=NumberSelectorMode.BOX, unit_of_measurement="h", From fd18097668165438cb0b2c9e3f6c2ec85cdec448 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Mon, 22 Dec 2025 14:20:24 +0100 Subject: [PATCH 136/200] fix(config_flow): sanitize update_interval selector defaults --- CHANGELOG.md | 2 ++ custom_components/pollenlevels/config_flow.py | 18 ++++++++-- tests/test_config_flow.py | 34 +++++++++++++++++++ tests/test_options_flow.py | 32 +++++++++++++++++ 4 files changed, 83 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d196d978..a911ff1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ ### Fixed - Fixed options flow to preserve the stored per-day sensor mode when no override is set in entry options, preventing unintended resets to "none". +- Sanitized update interval defaults in setup and options forms to clamp + malformed stored values within supported bounds. - Normalized invalid stored per-day sensor mode values in the options flow to avoid persisting unsupported selector choices. - Simplified the per-day sensor mode fallback during options submission to reuse diff --git a/custom_components/pollenlevels/config_flow.py b/custom_components/pollenlevels/config_flow.py index 46648f14..d138ab2f 100644 --- a/custom_components/pollenlevels/config_flow.py +++ b/custom_components/pollenlevels/config_flow.py @@ -146,6 +146,13 @@ def _build_step_user_schema(hass: Any, user_input: dict[str, Any] | None) -> vol else: location_field = vol.Required(CONF_LOCATION) + update_interval_raw = user_input.get(CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL) + interval_default, _ = _parse_update_interval( + update_interval_raw, + DEFAULT_UPDATE_INTERVAL, + ) + interval_default = max(1, min(MAX_UPDATE_INTERVAL_HOURS, interval_default)) + section_schema = vol.Schema( { vol.Required(CONF_API_KEY): str, @@ -164,7 +171,7 @@ def _build_step_user_schema(hass: Any, user_input: dict[str, Any] | None) -> vol location_field: LocationSelector(LocationSelectorConfig(radius=False)), vol.Optional( CONF_UPDATE_INTERVAL, - default=user_input.get(CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL), + default=interval_default, ): NumberSelector( NumberSelectorConfig( min=1, @@ -210,7 +217,7 @@ def _build_step_user_schema(hass: Any, user_input: dict[str, Any] | None) -> vol location_field: LocationSelector(LocationSelectorConfig(radius=False)), vol.Optional( CONF_UPDATE_INTERVAL, - default=user_input.get(CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL), + default=interval_default, ): NumberSelector( NumberSelectorConfig( min=1, @@ -661,10 +668,15 @@ async def async_step_init(self, user_input=None): errors: dict[str, str] = {} placeholders = {"title": self.entry.title or DEFAULT_ENTRY_TITLE} - current_interval = self.entry.options.get( + current_interval_raw = self.entry.options.get( CONF_UPDATE_INTERVAL, self.entry.data.get(CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL), ) + current_interval, _ = _parse_update_interval( + current_interval_raw, + DEFAULT_UPDATE_INTERVAL, + ) + current_interval = max(1, min(MAX_UPDATE_INTERVAL_HOURS, current_interval)) current_lang = self.entry.options.get( CONF_LANGUAGE_CODE, self.entry.data.get(CONF_LANGUAGE_CODE, self.hass.config.language), diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 9916d124..3f24a6b4 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -338,6 +338,8 @@ def __init__(self, schema): CONF_LANGUAGE_CODE, CONF_UPDATE_INTERVAL, DEFAULT_ENTRY_TITLE, + DEFAULT_UPDATE_INTERVAL, + MAX_UPDATE_INTERVAL_HOURS, normalize_http_referer, ) @@ -620,6 +622,38 @@ async def fake_validate( assert result["data"][CONF_HTTP_REFERER] == "https://example.com" +@pytest.mark.parametrize( + ("raw_value", "expected"), + [ + ("not-a-number", DEFAULT_UPDATE_INTERVAL), + (0, 1), + (999, MAX_UPDATE_INTERVAL_HOURS), + ], +) +def test_setup_schema_update_interval_default_is_sanitized( + monkeypatch: pytest.MonkeyPatch, + raw_value: object, + expected: int, +) -> None: + """Update interval defaults should be sanitized for form rendering.""" + + captured_defaults: list[int | None] = [] + + def _capture_optional(key, **kwargs): + if key == CONF_UPDATE_INTERVAL: + captured_defaults.append(kwargs.get("default")) + return key + + monkeypatch.setattr(cf.vol, "Optional", _capture_optional) + + hass = SimpleNamespace( + config=SimpleNamespace(latitude=1.0, longitude=2.0, language="en") + ) + cf._build_step_user_schema(hass, {CONF_UPDATE_INTERVAL: raw_value}) + + assert captured_defaults == [expected, expected] + + def test_async_step_user_drops_blank_http_referer() -> None: """Blank HTTP referrer values should not be persisted.""" diff --git a/tests/test_options_flow.py b/tests/test_options_flow.py index a62153be..12388f23 100644 --- a/tests/test_options_flow.py +++ b/tests/test_options_flow.py @@ -15,6 +15,8 @@ CONF_LATITUDE, CONF_LONGITUDE, CONF_UPDATE_INTERVAL, + DEFAULT_UPDATE_INTERVAL, + MAX_UPDATE_INTERVAL_HOURS, ) from tests import test_config_flow as base @@ -166,3 +168,33 @@ def test_options_flow_invalid_update_interval_short_circuits() -> None: ) assert result["errors"] == {CONF_UPDATE_INTERVAL: "invalid_update_interval"} + + +@pytest.mark.parametrize( + ("raw_value", "expected"), + [ + ("not-a-number", DEFAULT_UPDATE_INTERVAL), + (0, 1), + (999, MAX_UPDATE_INTERVAL_HOURS), + ], +) +def test_options_schema_update_interval_default_is_sanitized( + monkeypatch: pytest.MonkeyPatch, + raw_value: object, + expected: int, +) -> None: + """Options form should clamp invalid update interval defaults.""" + + captured_defaults: list[int | None] = [] + + def _capture_optional(key, **kwargs): + if key == CONF_UPDATE_INTERVAL: + captured_defaults.append(kwargs.get("default")) + return key + + monkeypatch.setattr(base.cf.vol, "Optional", _capture_optional) + + flow = _flow(options={CONF_UPDATE_INTERVAL: raw_value}) + asyncio.run(flow.async_step_init(user_input=None)) + + assert captured_defaults == [expected] From 392df1730a1e17dd43a3e42e193556ab15909aa2 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Mon, 22 Dec 2025 14:45:37 +0100 Subject: [PATCH 137/200] refactor(config_flow): centralize update_interval default sanitization --- custom_components/pollenlevels/config_flow.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/custom_components/pollenlevels/config_flow.py b/custom_components/pollenlevels/config_flow.py index d138ab2f..938e2fe5 100644 --- a/custom_components/pollenlevels/config_flow.py +++ b/custom_components/pollenlevels/config_flow.py @@ -147,11 +147,7 @@ def _build_step_user_schema(hass: Any, user_input: dict[str, Any] | None) -> vol location_field = vol.Required(CONF_LOCATION) update_interval_raw = user_input.get(CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL) - interval_default, _ = _parse_update_interval( - update_interval_raw, - DEFAULT_UPDATE_INTERVAL, - ) - interval_default = max(1, min(MAX_UPDATE_INTERVAL_HOURS, interval_default)) + interval_default = _sanitize_update_interval_for_default(update_interval_raw) section_schema = vol.Schema( { @@ -324,6 +320,12 @@ def _parse_update_interval(value: Any, default: int) -> tuple[int, str | None]: ) +def _sanitize_update_interval_for_default(raw_value: Any) -> int: + """Parse and clamp an update interval value to be used as a UI default.""" + parsed, _ = _parse_update_interval(raw_value, DEFAULT_UPDATE_INTERVAL) + return max(1, min(MAX_UPDATE_INTERVAL_HOURS, parsed)) + + class PollenLevelsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Config flow for Pollen Levels.""" @@ -672,11 +674,7 @@ async def async_step_init(self, user_input=None): CONF_UPDATE_INTERVAL, self.entry.data.get(CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL), ) - current_interval, _ = _parse_update_interval( - current_interval_raw, - DEFAULT_UPDATE_INTERVAL, - ) - current_interval = max(1, min(MAX_UPDATE_INTERVAL_HOURS, current_interval)) + current_interval = _sanitize_update_interval_for_default(current_interval_raw) current_lang = self.entry.options.get( CONF_LANGUAGE_CODE, self.entry.data.get(CONF_LANGUAGE_CODE, self.hass.config.language), From 9660d2d47dba8030b1712355a865529c68e2116e Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Mon, 22 Dec 2025 14:46:27 +0100 Subject: [PATCH 138/200] fix(config_flow): validate update_interval upper bound --- CHANGELOG.md | 1 + custom_components/pollenlevels/config_flow.py | 1 + .../pollenlevels/translations/ca.json | 4 ++-- .../pollenlevels/translations/cs.json | 4 ++-- .../pollenlevels/translations/da.json | 4 ++-- .../pollenlevels/translations/de.json | 4 ++-- .../pollenlevels/translations/en.json | 4 ++-- .../pollenlevels/translations/es.json | 4 ++-- .../pollenlevels/translations/fi.json | 4 ++-- .../pollenlevels/translations/fr.json | 4 ++-- .../pollenlevels/translations/hu.json | 4 ++-- .../pollenlevels/translations/it.json | 4 ++-- .../pollenlevels/translations/nb.json | 4 ++-- .../pollenlevels/translations/nl.json | 4 ++-- .../pollenlevels/translations/pl.json | 4 ++-- .../pollenlevels/translations/pt-BR.json | 4 ++-- .../pollenlevels/translations/pt-PT.json | 4 ++-- .../pollenlevels/translations/ro.json | 4 ++-- .../pollenlevels/translations/ru.json | 4 ++-- .../pollenlevels/translations/sv.json | 4 ++-- .../pollenlevels/translations/uk.json | 4 ++-- .../pollenlevels/translations/zh-Hans.json | 4 ++-- .../pollenlevels/translations/zh-Hant.json | 4 ++-- tests/test_options_flow.py | 19 +++++++++++++++++++ 24 files changed, 63 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a911ff1c..ecdec3a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ is set in entry options, preventing unintended resets to "none". - Sanitized update interval defaults in setup and options forms to clamp malformed stored values within supported bounds. +- Rejected update interval submissions above 24 hours to match selector limits. - Normalized invalid stored per-day sensor mode values in the options flow to avoid persisting unsupported selector choices. - Simplified the per-day sensor mode fallback during options submission to reuse diff --git a/custom_components/pollenlevels/config_flow.py b/custom_components/pollenlevels/config_flow.py index 938e2fe5..cfe3e5ec 100644 --- a/custom_components/pollenlevels/config_flow.py +++ b/custom_components/pollenlevels/config_flow.py @@ -316,6 +316,7 @@ def _parse_update_interval(value: Any, default: int) -> tuple[int, str | None]: value, default=default, min_value=1, + max_value=MAX_UPDATE_INTERVAL_HOURS, error_key="invalid_update_interval", ) diff --git a/custom_components/pollenlevels/translations/ca.json b/custom_components/pollenlevels/translations/ca.json index 998ebd8b..52410e08 100644 --- a/custom_components/pollenlevels/translations/ca.json +++ b/custom_components/pollenlevels/translations/ca.json @@ -41,7 +41,7 @@ "invalid_coordinates": "Selecciona una ubicació vàlida al mapa.", "unknown": "Error desconegut", "invalid_http_referrer": "Valor de HTTP Referer no vàlid. No pot contenir salts de línia.", - "invalid_update_interval": "L’interval d’actualització ha de ser d’almenys 1 hora.", + "invalid_update_interval": "L’interval d’actualització ha d’estar entre 1 i 24 hores.", "invalid_forecast_days": "Els dies de previsió han d’estar entre 1 i 5." }, "abort": { @@ -68,7 +68,7 @@ "empty": "Aquest camp no pot estar buit", "invalid_option_combo": "Augmenta els 'Dies de previsió' per cobrir els sensors per dia seleccionats.", "unknown": "Error desconegut", - "invalid_update_interval": "L’interval d’actualització ha de ser d’almenys 1 hora.", + "invalid_update_interval": "L’interval d’actualització ha d’estar entre 1 i 24 hores.", "invalid_forecast_days": "Els dies de previsió han d’estar entre 1 i 5." } }, diff --git a/custom_components/pollenlevels/translations/cs.json b/custom_components/pollenlevels/translations/cs.json index 88c9ac6b..c3aa68b7 100644 --- a/custom_components/pollenlevels/translations/cs.json +++ b/custom_components/pollenlevels/translations/cs.json @@ -15,7 +15,7 @@ "invalid_option_combo": "Zvyšte „Dny předpovědi“, aby pokryly vybrané senzory po dnech.", "quota_exceeded": "Překročena kvóta\n\n{error_message}", "unknown": "Neznámá chyba", - "invalid_update_interval": "Interval aktualizace musí být alespoň 1 hodina.", + "invalid_update_interval": "Interval aktualizace musí být mezi 1 a 24 hodinami.", "invalid_forecast_days": "Dny předpovědi musí být v rozmezí 1–5." }, "step": { @@ -80,7 +80,7 @@ "invalid_language_format": "Použijte kanonický kód BCP-47, například \"en\" nebo \"es-ES\".", "invalid_option_combo": "Zvyšte „Dny předpovědi“, aby pokryly vybrané senzory po dnech.", "unknown": "Neznámá chyba", - "invalid_update_interval": "Interval aktualizace musí být alespoň 1 hodina.", + "invalid_update_interval": "Interval aktualizace musí být mezi 1 a 24 hodinami.", "invalid_forecast_days": "Dny předpovědi musí být v rozmezí 1–5." }, "step": { diff --git a/custom_components/pollenlevels/translations/da.json b/custom_components/pollenlevels/translations/da.json index dff4c79d..4b0a9e4e 100644 --- a/custom_components/pollenlevels/translations/da.json +++ b/custom_components/pollenlevels/translations/da.json @@ -15,7 +15,7 @@ "invalid_option_combo": "Forøg \"Prognosedage\" for at dække valgte sensorer pr. dag.", "quota_exceeded": "Kvote overskredet\n\n{error_message}", "unknown": "Ukendt fejl", - "invalid_update_interval": "Opdateringsintervallet skal være mindst 1 time.", + "invalid_update_interval": "Opdateringsintervallet skal være mellem 1 og 24 timer.", "invalid_forecast_days": "Prognosedage skal være mellem 1 og 5." }, "step": { @@ -80,7 +80,7 @@ "invalid_language_format": "Brug en kanonisk BCP-47-kode som \"en\" eller \"es-ES\".", "invalid_option_combo": "Forøg \"Prognosedage\" for at dække valgte sensorer pr. dag.", "unknown": "Ukendt fejl", - "invalid_update_interval": "Opdateringsintervallet skal være mindst 1 time.", + "invalid_update_interval": "Opdateringsintervallet skal være mellem 1 og 24 timer.", "invalid_forecast_days": "Prognosedage skal være mellem 1 og 5." }, "step": { diff --git a/custom_components/pollenlevels/translations/de.json b/custom_components/pollenlevels/translations/de.json index 61b46d8f..6039c972 100644 --- a/custom_components/pollenlevels/translations/de.json +++ b/custom_components/pollenlevels/translations/de.json @@ -15,7 +15,7 @@ "invalid_option_combo": "Erhöhe 'Vorhersagetage', um die gewählten Tagessensoren abzudecken.", "quota_exceeded": "Kontingent überschritten\n\n{error_message}", "unknown": "Unbekannter Fehler", - "invalid_update_interval": "Das Aktualisierungsintervall muss mindestens 1 Stunde betragen.", + "invalid_update_interval": "Das Aktualisierungsintervall muss zwischen 1 und 24 Stunden liegen.", "invalid_forecast_days": "Vorhersagetage müssen zwischen 1 und 5 liegen." }, "step": { @@ -80,7 +80,7 @@ "invalid_language_format": "Verwenden Sie einen kanonischen BCP-47-Code wie \"en\" oder \"es-ES\".", "invalid_option_combo": "Erhöhe 'Vorhersagetage', um die gewählten Tagessensoren abzudecken.", "unknown": "Unbekannter Fehler", - "invalid_update_interval": "Das Aktualisierungsintervall muss mindestens 1 Stunde betragen.", + "invalid_update_interval": "Das Aktualisierungsintervall muss zwischen 1 und 24 Stunden liegen.", "invalid_forecast_days": "Vorhersagetage müssen zwischen 1 und 5 liegen." }, "step": { diff --git a/custom_components/pollenlevels/translations/en.json b/custom_components/pollenlevels/translations/en.json index 220784c8..b8f72bf4 100644 --- a/custom_components/pollenlevels/translations/en.json +++ b/custom_components/pollenlevels/translations/en.json @@ -41,7 +41,7 @@ "invalid_coordinates": "Please select a valid location on the map.", "unknown": "Unknown error", "invalid_http_referrer": "Invalid value for HTTP Referer. It must not contain newline characters.", - "invalid_update_interval": "Update interval must be at least 1 hour.", + "invalid_update_interval": "Update interval must be between 1 and 24 hours.", "invalid_forecast_days": "Forecast days must be between 1 and 5." }, "abort": { @@ -68,7 +68,7 @@ "empty": "This field cannot be empty", "invalid_option_combo": "Increase 'Forecast days' to cover selected per-day sensors.", "unknown": "Unknown error", - "invalid_update_interval": "Update interval must be at least 1 hour.", + "invalid_update_interval": "Update interval must be between 1 and 24 hours.", "invalid_forecast_days": "Forecast days must be between 1 and 5." } }, diff --git a/custom_components/pollenlevels/translations/es.json b/custom_components/pollenlevels/translations/es.json index 0dbe32d0..04662421 100644 --- a/custom_components/pollenlevels/translations/es.json +++ b/custom_components/pollenlevels/translations/es.json @@ -41,7 +41,7 @@ "invalid_coordinates": "Selecciona una ubicación válida en el mapa.", "unknown": "Error desconocido", "invalid_http_referrer": "Valor de HTTP Referer no válido. No debe contener saltos de línea.", - "invalid_update_interval": "El intervalo de actualización debe ser de al menos 1 hora.", + "invalid_update_interval": "El intervalo de actualización debe estar entre 1 y 24 horas.", "invalid_forecast_days": "Los días de previsión deben estar entre 1 y 5." }, "abort": { @@ -68,7 +68,7 @@ "empty": "Este campo no puede estar vacío", "invalid_option_combo": "Aumenta 'Días de previsión' para cubrir los sensores por día seleccionados.", "unknown": "Error desconocido", - "invalid_update_interval": "El intervalo de actualización debe ser de al menos 1 hora.", + "invalid_update_interval": "El intervalo de actualización debe estar entre 1 y 24 horas.", "invalid_forecast_days": "Los días de previsión deben estar entre 1 y 5." } }, diff --git a/custom_components/pollenlevels/translations/fi.json b/custom_components/pollenlevels/translations/fi.json index 635a1d31..bde11751 100644 --- a/custom_components/pollenlevels/translations/fi.json +++ b/custom_components/pollenlevels/translations/fi.json @@ -15,7 +15,7 @@ "invalid_option_combo": "Lisää \"Ennustepäiviä\", jotta valitut päiväsensorit katetaan.", "quota_exceeded": "Kiintiö ylitetty\n\n{error_message}", "unknown": "Tuntematon virhe", - "invalid_update_interval": "Päivitysvälin on oltava vähintään 1 tunti.", + "invalid_update_interval": "Päivitysvälin on oltava 1–24 tuntia.", "invalid_forecast_days": "Ennustepäivien on oltava välillä 1–5." }, "step": { @@ -80,7 +80,7 @@ "invalid_language_format": "Käytä kanonista BCP-47-koodia, esimerkiksi \"en\" tai \"es-ES\".", "invalid_option_combo": "Lisää \"Ennustepäiviä\", jotta valitut päiväsensorit katetaan.", "unknown": "Tuntematon virhe", - "invalid_update_interval": "Päivitysvälin on oltava vähintään 1 tunti.", + "invalid_update_interval": "Päivitysvälin on oltava 1–24 tuntia.", "invalid_forecast_days": "Ennustepäivien on oltava välillä 1–5." }, "step": { diff --git a/custom_components/pollenlevels/translations/fr.json b/custom_components/pollenlevels/translations/fr.json index 304ba713..b0c3731d 100644 --- a/custom_components/pollenlevels/translations/fr.json +++ b/custom_components/pollenlevels/translations/fr.json @@ -15,7 +15,7 @@ "invalid_option_combo": "Augmentez le nombre de « Jours de prévision » afin de couvrir les capteurs par jour sélectionnés.", "quota_exceeded": "Quota dépassé\n\n{error_message}", "unknown": "Erreur inconnue", - "invalid_update_interval": "L’intervalle de mise à jour doit être d’au moins 1 heure.", + "invalid_update_interval": "L’intervalle de mise à jour doit être compris entre 1 et 24 heures.", "invalid_forecast_days": "Les jours de prévision doivent être compris entre 1 et 5." }, "step": { @@ -80,7 +80,7 @@ "invalid_language_format": "Utilisez un code BCP-47 canonique tel que \"en\" ou \"es-ES\".", "invalid_option_combo": "Augmentez le nombre de « Jours de prévision » afin de couvrir les capteurs par jour sélectionnés.", "unknown": "Erreur inconnue", - "invalid_update_interval": "L’intervalle de mise à jour doit être d’au moins 1 heure.", + "invalid_update_interval": "L’intervalle de mise à jour doit être compris entre 1 et 24 heures.", "invalid_forecast_days": "Les jours de prévision doivent être compris entre 1 et 5." }, "step": { diff --git a/custom_components/pollenlevels/translations/hu.json b/custom_components/pollenlevels/translations/hu.json index a87196b2..60ae86ed 100644 --- a/custom_components/pollenlevels/translations/hu.json +++ b/custom_components/pollenlevels/translations/hu.json @@ -15,7 +15,7 @@ "invalid_option_combo": "Növeld a \"Előrejelzési napok\" értéket a kiválasztott napi szenzorok lefedéséhez.", "quota_exceeded": "Kvóta túllépve\n\n{error_message}", "unknown": "Ismeretlen hiba", - "invalid_update_interval": "A frissítési időköznek legalább 1 órának kell lennie.", + "invalid_update_interval": "A frissítési időköznek 1 és 24 óra között kell lennie.", "invalid_forecast_days": "Az előrejelzési napoknak 1 és 5 között kell lenniük." }, "step": { @@ -80,7 +80,7 @@ "invalid_language_format": "Használjon kanonikus BCP-47 kódot, például \"en\" vagy \"es-ES\".", "invalid_option_combo": "Növeld a \"Előrejelzési napok\" értéket a kiválasztott napi szenzorok lefedéséhez.", "unknown": "Ismeretlen hiba", - "invalid_update_interval": "A frissítési időköznek legalább 1 órának kell lennie.", + "invalid_update_interval": "A frissítési időköznek 1 és 24 óra között kell lennie.", "invalid_forecast_days": "Az előrejelzési napoknak 1 és 5 között kell lenniük." }, "step": { diff --git a/custom_components/pollenlevels/translations/it.json b/custom_components/pollenlevels/translations/it.json index 7fffd25d..a7a8ae0f 100644 --- a/custom_components/pollenlevels/translations/it.json +++ b/custom_components/pollenlevels/translations/it.json @@ -15,7 +15,7 @@ "invalid_option_combo": "Aumenta 'Giorni di previsione' per coprire i sensori giornalieri selezionati.", "quota_exceeded": "Quota superata\n\n{error_message}", "unknown": "Errore sconosciuto", - "invalid_update_interval": "L’intervallo di aggiornamento deve essere di almeno 1 ora.", + "invalid_update_interval": "L’intervallo di aggiornamento deve essere compreso tra 1 e 24 ore.", "invalid_forecast_days": "I giorni di previsione devono essere compresi tra 1 e 5." }, "step": { @@ -80,7 +80,7 @@ "invalid_language_format": "Usa un codice BCP-47 canonico come \"en\" o \"es-ES\".", "invalid_option_combo": "Aumenta 'Giorni di previsione' per coprire i sensori giornalieri selezionati.", "unknown": "Errore sconosciuto", - "invalid_update_interval": "L’intervallo di aggiornamento deve essere di almeno 1 ora.", + "invalid_update_interval": "L’intervallo di aggiornamento deve essere compreso tra 1 e 24 ore.", "invalid_forecast_days": "I giorni di previsione devono essere compresi tra 1 e 5." }, "step": { diff --git a/custom_components/pollenlevels/translations/nb.json b/custom_components/pollenlevels/translations/nb.json index 8c3153d2..847e5812 100644 --- a/custom_components/pollenlevels/translations/nb.json +++ b/custom_components/pollenlevels/translations/nb.json @@ -15,7 +15,7 @@ "invalid_option_combo": "Øk «Prognosedager» for å dekke valgte sensorer per dag.", "quota_exceeded": "Kvote overskredet\n\n{error_message}", "unknown": "Ukjent feil", - "invalid_update_interval": "Oppdateringsintervallet må være minst 1 time.", + "invalid_update_interval": "Oppdateringsintervallet må være mellom 1 og 24 timer.", "invalid_forecast_days": "Prognosedager må være mellom 1 og 5." }, "step": { @@ -80,7 +80,7 @@ "invalid_language_format": "Bruk en kanonisk BCP-47-kode som \"en\" eller \"es-ES\".", "invalid_option_combo": "Øk «Prognosedager» for å dekke valgte sensorer per dag.", "unknown": "Ukjent feil", - "invalid_update_interval": "Oppdateringsintervallet må være minst 1 time.", + "invalid_update_interval": "Oppdateringsintervallet må være mellom 1 og 24 timer.", "invalid_forecast_days": "Prognosedager må være mellom 1 og 5." }, "step": { diff --git a/custom_components/pollenlevels/translations/nl.json b/custom_components/pollenlevels/translations/nl.json index 2069389a..b98968dd 100644 --- a/custom_components/pollenlevels/translations/nl.json +++ b/custom_components/pollenlevels/translations/nl.json @@ -15,7 +15,7 @@ "invalid_option_combo": "Verhoog 'Voorspellingsdagen' om de geselecteerde per-dag sensoren te dekken.", "quota_exceeded": "Limiet overschreden\n\n{error_message}", "unknown": "Onbekende fout", - "invalid_update_interval": "Het update-interval moet minimaal 1 uur zijn.", + "invalid_update_interval": "Het update-interval moet tussen 1 en 24 uur liggen.", "invalid_forecast_days": "Voorspellingsdagen moeten tussen 1 en 5 liggen." }, "step": { @@ -80,7 +80,7 @@ "invalid_language_format": "Gebruik een canonieke BCP-47-code zoals \"en\" of \"es-ES\".", "invalid_option_combo": "Verhoog 'Voorspellingsdagen' om de geselecteerde per-dag sensoren te dekken.", "unknown": "Onbekende fout", - "invalid_update_interval": "Het update-interval moet minimaal 1 uur zijn.", + "invalid_update_interval": "Het update-interval moet tussen 1 en 24 uur liggen.", "invalid_forecast_days": "Voorspellingsdagen moeten tussen 1 en 5 liggen." }, "step": { diff --git a/custom_components/pollenlevels/translations/pl.json b/custom_components/pollenlevels/translations/pl.json index 5c06fbe2..0993b66e 100644 --- a/custom_components/pollenlevels/translations/pl.json +++ b/custom_components/pollenlevels/translations/pl.json @@ -15,7 +15,7 @@ "invalid_option_combo": "Zwiększ 'Dni prognozy', aby objąć wybrane czujniki dzienne.", "quota_exceeded": "Przekroczono limit\n\n{error_message}", "unknown": "Nieznany błąd", - "invalid_update_interval": "Interwał aktualizacji musi wynosić co najmniej 1 godzinę.", + "invalid_update_interval": "Interwał aktualizacji musi wynosić od 1 do 24 godzin.", "invalid_forecast_days": "Dni prognozy muszą mieścić się w zakresie 1–5." }, "step": { @@ -80,7 +80,7 @@ "invalid_language_format": "Użyj kanonicznego kodu BCP-47, np. \"en\" lub \"es-ES\".", "invalid_option_combo": "Zwiększ 'Dni prognozy', aby objąć wybrane czujniki dzienne.", "unknown": "Nieznany błąd", - "invalid_update_interval": "Interwał aktualizacji musi wynosić co najmniej 1 godzinę.", + "invalid_update_interval": "Interwał aktualizacji musi wynosić od 1 do 24 godzin.", "invalid_forecast_days": "Dni prognozy muszą mieścić się w zakresie 1–5." }, "step": { diff --git a/custom_components/pollenlevels/translations/pt-BR.json b/custom_components/pollenlevels/translations/pt-BR.json index 4bb52a69..62b769d2 100644 --- a/custom_components/pollenlevels/translations/pt-BR.json +++ b/custom_components/pollenlevels/translations/pt-BR.json @@ -15,7 +15,7 @@ "invalid_option_combo": "Aumente \"Dias de previsão\" para cobrir os sensores por dia selecionados.", "quota_exceeded": "Cota excedida\n\n{error_message}", "unknown": "Erro desconhecido", - "invalid_update_interval": "O intervalo de atualização deve ser de pelo menos 1 hora.", + "invalid_update_interval": "O intervalo de atualização deve estar entre 1 e 24 horas.", "invalid_forecast_days": "Os dias de previsão devem estar entre 1 e 5." }, "step": { @@ -80,7 +80,7 @@ "invalid_language_format": "Use um código BCP-47 canônico, como \"en\" ou \"es-ES\".", "invalid_option_combo": "Aumente \"Dias de previsão\" para cobrir os sensores por dia selecionados.", "unknown": "Erro desconhecido", - "invalid_update_interval": "O intervalo de atualização deve ser de pelo menos 1 hora.", + "invalid_update_interval": "O intervalo de atualização deve estar entre 1 e 24 horas.", "invalid_forecast_days": "Os dias de previsão devem estar entre 1 e 5." }, "step": { diff --git a/custom_components/pollenlevels/translations/pt-PT.json b/custom_components/pollenlevels/translations/pt-PT.json index 1e961335..902b0ab7 100644 --- a/custom_components/pollenlevels/translations/pt-PT.json +++ b/custom_components/pollenlevels/translations/pt-PT.json @@ -15,7 +15,7 @@ "invalid_option_combo": "Aumente \"Dias de previsão\" para cobrir os sensores por dia selecionados.", "quota_exceeded": "Quota excedida\n\n{error_message}", "unknown": "Erro desconhecido", - "invalid_update_interval": "O intervalo de atualização deve ser de pelo menos 1 hora.", + "invalid_update_interval": "O intervalo de atualização deve estar entre 1 e 24 horas.", "invalid_forecast_days": "Os dias de previsão devem estar entre 1 e 5." }, "step": { @@ -80,7 +80,7 @@ "invalid_language_format": "Use um código BCP-47 canónico, como \"en\" ou \"es-ES\".", "invalid_option_combo": "Aumente \"Dias de previsão\" para cobrir os sensores por dia selecionados.", "unknown": "Erro desconhecido", - "invalid_update_interval": "O intervalo de atualização deve ser de pelo menos 1 hora.", + "invalid_update_interval": "O intervalo de atualização deve estar entre 1 e 24 horas.", "invalid_forecast_days": "Os dias de previsão devem estar entre 1 e 5." }, "step": { diff --git a/custom_components/pollenlevels/translations/ro.json b/custom_components/pollenlevels/translations/ro.json index 8a1ee25c..294d3a0e 100644 --- a/custom_components/pollenlevels/translations/ro.json +++ b/custom_components/pollenlevels/translations/ro.json @@ -15,7 +15,7 @@ "invalid_option_combo": "Măriți \"Zilele de prognoză\" pentru a acoperi senzorii selectați pe zile.", "quota_exceeded": "Cota depășită\n\n{error_message}", "unknown": "Eroare necunoscută", - "invalid_update_interval": "Intervalul de actualizare trebuie să fie de cel puțin 1 oră.", + "invalid_update_interval": "Intervalul de actualizare trebuie să fie între 1 și 24 de ore.", "invalid_forecast_days": "Zilele de prognoză trebuie să fie între 1 și 5." }, "step": { @@ -80,7 +80,7 @@ "invalid_language_format": "Folosiți un cod BCP-47 canonic, de exemplu \"en\" sau \"es-ES\".", "invalid_option_combo": "Măriți \"Zilele de prognoză\" pentru a acoperi senzorii selectați pe zile.", "unknown": "Eroare necunoscută", - "invalid_update_interval": "Intervalul de actualizare trebuie să fie de cel puțin 1 oră.", + "invalid_update_interval": "Intervalul de actualizare trebuie să fie între 1 și 24 de ore.", "invalid_forecast_days": "Zilele de prognoză trebuie să fie între 1 și 5." }, "step": { diff --git a/custom_components/pollenlevels/translations/ru.json b/custom_components/pollenlevels/translations/ru.json index 21b71727..99eaa319 100644 --- a/custom_components/pollenlevels/translations/ru.json +++ b/custom_components/pollenlevels/translations/ru.json @@ -15,7 +15,7 @@ "invalid_option_combo": "Увеличьте «Дни прогноза», чтобы охватить выбранные датчики по дням.", "quota_exceeded": "Превышен лимит запросов\n\n{error_message}", "unknown": "Неизвестная ошибка", - "invalid_update_interval": "Интервал обновления должен быть не менее 1 часа.", + "invalid_update_interval": "Интервал обновления должен быть от 1 до 24 часов.", "invalid_forecast_days": "Дни прогноза должны быть от 1 до 5." }, "step": { @@ -80,7 +80,7 @@ "invalid_language_format": "Используйте канонический код BCP-47, например \"en\" или \"es-ES\".", "invalid_option_combo": "Увеличьте «Дни прогноза», чтобы охватить выбранные датчики по дням.", "unknown": "Неизвестная ошибка", - "invalid_update_interval": "Интервал обновления должен быть не менее 1 часа.", + "invalid_update_interval": "Интервал обновления должен быть от 1 до 24 часов.", "invalid_forecast_days": "Дни прогноза должны быть от 1 до 5." }, "step": { diff --git a/custom_components/pollenlevels/translations/sv.json b/custom_components/pollenlevels/translations/sv.json index b508a975..0c077dfe 100644 --- a/custom_components/pollenlevels/translations/sv.json +++ b/custom_components/pollenlevels/translations/sv.json @@ -15,7 +15,7 @@ "invalid_option_combo": "Öka \"Prognosdagar\" för att täcka valda sensorer per dag.", "quota_exceeded": "Kvoten har överskridits\n\n{error_message}", "unknown": "Okänt fel", - "invalid_update_interval": "Uppdateringsintervallet måste vara minst 1 timme.", + "invalid_update_interval": "Uppdateringsintervallet måste vara mellan 1 och 24 timmar.", "invalid_forecast_days": "Prognosdagar måste vara mellan 1 och 5." }, "step": { @@ -80,7 +80,7 @@ "invalid_language_format": "Använd en kanonisk BCP-47-kod som \"en\" eller \"es-ES\".", "invalid_option_combo": "Öka \"Prognosdagar\" för att täcka valda sensorer per dag.", "unknown": "Okänt fel", - "invalid_update_interval": "Uppdateringsintervallet måste vara minst 1 timme.", + "invalid_update_interval": "Uppdateringsintervallet måste vara mellan 1 och 24 timmar.", "invalid_forecast_days": "Prognosdagar måste vara mellan 1 och 5." }, "step": { diff --git a/custom_components/pollenlevels/translations/uk.json b/custom_components/pollenlevels/translations/uk.json index cf9ac3d2..8fbc54b1 100644 --- a/custom_components/pollenlevels/translations/uk.json +++ b/custom_components/pollenlevels/translations/uk.json @@ -15,7 +15,7 @@ "invalid_option_combo": "Збільшіть «Дні прогнозу», щоб охопити вибрані денні датчики.", "quota_exceeded": "Перевищено ліміт запитів\n\n{error_message}", "unknown": "Невідома помилка", - "invalid_update_interval": "Інтервал оновлення має бути щонайменше 1 година.", + "invalid_update_interval": "Інтервал оновлення має бути між 1 і 24 годинами.", "invalid_forecast_days": "Дні прогнозу мають бути від 1 до 5." }, "step": { @@ -80,7 +80,7 @@ "invalid_language_format": "Використовуйте канонічний код BCP-47, наприклад \"en\" або \"es-ES\".", "invalid_option_combo": "Збільшіть «Дні прогнозу», щоб охопити вибрані денні датчики.", "unknown": "Невідома помилка", - "invalid_update_interval": "Інтервал оновлення має бути щонайменше 1 година.", + "invalid_update_interval": "Інтервал оновлення має бути між 1 і 24 годинами.", "invalid_forecast_days": "Дні прогнозу мають бути від 1 до 5." }, "step": { diff --git a/custom_components/pollenlevels/translations/zh-Hans.json b/custom_components/pollenlevels/translations/zh-Hans.json index d016ddd9..0d19ec81 100644 --- a/custom_components/pollenlevels/translations/zh-Hans.json +++ b/custom_components/pollenlevels/translations/zh-Hans.json @@ -15,7 +15,7 @@ "invalid_option_combo": "请增加“预测天数”,以覆盖所选的逐日类型传感器。", "quota_exceeded": "配额已用尽\n\n{error_message}", "unknown": "未知错误", - "invalid_update_interval": "更新间隔必须至少为 1 小时。", + "invalid_update_interval": "更新间隔必须在 1 到 24 小时之间。", "invalid_forecast_days": "预测天数必须在 1 到 5 之间。" }, "step": { @@ -80,7 +80,7 @@ "invalid_language_format": "请使用规范的 BCP-47 代码,例如 \"en\" 或 \"es-ES\"。", "invalid_option_combo": "请增加“预测天数”,以覆盖所选的逐日类型传感器。", "unknown": "未知错误", - "invalid_update_interval": "更新间隔必须至少为 1 小时。", + "invalid_update_interval": "更新间隔必须在 1 到 24 小时之间。", "invalid_forecast_days": "预测天数必须在 1 到 5 之间。" }, "step": { diff --git a/custom_components/pollenlevels/translations/zh-Hant.json b/custom_components/pollenlevels/translations/zh-Hant.json index 64365404..f68ab221 100644 --- a/custom_components/pollenlevels/translations/zh-Hant.json +++ b/custom_components/pollenlevels/translations/zh-Hant.json @@ -15,7 +15,7 @@ "invalid_option_combo": "請增加「預測天數」以涵蓋所選的逐日類型感測器。", "quota_exceeded": "超出配額\n\n{error_message}", "unknown": "未知錯誤", - "invalid_update_interval": "更新間隔必須至少為 1 小時。", + "invalid_update_interval": "更新間隔必須在 1 到 24 小時之間。", "invalid_forecast_days": "預測天數必須在 1 到 5 之間。" }, "step": { @@ -80,7 +80,7 @@ "invalid_language_format": "請使用標準的 BCP-47 代碼,例如 \"en\" 或 \"es-ES\"。", "invalid_option_combo": "請增加「預測天數」以涵蓋所選的逐日類型感測器。", "unknown": "未知錯誤", - "invalid_update_interval": "更新間隔必須至少為 1 小時。", + "invalid_update_interval": "更新間隔必須在 1 到 24 小時之間。", "invalid_forecast_days": "預測天數必須在 1 到 5 之間。" }, "step": { diff --git a/tests/test_options_flow.py b/tests/test_options_flow.py index 12388f23..4bf9faaa 100644 --- a/tests/test_options_flow.py +++ b/tests/test_options_flow.py @@ -170,6 +170,25 @@ def test_options_flow_invalid_update_interval_short_circuits() -> None: assert result["errors"] == {CONF_UPDATE_INTERVAL: "invalid_update_interval"} +def test_options_flow_update_interval_above_max_sets_error() -> None: + """Over-max update intervals should raise a field error.""" + + flow = _flow() + + result = asyncio.run( + flow.async_step_init( + { + CONF_LANGUAGE_CODE: "en", + CONF_FORECAST_DAYS: 2, + CONF_CREATE_FORECAST_SENSORS: "none", + CONF_UPDATE_INTERVAL: 999, + } + ) + ) + + assert result["errors"] == {CONF_UPDATE_INTERVAL: "invalid_update_interval"} + + @pytest.mark.parametrize( ("raw_value", "expected"), [ From 4d2bab1febbc21ca5fe3e02e7349c965d21af9a6 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Tue, 23 Dec 2025 07:39:32 +0100 Subject: [PATCH 139/200] refactor(config_flow): use MIN_UPDATE_INTERVAL_HOURS constant --- custom_components/pollenlevels/config_flow.py | 11 ++++++----- custom_components/pollenlevels/const.py | 1 + 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/custom_components/pollenlevels/config_flow.py b/custom_components/pollenlevels/config_flow.py index cfe3e5ec..a7f7ee59 100644 --- a/custom_components/pollenlevels/config_flow.py +++ b/custom_components/pollenlevels/config_flow.py @@ -56,6 +56,7 @@ MAX_FORECAST_DAYS, MAX_UPDATE_INTERVAL_HOURS, MIN_FORECAST_DAYS, + MIN_UPDATE_INTERVAL_HOURS, POLLEN_API_KEY_URL, POLLEN_API_TIMEOUT, RESTRICTING_API_KEYS_URL, @@ -170,7 +171,7 @@ def _build_step_user_schema(hass: Any, user_input: dict[str, Any] | None) -> vol default=interval_default, ): NumberSelector( NumberSelectorConfig( - min=1, + min=MIN_UPDATE_INTERVAL_HOURS, max=MAX_UPDATE_INTERVAL_HOURS, step=1, mode=NumberSelectorMode.BOX, @@ -216,7 +217,7 @@ def _build_step_user_schema(hass: Any, user_input: dict[str, Any] | None) -> vol default=interval_default, ): NumberSelector( NumberSelectorConfig( - min=1, + min=MIN_UPDATE_INTERVAL_HOURS, max=MAX_UPDATE_INTERVAL_HOURS, step=1, mode=NumberSelectorMode.BOX, @@ -315,7 +316,7 @@ def _parse_update_interval(value: Any, default: int) -> tuple[int, str | None]: return _parse_int_option( value, default=default, - min_value=1, + min_value=MIN_UPDATE_INTERVAL_HOURS, max_value=MAX_UPDATE_INTERVAL_HOURS, error_key="invalid_update_interval", ) @@ -324,7 +325,7 @@ def _parse_update_interval(value: Any, default: int) -> tuple[int, str | None]: def _sanitize_update_interval_for_default(raw_value: Any) -> int: """Parse and clamp an update interval value to be used as a UI default.""" parsed, _ = _parse_update_interval(raw_value, DEFAULT_UPDATE_INTERVAL) - return max(1, min(MAX_UPDATE_INTERVAL_HOURS, parsed)) + return max(MIN_UPDATE_INTERVAL_HOURS, min(MAX_UPDATE_INTERVAL_HOURS, parsed)) class PollenLevelsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -695,7 +696,7 @@ async def async_step_init(self, user_input=None): CONF_UPDATE_INTERVAL, default=current_interval ): NumberSelector( NumberSelectorConfig( - min=1, + min=MIN_UPDATE_INTERVAL_HOURS, max=MAX_UPDATE_INTERVAL_HOURS, step=1, mode=NumberSelectorMode.BOX, diff --git a/custom_components/pollenlevels/const.py b/custom_components/pollenlevels/const.py index 6f8486a4..0585d792 100644 --- a/custom_components/pollenlevels/const.py +++ b/custom_components/pollenlevels/const.py @@ -22,6 +22,7 @@ # Defaults DEFAULT_UPDATE_INTERVAL = 6 +MIN_UPDATE_INTERVAL_HOURS = 1 MAX_UPDATE_INTERVAL_HOURS = 24 DEFAULT_FORECAST_DAYS = 2 # today + 1 (tomorrow) DEFAULT_ENTRY_TITLE = "Pollen Levels" From 93c2eb6da107ac7d0d371c9e4b0338e62d6bc477 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Tue, 23 Dec 2025 08:02:33 +0100 Subject: [PATCH 140/200] refactor(config_flow): sanitize selector defaults --- custom_components/pollenlevels/__init__.py | 3 +- custom_components/pollenlevels/config_flow.py | 44 ++++++++++---- tests/test_config_flow.py | 60 +++++++++++++++++++ 3 files changed, 96 insertions(+), 11 deletions(-) diff --git a/custom_components/pollenlevels/__init__.py b/custom_components/pollenlevels/__init__.py index 38a13922..919e058e 100644 --- a/custom_components/pollenlevels/__init__.py +++ b/custom_components/pollenlevels/__init__.py @@ -37,6 +37,7 @@ MAX_FORECAST_DAYS, MAX_UPDATE_INTERVAL_HOURS, MIN_FORECAST_DAYS, + MIN_UPDATE_INTERVAL_HOURS, normalize_http_referer, ) from .coordinator import PollenDataUpdateCoordinator @@ -159,7 +160,7 @@ def _safe_int(value: Any, default: int) -> int: ), DEFAULT_UPDATE_INTERVAL, ) - hours = max(1, min(MAX_UPDATE_INTERVAL_HOURS, hours)) + hours = max(MIN_UPDATE_INTERVAL_HOURS, min(MAX_UPDATE_INTERVAL_HOURS, hours)) forecast_days = _safe_int( options.get( CONF_FORECAST_DAYS, diff --git a/custom_components/pollenlevels/config_flow.py b/custom_components/pollenlevels/config_flow.py index a7f7ee59..571ce62c 100644 --- a/custom_components/pollenlevels/config_flow.py +++ b/custom_components/pollenlevels/config_flow.py @@ -149,6 +149,12 @@ def _build_step_user_schema(hass: Any, user_input: dict[str, Any] | None) -> vol update_interval_raw = user_input.get(CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL) interval_default = _sanitize_update_interval_for_default(update_interval_raw) + forecast_days_default = _sanitize_forecast_days_for_default( + user_input.get(CONF_FORECAST_DAYS, DEFAULT_FORECAST_DAYS) + ) + sensor_mode_default = _sanitize_sensor_mode_for_default( + user_input.get(CONF_CREATE_FORECAST_SENSORS, FORECAST_SENSORS_CHOICES[0]) + ) section_schema = vol.Schema( { @@ -186,7 +192,7 @@ def _build_step_user_schema(hass: Any, user_input: dict[str, Any] | None) -> vol ): TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT)), vol.Optional( CONF_FORECAST_DAYS, - default=str(user_input.get(CONF_FORECAST_DAYS, DEFAULT_FORECAST_DAYS)), + default=forecast_days_default, ): SelectSelector( SelectSelectorConfig( mode=SelectSelectorMode.DROPDOWN, @@ -195,9 +201,7 @@ def _build_step_user_schema(hass: Any, user_input: dict[str, Any] | None) -> vol ), vol.Optional( CONF_CREATE_FORECAST_SENSORS, - default=user_input.get( - CONF_CREATE_FORECAST_SENSORS, FORECAST_SENSORS_CHOICES[0] - ), + default=sensor_mode_default, ): SelectSelector( SelectSelectorConfig( mode=SelectSelectorMode.DROPDOWN, @@ -232,7 +236,7 @@ def _build_step_user_schema(hass: Any, user_input: dict[str, Any] | None) -> vol ): TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT)), vol.Optional( CONF_FORECAST_DAYS, - default=str(user_input.get(CONF_FORECAST_DAYS, DEFAULT_FORECAST_DAYS)), + default=forecast_days_default, ): SelectSelector( SelectSelectorConfig( mode=SelectSelectorMode.DROPDOWN, @@ -241,9 +245,7 @@ def _build_step_user_schema(hass: Any, user_input: dict[str, Any] | None) -> vol ), vol.Optional( CONF_CREATE_FORECAST_SENSORS, - default=user_input.get( - CONF_CREATE_FORECAST_SENSORS, FORECAST_SENSORS_CHOICES[0] - ), + default=sensor_mode_default, ): SelectSelector( SelectSelectorConfig( mode=SelectSelectorMode.DROPDOWN, @@ -328,6 +330,26 @@ def _sanitize_update_interval_for_default(raw_value: Any) -> int: return max(MIN_UPDATE_INTERVAL_HOURS, min(MAX_UPDATE_INTERVAL_HOURS, parsed)) +def _sanitize_forecast_days_for_default(raw_value: Any) -> str: + """Parse and clamp forecast days to be used as a UI default.""" + parsed, _ = _parse_int_option( + raw_value, + DEFAULT_FORECAST_DAYS, + min_value=MIN_FORECAST_DAYS, + max_value=MAX_FORECAST_DAYS, + ) + parsed = max(MIN_FORECAST_DAYS, min(MAX_FORECAST_DAYS, parsed)) + return str(parsed) + + +def _sanitize_sensor_mode_for_default(raw_value: Any) -> str: + """Normalize sensor mode to be used as a UI default.""" + mode = normalize_sensor_mode(raw_value, _LOGGER) + if mode in FORECAST_SENSORS_CHOICES: + return mode + return FORECAST_SENSORS_CHOICES[0] + + class PollenLevelsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Config flow for Pollen Levels.""" @@ -681,10 +703,12 @@ async def async_step_init(self, user_input=None): CONF_LANGUAGE_CODE, self.entry.data.get(CONF_LANGUAGE_CODE, self.hass.config.language), ) - current_days = self.entry.options.get( + current_days_raw = self.entry.options.get( CONF_FORECAST_DAYS, self.entry.data.get(CONF_FORECAST_DAYS, DEFAULT_FORECAST_DAYS), ) + current_days_default = _sanitize_forecast_days_for_default(current_days_raw) + current_days = int(current_days_default) current_mode = self.entry.options.get(CONF_CREATE_FORECAST_SENSORS) if current_mode is None: current_mode = self.entry.data.get(CONF_CREATE_FORECAST_SENSORS, "none") @@ -707,7 +731,7 @@ async def async_step_init(self, user_input=None): TextSelectorConfig(type=TextSelectorType.TEXT) ), vol.Optional( - CONF_FORECAST_DAYS, default=str(current_days) + CONF_FORECAST_DAYS, default=current_days_default ): SelectSelector( SelectSelectorConfig( mode=SelectSelectorMode.DROPDOWN, diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 3f24a6b4..7ed25245 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -334,12 +334,18 @@ def __init__(self, schema): ) from custom_components.pollenlevels.const import ( CONF_API_KEY, + CONF_CREATE_FORECAST_SENSORS, + CONF_FORECAST_DAYS, CONF_HTTP_REFERER, CONF_LANGUAGE_CODE, CONF_UPDATE_INTERVAL, DEFAULT_ENTRY_TITLE, + DEFAULT_FORECAST_DAYS, DEFAULT_UPDATE_INTERVAL, + FORECAST_SENSORS_CHOICES, + MAX_FORECAST_DAYS, MAX_UPDATE_INTERVAL_HOURS, + MIN_FORECAST_DAYS, normalize_http_referer, ) @@ -654,6 +660,60 @@ def _capture_optional(key, **kwargs): assert captured_defaults == [expected, expected] +@pytest.mark.parametrize( + ("raw_value", "expected"), + [ + ("999", str(MAX_FORECAST_DAYS)), + (-5, str(MIN_FORECAST_DAYS)), + ("abc", str(DEFAULT_FORECAST_DAYS)), + ], +) +def test_setup_schema_forecast_days_default_is_sanitized( + monkeypatch: pytest.MonkeyPatch, + raw_value: object, + expected: str, +) -> None: + """Forecast days defaults should be sanitized for form rendering.""" + + captured_defaults: list[str | None] = [] + + def _capture_optional(key, **kwargs): + if key == CONF_FORECAST_DAYS: + captured_defaults.append(kwargs.get("default")) + return key + + monkeypatch.setattr(cf.vol, "Optional", _capture_optional) + + hass = SimpleNamespace( + config=SimpleNamespace(latitude=1.0, longitude=2.0, language="en") + ) + cf._build_step_user_schema(hass, {CONF_FORECAST_DAYS: raw_value}) + + assert captured_defaults == [expected, expected] + + +def test_setup_schema_sensor_mode_default_is_sanitized( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Per-day sensor defaults should fall back to a valid selector choice.""" + + captured_defaults: list[str | None] = [] + + def _capture_optional(key, **kwargs): + if key == CONF_CREATE_FORECAST_SENSORS: + captured_defaults.append(kwargs.get("default")) + return key + + monkeypatch.setattr(cf.vol, "Optional", _capture_optional) + + hass = SimpleNamespace( + config=SimpleNamespace(latitude=1.0, longitude=2.0, language="en") + ) + cf._build_step_user_schema(hass, {CONF_CREATE_FORECAST_SENSORS: "bad"}) + + assert captured_defaults == [FORECAST_SENSORS_CHOICES[0]] * 2 + + def test_async_step_user_drops_blank_http_referer() -> None: """Blank HTTP referrer values should not be persisted.""" From 254070f5957e3beefd206a4aea27215401a169ff Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Tue, 23 Dec 2025 08:32:45 +0100 Subject: [PATCH 141/200] fix(config_flow): sanitize SelectSelector defaults for forecast settings --- custom_components/pollenlevels/config_flow.py | 9 ++-- tests/test_options_flow.py | 54 +++++++++++++++++++ 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/custom_components/pollenlevels/config_flow.py b/custom_components/pollenlevels/config_flow.py index 571ce62c..a88803be 100644 --- a/custom_components/pollenlevels/config_flow.py +++ b/custom_components/pollenlevels/config_flow.py @@ -152,7 +152,7 @@ def _build_step_user_schema(hass: Any, user_input: dict[str, Any] | None) -> vol forecast_days_default = _sanitize_forecast_days_for_default( user_input.get(CONF_FORECAST_DAYS, DEFAULT_FORECAST_DAYS) ) - sensor_mode_default = _sanitize_sensor_mode_for_default( + sensor_mode_default = _sanitize_forecast_mode_for_default( user_input.get(CONF_CREATE_FORECAST_SENSORS, FORECAST_SENSORS_CHOICES[0]) ) @@ -337,13 +337,14 @@ def _sanitize_forecast_days_for_default(raw_value: Any) -> str: DEFAULT_FORECAST_DAYS, min_value=MIN_FORECAST_DAYS, max_value=MAX_FORECAST_DAYS, + error_key="invalid_forecast_days", ) parsed = max(MIN_FORECAST_DAYS, min(MAX_FORECAST_DAYS, parsed)) return str(parsed) -def _sanitize_sensor_mode_for_default(raw_value: Any) -> str: - """Normalize sensor mode to be used as a UI default.""" +def _sanitize_forecast_mode_for_default(raw_value: Any) -> str: + """Normalize forecast sensor mode to be used as a UI default.""" mode = normalize_sensor_mode(raw_value, _LOGGER) if mode in FORECAST_SENSORS_CHOICES: return mode @@ -712,7 +713,7 @@ async def async_step_init(self, user_input=None): current_mode = self.entry.options.get(CONF_CREATE_FORECAST_SENSORS) if current_mode is None: current_mode = self.entry.data.get(CONF_CREATE_FORECAST_SENSORS, "none") - current_mode = normalize_sensor_mode(current_mode, _LOGGER) + current_mode = _sanitize_forecast_mode_for_default(current_mode) options_schema = vol.Schema( { diff --git a/tests/test_options_flow.py b/tests/test_options_flow.py index 4bf9faaa..f1be3852 100644 --- a/tests/test_options_flow.py +++ b/tests/test_options_flow.py @@ -15,8 +15,12 @@ CONF_LATITUDE, CONF_LONGITUDE, CONF_UPDATE_INTERVAL, + DEFAULT_FORECAST_DAYS, DEFAULT_UPDATE_INTERVAL, + FORECAST_SENSORS_CHOICES, + MAX_FORECAST_DAYS, MAX_UPDATE_INTERVAL_HOURS, + MIN_FORECAST_DAYS, ) from tests import test_config_flow as base @@ -217,3 +221,53 @@ def _capture_optional(key, **kwargs): asyncio.run(flow.async_step_init(user_input=None)) assert captured_defaults == [expected] + + +@pytest.mark.parametrize( + ("raw_value", "expected"), + [ + (0, str(MIN_FORECAST_DAYS)), + (999, str(MAX_FORECAST_DAYS)), + ("abc", str(DEFAULT_FORECAST_DAYS)), + ], +) +def test_options_schema_forecast_days_default_is_sanitized( + monkeypatch: pytest.MonkeyPatch, + raw_value: object, + expected: str, +) -> None: + """Options form should clamp invalid forecast day defaults.""" + + captured_defaults: list[str | None] = [] + + def _capture_optional(key, **kwargs): + if key == CONF_FORECAST_DAYS: + captured_defaults.append(kwargs.get("default")) + return key + + monkeypatch.setattr(base.cf.vol, "Optional", _capture_optional) + + flow = _flow(options={CONF_FORECAST_DAYS: raw_value}) + asyncio.run(flow.async_step_init(user_input=None)) + + assert captured_defaults == [expected] + + +def test_options_schema_sensor_mode_default_is_sanitized( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Options form should fall back to a valid sensor mode default.""" + + captured_defaults: list[str | None] = [] + + def _capture_optional(key, **kwargs): + if key == CONF_CREATE_FORECAST_SENSORS: + captured_defaults.append(kwargs.get("default")) + return key + + monkeypatch.setattr(base.cf.vol, "Optional", _capture_optional) + + flow = _flow(options={CONF_CREATE_FORECAST_SENSORS: "bad"}) + asyncio.run(flow.async_step_init(user_input=None)) + + assert captured_defaults == [FORECAST_SENSORS_CHOICES[0]] From 4425a950845bef57e785d109d4eea55514bcbe20 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Tue, 23 Dec 2025 08:32:53 +0100 Subject: [PATCH 142/200] fix(const): harden referer validation and docs --- README.md | 2 +- custom_components/pollenlevels/const.py | 4 ++++ custom_components/pollenlevels/sensor.py | 5 +++-- tests/test_init.py | 10 ++++++++++ 4 files changed, 18 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 9d6059fb..cfde413b 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ Get sensors for **grass**, **tree**, **weed** pollen, plus individual plants lik You can change: -- **Update interval (hours)** +- **Update interval (hours)** (1–24) - **API response language code** - **Forecast days** (`1–5`) for pollen TYPES - **Per-day TYPE sensors** via `create_forecast_sensors`: diff --git a/custom_components/pollenlevels/const.py b/custom_components/pollenlevels/const.py index 0585d792..6a8d86f4 100644 --- a/custom_components/pollenlevels/const.py +++ b/custom_components/pollenlevels/const.py @@ -39,6 +39,7 @@ # Allowed values for create_forecast_sensors selector FORECAST_SENSORS_CHOICES: list[str] = ["none", "D+1", "D+1+2"] +ATTRIBUTION = "Data provided by Google Maps Pollen API" def is_invalid_api_key_message(message: str | None) -> bool: @@ -68,6 +69,9 @@ def normalize_http_referer(value: Any) -> str | None: if not text: return None + if any(ch.isspace() for ch in text): + raise ValueError("invalid http referer") + if "\r" in text or "\n" in text: raise ValueError("invalid http referer") diff --git a/custom_components/pollenlevels/sensor.py b/custom_components/pollenlevels/sensor.py index af2a4939..4332d41d 100644 --- a/custom_components/pollenlevels/sensor.py +++ b/custom_components/pollenlevels/sensor.py @@ -37,6 +37,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( + ATTRIBUTION, CONF_API_KEY, CONF_FORECAST_DAYS, CONF_LATITUDE, @@ -262,7 +263,7 @@ def extra_state_attributes(self): attrs = { "category": info.get("category"), # Always include explicit public attribution on all pollen sensors. - ATTR_ATTRIBUTION: "Data provided by Google Maps Pollen API", + ATTR_ATTRIBUTION: ATTRIBUTION, } for k in ( @@ -403,7 +404,7 @@ def extra_state_attributes(self) -> dict[str, Any] | None: This mirrors PollenSensor's attribution so *all* sensors in this integration consistently show the data source. """ - return {ATTR_ATTRIBUTION: "Data provided by Google Maps Pollen API"} + return {ATTR_ATTRIBUTION: ATTRIBUTION} class RegionSensor(_BaseMetaSensor): diff --git a/tests/test_init.py b/tests/test_init.py index db30b897..0b0256e2 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -535,3 +535,13 @@ def test_migrate_entry_marks_version_when_no_changes() -> None: assert asyncio.run(integration.async_migrate_entry(hass, entry)) is True assert entry.version == 2 + + +@pytest.mark.parametrize("version", [None, "x"]) +def test_migrate_entry_handles_non_int_version(version: object) -> None: + """Migration should normalize non-integer versions before bumping.""" + entry = _FakeEntry(options={}, version=version) + hass = _FakeHass(entries=[entry]) + + assert asyncio.run(integration.async_migrate_entry(hass, entry)) is True + assert entry.version == 2 From 73762063c809428a4efa3565e1e2c382fe4fd19d Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Tue, 23 Dec 2025 08:32:58 +0100 Subject: [PATCH 143/200] docs(changelog): update 1.9.0-alpha4 notes --- CHANGELOG.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ecdec3a2..0f1941b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,13 @@ # Changelog -## [1.9.0-alpha4] - 2025-12-20 +## [1.9.0-alpha4] - 2025-12-23 ### Fixed - Fixed options flow to preserve the stored per-day sensor mode when no override is set in entry options, preventing unintended resets to "none". - Sanitized update interval defaults in setup and options forms to clamp malformed stored values within supported bounds. - Rejected update interval submissions above 24 hours to match selector limits. +- Sanitized setup and options defaults for forecast days and per-day sensor mode + selectors to keep UI defaults within supported choices. - Normalized invalid stored per-day sensor mode values in the options flow to avoid persisting unsupported selector choices. - Simplified the per-day sensor mode fallback during options submission to reuse @@ -39,11 +41,15 @@ - Hardened numeric parsing to handle non-finite values without crashing setup. - Clamped update interval and forecast days in setup to supported ranges. - Limited update interval to a maximum of 24 hours in setup and options. +- Rejected HTTP Referer values containing whitespace to prevent invalid headers. - Clamped forecast day handling in sensor setup to the supported 1–5 range for consistent cleanup decisions. - Avoided treating empty indexInfo objects as valid forecast indices. - Added force_update service name/description for better UI discoverability. +### Changed +- Documented the 1–24 update interval range in the README options list. + ## [1.9.0-alpha3] - 2025-12-20 ### Fixed - Fixed config flow crash (500) caused by invalid section schema serialization; From d4ff8e367a8423a3a83983b10c326332a8a73532 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Tue, 23 Dec 2025 17:38:16 +0100 Subject: [PATCH 144/200] Align translation key ordering --- CHANGELOG.md | 4 + README.md | 17 +- custom_components/pollenlevels/__init__.py | 25 ++- custom_components/pollenlevels/client.py | 22 +-- custom_components/pollenlevels/config_flow.py | 113 +------------ custom_components/pollenlevels/const.py | 23 --- .../pollenlevels/translations/ca.json | 10 -- .../pollenlevels/translations/cs.json | 124 +++++++------- .../pollenlevels/translations/da.json | 124 +++++++------- .../pollenlevels/translations/de.json | 124 +++++++------- .../pollenlevels/translations/en.json | 10 -- .../pollenlevels/translations/es.json | 10 -- .../pollenlevels/translations/fi.json | 124 +++++++------- .../pollenlevels/translations/fr.json | 124 +++++++------- .../pollenlevels/translations/hu.json | 124 +++++++------- .../pollenlevels/translations/it.json | 124 +++++++------- .../pollenlevels/translations/nb.json | 124 +++++++------- .../pollenlevels/translations/nl.json | 124 +++++++------- .../pollenlevels/translations/pl.json | 124 +++++++------- .../pollenlevels/translations/pt-BR.json | 124 +++++++------- .../pollenlevels/translations/pt-PT.json | 124 +++++++------- .../pollenlevels/translations/ro.json | 124 +++++++------- .../pollenlevels/translations/ru.json | 124 +++++++------- .../pollenlevels/translations/sv.json | 124 +++++++------- .../pollenlevels/translations/uk.json | 124 +++++++------- .../pollenlevels/translations/zh-Hans.json | 124 +++++++------- .../pollenlevels/translations/zh-Hant.json | 124 +++++++------- tests/test_config_flow.py | 153 +----------------- tests/test_init.py | 20 ++- 29 files changed, 1067 insertions(+), 1572 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f1941b6..8e32109d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,8 @@ # Changelog +## [1.9.0-alpha5] - 2025-12-30 +### Removed +- Removed optional HTTP Referer (website restriction) support and related config UI. + ## [1.9.0-alpha4] - 2025-12-23 ### Fixed - Fixed options flow to preserve the stored per-day sensor mode when no override diff --git a/README.md b/README.md index cfde413b..220d3c04 100644 --- a/README.md +++ b/README.md @@ -89,9 +89,10 @@ You need a valid Google Cloud API key with access to the **Maps Pollen API**. 4. Go to **APIs & Services → Credentials → Create credentials → API key**. 5. **Restrict your key** (recommended): - **API restrictions** → **Restrict key** → select **Maps Pollen API** only. - - **Application restrictions** (optional but recommended): - - **HTTP referrers** (for frontend usages) or - - **IP addresses** (for server-side usage, e.g. your HA host). + - **Application restrictions** (optional): + - Prefer **IP addresses** for server-side usage (your HA host). + - If your IP is dynamic, consider **no application restriction** and rely on + the API restriction above. 6. **Copy** the key and paste it in the integration setup. The setup form also links directly to the Google documentation for obtaining @@ -99,18 +100,14 @@ an API key and best-practice restrictions. 👉 See the **[FAQ](FAQ.md)** for **quota tips**, rate-limit behavior, and best practices to avoid exhausting your free tier. -### Optional HTTP Referrer header - -If your API key is restricted by HTTP referrers (website origins), you can add -an optional **HTTP Referrer** value in the advanced section of the config -flow. When set, the integration sends it as the `Referer` header on API -requests. Leave it blank for unrestricted or IP-restricted keys. +HTTP referrer (website) restrictions are intended for browser-based apps and +are not supported by this integration. ### Troubleshooting 403 errors 403 responses during setup or updates now include the API’s reason (when available). They often indicate billing is disabled, the Pollen API is not -enabled, or referrer restrictions are blocking the request. +enabled, or your key restrictions do not match your Home Assistant host. --- diff --git a/custom_components/pollenlevels/__init__.py b/custom_components/pollenlevels/__init__.py index 919e058e..83947e96 100644 --- a/custom_components/pollenlevels/__init__.py +++ b/custom_components/pollenlevels/__init__.py @@ -25,7 +25,6 @@ CONF_API_KEY, CONF_CREATE_FORECAST_SENSORS, CONF_FORECAST_DAYS, - CONF_HTTP_REFERER, CONF_LANGUAGE_CODE, CONF_LATITUDE, CONF_LONGITUDE, @@ -38,7 +37,6 @@ MAX_UPDATE_INTERVAL_HOURS, MIN_FORECAST_DAYS, MIN_UPDATE_INTERVAL_HOURS, - normalize_http_referer, ) from .coordinator import PollenDataUpdateCoordinator from .runtime import PollenLevelsConfigEntry, PollenLevelsRuntimeData @@ -49,6 +47,7 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) _LOGGER = logging.getLogger(__name__) +TARGET_ENTRY_VERSION = 3 # ---- Service ------------------------------------------------------------- @@ -56,7 +55,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Migrate config entry data to options when needed.""" try: - target_version = 2 + target_version = TARGET_ENTRY_VERSION current_version_raw = getattr(entry, "version", 1) current_version = ( current_version_raw if isinstance(current_version_raw, int) else 1 @@ -64,6 +63,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if current_version >= target_version: return True + new_data = dict(entry.data) new_options = dict(entry.options) mode = new_options.get(CONF_CREATE_FORECAST_SENSORS) if mode is None: @@ -76,9 +76,13 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: elif CONF_CREATE_FORECAST_SENSORS in new_options: new_options.pop(CONF_CREATE_FORECAST_SENSORS) - if new_options != entry.options: + legacy_key = "http_referer" + new_data.pop(legacy_key, None) + new_options.pop(legacy_key, None) + + if new_data != entry.data or new_options != entry.options: hass.config_entries.async_update_entry( - entry, options=new_options, version=target_version + entry, data=new_data, options=new_options, version=target_version ) else: hass.config_entries.async_update_entry(entry, version=target_version) @@ -185,20 +189,11 @@ def _safe_int(value: Any, default: int) -> int: if not api_key: raise ConfigEntryAuthFailed("Missing API key") - http_referer: str | None = None - try: - http_referer = normalize_http_referer(entry.data.get(CONF_HTTP_REFERER)) - except ValueError: - _LOGGER.warning( - "Ignoring http_referer for entry %s because it contains newline characters", - entry.entry_id, - ) - raw_title = entry.title or "" clean_title = raw_title.strip() or DEFAULT_ENTRY_TITLE session = async_get_clientsession(hass) - client = GooglePollenApiClient(session, api_key, http_referer) + client = GooglePollenApiClient(session, api_key) coordinator = PollenDataUpdateCoordinator( hass=hass, diff --git a/custom_components/pollenlevels/client.py b/custom_components/pollenlevels/client.py index bbf68924..2363c092 100644 --- a/custom_components/pollenlevels/client.py +++ b/custom_components/pollenlevels/client.py @@ -15,12 +15,7 @@ from homeassistant.helpers.update_coordinator import UpdateFailed from homeassistant.util import dt as dt_util -from .const import ( - MAX_RETRIES, - POLLEN_API_TIMEOUT, - is_invalid_api_key_message, - normalize_http_referer, -) +from .const import MAX_RETRIES, POLLEN_API_TIMEOUT, is_invalid_api_key_message from .util import extract_error_message, redact_api_key _LOGGER = logging.getLogger(__name__) @@ -37,12 +32,9 @@ def _format_http_message(status: int, raw_message: str | None) -> str: class GooglePollenApiClient: """Thin async client wrapper for the Google Pollen API.""" - def __init__( - self, session: ClientSession, api_key: str, http_referer: str | None = None - ) -> None: + def __init__(self, session: ClientSession, api_key: str) -> None: self._session = session self._api_key = api_key - self._http_referer = http_referer def _parse_retry_after(self, retry_after_raw: str) -> float: """Translate a Retry-After header into a delay in seconds.""" @@ -97,22 +89,12 @@ async def async_fetch_pollen_data( ) max_retries = MAX_RETRIES - headers: dict[str, str] | None = None - if self._http_referer: - try: - referer = normalize_http_referer(self._http_referer) - if referer: - headers = {"Referer": referer} - except ValueError: - _LOGGER.warning("Ignoring http_referer containing newline characters") - for attempt in range(0, max_retries + 1): try: async with self._session.get( url, params=params, timeout=ClientTimeout(total=POLLEN_API_TIMEOUT), - headers=headers, ) as resp: if resp.status == 401: raw_message = redact_api_key( diff --git a/custom_components/pollenlevels/config_flow.py b/custom_components/pollenlevels/config_flow.py index a88803be..fd04e3e9 100644 --- a/custom_components/pollenlevels/config_flow.py +++ b/custom_components/pollenlevels/config_flow.py @@ -8,9 +8,7 @@ so catching `TimeoutError` is sufficient and preferred. IMPORTANT: -- Some HA versions cannot serialize nested mapping schemas (e.g. sections) via - voluptuous_serialize when schemas are flattened and rebuilt. Construct the schema in - one pass so the section marker stays intact. +- Keep schema construction centralized so defaults are applied consistently. """ from __future__ import annotations @@ -25,7 +23,6 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, CONF_NAME -from homeassistant.data_entry_flow import section from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import ( LocationSelector, @@ -45,7 +42,6 @@ CONF_API_KEY, CONF_CREATE_FORECAST_SENSORS, CONF_FORECAST_DAYS, - CONF_HTTP_REFERER, CONF_LANGUAGE_CODE, CONF_UPDATE_INTERVAL, DEFAULT_ENTRY_TITLE, @@ -60,9 +56,7 @@ POLLEN_API_KEY_URL, POLLEN_API_TIMEOUT, RESTRICTING_API_KEYS_URL, - SECTION_API_KEY_OPTIONS, is_invalid_api_key_message, - normalize_http_referer, ) from .util import extract_error_message, normalize_sensor_mode, redact_api_key @@ -119,14 +113,6 @@ def _build_step_user_schema(hass: Any, user_input: dict[str, Any] | None) -> vol """Build the full step user schema without flattening nested sections.""" user_input = user_input or {} - http_referer_default = user_input.get(CONF_HTTP_REFERER) - if http_referer_default is None: - section_values = user_input.get(SECTION_API_KEY_OPTIONS) - if isinstance(section_values, dict): - http_referer_default = section_values.get(CONF_HTTP_REFERER, "") - else: - http_referer_default = "" - default_name = str( user_input.get(CONF_NAME) or getattr(hass.config, "location_name", "") @@ -156,62 +142,7 @@ def _build_step_user_schema(hass: Any, user_input: dict[str, Any] | None) -> vol user_input.get(CONF_CREATE_FORECAST_SENSORS, FORECAST_SENSORS_CHOICES[0]) ) - section_schema = vol.Schema( - { - vol.Required(CONF_API_KEY): str, - vol.Optional(SECTION_API_KEY_OPTIONS): section( - vol.Schema( - { - vol.Optional( - CONF_HTTP_REFERER, - default=http_referer_default, - ): TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT)) - } - ), - {"collapsed": True}, - ), - vol.Required(CONF_NAME, default=default_name): str, - location_field: LocationSelector(LocationSelectorConfig(radius=False)), - vol.Optional( - CONF_UPDATE_INTERVAL, - default=interval_default, - ): NumberSelector( - NumberSelectorConfig( - min=MIN_UPDATE_INTERVAL_HOURS, - max=MAX_UPDATE_INTERVAL_HOURS, - step=1, - mode=NumberSelectorMode.BOX, - unit_of_measurement="h", - ) - ), - vol.Optional( - CONF_LANGUAGE_CODE, - default=user_input.get( - CONF_LANGUAGE_CODE, getattr(hass.config, "language", "") - ), - ): TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT)), - vol.Optional( - CONF_FORECAST_DAYS, - default=forecast_days_default, - ): SelectSelector( - SelectSelectorConfig( - mode=SelectSelectorMode.DROPDOWN, - options=FORECAST_DAYS_OPTIONS, - ) - ), - vol.Optional( - CONF_CREATE_FORECAST_SENSORS, - default=sensor_mode_default, - ): SelectSelector( - SelectSelectorConfig( - mode=SelectSelectorMode.DROPDOWN, - options=FORECAST_SENSORS_CHOICES, - ) - ), - } - ) - - flat_schema = vol.Schema( + schema = vol.Schema( { vol.Required(CONF_API_KEY): str, vol.Required(CONF_NAME, default=default_name): str, @@ -252,20 +183,9 @@ def _build_step_user_schema(hass: Any, user_input: dict[str, Any] | None) -> vol options=FORECAST_SENSORS_CHOICES, ) ), - vol.Optional( - CONF_HTTP_REFERER, - default=http_referer_default, - ): TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT)), } ) - - try: - from voluptuous_serialize import convert - - convert(section_schema, custom_serializer=cv.custom_serializer) - return section_schema - except Exception: # noqa: BLE001 - return flat_schema + return schema def _validate_location_dict( @@ -388,19 +308,6 @@ async def _async_validate_input( errors[CONF_API_KEY] = "empty" return errors, None - headers: dict[str, str] | None = None - try: - http_referer = normalize_http_referer(normalized.get(CONF_HTTP_REFERER)) - except ValueError: - errors[CONF_HTTP_REFERER] = "invalid_http_referrer" - return errors, None - - if http_referer: - headers = {"Referer": http_referer} - normalized[CONF_HTTP_REFERER] = http_referer - else: - normalized.pop(CONF_HTTP_REFERER, None) - interval_value, interval_error = _parse_update_interval( normalized.get(CONF_UPDATE_INTERVAL), default=DEFAULT_UPDATE_INTERVAL, @@ -494,7 +401,6 @@ async def _async_validate_input( async with session.get( url, params=params, - headers=headers, timeout=aiohttp.ClientTimeout(total=POLLEN_API_TIMEOUT), ) as resp: status = resp.status @@ -602,19 +508,6 @@ async def async_step_user(self, user_input=None): if user_input: sanitized_input: dict[str, Any] = dict(user_input) - # Backward/forward compatible extraction if the UI ever posts a section payload. - section_values = sanitized_input.get(SECTION_API_KEY_OPTIONS) - raw_http_referer = None - if isinstance(section_values, dict): - raw_http_referer = section_values.get(CONF_HTTP_REFERER) - if raw_http_referer is None: - raw_http_referer = sanitized_input.get(CONF_HTTP_REFERER) - sanitized_input.pop(SECTION_API_KEY_OPTIONS, None) - - sanitized_input.pop(CONF_HTTP_REFERER, None) - if raw_http_referer: - sanitized_input[CONF_HTTP_REFERER] = raw_http_referer - errors, normalized = await self._async_validate_input( sanitized_input, check_unique_id=True, diff --git a/custom_components/pollenlevels/const.py b/custom_components/pollenlevels/const.py index 6a8d86f4..d487b672 100644 --- a/custom_components/pollenlevels/const.py +++ b/custom_components/pollenlevels/const.py @@ -1,7 +1,5 @@ from __future__ import annotations -from typing import Any - # Define constants for Pollen Levels integration DOMAIN = "pollenlevels" @@ -11,14 +9,12 @@ CONF_LONGITUDE = "longitude" CONF_UPDATE_INTERVAL = "update_interval" CONF_LANGUAGE_CODE = "language_code" -CONF_HTTP_REFERER = "http_referer" # Forecast-related options (Phase 1.1: types only) CONF_FORECAST_DAYS = "forecast_days" CONF_CREATE_FORECAST_SENSORS = ( "create_forecast_sensors" # values: "none" | "D+1" | "D+1+2" ) -SECTION_API_KEY_OPTIONS = "api_key_options" # Defaults DEFAULT_UPDATE_INTERVAL = 6 @@ -57,22 +53,3 @@ def is_invalid_api_key_message(message: str | None) -> bool: "api key is not valid", ) return any(signal in msg for signal in signals) - - -def normalize_http_referer(value: Any) -> str | None: - """Normalize HTTP referrer input and reject CR/LF.""" - - if value is None: - return None - - text = str(value).strip() - if not text: - return None - - if any(ch.isspace() for ch in text): - raise ValueError("invalid http referer") - - if "\r" in text or "\n" in text: - raise ValueError("invalid http referer") - - return text diff --git a/custom_components/pollenlevels/translations/ca.json b/custom_components/pollenlevels/translations/ca.json index 52410e08..cb3252e4 100644 --- a/custom_components/pollenlevels/translations/ca.json +++ b/custom_components/pollenlevels/translations/ca.json @@ -10,17 +10,8 @@ "location": "Ubicació", "update_interval": "Interval d’actualització (hores)", "language_code": "Codi d’idioma de la resposta de l’API", - "http_referer": "HTTP Referer", "forecast_days": "Dies de previsió (1–5)", "create_forecast_sensors": "Abast dels sensors per dia (TIPUS)" - }, - "sections": { - "api_key_options": { - "name": "Opcions opcionals de la clau API" - } - }, - "data_description": { - "http_referer": "Especifica això només si la teva clau API té una [restricció d’aplicació del lloc web]({restricting_api_keys_url}) (HTTP Referer)." } }, "reauth_confirm": { @@ -40,7 +31,6 @@ "invalid_option_combo": "Augmenta els 'Dies de previsió' per cobrir els sensors per dia seleccionats.", "invalid_coordinates": "Selecciona una ubicació vàlida al mapa.", "unknown": "Error desconegut", - "invalid_http_referrer": "Valor de HTTP Referer no vàlid. No pot contenir salts de línia.", "invalid_update_interval": "L’interval d’actualització ha d’estar entre 1 i 24 hores.", "invalid_forecast_days": "Els dies de previsió han d’estar entre 1 i 5." }, diff --git a/custom_components/pollenlevels/translations/cs.json b/custom_components/pollenlevels/translations/cs.json index c3aa68b7..b5ef666f 100644 --- a/custom_components/pollenlevels/translations/cs.json +++ b/custom_components/pollenlevels/translations/cs.json @@ -1,105 +1,95 @@ { "config": { - "abort": { - "already_configured": "Toto umístění je již nakonfigurováno.", - "reauth_failed": "Opětovné ověření se nezdařilo. Zkuste to znovu.", - "reauth_successful": "Opětovné ověření proběhlo úspěšně." + "step": { + "user": { + "title": "Konfigurace úrovní pylu", + "description": "Zadejte svůj Google API klíč ([získejte jej zde]({api_key_url})) a přečtěte si doporučené postupy ([doporučené postupy]({restricting_api_keys_url})). Vyberte polohu na mapě, interval aktualizace (v hodinách) a jazykový kód odpovědi API. Můžete také nastavit dny předpovědi a rozsah senzorů po dnech (TYPY).", + "data": { + "api_key": "Klíč API", + "name": "Název", + "location": "Poloha", + "update_interval": "Interval aktualizace (hodiny)", + "language_code": "Kód jazyka odpovědi API", + "forecast_days": "Dny předpovědi (1–5)", + "create_forecast_sensors": "Rozsah senzorů po dnech (TYPY)" + } + }, + "reauth_confirm": { + "title": "Znovu ověřte Pollen Levels", + "description": "Klíč API pro {latitude},{longitude} již není platný. Zadejte nový klíč, aby se obnovily aktualizace.", + "data": { + "api_key": "Klíč API" + } + } }, "error": { - "cannot_connect": "Nelze se připojit ke službě\n\n{error_message}", - "empty": "Toto pole nemůže být prázdné", "invalid_auth": "Neplatný klíč API\n\n{error_message}", - "invalid_coordinates": "Vyberte platné umístění na mapě.", - "invalid_http_referrer": "Neplatná hodnota HTTP Referer. Nesmí obsahovat znaky nového řádku.", + "cannot_connect": "Nelze se připojit ke službě\n\n{error_message}", + "quota_exceeded": "Překročena kvóta\n\n{error_message}", "invalid_language_format": "Použijte kanonický kód BCP-47, například \"en\" nebo \"es-ES\".", + "empty": "Toto pole nemůže být prázdné", "invalid_option_combo": "Zvyšte „Dny předpovědi“, aby pokryly vybrané senzory po dnech.", - "quota_exceeded": "Překročena kvóta\n\n{error_message}", + "invalid_coordinates": "Vyberte platné umístění na mapě.", "unknown": "Neznámá chyba", "invalid_update_interval": "Interval aktualizace musí být mezi 1 a 24 hodinami.", "invalid_forecast_days": "Dny předpovědi musí být v rozmezí 1–5." }, + "abort": { + "already_configured": "Toto umístění je již nakonfigurováno.", + "reauth_successful": "Opětovné ověření proběhlo úspěšně.", + "reauth_failed": "Opětovné ověření se nezdařilo. Zkuste to znovu." + } + }, + "options": { "step": { - "reauth_confirm": { - "data": { - "api_key": "Klíč API" - }, - "description": "Klíč API pro {latitude},{longitude} již není platný. Zadejte nový klíč, aby se obnovily aktualizace.", - "title": "Znovu ověřte Pollen Levels" - }, - "user": { + "init": { + "title": "Pollen Levels – Možnosti", + "description": "Změňte interval aktualizace, jazyk API, dny předpovědi a senzory po dnech pro {title}.\nMožnosti senzorů po dnech (TYPY): Pouze dnes (none), Do zítra (D+1), Do pozítří (D+1+2).", "data": { - "api_key": "Klíč API", - "language_code": "Kód jazyka odpovědi API", - "location": "Poloha", - "name": "Název", "update_interval": "Interval aktualizace (hodiny)", - "http_referer": "HTTP Referer", + "language_code": "Kód jazyka odpovědi API", "forecast_days": "Dny předpovědi (1–5)", "create_forecast_sensors": "Rozsah senzorů po dnech (TYPY)" - }, - "description": "Zadejte svůj Google API klíč ([získejte jej zde]({api_key_url})) a přečtěte si doporučené postupy ([doporučené postupy]({restricting_api_keys_url})). Vyberte polohu na mapě, interval aktualizace (v hodinách) a jazykový kód odpovědi API. Můžete také nastavit dny předpovědi a rozsah senzorů po dnech (TYPY).", - "title": "Konfigurace úrovní pylu", - "sections": { - "api_key_options": { - "name": "Volitelné možnosti API klíče" - } - }, - "data_description": { - "http_referer": "Potřebné pouze pokud má váš API klíč [omezení webu]({restricting_api_keys_url}) (HTTP Referer)." } } + }, + "error": { + "invalid_language_format": "Použijte kanonický kód BCP-47, například \"en\" nebo \"es-ES\".", + "empty": "Toto pole nemůže být prázdné", + "invalid_option_combo": "Zvyšte „Dny předpovědi“, aby pokryly vybrané senzory po dnech.", + "unknown": "Neznámá chyba", + "invalid_update_interval": "Interval aktualizace musí být mezi 1 a 24 hodinami.", + "invalid_forecast_days": "Dny předpovědi musí být v rozmezí 1–5." } }, "device": { - "info": { - "name": "{title} - Informace o pylu ({latitude},{longitude})" + "types": { + "name": "{title} - Typy pylu ({latitude},{longitude})" }, "plants": { "name": "{title} - Rostliny ({latitude},{longitude})" }, - "types": { - "name": "{title} - Typy pylu ({latitude},{longitude})" + "info": { + "name": "{title} - Informace o pylu ({latitude},{longitude})" + } + }, + "services": { + "force_update": { + "name": "Vynutit aktualizaci", + "description": "Ručně obnoví údaje o pylu pro všechna nastavená místa." } }, "entity": { "sensor": { + "region": { + "name": "Oblast" + }, "date": { "name": "Datum" }, "last_updated": { "name": "Poslední aktualizace" - }, - "region": { - "name": "Oblast" - } - } - }, - "options": { - "error": { - "empty": "Toto pole nemůže být prázdné", - "invalid_language_format": "Použijte kanonický kód BCP-47, například \"en\" nebo \"es-ES\".", - "invalid_option_combo": "Zvyšte „Dny předpovědi“, aby pokryly vybrané senzory po dnech.", - "unknown": "Neznámá chyba", - "invalid_update_interval": "Interval aktualizace musí být mezi 1 a 24 hodinami.", - "invalid_forecast_days": "Dny předpovědi musí být v rozmezí 1–5." - }, - "step": { - "init": { - "data": { - "create_forecast_sensors": "Rozsah senzorů po dnech (TYPY)", - "forecast_days": "Dny předpovědi (1–5)", - "language_code": "Kód jazyka odpovědi API", - "update_interval": "Interval aktualizace (hodiny)" - }, - "description": "Změňte interval aktualizace, jazyk API, dny předpovědi a senzory po dnech pro {title}.\nMožnosti senzorů po dnech (TYPY): Pouze dnes (none), Do zítra (D+1), Do pozítří (D+1+2).", - "title": "Pollen Levels – Možnosti" } } - }, - "services": { - "force_update": { - "description": "Ručně obnoví údaje o pylu pro všechna nastavená místa.", - "name": "Vynutit aktualizaci" - } } } diff --git a/custom_components/pollenlevels/translations/da.json b/custom_components/pollenlevels/translations/da.json index 4b0a9e4e..548c3c69 100644 --- a/custom_components/pollenlevels/translations/da.json +++ b/custom_components/pollenlevels/translations/da.json @@ -1,105 +1,95 @@ { "config": { - "abort": { - "already_configured": "Denne placering er allerede konfigureret.", - "reauth_failed": "Genautentificering mislykkedes. Prøv igen.", - "reauth_successful": "Genautentificering fuldført." + "step": { + "user": { + "title": "Konfiguration af pollenniveauer", + "description": "Indtast din Google API-nøgle ([hent den her]({api_key_url})) og læs bedste praksis ([bedste praksis]({restricting_api_keys_url})). Vælg din placering på kortet, opdateringsinterval (timer) og sprogkode for API-svaret. Du kan også angive prognosedage og omfanget af sensorer pr. dag (TYPER).", + "data": { + "api_key": "API-nøgle", + "name": "Navn", + "location": "Placering", + "update_interval": "Opdateringsinterval (timer)", + "language_code": "Sprogkode for API-svar", + "forecast_days": "Prognosedage (1–5)", + "create_forecast_sensors": "Omfang af sensorer pr. dag (TYPER)" + } + }, + "reauth_confirm": { + "title": "Godkend Pollen Levels igen", + "description": "API-nøglen for {latitude},{longitude} er ikke længere gyldig. Indtast en ny nøgle for at genoptage opdateringerne.", + "data": { + "api_key": "API-nøgle" + } + } }, "error": { - "cannot_connect": "Kan ikke oprette forbindelse til tjenesten\n\n{error_message}", - "empty": "Dette felt må ikke være tomt", "invalid_auth": "Ugyldig API-nøgle\n\n{error_message}", - "invalid_coordinates": "Vælg en gyldig placering på kortet.", - "invalid_http_referrer": "Ugyldig værdi for HTTP Referer. Den må ikke indeholde linjeskift.", + "cannot_connect": "Kan ikke oprette forbindelse til tjenesten\n\n{error_message}", + "quota_exceeded": "Kvote overskredet\n\n{error_message}", "invalid_language_format": "Brug en kanonisk BCP-47-kode som \"en\" eller \"es-ES\".", + "empty": "Dette felt må ikke være tomt", "invalid_option_combo": "Forøg \"Prognosedage\" for at dække valgte sensorer pr. dag.", - "quota_exceeded": "Kvote overskredet\n\n{error_message}", + "invalid_coordinates": "Vælg en gyldig placering på kortet.", "unknown": "Ukendt fejl", "invalid_update_interval": "Opdateringsintervallet skal være mellem 1 og 24 timer.", "invalid_forecast_days": "Prognosedage skal være mellem 1 og 5." }, + "abort": { + "already_configured": "Denne placering er allerede konfigureret.", + "reauth_successful": "Genautentificering fuldført.", + "reauth_failed": "Genautentificering mislykkedes. Prøv igen." + } + }, + "options": { "step": { - "reauth_confirm": { - "data": { - "api_key": "API-nøgle" - }, - "description": "API-nøglen for {latitude},{longitude} er ikke længere gyldig. Indtast en ny nøgle for at genoptage opdateringerne.", - "title": "Godkend Pollen Levels igen" - }, - "user": { + "init": { + "title": "Pollen Levels – Indstillinger", + "description": "Skift opdateringsinterval, API-sprog, prognosedage og sensorer pr. dag for {title}.\nIndstillinger for sensorer pr. dag (TYPER): Kun i dag (none), Til og med i morgen (D+1), Til og med overmorgen (D+1+2).", "data": { - "api_key": "API-nøgle", - "language_code": "Sprogkode for API-svar", - "location": "Placering", - "name": "Navn", "update_interval": "Opdateringsinterval (timer)", - "http_referer": "HTTP Referer", + "language_code": "Sprogkode for API-svar", "forecast_days": "Prognosedage (1–5)", "create_forecast_sensors": "Omfang af sensorer pr. dag (TYPER)" - }, - "description": "Indtast din Google API-nøgle ([hent den her]({api_key_url})) og læs bedste praksis ([bedste praksis]({restricting_api_keys_url})). Vælg din placering på kortet, opdateringsinterval (timer) og sprogkode for API-svaret. Du kan også angive prognosedage og omfanget af sensorer pr. dag (TYPER).", - "title": "Konfiguration af pollenniveauer", - "sections": { - "api_key_options": { - "name": "Valgfrie API-nøgleindstillinger" - } - }, - "data_description": { - "http_referer": "Kun nødvendig hvis din API-nøgle har en [webstedsbegrænsning]({restricting_api_keys_url}) (HTTP Referer)." } } + }, + "error": { + "invalid_language_format": "Brug en kanonisk BCP-47-kode som \"en\" eller \"es-ES\".", + "empty": "Dette felt må ikke være tomt", + "invalid_option_combo": "Forøg \"Prognosedage\" for at dække valgte sensorer pr. dag.", + "unknown": "Ukendt fejl", + "invalid_update_interval": "Opdateringsintervallet skal være mellem 1 og 24 timer.", + "invalid_forecast_days": "Prognosedage skal være mellem 1 og 5." } }, "device": { - "info": { - "name": "{title} - Polleninfo ({latitude},{longitude})" + "types": { + "name": "{title} - Pollentyper ({latitude},{longitude})" }, "plants": { "name": "{title} - Planter ({latitude},{longitude})" }, - "types": { - "name": "{title} - Pollentyper ({latitude},{longitude})" + "info": { + "name": "{title} - Polleninfo ({latitude},{longitude})" + } + }, + "services": { + "force_update": { + "name": "Gennemtving opdatering", + "description": "Opdaterer manuelt pollendata for alle konfigurerede placeringer." } }, "entity": { "sensor": { + "region": { + "name": "Region" + }, "date": { "name": "Dato" }, "last_updated": { "name": "Sidst opdateret" - }, - "region": { - "name": "Region" - } - } - }, - "options": { - "error": { - "empty": "Dette felt må ikke være tomt", - "invalid_language_format": "Brug en kanonisk BCP-47-kode som \"en\" eller \"es-ES\".", - "invalid_option_combo": "Forøg \"Prognosedage\" for at dække valgte sensorer pr. dag.", - "unknown": "Ukendt fejl", - "invalid_update_interval": "Opdateringsintervallet skal være mellem 1 og 24 timer.", - "invalid_forecast_days": "Prognosedage skal være mellem 1 og 5." - }, - "step": { - "init": { - "data": { - "create_forecast_sensors": "Omfang af sensorer pr. dag (TYPER)", - "forecast_days": "Prognosedage (1–5)", - "language_code": "Sprogkode for API-svar", - "update_interval": "Opdateringsinterval (timer)" - }, - "description": "Skift opdateringsinterval, API-sprog, prognosedage og sensorer pr. dag for {title}.\nIndstillinger for sensorer pr. dag (TYPER): Kun i dag (none), Til og med i morgen (D+1), Til og med overmorgen (D+1+2).", - "title": "Pollen Levels – Indstillinger" } } - }, - "services": { - "force_update": { - "description": "Opdaterer manuelt pollendata for alle konfigurerede placeringer.", - "name": "Gennemtving opdatering" - } } } diff --git a/custom_components/pollenlevels/translations/de.json b/custom_components/pollenlevels/translations/de.json index 6039c972..a0975cc8 100644 --- a/custom_components/pollenlevels/translations/de.json +++ b/custom_components/pollenlevels/translations/de.json @@ -1,105 +1,95 @@ { "config": { - "abort": { - "already_configured": "Dieser Standort ist bereits konfiguriert.", - "reauth_failed": "Die erneute Authentifizierung ist fehlgeschlagen. Bitte versuche es erneut.", - "reauth_successful": "Die erneute Authentifizierung war erfolgreich." + "step": { + "user": { + "title": "Pollen Levels – Konfiguration", + "description": "Gib deinen Google API-Schlüssel ein ([hier abrufen]({api_key_url})) und lies die Best Practices ([Best Practices]({restricting_api_keys_url})). Wähle deinen Standort auf der Karte, das Aktualisierungsintervall (Stunden) und den Sprachcode der API-Antwort. Sie können auch Vorhersagetage und den Umfang der Tagessensoren (TYPEN) festlegen.", + "data": { + "api_key": "API-Schlüssel", + "name": "Name", + "location": "Standort", + "update_interval": "Aktualisierungsintervall (Stunden)", + "language_code": "Sprachcode für die API-Antwort", + "forecast_days": "Vorhersagetage (1–5)", + "create_forecast_sensors": "Bereich der Tagessensoren (TYPEN)" + } + }, + "reauth_confirm": { + "title": "Pollen Levels erneut authentifizieren", + "description": "Der API-Schlüssel für {latitude},{longitude} ist nicht mehr gültig. Gib einen neuen Schlüssel ein, um die Aktualisierungen fortzusetzen.", + "data": { + "api_key": "API-Schlüssel" + } + } }, "error": { - "cannot_connect": "Verbindung zum Dienst fehlgeschlagen\n\n{error_message}", - "empty": "Dieses Feld darf nicht leer sein", "invalid_auth": "Ungültiger API-Schlüssel\n\n{error_message}", - "invalid_coordinates": "Wähle einen gültigen Standort auf der Karte aus.", - "invalid_http_referrer": "Ungültiger Wert für HTTP Referer. Er darf keine Zeilenumbrüche enthalten.", + "cannot_connect": "Verbindung zum Dienst fehlgeschlagen\n\n{error_message}", + "quota_exceeded": "Kontingent überschritten\n\n{error_message}", "invalid_language_format": "Verwenden Sie einen kanonischen BCP-47-Code wie \"en\" oder \"es-ES\".", + "empty": "Dieses Feld darf nicht leer sein", "invalid_option_combo": "Erhöhe 'Vorhersagetage', um die gewählten Tagessensoren abzudecken.", - "quota_exceeded": "Kontingent überschritten\n\n{error_message}", + "invalid_coordinates": "Wähle einen gültigen Standort auf der Karte aus.", "unknown": "Unbekannter Fehler", "invalid_update_interval": "Das Aktualisierungsintervall muss zwischen 1 und 24 Stunden liegen.", "invalid_forecast_days": "Vorhersagetage müssen zwischen 1 und 5 liegen." }, + "abort": { + "already_configured": "Dieser Standort ist bereits konfiguriert.", + "reauth_successful": "Die erneute Authentifizierung war erfolgreich.", + "reauth_failed": "Die erneute Authentifizierung ist fehlgeschlagen. Bitte versuche es erneut." + } + }, + "options": { "step": { - "reauth_confirm": { - "data": { - "api_key": "API-Schlüssel" - }, - "description": "Der API-Schlüssel für {latitude},{longitude} ist nicht mehr gültig. Gib einen neuen Schlüssel ein, um die Aktualisierungen fortzusetzen.", - "title": "Pollen Levels erneut authentifizieren" - }, - "user": { + "init": { + "title": "Pollen Levels – Optionen", + "description": "Ändere Aktualisierungsintervall, API-Sprache, Vorhersagetage und Tagessensoren für {title}.\nOptionen für Tagessensoren (TYPEN): Nur heute (none), Bis morgen (D+1), Bis übermorgen (D+1+2).", "data": { - "api_key": "API-Schlüssel", - "language_code": "Sprachcode für die API-Antwort", - "location": "Standort", - "name": "Name", "update_interval": "Aktualisierungsintervall (Stunden)", - "http_referer": "HTTP Referer", + "language_code": "Sprachcode für die API-Antwort", "forecast_days": "Vorhersagetage (1–5)", "create_forecast_sensors": "Bereich der Tagessensoren (TYPEN)" - }, - "description": "Gib deinen Google API-Schlüssel ein ([hier abrufen]({api_key_url})) und lies die Best Practices ([Best Practices]({restricting_api_keys_url})). Wähle deinen Standort auf der Karte, das Aktualisierungsintervall (Stunden) und den Sprachcode der API-Antwort. Sie können auch Vorhersagetage und den Umfang der Tagessensoren (TYPEN) festlegen.", - "title": "Pollen Levels – Konfiguration", - "sections": { - "api_key_options": { - "name": "Optionale API-Schlüssel-Optionen" - } - }, - "data_description": { - "http_referer": "Nur erforderlich, wenn dein API-Schlüssel eine [Website-Beschränkung]({restricting_api_keys_url}) (HTTP Referer) verwendet." } } + }, + "error": { + "invalid_language_format": "Verwenden Sie einen kanonischen BCP-47-Code wie \"en\" oder \"es-ES\".", + "empty": "Dieses Feld darf nicht leer sein", + "invalid_option_combo": "Erhöhe 'Vorhersagetage', um die gewählten Tagessensoren abzudecken.", + "unknown": "Unbekannter Fehler", + "invalid_update_interval": "Das Aktualisierungsintervall muss zwischen 1 und 24 Stunden liegen.", + "invalid_forecast_days": "Vorhersagetage müssen zwischen 1 und 5 liegen." } }, "device": { - "info": { - "name": "{title} - Polleninformationen ({latitude},{longitude})" + "types": { + "name": "{title} - Pollenarten ({latitude},{longitude})" }, "plants": { "name": "{title} - Pflanzen ({latitude},{longitude})" }, - "types": { - "name": "{title} - Pollenarten ({latitude},{longitude})" + "info": { + "name": "{title} - Polleninformationen ({latitude},{longitude})" + } + }, + "services": { + "force_update": { + "name": "Aktualisierung erzwingen", + "description": "Aktualisiert die Pollendaten für alle konfigurierten Standorte manuell." } }, "entity": { "sensor": { + "region": { + "name": "Region" + }, "date": { "name": "Datum" }, "last_updated": { "name": "Letzte Aktualisierung" - }, - "region": { - "name": "Region" - } - } - }, - "options": { - "error": { - "empty": "Dieses Feld darf nicht leer sein", - "invalid_language_format": "Verwenden Sie einen kanonischen BCP-47-Code wie \"en\" oder \"es-ES\".", - "invalid_option_combo": "Erhöhe 'Vorhersagetage', um die gewählten Tagessensoren abzudecken.", - "unknown": "Unbekannter Fehler", - "invalid_update_interval": "Das Aktualisierungsintervall muss zwischen 1 und 24 Stunden liegen.", - "invalid_forecast_days": "Vorhersagetage müssen zwischen 1 und 5 liegen." - }, - "step": { - "init": { - "data": { - "create_forecast_sensors": "Bereich der Tagessensoren (TYPEN)", - "forecast_days": "Vorhersagetage (1–5)", - "language_code": "Sprachcode für die API-Antwort", - "update_interval": "Aktualisierungsintervall (Stunden)" - }, - "description": "Ändere Aktualisierungsintervall, API-Sprache, Vorhersagetage und Tagessensoren für {title}.\nOptionen für Tagessensoren (TYPEN): Nur heute (none), Bis morgen (D+1), Bis übermorgen (D+1+2).", - "title": "Pollen Levels – Optionen" } } - }, - "services": { - "force_update": { - "description": "Aktualisiert die Pollendaten für alle konfigurierten Standorte manuell.", - "name": "Aktualisierung erzwingen" - } } } diff --git a/custom_components/pollenlevels/translations/en.json b/custom_components/pollenlevels/translations/en.json index b8f72bf4..9a6630bd 100644 --- a/custom_components/pollenlevels/translations/en.json +++ b/custom_components/pollenlevels/translations/en.json @@ -10,17 +10,8 @@ "location": "Location", "update_interval": "Update interval (hours)", "language_code": "API response language code", - "http_referer": "HTTP Referer", "forecast_days": "Forecast days (1–5)", "create_forecast_sensors": "Per-day TYPE sensors range" - }, - "sections": { - "api_key_options": { - "name": "Optional API key options" - } - }, - "data_description": { - "http_referer": "Only needed if your API key uses a [website restriction]({restricting_api_keys_url}) (HTTP Referer)." } }, "reauth_confirm": { @@ -40,7 +31,6 @@ "invalid_option_combo": "Increase 'Forecast days' to cover selected per-day sensors.", "invalid_coordinates": "Please select a valid location on the map.", "unknown": "Unknown error", - "invalid_http_referrer": "Invalid value for HTTP Referer. It must not contain newline characters.", "invalid_update_interval": "Update interval must be between 1 and 24 hours.", "invalid_forecast_days": "Forecast days must be between 1 and 5." }, diff --git a/custom_components/pollenlevels/translations/es.json b/custom_components/pollenlevels/translations/es.json index 04662421..afadb970 100644 --- a/custom_components/pollenlevels/translations/es.json +++ b/custom_components/pollenlevels/translations/es.json @@ -10,17 +10,8 @@ "location": "Ubicación", "update_interval": "Intervalo de actualización (horas)", "language_code": "Código de idioma de la respuesta de la API", - "http_referer": "HTTP Referer", "forecast_days": "Días de previsión (1–5)", "create_forecast_sensors": "Alcance de sensores por día (TIPOS)" - }, - "sections": { - "api_key_options": { - "name": "Opciones opcionales de la clave API" - } - }, - "data_description": { - "http_referer": "Solo es necesario si tu clave API tiene una restricción de tipo \"HTTP Referer\" (sitios web). Consulta [cómo restringir claves API]({restricting_api_keys_url})." } }, "reauth_confirm": { @@ -40,7 +31,6 @@ "invalid_option_combo": "Aumenta 'Días de previsión' para cubrir los sensores por día seleccionados.", "invalid_coordinates": "Selecciona una ubicación válida en el mapa.", "unknown": "Error desconocido", - "invalid_http_referrer": "Valor de HTTP Referer no válido. No debe contener saltos de línea.", "invalid_update_interval": "El intervalo de actualización debe estar entre 1 y 24 horas.", "invalid_forecast_days": "Los días de previsión deben estar entre 1 y 5." }, diff --git a/custom_components/pollenlevels/translations/fi.json b/custom_components/pollenlevels/translations/fi.json index bde11751..09ca3740 100644 --- a/custom_components/pollenlevels/translations/fi.json +++ b/custom_components/pollenlevels/translations/fi.json @@ -1,105 +1,95 @@ { "config": { - "abort": { - "already_configured": "Tämä sijainti on jo määritetty.", - "reauth_failed": "Uudelleentodennus epäonnistui. Yritä uudelleen.", - "reauth_successful": "Uudelleentodennus onnistui." + "step": { + "user": { + "title": "Siitepölytason asetukset", + "description": "Syötä Google API -avaimesi ([hanki se täältä]({api_key_url})) ja tutustu parhaisiin käytäntöihin ([parhaat käytännöt]({restricting_api_keys_url})). Valitse sijainti kartalta, päivitysväli (tunteina) ja API-vastauksen kielikoodi. Voit myös määrittää ennustepäivät ja päiväsensorien laajuuden (TYYPIT).", + "data": { + "api_key": "API-avain", + "name": "Nimi", + "location": "Sijainti", + "update_interval": "Päivitysväli (tunnit)", + "language_code": "API-vastauksen kielikoodi", + "forecast_days": "Ennustepäivät (1–5)", + "create_forecast_sensors": "Päiväsensorien laajuus (TYYPIT)" + } + }, + "reauth_confirm": { + "title": "Todenna Pollen Levels uudelleen", + "description": "API-avain sijainnille {latitude},{longitude} ei ole enää voimassa. Anna uusi avain, jotta päivitykset jatkuvat.", + "data": { + "api_key": "API-avain" + } + } }, "error": { - "cannot_connect": "Palveluun ei saada yhteyttä\n\n{error_message}", - "empty": "Tämä kenttä ei voi olla tyhjä", "invalid_auth": "Virheellinen API-avain\n\n{error_message}", - "invalid_coordinates": "Valitse kartalta kelvollinen sijainti.", - "invalid_http_referrer": "Virheellinen arvo kohteelle HTTP Referer. Se ei saa sisältää rivinvaihtomerkkejä.", + "cannot_connect": "Palveluun ei saada yhteyttä\n\n{error_message}", + "quota_exceeded": "Kiintiö ylitetty\n\n{error_message}", "invalid_language_format": "Käytä kanonista BCP-47-koodia, esimerkiksi \"en\" tai \"es-ES\".", + "empty": "Tämä kenttä ei voi olla tyhjä", "invalid_option_combo": "Lisää \"Ennustepäiviä\", jotta valitut päiväsensorit katetaan.", - "quota_exceeded": "Kiintiö ylitetty\n\n{error_message}", + "invalid_coordinates": "Valitse kartalta kelvollinen sijainti.", "unknown": "Tuntematon virhe", "invalid_update_interval": "Päivitysvälin on oltava 1–24 tuntia.", "invalid_forecast_days": "Ennustepäivien on oltava välillä 1–5." }, + "abort": { + "already_configured": "Tämä sijainti on jo määritetty.", + "reauth_successful": "Uudelleentodennus onnistui.", + "reauth_failed": "Uudelleentodennus epäonnistui. Yritä uudelleen." + } + }, + "options": { "step": { - "reauth_confirm": { - "data": { - "api_key": "API-avain" - }, - "description": "API-avain sijainnille {latitude},{longitude} ei ole enää voimassa. Anna uusi avain, jotta päivitykset jatkuvat.", - "title": "Todenna Pollen Levels uudelleen" - }, - "user": { + "init": { + "title": "Pollen Levels – Asetukset", + "description": "Muuta päivitysväliä, API-kieltä, ennustepäiviä ja päiväsensoreita TYYPEILLE kohteelle {title}.\nPäiväsensorien vaihtoehdot (TYYPIT): Vain tänään (none), Huomiseen asti (D+1), Ylihuomiseen asti (D+1+2).", "data": { - "api_key": "API-avain", - "language_code": "API-vastauksen kielikoodi", - "location": "Sijainti", - "name": "Nimi", "update_interval": "Päivitysväli (tunnit)", - "http_referer": "HTTP Referer", + "language_code": "API-vastauksen kielikoodi", "forecast_days": "Ennustepäivät (1–5)", "create_forecast_sensors": "Päiväsensorien laajuus (TYYPIT)" - }, - "description": "Syötä Google API -avaimesi ([hanki se täältä]({api_key_url})) ja tutustu parhaisiin käytäntöihin ([parhaat käytännöt]({restricting_api_keys_url})). Valitse sijainti kartalta, päivitysväli (tunteina) ja API-vastauksen kielikoodi. Voit myös määrittää ennustepäivät ja päiväsensorien laajuuden (TYYPIT).", - "title": "Siitepölytason asetukset", - "sections": { - "api_key_options": { - "name": "Valinnaiset API-avaimen asetukset" - } - }, - "data_description": { - "http_referer": "Tarvitaan vain, jos API-avaimesi käyttää [verkkosivustorajoitusta]({restricting_api_keys_url}) (HTTP Referer)." } } + }, + "error": { + "invalid_language_format": "Käytä kanonista BCP-47-koodia, esimerkiksi \"en\" tai \"es-ES\".", + "empty": "Tämä kenttä ei voi olla tyhjä", + "invalid_option_combo": "Lisää \"Ennustepäiviä\", jotta valitut päiväsensorit katetaan.", + "unknown": "Tuntematon virhe", + "invalid_update_interval": "Päivitysvälin on oltava 1–24 tuntia.", + "invalid_forecast_days": "Ennustepäivien on oltava välillä 1–5." } }, "device": { - "info": { - "name": "{title} - Siitepölytiedot ({latitude},{longitude})" + "types": { + "name": "{title} - Siitepölytyypit ({latitude},{longitude})" }, "plants": { "name": "{title} - Kasvit ({latitude},{longitude})" }, - "types": { - "name": "{title} - Siitepölytyypit ({latitude},{longitude})" + "info": { + "name": "{title} - Siitepölytiedot ({latitude},{longitude})" + } + }, + "services": { + "force_update": { + "name": "Pakota päivitys", + "description": "Päivitä siitepölytiedot manuaalisesti kaikille määritetyille sijainneille." } }, "entity": { "sensor": { + "region": { + "name": "Alue" + }, "date": { "name": "Päivämäärä" }, "last_updated": { "name": "Viimeksi päivitetty" - }, - "region": { - "name": "Alue" - } - } - }, - "options": { - "error": { - "empty": "Tämä kenttä ei voi olla tyhjä", - "invalid_language_format": "Käytä kanonista BCP-47-koodia, esimerkiksi \"en\" tai \"es-ES\".", - "invalid_option_combo": "Lisää \"Ennustepäiviä\", jotta valitut päiväsensorit katetaan.", - "unknown": "Tuntematon virhe", - "invalid_update_interval": "Päivitysvälin on oltava 1–24 tuntia.", - "invalid_forecast_days": "Ennustepäivien on oltava välillä 1–5." - }, - "step": { - "init": { - "data": { - "create_forecast_sensors": "Päiväsensorien laajuus (TYYPIT)", - "forecast_days": "Ennustepäivät (1–5)", - "language_code": "API-vastauksen kielikoodi", - "update_interval": "Päivitysväli (tunnit)" - }, - "description": "Muuta päivitysväliä, API-kieltä, ennustepäiviä ja päiväsensoreita TYYPEILLE kohteelle {title}.\nPäiväsensorien vaihtoehdot (TYYPIT): Vain tänään (none), Huomiseen asti (D+1), Ylihuomiseen asti (D+1+2).", - "title": "Pollen Levels – Asetukset" } } - }, - "services": { - "force_update": { - "description": "Päivitä siitepölytiedot manuaalisesti kaikille määritetyille sijainneille.", - "name": "Pakota päivitys" - } } } diff --git a/custom_components/pollenlevels/translations/fr.json b/custom_components/pollenlevels/translations/fr.json index b0c3731d..e0371aa6 100644 --- a/custom_components/pollenlevels/translations/fr.json +++ b/custom_components/pollenlevels/translations/fr.json @@ -1,105 +1,95 @@ { "config": { - "abort": { - "already_configured": "Cet emplacement est déjà configuré.", - "reauth_failed": "La réauthentification a échoué. Veuillez réessayer.", - "reauth_successful": "La réauthentification a réussi." + "step": { + "user": { + "title": "Pollen Levels – Configuration", + "description": "Saisissez votre clé API Google ([l’obtenir ici]({api_key_url})) et consultez les bonnes pratiques ([bonnes pratiques]({restricting_api_keys_url})). Sélectionnez votre emplacement sur la carte, l’intervalle de mise à jour (heures) et le code de langue de la réponse de l’API. Vous pouvez aussi définir les jours de prévision et la portée des capteurs par jour (TYPES).", + "data": { + "api_key": "Clé API", + "name": "Nom", + "location": "Emplacement", + "update_interval": "Intervalle de mise à jour (heures)", + "language_code": "Code de langue pour la réponse de l’API", + "forecast_days": "Jours de prévision (1–5)", + "create_forecast_sensors": "Portée des capteurs par jour (TYPES)" + } + }, + "reauth_confirm": { + "title": "Réauthentifier Pollen Levels", + "description": "La clé API pour {latitude},{longitude} n’est plus valide. Saisissez une nouvelle clé pour reprendre les mises à jour.", + "data": { + "api_key": "Clé API" + } + } }, "error": { - "cannot_connect": "Impossible de se connecter au service\n\n{error_message}", - "empty": "Ce champ ne peut pas être vide", "invalid_auth": "Clé API invalide\n\n{error_message}", - "invalid_coordinates": "Sélectionnez un emplacement valide sur la carte.", - "invalid_http_referrer": "Valeur pour HTTP Referer invalide. Elle ne doit pas contenir de retours à la ligne.", + "cannot_connect": "Impossible de se connecter au service\n\n{error_message}", + "quota_exceeded": "Quota dépassé\n\n{error_message}", "invalid_language_format": "Utilisez un code BCP-47 canonique tel que \"en\" ou \"es-ES\".", + "empty": "Ce champ ne peut pas être vide", "invalid_option_combo": "Augmentez le nombre de « Jours de prévision » afin de couvrir les capteurs par jour sélectionnés.", - "quota_exceeded": "Quota dépassé\n\n{error_message}", + "invalid_coordinates": "Sélectionnez un emplacement valide sur la carte.", "unknown": "Erreur inconnue", "invalid_update_interval": "L’intervalle de mise à jour doit être compris entre 1 et 24 heures.", "invalid_forecast_days": "Les jours de prévision doivent être compris entre 1 et 5." }, + "abort": { + "already_configured": "Cet emplacement est déjà configuré.", + "reauth_successful": "La réauthentification a réussi.", + "reauth_failed": "La réauthentification a échoué. Veuillez réessayer." + } + }, + "options": { "step": { - "reauth_confirm": { - "data": { - "api_key": "Clé API" - }, - "description": "La clé API pour {latitude},{longitude} n’est plus valide. Saisissez une nouvelle clé pour reprendre les mises à jour.", - "title": "Réauthentifier Pollen Levels" - }, - "user": { + "init": { + "title": "Pollen Levels – Options", + "description": "Modifiez l’intervalle de mise à jour, la langue de l’API, les jours de prévision et les capteurs par jour pour {title}.\nOptions des capteurs par jour (TYPES) : Aujourd’hui uniquement (none), Jusqu’à demain (D+1), Jusqu’au surlendemain (D+1+2).", "data": { - "api_key": "Clé API", - "language_code": "Code de langue pour la réponse de l’API", - "location": "Emplacement", - "name": "Nom", "update_interval": "Intervalle de mise à jour (heures)", - "http_referer": "HTTP Referer", + "language_code": "Code de langue pour la réponse de l’API", "forecast_days": "Jours de prévision (1–5)", "create_forecast_sensors": "Portée des capteurs par jour (TYPES)" - }, - "description": "Saisissez votre clé API Google ([l’obtenir ici]({api_key_url})) et consultez les bonnes pratiques ([bonnes pratiques]({restricting_api_keys_url})). Sélectionnez votre emplacement sur la carte, l’intervalle de mise à jour (heures) et le code de langue de la réponse de l’API. Vous pouvez aussi définir les jours de prévision et la portée des capteurs par jour (TYPES).", - "title": "Pollen Levels – Configuration", - "sections": { - "api_key_options": { - "name": "Options facultatives de la clé API" - } - }, - "data_description": { - "http_referer": "Renseignez ceci uniquement si votre clé API a une [restriction de site web]({restricting_api_keys_url}) (HTTP Referer)." } } + }, + "error": { + "invalid_language_format": "Utilisez un code BCP-47 canonique tel que \"en\" ou \"es-ES\".", + "empty": "Ce champ ne peut pas être vide", + "invalid_option_combo": "Augmentez le nombre de « Jours de prévision » afin de couvrir les capteurs par jour sélectionnés.", + "unknown": "Erreur inconnue", + "invalid_update_interval": "L’intervalle de mise à jour doit être compris entre 1 et 24 heures.", + "invalid_forecast_days": "Les jours de prévision doivent être compris entre 1 et 5." } }, "device": { - "info": { - "name": "{title} - Info pollen ({latitude},{longitude})" + "types": { + "name": "{title} - Types de pollen ({latitude},{longitude})" }, "plants": { "name": "{title} - Plantes ({latitude},{longitude})" }, - "types": { - "name": "{title} - Types de pollen ({latitude},{longitude})" + "info": { + "name": "{title} - Info pollen ({latitude},{longitude})" + } + }, + "services": { + "force_update": { + "name": "Forcer la mise à jour", + "description": "Actualise manuellement les données de pollen pour tous les emplacements configurés." } }, "entity": { "sensor": { + "region": { + "name": "Région" + }, "date": { "name": "Date" }, "last_updated": { "name": "Dernière mise à jour" - }, - "region": { - "name": "Région" - } - } - }, - "options": { - "error": { - "empty": "Ce champ ne peut pas être vide", - "invalid_language_format": "Utilisez un code BCP-47 canonique tel que \"en\" ou \"es-ES\".", - "invalid_option_combo": "Augmentez le nombre de « Jours de prévision » afin de couvrir les capteurs par jour sélectionnés.", - "unknown": "Erreur inconnue", - "invalid_update_interval": "L’intervalle de mise à jour doit être compris entre 1 et 24 heures.", - "invalid_forecast_days": "Les jours de prévision doivent être compris entre 1 et 5." - }, - "step": { - "init": { - "data": { - "create_forecast_sensors": "Portée des capteurs par jour (TYPES)", - "forecast_days": "Jours de prévision (1–5)", - "language_code": "Code de langue pour la réponse de l’API", - "update_interval": "Intervalle de mise à jour (heures)" - }, - "description": "Modifiez l’intervalle de mise à jour, la langue de l’API, les jours de prévision et les capteurs par jour pour {title}.\nOptions des capteurs par jour (TYPES) : Aujourd’hui uniquement (none), Jusqu’à demain (D+1), Jusqu’au surlendemain (D+1+2).", - "title": "Pollen Levels – Options" } } - }, - "services": { - "force_update": { - "description": "Actualise manuellement les données de pollen pour tous les emplacements configurés.", - "name": "Forcer la mise à jour" - } } } diff --git a/custom_components/pollenlevels/translations/hu.json b/custom_components/pollenlevels/translations/hu.json index 60ae86ed..82322558 100644 --- a/custom_components/pollenlevels/translations/hu.json +++ b/custom_components/pollenlevels/translations/hu.json @@ -1,105 +1,95 @@ { "config": { - "abort": { - "already_configured": "Ez a hely már konfigurálva van.", - "reauth_failed": "Az újrahitelesítés nem sikerült. Próbáld meg újra.", - "reauth_successful": "Az újrahitelesítés sikerült." + "step": { + "user": { + "title": "Pollen szintek – beállítás", + "description": "Add meg a Google API-kulcsodat ([itt szerezhető be]({api_key_url})) és nézd át a bevált gyakorlatokat ([bevált gyakorlatok]({restricting_api_keys_url})). Válaszd ki a helyszínt a térképen, a frissítési időközt (órában) és az API-válasz nyelvi kódját. Beállíthatod az előrejelzési napokat és a napi szenzorok tartományát (TÍPUSOK).", + "data": { + "api_key": "API-kulcs", + "name": "Név", + "location": "Helyszín", + "update_interval": "Frissítési időköz (óra)", + "language_code": "API-válasz nyelvi kódja", + "forecast_days": "Előrejelzési napok (1–5)", + "create_forecast_sensors": "Napi TÍPUS szenzorok tartománya" + } + }, + "reauth_confirm": { + "title": "Hitelesítsd újra a Pollen Levels-t", + "description": "A(z) {latitude},{longitude} API-kulcsa már nem érvényes. Adj meg új kulcsot a frissítések folytatásához.", + "data": { + "api_key": "API-kulcs" + } + } }, "error": { - "cannot_connect": "Nem lehet csatlakozni a szolgáltatáshoz\n\n{error_message}", - "empty": "A mező nem lehet üres", "invalid_auth": "Érvénytelen API-kulcs\n\n{error_message}", - "invalid_coordinates": "Válassz érvényes helyet a térképen.", - "invalid_http_referrer": "Érvénytelen érték a HTTP Referer mezőhöz. Nem tartalmazhat sortörés karaktereket.", + "cannot_connect": "Nem lehet csatlakozni a szolgáltatáshoz\n\n{error_message}", + "quota_exceeded": "Kvóta túllépve\n\n{error_message}", "invalid_language_format": "Használjon kanonikus BCP-47 kódot, például \"en\" vagy \"es-ES\".", + "empty": "A mező nem lehet üres", "invalid_option_combo": "Növeld a \"Előrejelzési napok\" értéket a kiválasztott napi szenzorok lefedéséhez.", - "quota_exceeded": "Kvóta túllépve\n\n{error_message}", + "invalid_coordinates": "Válassz érvényes helyet a térképen.", "unknown": "Ismeretlen hiba", "invalid_update_interval": "A frissítési időköznek 1 és 24 óra között kell lennie.", "invalid_forecast_days": "Az előrejelzési napoknak 1 és 5 között kell lenniük." }, + "abort": { + "already_configured": "Ez a hely már konfigurálva van.", + "reauth_successful": "Az újrahitelesítés sikerült.", + "reauth_failed": "Az újrahitelesítés nem sikerült. Próbáld meg újra." + } + }, + "options": { "step": { - "reauth_confirm": { - "data": { - "api_key": "API-kulcs" - }, - "description": "A(z) {latitude},{longitude} API-kulcsa már nem érvényes. Adj meg új kulcsot a frissítések folytatásához.", - "title": "Hitelesítsd újra a Pollen Levels-t" - }, - "user": { + "init": { + "title": "Pollen Levels – Beállítások", + "description": "Módosítsd a frissítési időközt, az API nyelvét, az előrejelzési napokat és a napi TÍPUS szenzorokat a(z) {title} bejegyzéshez.\nNapi TÍPUS szenzorok: Csak ma (none), Holnapig (D+1), Holnaputánig (D+1+2).", "data": { - "api_key": "API-kulcs", - "language_code": "API-válasz nyelvi kódja", - "location": "Helyszín", - "name": "Név", "update_interval": "Frissítési időköz (óra)", - "http_referer": "HTTP Referer", + "language_code": "API-válasz nyelvi kódja", "forecast_days": "Előrejelzési napok (1–5)", "create_forecast_sensors": "Napi TÍPUS szenzorok tartománya" - }, - "description": "Add meg a Google API-kulcsodat ([itt szerezhető be]({api_key_url})) és nézd át a bevált gyakorlatokat ([bevált gyakorlatok]({restricting_api_keys_url})). Válaszd ki a helyszínt a térképen, a frissítési időközt (órában) és az API-válasz nyelvi kódját. Beállíthatod az előrejelzési napokat és a napi szenzorok tartományát (TÍPUSOK).", - "title": "Pollen szintek – beállítás", - "sections": { - "api_key_options": { - "name": "Opcionális API-kulcs beállítások" - } - }, - "data_description": { - "http_referer": "Csak akkor szükséges, ha az API-kulcsod [webhely-korlátozást]({restricting_api_keys_url}) használ (HTTP Referer)." } } + }, + "error": { + "invalid_language_format": "Használjon kanonikus BCP-47 kódot, például \"en\" vagy \"es-ES\".", + "empty": "A mező nem lehet üres", + "invalid_option_combo": "Növeld a \"Előrejelzési napok\" értéket a kiválasztott napi szenzorok lefedéséhez.", + "unknown": "Ismeretlen hiba", + "invalid_update_interval": "A frissítési időköznek 1 és 24 óra között kell lennie.", + "invalid_forecast_days": "Az előrejelzési napoknak 1 és 5 között kell lenniük." } }, "device": { - "info": { - "name": "{title} - Polleninformáció ({latitude},{longitude})" + "types": { + "name": "{title} - Pollentípusok ({latitude},{longitude})" }, "plants": { "name": "{title} - Növények ({latitude},{longitude})" }, - "types": { - "name": "{title} - Pollentípusok ({latitude},{longitude})" + "info": { + "name": "{title} - Polleninformáció ({latitude},{longitude})" + } + }, + "services": { + "force_update": { + "name": "Frissítés kényszerítése", + "description": "Kézi frissítés a pollenadatokhoz minden beállított helyhez." } }, "entity": { "sensor": { + "region": { + "name": "Régió" + }, "date": { "name": "Dátum" }, "last_updated": { "name": "Utoljára frissítve" - }, - "region": { - "name": "Régió" - } - } - }, - "options": { - "error": { - "empty": "A mező nem lehet üres", - "invalid_language_format": "Használjon kanonikus BCP-47 kódot, például \"en\" vagy \"es-ES\".", - "invalid_option_combo": "Növeld a \"Előrejelzési napok\" értéket a kiválasztott napi szenzorok lefedéséhez.", - "unknown": "Ismeretlen hiba", - "invalid_update_interval": "A frissítési időköznek 1 és 24 óra között kell lennie.", - "invalid_forecast_days": "Az előrejelzési napoknak 1 és 5 között kell lenniük." - }, - "step": { - "init": { - "data": { - "create_forecast_sensors": "Napi TÍPUS szenzorok tartománya", - "forecast_days": "Előrejelzési napok (1–5)", - "language_code": "API-válasz nyelvi kódja", - "update_interval": "Frissítési időköz (óra)" - }, - "description": "Módosítsd a frissítési időközt, az API nyelvét, az előrejelzési napokat és a napi TÍPUS szenzorokat a(z) {title} bejegyzéshez.\nNapi TÍPUS szenzorok: Csak ma (none), Holnapig (D+1), Holnaputánig (D+1+2).", - "title": "Pollen Levels – Beállítások" } } - }, - "services": { - "force_update": { - "description": "Kézi frissítés a pollenadatokhoz minden beállított helyhez.", - "name": "Frissítés kényszerítése" - } } } diff --git a/custom_components/pollenlevels/translations/it.json b/custom_components/pollenlevels/translations/it.json index a7a8ae0f..8654dc4b 100644 --- a/custom_components/pollenlevels/translations/it.json +++ b/custom_components/pollenlevels/translations/it.json @@ -1,105 +1,95 @@ { "config": { - "abort": { - "already_configured": "Questa posizione è già configurata.", - "reauth_failed": "La ri-autenticazione non è riuscita. Riprova.", - "reauth_successful": "La ri-autenticazione è stata completata con successo." + "step": { + "user": { + "title": "Configurazione Livelli di polline", + "description": "Inserisci la tua chiave API di Google ([ottienila qui]({api_key_url})) e consulta le best practice ([best practice]({restricting_api_keys_url})). Seleziona la posizione sulla mappa, l’intervallo di aggiornamento (ore) e il codice lingua della risposta dell’API. Puoi anche impostare i giorni di previsione e l'ambito dei sensori per giorno (TIPI).", + "data": { + "api_key": "Chiave API", + "name": "Nome", + "location": "Posizione", + "update_interval": "Intervallo di aggiornamento (ore)", + "language_code": "Codice lingua per la risposta dell'API", + "forecast_days": "Giorni di previsione (1–5)", + "create_forecast_sensors": "Ambito dei sensori per giorno (TIPI)" + } + }, + "reauth_confirm": { + "title": "Ri-autentica Pollen Levels", + "description": "La chiave API per {latitude},{longitude} non è più valida. Inserisci una nuova chiave per riprendere gli aggiornamenti.", + "data": { + "api_key": "Chiave API" + } + } }, "error": { - "cannot_connect": "Impossibile connettersi al servizio\n\n{error_message}", - "empty": "Questo campo non può essere vuoto", "invalid_auth": "Chiave API non valida\n\n{error_message}", - "invalid_coordinates": "Seleziona una posizione valida sulla mappa.", - "invalid_http_referrer": "Valore per HTTP Referer non valido. Non deve contenere caratteri di nuova riga.", + "cannot_connect": "Impossibile connettersi al servizio\n\n{error_message}", + "quota_exceeded": "Quota superata\n\n{error_message}", "invalid_language_format": "Usa un codice BCP-47 canonico come \"en\" o \"es-ES\".", + "empty": "Questo campo non può essere vuoto", "invalid_option_combo": "Aumenta 'Giorni di previsione' per coprire i sensori giornalieri selezionati.", - "quota_exceeded": "Quota superata\n\n{error_message}", + "invalid_coordinates": "Seleziona una posizione valida sulla mappa.", "unknown": "Errore sconosciuto", "invalid_update_interval": "L’intervallo di aggiornamento deve essere compreso tra 1 e 24 ore.", "invalid_forecast_days": "I giorni di previsione devono essere compresi tra 1 e 5." }, + "abort": { + "already_configured": "Questa posizione è già configurata.", + "reauth_successful": "La ri-autenticazione è stata completata con successo.", + "reauth_failed": "La ri-autenticazione non è riuscita. Riprova." + } + }, + "options": { "step": { - "reauth_confirm": { - "data": { - "api_key": "Chiave API" - }, - "description": "La chiave API per {latitude},{longitude} non è più valida. Inserisci una nuova chiave per riprendere gli aggiornamenti.", - "title": "Ri-autentica Pollen Levels" - }, - "user": { + "init": { + "title": "Pollen Levels – Opzioni", + "description": "Modifica l’intervallo di aggiornamento, la lingua della risposta dell’API, i giorni di previsione e i sensori giornalieri per i TIPI per {title}.\nOpzioni dei sensori giornalieri (TIPI): Solo oggi (none), Fino a domani (D+1), Fino a dopodomani (D+1+2).", "data": { - "api_key": "Chiave API", - "language_code": "Codice lingua per la risposta dell'API", - "location": "Posizione", - "name": "Nome", "update_interval": "Intervallo di aggiornamento (ore)", - "http_referer": "HTTP Referer", + "language_code": "Codice lingua per la risposta dell'API", "forecast_days": "Giorni di previsione (1–5)", "create_forecast_sensors": "Ambito dei sensori per giorno (TIPI)" - }, - "description": "Inserisci la tua chiave API di Google ([ottienila qui]({api_key_url})) e consulta le best practice ([best practice]({restricting_api_keys_url})). Seleziona la posizione sulla mappa, l’intervallo di aggiornamento (ore) e il codice lingua della risposta dell’API. Puoi anche impostare i giorni di previsione e l'ambito dei sensori per giorno (TIPI).", - "title": "Configurazione Livelli di polline", - "sections": { - "api_key_options": { - "name": "Opzioni facoltative della chiave API" - } - }, - "data_description": { - "http_referer": "Necessario solo se la tua chiave API ha una [restrizione per siti web]({restricting_api_keys_url}) (HTTP Referer)." } } + }, + "error": { + "invalid_language_format": "Usa un codice BCP-47 canonico come \"en\" o \"es-ES\".", + "empty": "Questo campo non può essere vuoto", + "invalid_option_combo": "Aumenta 'Giorni di previsione' per coprire i sensori giornalieri selezionati.", + "unknown": "Errore sconosciuto", + "invalid_update_interval": "L’intervallo di aggiornamento deve essere compreso tra 1 e 24 ore.", + "invalid_forecast_days": "I giorni di previsione devono essere compresi tra 1 e 5." } }, "device": { - "info": { - "name": "{title} - Informazioni sul polline ({latitude},{longitude})" + "types": { + "name": "{title} - Tipi di polline ({latitude},{longitude})" }, "plants": { "name": "{title} - Piante ({latitude},{longitude})" }, - "types": { - "name": "{title} - Tipi di polline ({latitude},{longitude})" + "info": { + "name": "{title} - Informazioni sul polline ({latitude},{longitude})" + } + }, + "services": { + "force_update": { + "name": "Forza aggiornamento", + "description": "Aggiorna manualmente i dati dei pollini per tutte le località configurate." } }, "entity": { "sensor": { + "region": { + "name": "Regione" + }, "date": { "name": "Data" }, "last_updated": { "name": "Ultimo aggiornamento" - }, - "region": { - "name": "Regione" - } - } - }, - "options": { - "error": { - "empty": "Questo campo non può essere vuoto", - "invalid_language_format": "Usa un codice BCP-47 canonico come \"en\" o \"es-ES\".", - "invalid_option_combo": "Aumenta 'Giorni di previsione' per coprire i sensori giornalieri selezionati.", - "unknown": "Errore sconosciuto", - "invalid_update_interval": "L’intervallo di aggiornamento deve essere compreso tra 1 e 24 ore.", - "invalid_forecast_days": "I giorni di previsione devono essere compresi tra 1 e 5." - }, - "step": { - "init": { - "data": { - "create_forecast_sensors": "Ambito dei sensori per giorno (TIPI)", - "forecast_days": "Giorni di previsione (1–5)", - "language_code": "Codice lingua per la risposta dell'API", - "update_interval": "Intervallo di aggiornamento (ore)" - }, - "description": "Modifica l’intervallo di aggiornamento, la lingua della risposta dell’API, i giorni di previsione e i sensori giornalieri per i TIPI per {title}.\nOpzioni dei sensori giornalieri (TIPI): Solo oggi (none), Fino a domani (D+1), Fino a dopodomani (D+1+2).", - "title": "Pollen Levels – Opzioni" } } - }, - "services": { - "force_update": { - "description": "Aggiorna manualmente i dati dei pollini per tutte le località configurate.", - "name": "Forza aggiornamento" - } } } diff --git a/custom_components/pollenlevels/translations/nb.json b/custom_components/pollenlevels/translations/nb.json index 847e5812..e89453a2 100644 --- a/custom_components/pollenlevels/translations/nb.json +++ b/custom_components/pollenlevels/translations/nb.json @@ -1,105 +1,95 @@ { "config": { - "abort": { - "already_configured": "Dette stedet er allerede konfigurert.", - "reauth_failed": "Ny autentisering mislyktes. Prøv igjen.", - "reauth_successful": "Ny autentisering fullført." + "step": { + "user": { + "title": "Konfigurasjon av pollennivåer", + "description": "Oppgi Google API-nøkkelen din ([få den her]({api_key_url})) og les beste praksis ([beste praksis]({restricting_api_keys_url})). Velg posisjonen din på kartet, oppdateringsintervallet (timer) og språkkoden for API-svaret. Du kan også angi prognosedager og omfanget av sensorer per dag (TYPER).", + "data": { + "api_key": "API-nøkkel", + "name": "Navn", + "location": "Posisjon", + "update_interval": "Oppdateringsintervall (timer)", + "language_code": "Språkkode for API-svar", + "forecast_days": "Prognosedager (1–5)", + "create_forecast_sensors": "Omfang av sensorer per dag (TYPER)" + } + }, + "reauth_confirm": { + "title": "Autentiser Pollen Levels på nytt", + "description": "API-nøkkelen for {latitude},{longitude} er ikke lenger gyldig. Angi en ny nøkkel for å gjenoppta oppdateringene.", + "data": { + "api_key": "API-nøkkel" + } + } }, "error": { - "cannot_connect": "Kan ikke koble til tjenesten\n\n{error_message}", - "empty": "Dette feltet kan ikke være tomt", "invalid_auth": "Ugyldig API-nøkkel\n\n{error_message}", - "invalid_coordinates": "Velg en gyldig posisjon på kartet.", - "invalid_http_referrer": "Ugyldig verdi for HTTP Referer. Den kan ikke inneholde linjeskift.", + "cannot_connect": "Kan ikke koble til tjenesten\n\n{error_message}", + "quota_exceeded": "Kvote overskredet\n\n{error_message}", "invalid_language_format": "Bruk en kanonisk BCP-47-kode som \"en\" eller \"es-ES\".", + "empty": "Dette feltet kan ikke være tomt", "invalid_option_combo": "Øk «Prognosedager» for å dekke valgte sensorer per dag.", - "quota_exceeded": "Kvote overskredet\n\n{error_message}", + "invalid_coordinates": "Velg en gyldig posisjon på kartet.", "unknown": "Ukjent feil", "invalid_update_interval": "Oppdateringsintervallet må være mellom 1 og 24 timer.", "invalid_forecast_days": "Prognosedager må være mellom 1 og 5." }, + "abort": { + "already_configured": "Dette stedet er allerede konfigurert.", + "reauth_successful": "Ny autentisering fullført.", + "reauth_failed": "Ny autentisering mislyktes. Prøv igjen." + } + }, + "options": { "step": { - "reauth_confirm": { - "data": { - "api_key": "API-nøkkel" - }, - "description": "API-nøkkelen for {latitude},{longitude} er ikke lenger gyldig. Angi en ny nøkkel for å gjenoppta oppdateringene.", - "title": "Autentiser Pollen Levels på nytt" - }, - "user": { + "init": { + "title": "Pollen Levels – Innstillinger", + "description": "Endre oppdateringsintervall, API-språk, prognosedager og sensorer per dag for {title}.\nAlternativer for sensorer per dag (TYPER): Kun i dag (none), Til og med i morgen (D+1), Til og med i overmorgen (D+1+2).", "data": { - "api_key": "API-nøkkel", - "language_code": "Språkkode for API-svar", - "location": "Posisjon", - "name": "Navn", "update_interval": "Oppdateringsintervall (timer)", - "http_referer": "HTTP Referer", + "language_code": "Språkkode for API-svar", "forecast_days": "Prognosedager (1–5)", "create_forecast_sensors": "Omfang av sensorer per dag (TYPER)" - }, - "description": "Oppgi Google API-nøkkelen din ([få den her]({api_key_url})) og les beste praksis ([beste praksis]({restricting_api_keys_url})). Velg posisjonen din på kartet, oppdateringsintervallet (timer) og språkkoden for API-svaret. Du kan også angi prognosedager og omfanget av sensorer per dag (TYPER).", - "title": "Konfigurasjon av pollennivåer", - "sections": { - "api_key_options": { - "name": "Valgfrie API-nøkkelalternativer" - } - }, - "data_description": { - "http_referer": "Bare nødvendig hvis API-nøkkelen din har en [nettstedbegrensning]({restricting_api_keys_url}) (HTTP Referer)." } } + }, + "error": { + "invalid_language_format": "Bruk en kanonisk BCP-47-kode som \"en\" eller \"es-ES\".", + "empty": "Dette feltet kan ikke være tomt", + "invalid_option_combo": "Øk «Prognosedager» for å dekke valgte sensorer per dag.", + "unknown": "Ukjent feil", + "invalid_update_interval": "Oppdateringsintervallet må være mellom 1 og 24 timer.", + "invalid_forecast_days": "Prognosedager må være mellom 1 og 5." } }, "device": { - "info": { - "name": "{title} - Polleninfo ({latitude},{longitude})" + "types": { + "name": "{title} - Pollentyper ({latitude},{longitude})" }, "plants": { "name": "{title} - Planter ({latitude},{longitude})" }, - "types": { - "name": "{title} - Pollentyper ({latitude},{longitude})" + "info": { + "name": "{title} - Polleninfo ({latitude},{longitude})" + } + }, + "services": { + "force_update": { + "name": "Tving oppdatering", + "description": "Oppdater pollendata manuelt for alle konfigurerte steder." } }, "entity": { "sensor": { + "region": { + "name": "Region" + }, "date": { "name": "Dato" }, "last_updated": { "name": "Sist oppdatert" - }, - "region": { - "name": "Region" - } - } - }, - "options": { - "error": { - "empty": "Dette feltet kan ikke være tomt", - "invalid_language_format": "Bruk en kanonisk BCP-47-kode som \"en\" eller \"es-ES\".", - "invalid_option_combo": "Øk «Prognosedager» for å dekke valgte sensorer per dag.", - "unknown": "Ukjent feil", - "invalid_update_interval": "Oppdateringsintervallet må være mellom 1 og 24 timer.", - "invalid_forecast_days": "Prognosedager må være mellom 1 og 5." - }, - "step": { - "init": { - "data": { - "create_forecast_sensors": "Omfang av sensorer per dag (TYPER)", - "forecast_days": "Prognosedager (1–5)", - "language_code": "Språkkode for API-svar", - "update_interval": "Oppdateringsintervall (timer)" - }, - "description": "Endre oppdateringsintervall, API-språk, prognosedager og sensorer per dag for {title}.\nAlternativer for sensorer per dag (TYPER): Kun i dag (none), Til og med i morgen (D+1), Til og med i overmorgen (D+1+2).", - "title": "Pollen Levels – Innstillinger" } } - }, - "services": { - "force_update": { - "description": "Oppdater pollendata manuelt for alle konfigurerte steder.", - "name": "Tving oppdatering" - } } } diff --git a/custom_components/pollenlevels/translations/nl.json b/custom_components/pollenlevels/translations/nl.json index b98968dd..fe0e6045 100644 --- a/custom_components/pollenlevels/translations/nl.json +++ b/custom_components/pollenlevels/translations/nl.json @@ -1,105 +1,95 @@ { "config": { - "abort": { - "already_configured": "Deze locatie is al geconfigureerd.", - "reauth_failed": "Opnieuw autoriseren is mislukt. Probeer het opnieuw.", - "reauth_successful": "Opnieuw autoriseren voltooid." + "step": { + "user": { + "title": "Pollen Levels – Configuratie", + "description": "Voer je Google API-sleutel in ([haal hem hier]({api_key_url})) en bekijk de best practices ([best practices]({restricting_api_keys_url})). Selecteer je locatie op de kaart, het update-interval (uren) en de taalcode van de API-respons. Je kunt ook voorspellingsdagen en het bereik van per-dag TYPE-sensoren instellen.", + "data": { + "api_key": "API-sleutel", + "name": "Naam", + "location": "Locatie", + "update_interval": "Update-interval (uren)", + "language_code": "Taalcode voor API-respons", + "forecast_days": "Voorspellingsdagen (1–5)", + "create_forecast_sensors": "Bereik van per-dag TYPE-sensoren" + } + }, + "reauth_confirm": { + "title": "Autoriseer Pollen Levels opnieuw", + "description": "De API-sleutel voor {latitude},{longitude} is niet meer geldig. Voer een nieuwe sleutel in om de updates te hervatten.", + "data": { + "api_key": "API-sleutel" + } + } }, "error": { - "cannot_connect": "Kan geen verbinding maken met de service\n\n{error_message}", - "empty": "Dit veld mag niet leeg zijn", "invalid_auth": "Ongeldige API-sleutel\n\n{error_message}", - "invalid_coordinates": "Selecteer een geldige locatie op de kaart.", - "invalid_http_referrer": "Ongeldige waarde voor HTTP Referer. Deze mag geen regeleinden bevatten.", + "cannot_connect": "Kan geen verbinding maken met de service\n\n{error_message}", + "quota_exceeded": "Limiet overschreden\n\n{error_message}", "invalid_language_format": "Gebruik een canonieke BCP-47-code zoals \"en\" of \"es-ES\".", + "empty": "Dit veld mag niet leeg zijn", "invalid_option_combo": "Verhoog 'Voorspellingsdagen' om de geselecteerde per-dag sensoren te dekken.", - "quota_exceeded": "Limiet overschreden\n\n{error_message}", + "invalid_coordinates": "Selecteer een geldige locatie op de kaart.", "unknown": "Onbekende fout", "invalid_update_interval": "Het update-interval moet tussen 1 en 24 uur liggen.", "invalid_forecast_days": "Voorspellingsdagen moeten tussen 1 en 5 liggen." }, + "abort": { + "already_configured": "Deze locatie is al geconfigureerd.", + "reauth_successful": "Opnieuw autoriseren voltooid.", + "reauth_failed": "Opnieuw autoriseren is mislukt. Probeer het opnieuw." + } + }, + "options": { "step": { - "reauth_confirm": { - "data": { - "api_key": "API-sleutel" - }, - "description": "De API-sleutel voor {latitude},{longitude} is niet meer geldig. Voer een nieuwe sleutel in om de updates te hervatten.", - "title": "Autoriseer Pollen Levels opnieuw" - }, - "user": { + "init": { + "title": "Pollen Levels – Opties", + "description": "Wijzig het update-interval, de API-taal, het aantal voorspellingsdagen en de per-dag TYPE-sensoren voor {title}.\nOpties voor per-dag TYPE-sensoren: Alleen vandaag (none), Tot en met morgen (D+1), Tot en met overmorgen (D+1+2).", "data": { - "api_key": "API-sleutel", - "language_code": "Taalcode voor API-respons", - "location": "Locatie", - "name": "Naam", "update_interval": "Update-interval (uren)", - "http_referer": "HTTP Referer", + "language_code": "Taalcode voor API-respons", "forecast_days": "Voorspellingsdagen (1–5)", "create_forecast_sensors": "Bereik van per-dag TYPE-sensoren" - }, - "description": "Voer je Google API-sleutel in ([haal hem hier]({api_key_url})) en bekijk de best practices ([best practices]({restricting_api_keys_url})). Selecteer je locatie op de kaart, het update-interval (uren) en de taalcode van de API-respons. Je kunt ook voorspellingsdagen en het bereik van per-dag TYPE-sensoren instellen.", - "title": "Pollen Levels – Configuratie", - "sections": { - "api_key_options": { - "name": "Optionele API-sleutelopties" - } - }, - "data_description": { - "http_referer": "Alleen nodig als je API-sleutel een [websitebeperking]({restricting_api_keys_url}) gebruikt (HTTP Referer)." } } + }, + "error": { + "invalid_language_format": "Gebruik een canonieke BCP-47-code zoals \"en\" of \"es-ES\".", + "empty": "Dit veld mag niet leeg zijn", + "invalid_option_combo": "Verhoog 'Voorspellingsdagen' om de geselecteerde per-dag sensoren te dekken.", + "unknown": "Onbekende fout", + "invalid_update_interval": "Het update-interval moet tussen 1 en 24 uur liggen.", + "invalid_forecast_days": "Voorspellingsdagen moeten tussen 1 en 5 liggen." } }, "device": { - "info": { - "name": "{title} - Polleninfo ({latitude},{longitude})" + "types": { + "name": "{title} - Pollentypen ({latitude},{longitude})" }, "plants": { "name": "{title} - Planten ({latitude},{longitude})" }, - "types": { - "name": "{title} - Pollentypen ({latitude},{longitude})" + "info": { + "name": "{title} - Polleninfo ({latitude},{longitude})" + } + }, + "services": { + "force_update": { + "name": "Update forceren", + "description": "Ververs handmatig pollengegevens voor alle geconfigureerde locaties." } }, "entity": { "sensor": { + "region": { + "name": "Regio" + }, "date": { "name": "Datum" }, "last_updated": { "name": "Laatst bijgewerkt" - }, - "region": { - "name": "Regio" - } - } - }, - "options": { - "error": { - "empty": "Dit veld mag niet leeg zijn", - "invalid_language_format": "Gebruik een canonieke BCP-47-code zoals \"en\" of \"es-ES\".", - "invalid_option_combo": "Verhoog 'Voorspellingsdagen' om de geselecteerde per-dag sensoren te dekken.", - "unknown": "Onbekende fout", - "invalid_update_interval": "Het update-interval moet tussen 1 en 24 uur liggen.", - "invalid_forecast_days": "Voorspellingsdagen moeten tussen 1 en 5 liggen." - }, - "step": { - "init": { - "data": { - "create_forecast_sensors": "Bereik van per-dag TYPE-sensoren", - "forecast_days": "Voorspellingsdagen (1–5)", - "language_code": "Taalcode voor API-respons", - "update_interval": "Update-interval (uren)" - }, - "description": "Wijzig het update-interval, de API-taal, het aantal voorspellingsdagen en de per-dag TYPE-sensoren voor {title}.\nOpties voor per-dag TYPE-sensoren: Alleen vandaag (none), Tot en met morgen (D+1), Tot en met overmorgen (D+1+2).", - "title": "Pollen Levels – Opties" } } - }, - "services": { - "force_update": { - "description": "Ververs handmatig pollengegevens voor alle geconfigureerde locaties.", - "name": "Update forceren" - } } } diff --git a/custom_components/pollenlevels/translations/pl.json b/custom_components/pollenlevels/translations/pl.json index 0993b66e..4f71da9b 100644 --- a/custom_components/pollenlevels/translations/pl.json +++ b/custom_components/pollenlevels/translations/pl.json @@ -1,105 +1,95 @@ { "config": { - "abort": { - "already_configured": "Ta lokalizacja jest już skonfigurowana.", - "reauth_failed": "Ponowne uwierzytelnienie nie powiodło się. Spróbuj ponownie.", - "reauth_successful": "Ponowne uwierzytelnienie zakończone pomyślnie." + "step": { + "user": { + "title": "Konfiguracja poziomów pyłku", + "description": "Wprowadź swój klucz Google API ([uzyskaj go tutaj]({api_key_url})) i zapoznaj się z dobrymi praktykami ([dobre praktyki]({restricting_api_keys_url})). Wybierz lokalizację na mapie, interwał aktualizacji (godziny) oraz kod języka odpowiedzi API. Możesz także ustawić dni prognozy oraz zakres czujników dziennych (TYPY).", + "data": { + "api_key": "Klucz API", + "name": "Nazwa", + "location": "Lokalizacja", + "update_interval": "Interwał aktualizacji (godziny)", + "language_code": "Kod języka odpowiedzi API", + "forecast_days": "Dni prognozy (1–5)", + "create_forecast_sensors": "Zakres czujników dziennych (TYPY)" + } + }, + "reauth_confirm": { + "title": "Ponownie uwierzytelnij Pollen Levels", + "description": "Klucz API dla {latitude},{longitude} jest już nieważny. Wprowadź nowy klucz, aby wznowić aktualizacje.", + "data": { + "api_key": "Klucz API" + } + } }, "error": { - "cannot_connect": "Brak połączenia z usługą\n\n{error_message}", - "empty": "To pole nie może być puste", "invalid_auth": "Nieprawidłowy klucz API\n\n{error_message}", - "invalid_coordinates": "Wybierz prawidłową lokalizację na mapie.", - "invalid_http_referrer": "Nieprawidłowa wartość dla HTTP Referer. Nie może zawierać znaków nowej linii.", + "cannot_connect": "Brak połączenia z usługą\n\n{error_message}", + "quota_exceeded": "Przekroczono limit\n\n{error_message}", "invalid_language_format": "Użyj kanonicznego kodu BCP-47, np. \"en\" lub \"es-ES\".", + "empty": "To pole nie może być puste", "invalid_option_combo": "Zwiększ 'Dni prognozy', aby objąć wybrane czujniki dzienne.", - "quota_exceeded": "Przekroczono limit\n\n{error_message}", + "invalid_coordinates": "Wybierz prawidłową lokalizację na mapie.", "unknown": "Nieznany błąd", "invalid_update_interval": "Interwał aktualizacji musi wynosić od 1 do 24 godzin.", "invalid_forecast_days": "Dni prognozy muszą mieścić się w zakresie 1–5." }, + "abort": { + "already_configured": "Ta lokalizacja jest już skonfigurowana.", + "reauth_successful": "Ponowne uwierzytelnienie zakończone pomyślnie.", + "reauth_failed": "Ponowne uwierzytelnienie nie powiodło się. Spróbuj ponownie." + } + }, + "options": { "step": { - "reauth_confirm": { - "data": { - "api_key": "Klucz API" - }, - "description": "Klucz API dla {latitude},{longitude} jest już nieważny. Wprowadź nowy klucz, aby wznowić aktualizacje.", - "title": "Ponownie uwierzytelnij Pollen Levels" - }, - "user": { + "init": { + "title": "Pollen Levels – Opcje", + "description": "Zmień interwał aktualizacji, język odpowiedzi API, liczbę dni prognozy oraz czujniki dzienne dla TYPÓW dla {title}.\nOpcje czujników dziennych (TYPY): Tylko dziś (none), Do jutra (D+1), Do pojutrza (D+1+2).", "data": { - "api_key": "Klucz API", - "language_code": "Kod języka odpowiedzi API", - "location": "Lokalizacja", - "name": "Nazwa", "update_interval": "Interwał aktualizacji (godziny)", - "http_referer": "HTTP Referer", + "language_code": "Kod języka odpowiedzi API", "forecast_days": "Dni prognozy (1–5)", "create_forecast_sensors": "Zakres czujników dziennych (TYPY)" - }, - "description": "Wprowadź swój klucz Google API ([uzyskaj go tutaj]({api_key_url})) i zapoznaj się z dobrymi praktykami ([dobre praktyki]({restricting_api_keys_url})). Wybierz lokalizację na mapie, interwał aktualizacji (godziny) oraz kod języka odpowiedzi API. Możesz także ustawić dni prognozy oraz zakres czujników dziennych (TYPY).", - "title": "Konfiguracja poziomów pyłku", - "sections": { - "api_key_options": { - "name": "Opcjonalne opcje klucza API" - } - }, - "data_description": { - "http_referer": "Wymagane tylko, jeśli Twój klucz API ma [ograniczenie witryny]({restricting_api_keys_url}) (HTTP Referer)." } } + }, + "error": { + "invalid_language_format": "Użyj kanonicznego kodu BCP-47, np. \"en\" lub \"es-ES\".", + "empty": "To pole nie może być puste", + "invalid_option_combo": "Zwiększ 'Dni prognozy', aby objąć wybrane czujniki dzienne.", + "unknown": "Nieznany błąd", + "invalid_update_interval": "Interwał aktualizacji musi wynosić od 1 do 24 godzin.", + "invalid_forecast_days": "Dni prognozy muszą mieścić się w zakresie 1–5." } }, "device": { - "info": { - "name": "{title} - Informacje o pyłkach ({latitude},{longitude})" + "types": { + "name": "{title} - Typy pyłków ({latitude},{longitude})" }, "plants": { "name": "{title} - Rośliny ({latitude},{longitude})" }, - "types": { - "name": "{title} - Typy pyłków ({latitude},{longitude})" + "info": { + "name": "{title} - Informacje o pyłkach ({latitude},{longitude})" + } + }, + "services": { + "force_update": { + "name": "Wymuś aktualizację", + "description": "Ręcznie odśwież dane o pyłkach dla wszystkich skonfigurowanych lokalizacji." } }, "entity": { "sensor": { + "region": { + "name": "Region" + }, "date": { "name": "Data" }, "last_updated": { "name": "Ostatnia aktualizacja" - }, - "region": { - "name": "Region" - } - } - }, - "options": { - "error": { - "empty": "To pole nie może być puste", - "invalid_language_format": "Użyj kanonicznego kodu BCP-47, np. \"en\" lub \"es-ES\".", - "invalid_option_combo": "Zwiększ 'Dni prognozy', aby objąć wybrane czujniki dzienne.", - "unknown": "Nieznany błąd", - "invalid_update_interval": "Interwał aktualizacji musi wynosić od 1 do 24 godzin.", - "invalid_forecast_days": "Dni prognozy muszą mieścić się w zakresie 1–5." - }, - "step": { - "init": { - "data": { - "create_forecast_sensors": "Zakres czujników dziennych (TYPY)", - "forecast_days": "Dni prognozy (1–5)", - "language_code": "Kod języka odpowiedzi API", - "update_interval": "Interwał aktualizacji (godziny)" - }, - "description": "Zmień interwał aktualizacji, język odpowiedzi API, liczbę dni prognozy oraz czujniki dzienne dla TYPÓW dla {title}.\nOpcje czujników dziennych (TYPY): Tylko dziś (none), Do jutra (D+1), Do pojutrza (D+1+2).", - "title": "Pollen Levels – Opcje" } } - }, - "services": { - "force_update": { - "description": "Ręcznie odśwież dane o pyłkach dla wszystkich skonfigurowanych lokalizacji.", - "name": "Wymuś aktualizację" - } } } diff --git a/custom_components/pollenlevels/translations/pt-BR.json b/custom_components/pollenlevels/translations/pt-BR.json index 62b769d2..6eb7e74f 100644 --- a/custom_components/pollenlevels/translations/pt-BR.json +++ b/custom_components/pollenlevels/translations/pt-BR.json @@ -1,105 +1,95 @@ { "config": { - "abort": { - "already_configured": "Este local já está configurado.", - "reauth_failed": "A reautenticação falhou. Tente novamente.", - "reauth_successful": "Reautenticação concluída com sucesso." + "step": { + "user": { + "title": "Configuração dos Níveis de Pólen", + "description": "Insira sua chave de API do Google ([obtenha aqui]({api_key_url})) e consulte as melhores práticas ([melhores práticas]({restricting_api_keys_url})). Selecione sua localização no mapa, o intervalo de atualização (horas) e o código de idioma da resposta da API. Você também pode definir os dias de previsão e o escopo dos sensores por dia (TIPOS).", + "data": { + "api_key": "Chave da API", + "name": "Nome", + "location": "Localização", + "update_interval": "Intervalo de atualização (horas)", + "language_code": "Código de idioma da resposta da API", + "forecast_days": "Dias de previsão (1–5)", + "create_forecast_sensors": "Escopo dos sensores por dia (TIPOS)" + } + }, + "reauth_confirm": { + "title": "Reautenticar Pollen Levels", + "description": "A chave da API para {latitude},{longitude} não é mais válida. Insira uma nova chave para retomar as atualizações.", + "data": { + "api_key": "Chave da API" + } + } }, "error": { - "cannot_connect": "Não foi possível conectar ao serviço\n\n{error_message}", - "empty": "Este campo não pode ficar vazio", "invalid_auth": "Chave de API inválida\n\n{error_message}", - "invalid_coordinates": "Selecione um local válido no mapa.", - "invalid_http_referrer": "Valor inválido para HTTP Referer. Não deve conter quebras de linha.", + "cannot_connect": "Não foi possível conectar ao serviço\n\n{error_message}", + "quota_exceeded": "Cota excedida\n\n{error_message}", "invalid_language_format": "Use um código BCP-47 canônico, como \"en\" ou \"es-ES\".", + "empty": "Este campo não pode ficar vazio", "invalid_option_combo": "Aumente \"Dias de previsão\" para cobrir os sensores por dia selecionados.", - "quota_exceeded": "Cota excedida\n\n{error_message}", + "invalid_coordinates": "Selecione um local válido no mapa.", "unknown": "Erro desconhecido", "invalid_update_interval": "O intervalo de atualização deve estar entre 1 e 24 horas.", "invalid_forecast_days": "Os dias de previsão devem estar entre 1 e 5." }, + "abort": { + "already_configured": "Este local já está configurado.", + "reauth_successful": "Reautenticação concluída com sucesso.", + "reauth_failed": "A reautenticação falhou. Tente novamente." + } + }, + "options": { "step": { - "reauth_confirm": { - "data": { - "api_key": "Chave da API" - }, - "description": "A chave da API para {latitude},{longitude} não é mais válida. Insira uma nova chave para retomar as atualizações.", - "title": "Reautenticar Pollen Levels" - }, - "user": { + "init": { + "title": "Pollen Levels – Opções", + "description": "Altere o intervalo de atualização, o idioma da API, os dias de previsão e os sensores por dia para {title}.\nOpções de sensores por dia (TIPOS): Apenas hoje (none), Até amanhã (D+1), Até depois de amanhã (D+1+2).", "data": { - "api_key": "Chave da API", - "language_code": "Código de idioma da resposta da API", - "location": "Localização", - "name": "Nome", "update_interval": "Intervalo de atualização (horas)", - "http_referer": "HTTP Referer", + "language_code": "Código de idioma da resposta da API", "forecast_days": "Dias de previsão (1–5)", "create_forecast_sensors": "Escopo dos sensores por dia (TIPOS)" - }, - "description": "Insira sua chave de API do Google ([obtenha aqui]({api_key_url})) e consulte as melhores práticas ([melhores práticas]({restricting_api_keys_url})). Selecione sua localização no mapa, o intervalo de atualização (horas) e o código de idioma da resposta da API. Você também pode definir os dias de previsão e o escopo dos sensores por dia (TIPOS).", - "title": "Configuração dos Níveis de Pólen", - "sections": { - "api_key_options": { - "name": "Opções opcionais de chave de API" - } - }, - "data_description": { - "http_referer": "Necessário apenas se sua chave de API tiver uma [restrição de site]({restricting_api_keys_url}) (HTTP Referer)." } } + }, + "error": { + "invalid_language_format": "Use um código BCP-47 canônico, como \"en\" ou \"es-ES\".", + "empty": "Este campo não pode ficar vazio", + "invalid_option_combo": "Aumente \"Dias de previsão\" para cobrir os sensores por dia selecionados.", + "unknown": "Erro desconhecido", + "invalid_update_interval": "O intervalo de atualização deve estar entre 1 e 24 horas.", + "invalid_forecast_days": "Os dias de previsão devem estar entre 1 e 5." } }, "device": { - "info": { - "name": "{title} - Informações de pólen ({latitude},{longitude})" + "types": { + "name": "{title} - Tipos de pólen ({latitude},{longitude})" }, "plants": { "name": "{title} - Plantas ({latitude},{longitude})" }, - "types": { - "name": "{title} - Tipos de pólen ({latitude},{longitude})" + "info": { + "name": "{title} - Informações de pólen ({latitude},{longitude})" + } + }, + "services": { + "force_update": { + "name": "Forçar atualização", + "description": "Atualize manualmente os dados de pólen para todas as localizações configuradas." } }, "entity": { "sensor": { + "region": { + "name": "Região" + }, "date": { "name": "Data" }, "last_updated": { "name": "Última atualização" - }, - "region": { - "name": "Região" - } - } - }, - "options": { - "error": { - "empty": "Este campo não pode ficar vazio", - "invalid_language_format": "Use um código BCP-47 canônico, como \"en\" ou \"es-ES\".", - "invalid_option_combo": "Aumente \"Dias de previsão\" para cobrir os sensores por dia selecionados.", - "unknown": "Erro desconhecido", - "invalid_update_interval": "O intervalo de atualização deve estar entre 1 e 24 horas.", - "invalid_forecast_days": "Os dias de previsão devem estar entre 1 e 5." - }, - "step": { - "init": { - "data": { - "create_forecast_sensors": "Escopo dos sensores por dia (TIPOS)", - "forecast_days": "Dias de previsão (1–5)", - "language_code": "Código de idioma da resposta da API", - "update_interval": "Intervalo de atualização (horas)" - }, - "description": "Altere o intervalo de atualização, o idioma da API, os dias de previsão e os sensores por dia para {title}.\nOpções de sensores por dia (TIPOS): Apenas hoje (none), Até amanhã (D+1), Até depois de amanhã (D+1+2).", - "title": "Pollen Levels – Opções" } } - }, - "services": { - "force_update": { - "description": "Atualize manualmente os dados de pólen para todas as localizações configuradas.", - "name": "Forçar atualização" - } } } diff --git a/custom_components/pollenlevels/translations/pt-PT.json b/custom_components/pollenlevels/translations/pt-PT.json index 902b0ab7..0b0c5170 100644 --- a/custom_components/pollenlevels/translations/pt-PT.json +++ b/custom_components/pollenlevels/translations/pt-PT.json @@ -1,105 +1,95 @@ { "config": { - "abort": { - "already_configured": "Esta localização já está configurada.", - "reauth_failed": "A reautenticação falhou. Tente novamente.", - "reauth_successful": "Reautenticação concluída com sucesso." + "step": { + "user": { + "title": "Configuração dos Níveis de Pólen", + "description": "Introduza a sua chave de API do Google ([obtenha-a aqui]({api_key_url})) e reveja as melhores práticas ([melhores práticas]({restricting_api_keys_url})). Selecione a sua localização no mapa, o intervalo de atualização (horas) e o código de idioma da resposta da API. Também pode definir os dias de previsão e o âmbito dos sensores por dia (TIPOS).", + "data": { + "api_key": "Chave da API", + "name": "Nome", + "location": "Localização", + "update_interval": "Intervalo de atualização (horas)", + "language_code": "Código de idioma da resposta da API", + "forecast_days": "Dias de previsão (1–5)", + "create_forecast_sensors": "Âmbito dos sensores por dia (TIPOS)" + } + }, + "reauth_confirm": { + "title": "Reautenticar Pollen Levels", + "description": "A chave da API para {latitude},{longitude} deixou de ser válida. Introduza uma nova chave para retomar as atualizações.", + "data": { + "api_key": "Chave da API" + } + } }, "error": { - "cannot_connect": "Não é possível ligar ao serviço\n\n{error_message}", - "empty": "Este campo não pode estar vazio", "invalid_auth": "Chave da API inválida\n\n{error_message}", - "invalid_coordinates": "Selecione uma localização válida no mapa.", - "invalid_http_referrer": "Valor inválido para HTTP Referer. Não pode conter quebras de linha.", + "cannot_connect": "Não é possível ligar ao serviço\n\n{error_message}", + "quota_exceeded": "Quota excedida\n\n{error_message}", "invalid_language_format": "Use um código BCP-47 canónico, como \"en\" ou \"es-ES\".", + "empty": "Este campo não pode estar vazio", "invalid_option_combo": "Aumente \"Dias de previsão\" para cobrir os sensores por dia selecionados.", - "quota_exceeded": "Quota excedida\n\n{error_message}", + "invalid_coordinates": "Selecione uma localização válida no mapa.", "unknown": "Erro desconhecido", "invalid_update_interval": "O intervalo de atualização deve estar entre 1 e 24 horas.", "invalid_forecast_days": "Os dias de previsão devem estar entre 1 e 5." }, + "abort": { + "already_configured": "Esta localização já está configurada.", + "reauth_successful": "Reautenticação concluída com sucesso.", + "reauth_failed": "A reautenticação falhou. Tente novamente." + } + }, + "options": { "step": { - "reauth_confirm": { - "data": { - "api_key": "Chave da API" - }, - "description": "A chave da API para {latitude},{longitude} deixou de ser válida. Introduza uma nova chave para retomar as atualizações.", - "title": "Reautenticar Pollen Levels" - }, - "user": { + "init": { + "title": "Pollen Levels – Opções", + "description": "Altere o intervalo de atualização, o idioma da API, os dias de previsão e os sensores por dia para {title}.\nOpções de sensores por dia (TIPOS): Apenas hoje (none), Até amanhã (D+1), Até depois de amanhã (D+1+2).", "data": { - "api_key": "Chave da API", - "language_code": "Código de idioma da resposta da API", - "location": "Localização", - "name": "Nome", "update_interval": "Intervalo de atualização (horas)", - "http_referer": "HTTP Referer", + "language_code": "Código de idioma da resposta da API", "forecast_days": "Dias de previsão (1–5)", "create_forecast_sensors": "Âmbito dos sensores por dia (TIPOS)" - }, - "description": "Introduza a sua chave de API do Google ([obtenha-a aqui]({api_key_url})) e reveja as melhores práticas ([melhores práticas]({restricting_api_keys_url})). Selecione a sua localização no mapa, o intervalo de atualização (horas) e o código de idioma da resposta da API. Também pode definir os dias de previsão e o âmbito dos sensores por dia (TIPOS).", - "title": "Configuração dos Níveis de Pólen", - "sections": { - "api_key_options": { - "name": "Opções opcionais da chave de API" - } - }, - "data_description": { - "http_referer": "Apenas necessário se a sua chave de API tiver uma [restrição de site]({restricting_api_keys_url}) (HTTP Referer)." } } + }, + "error": { + "invalid_language_format": "Use um código BCP-47 canónico, como \"en\" ou \"es-ES\".", + "empty": "Este campo não pode estar vazio", + "invalid_option_combo": "Aumente \"Dias de previsão\" para cobrir os sensores por dia selecionados.", + "unknown": "Erro desconhecido", + "invalid_update_interval": "O intervalo de atualização deve estar entre 1 e 24 horas.", + "invalid_forecast_days": "Os dias de previsão devem estar entre 1 e 5." } }, "device": { - "info": { - "name": "{title} - Informações de pólen ({latitude},{longitude})" + "types": { + "name": "{title} - Tipos de pólen ({latitude},{longitude})" }, "plants": { "name": "{title} - Plantas ({latitude},{longitude})" }, - "types": { - "name": "{title} - Tipos de pólen ({latitude},{longitude})" + "info": { + "name": "{title} - Informações de pólen ({latitude},{longitude})" + } + }, + "services": { + "force_update": { + "name": "Forçar atualização", + "description": "Atualiza manualmente os dados de pólen para todas as localizações configuradas." } }, "entity": { "sensor": { + "region": { + "name": "Região" + }, "date": { "name": "Data" }, "last_updated": { "name": "Última atualização" - }, - "region": { - "name": "Região" - } - } - }, - "options": { - "error": { - "empty": "Este campo não pode estar vazio", - "invalid_language_format": "Use um código BCP-47 canónico, como \"en\" ou \"es-ES\".", - "invalid_option_combo": "Aumente \"Dias de previsão\" para cobrir os sensores por dia selecionados.", - "unknown": "Erro desconhecido", - "invalid_update_interval": "O intervalo de atualização deve estar entre 1 e 24 horas.", - "invalid_forecast_days": "Os dias de previsão devem estar entre 1 e 5." - }, - "step": { - "init": { - "data": { - "create_forecast_sensors": "Âmbito dos sensores por dia (TIPOS)", - "forecast_days": "Dias de previsão (1–5)", - "language_code": "Código de idioma da resposta da API", - "update_interval": "Intervalo de atualização (horas)" - }, - "description": "Altere o intervalo de atualização, o idioma da API, os dias de previsão e os sensores por dia para {title}.\nOpções de sensores por dia (TIPOS): Apenas hoje (none), Até amanhã (D+1), Até depois de amanhã (D+1+2).", - "title": "Pollen Levels – Opções" } } - }, - "services": { - "force_update": { - "description": "Atualiza manualmente os dados de pólen para todas as localizações configuradas.", - "name": "Forçar atualização" - } } } diff --git a/custom_components/pollenlevels/translations/ro.json b/custom_components/pollenlevels/translations/ro.json index 294d3a0e..15905946 100644 --- a/custom_components/pollenlevels/translations/ro.json +++ b/custom_components/pollenlevels/translations/ro.json @@ -1,105 +1,95 @@ { "config": { - "abort": { - "already_configured": "Această locație este deja configurată.", - "reauth_failed": "Reautentificarea a eșuat. Încercați din nou.", - "reauth_successful": "Reautentificarea s-a încheiat cu succes." + "step": { + "user": { + "title": "Configurare Niveluri de Polen", + "description": "Introdu cheia ta API Google ([obține-o aici]({api_key_url})) și consultă cele mai bune practici ([cele mai bune practici]({restricting_api_keys_url})). Selectează locația pe hartă, intervalul de actualizare (ore) și codul de limbă al răspunsului API. Poți seta și zilele de prognoză și domeniul senzorilor pe zile (TIPURI).", + "data": { + "api_key": "Cheie API", + "name": "Nume", + "location": "Locație", + "update_interval": "Interval de actualizare (ore)", + "language_code": "Codul limbii pentru răspunsul API", + "forecast_days": "Zile de prognoză (1–5)", + "create_forecast_sensors": "Domeniul senzorilor pe zile (TIPURI)" + } + }, + "reauth_confirm": { + "title": "Reautentificați Pollen Levels", + "description": "Cheia API pentru {latitude},{longitude} nu mai este valabilă. Introduceți o cheie nouă pentru a relua actualizările.", + "data": { + "api_key": "Cheie API" + } + } }, "error": { - "cannot_connect": "Nu se poate conecta la serviciu\n\n{error_message}", - "empty": "Acest câmp nu poate fi gol", "invalid_auth": "Cheie API nevalidă\n\n{error_message}", - "invalid_coordinates": "Selectează o locație validă pe hartă.", - "invalid_http_referrer": "Valoare invalidă pentru HTTP Referer. Nu trebuie să conțină caractere de linie nouă.", + "cannot_connect": "Nu se poate conecta la serviciu\n\n{error_message}", + "quota_exceeded": "Cota depășită\n\n{error_message}", "invalid_language_format": "Folosiți un cod BCP-47 canonic, de exemplu \"en\" sau \"es-ES\".", + "empty": "Acest câmp nu poate fi gol", "invalid_option_combo": "Măriți \"Zilele de prognoză\" pentru a acoperi senzorii selectați pe zile.", - "quota_exceeded": "Cota depășită\n\n{error_message}", + "invalid_coordinates": "Selectează o locație validă pe hartă.", "unknown": "Eroare necunoscută", "invalid_update_interval": "Intervalul de actualizare trebuie să fie între 1 și 24 de ore.", "invalid_forecast_days": "Zilele de prognoză trebuie să fie între 1 și 5." }, + "abort": { + "already_configured": "Această locație este deja configurată.", + "reauth_successful": "Reautentificarea s-a încheiat cu succes.", + "reauth_failed": "Reautentificarea a eșuat. Încercați din nou." + } + }, + "options": { "step": { - "reauth_confirm": { - "data": { - "api_key": "Cheie API" - }, - "description": "Cheia API pentru {latitude},{longitude} nu mai este valabilă. Introduceți o cheie nouă pentru a relua actualizările.", - "title": "Reautentificați Pollen Levels" - }, - "user": { + "init": { + "title": "Pollen Levels – Opțiuni", + "description": "Modificați intervalul de actualizare, limba API, zilele de prognoză și senzorii pe zile pentru {title}.\nOpțiuni pentru senzorii pe zile (TIPURI): Doar azi (none), Până mâine (D+1), Până poimâine (D+1+2).", "data": { - "api_key": "Cheie API", - "language_code": "Codul limbii pentru răspunsul API", - "location": "Locație", - "name": "Nume", "update_interval": "Interval de actualizare (ore)", - "http_referer": "HTTP Referer", + "language_code": "Codul limbii pentru răspunsul API", "forecast_days": "Zile de prognoză (1–5)", "create_forecast_sensors": "Domeniul senzorilor pe zile (TIPURI)" - }, - "description": "Introdu cheia ta API Google ([obține-o aici]({api_key_url})) și consultă cele mai bune practici ([cele mai bune practici]({restricting_api_keys_url})). Selectează locația pe hartă, intervalul de actualizare (ore) și codul de limbă al răspunsului API. Poți seta și zilele de prognoză și domeniul senzorilor pe zile (TIPURI).", - "title": "Configurare Niveluri de Polen", - "sections": { - "api_key_options": { - "name": "Opțiuni opționale pentru cheia API" - } - }, - "data_description": { - "http_referer": "Necesar doar dacă cheia ta API are o [restricție de site web]({restricting_api_keys_url}) (HTTP Referer)." } } + }, + "error": { + "invalid_language_format": "Folosiți un cod BCP-47 canonic, de exemplu \"en\" sau \"es-ES\".", + "empty": "Acest câmp nu poate fi gol", + "invalid_option_combo": "Măriți \"Zilele de prognoză\" pentru a acoperi senzorii selectați pe zile.", + "unknown": "Eroare necunoscută", + "invalid_update_interval": "Intervalul de actualizare trebuie să fie între 1 și 24 de ore.", + "invalid_forecast_days": "Zilele de prognoză trebuie să fie între 1 și 5." } }, "device": { - "info": { - "name": "{title} - Informații polen ({latitude},{longitude})" + "types": { + "name": "{title} - Tipuri de polen ({latitude},{longitude})" }, "plants": { "name": "{title} - Plante ({latitude},{longitude})" }, - "types": { - "name": "{title} - Tipuri de polen ({latitude},{longitude})" + "info": { + "name": "{title} - Informații polen ({latitude},{longitude})" + } + }, + "services": { + "force_update": { + "name": "Forțează actualizarea", + "description": "Actualizează manual datele despre polen pentru toate locațiile configurate." } }, "entity": { "sensor": { + "region": { + "name": "Regiune" + }, "date": { "name": "Data" }, "last_updated": { "name": "Ultima actualizare" - }, - "region": { - "name": "Regiune" - } - } - }, - "options": { - "error": { - "empty": "Acest câmp nu poate fi gol", - "invalid_language_format": "Folosiți un cod BCP-47 canonic, de exemplu \"en\" sau \"es-ES\".", - "invalid_option_combo": "Măriți \"Zilele de prognoză\" pentru a acoperi senzorii selectați pe zile.", - "unknown": "Eroare necunoscută", - "invalid_update_interval": "Intervalul de actualizare trebuie să fie între 1 și 24 de ore.", - "invalid_forecast_days": "Zilele de prognoză trebuie să fie între 1 și 5." - }, - "step": { - "init": { - "data": { - "create_forecast_sensors": "Domeniul senzorilor pe zile (TIPURI)", - "forecast_days": "Zile de prognoză (1–5)", - "language_code": "Codul limbii pentru răspunsul API", - "update_interval": "Interval de actualizare (ore)" - }, - "description": "Modificați intervalul de actualizare, limba API, zilele de prognoză și senzorii pe zile pentru {title}.\nOpțiuni pentru senzorii pe zile (TIPURI): Doar azi (none), Până mâine (D+1), Până poimâine (D+1+2).", - "title": "Pollen Levels – Opțiuni" } } - }, - "services": { - "force_update": { - "description": "Actualizează manual datele despre polen pentru toate locațiile configurate.", - "name": "Forțează actualizarea" - } } } diff --git a/custom_components/pollenlevels/translations/ru.json b/custom_components/pollenlevels/translations/ru.json index 99eaa319..93d0bb32 100644 --- a/custom_components/pollenlevels/translations/ru.json +++ b/custom_components/pollenlevels/translations/ru.json @@ -1,105 +1,95 @@ { "config": { - "abort": { - "already_configured": "Это местоположение уже настроено.", - "reauth_failed": "Повторная аутентификация не удалась. Повторите попытку.", - "reauth_successful": "Повторная аутентификация выполнена успешно." + "step": { + "user": { + "title": "Настройка уровней пыльцы", + "description": "Введите ключ Google API ([получите его здесь]({api_key_url})) и изучите рекомендации ([лучшие практики]({restricting_api_keys_url})). Выберите местоположение на карте, интервал обновления (часы) и языковой код ответа API. Вы также можете настроить дни прогноза и диапазон дневных датчиков (ТИПЫ).", + "data": { + "api_key": "Ключ API", + "name": "Имя", + "location": "Местоположение", + "update_interval": "Интервал обновления (в часах)", + "language_code": "Код языка ответа API", + "forecast_days": "Дни прогноза (1–5)", + "create_forecast_sensors": "Диапазон дневных датчиков (ТИПЫ)" + } + }, + "reauth_confirm": { + "title": "Повторная аутентификация Pollen Levels", + "description": "Ключ API для {latitude},{longitude} больше не действителен. Введите новый ключ, чтобы возобновить обновления.", + "data": { + "api_key": "Ключ API" + } + } }, "error": { - "cannot_connect": "Не удаётся подключиться к сервису\n\n{error_message}", - "empty": "Это поле не может быть пустым", "invalid_auth": "Неверный ключ API\n\n{error_message}", - "invalid_coordinates": "Выберите корректное местоположение на карте.", - "invalid_http_referrer": "Неверное значение для HTTP Referer. Оно не должно содержать символы новой строки.", + "cannot_connect": "Не удаётся подключиться к сервису\n\n{error_message}", + "quota_exceeded": "Превышен лимит запросов\n\n{error_message}", "invalid_language_format": "Используйте канонический код BCP-47, например \"en\" или \"es-ES\".", + "empty": "Это поле не может быть пустым", "invalid_option_combo": "Увеличьте «Дни прогноза», чтобы охватить выбранные датчики по дням.", - "quota_exceeded": "Превышен лимит запросов\n\n{error_message}", + "invalid_coordinates": "Выберите корректное местоположение на карте.", "unknown": "Неизвестная ошибка", "invalid_update_interval": "Интервал обновления должен быть от 1 до 24 часов.", "invalid_forecast_days": "Дни прогноза должны быть от 1 до 5." }, + "abort": { + "already_configured": "Это местоположение уже настроено.", + "reauth_successful": "Повторная аутентификация выполнена успешно.", + "reauth_failed": "Повторная аутентификация не удалась. Повторите попытку." + } + }, + "options": { "step": { - "reauth_confirm": { - "data": { - "api_key": "Ключ API" - }, - "description": "Ключ API для {latitude},{longitude} больше не действителен. Введите новый ключ, чтобы возобновить обновления.", - "title": "Повторная аутентификация Pollen Levels" - }, - "user": { + "init": { + "title": "Pollen Levels – Параметры", + "description": "Измените интервал обновления, язык ответа API, дни прогноза и дневные датчики для ТИПОВ для {title}.\nВарианты дневных датчиков (ТИПЫ): Только сегодня (none), До завтра (D+1), До послезавтра (D+1+2).", "data": { - "api_key": "Ключ API", - "language_code": "Код языка ответа API", - "location": "Местоположение", - "name": "Имя", "update_interval": "Интервал обновления (в часах)", - "http_referer": "HTTP Referer", + "language_code": "Код языка ответа API", "forecast_days": "Дни прогноза (1–5)", "create_forecast_sensors": "Диапазон дневных датчиков (ТИПЫ)" - }, - "description": "Введите ключ Google API ([получите его здесь]({api_key_url})) и изучите рекомендации ([лучшие практики]({restricting_api_keys_url})). Выберите местоположение на карте, интервал обновления (часы) и языковой код ответа API. Вы также можете настроить дни прогноза и диапазон дневных датчиков (ТИПЫ).", - "title": "Настройка уровней пыльцы", - "sections": { - "api_key_options": { - "name": "Дополнительные параметры ключа API" - } - }, - "data_description": { - "http_referer": "Нужно только если ваш ключ API имеет [ограничение по веб‑сайту]({restricting_api_keys_url}) (HTTP Referer)." } } + }, + "error": { + "invalid_language_format": "Используйте канонический код BCP-47, например \"en\" или \"es-ES\".", + "empty": "Это поле не может быть пустым", + "invalid_option_combo": "Увеличьте «Дни прогноза», чтобы охватить выбранные датчики по дням.", + "unknown": "Неизвестная ошибка", + "invalid_update_interval": "Интервал обновления должен быть от 1 до 24 часов.", + "invalid_forecast_days": "Дни прогноза должны быть от 1 до 5." } }, "device": { - "info": { - "name": "{title} - Информация о пыльце ({latitude},{longitude})" + "types": { + "name": "{title} - Типы пыльцы ({latitude},{longitude})" }, "plants": { "name": "{title} - Растения ({latitude},{longitude})" }, - "types": { - "name": "{title} - Типы пыльцы ({latitude},{longitude})" + "info": { + "name": "{title} - Информация о пыльце ({latitude},{longitude})" + } + }, + "services": { + "force_update": { + "name": "Принудительное обновление", + "description": "Вручную обновить данные о пыльце для всех настроенных местоположений." } }, "entity": { "sensor": { + "region": { + "name": "Регион" + }, "date": { "name": "Дата" }, "last_updated": { "name": "Последнее обновление" - }, - "region": { - "name": "Регион" - } - } - }, - "options": { - "error": { - "empty": "Это поле не может быть пустым", - "invalid_language_format": "Используйте канонический код BCP-47, например \"en\" или \"es-ES\".", - "invalid_option_combo": "Увеличьте «Дни прогноза», чтобы охватить выбранные датчики по дням.", - "unknown": "Неизвестная ошибка", - "invalid_update_interval": "Интервал обновления должен быть от 1 до 24 часов.", - "invalid_forecast_days": "Дни прогноза должны быть от 1 до 5." - }, - "step": { - "init": { - "data": { - "create_forecast_sensors": "Диапазон дневных датчиков (ТИПЫ)", - "forecast_days": "Дни прогноза (1–5)", - "language_code": "Код языка ответа API", - "update_interval": "Интервал обновления (в часах)" - }, - "description": "Измените интервал обновления, язык ответа API, дни прогноза и дневные датчики для ТИПОВ для {title}.\nВарианты дневных датчиков (ТИПЫ): Только сегодня (none), До завтра (D+1), До послезавтра (D+1+2).", - "title": "Pollen Levels – Параметры" } } - }, - "services": { - "force_update": { - "description": "Вручную обновить данные о пыльце для всех настроенных местоположений.", - "name": "Принудительное обновление" - } } } diff --git a/custom_components/pollenlevels/translations/sv.json b/custom_components/pollenlevels/translations/sv.json index 0c077dfe..3be5fd4b 100644 --- a/custom_components/pollenlevels/translations/sv.json +++ b/custom_components/pollenlevels/translations/sv.json @@ -1,105 +1,95 @@ { "config": { - "abort": { - "already_configured": "Den här platsen är redan konfigurerad.", - "reauth_failed": "Återautentiseringen misslyckades. Försök igen.", - "reauth_successful": "Återautentiseringen lyckades." + "step": { + "user": { + "title": "Konfiguration av pollennivåer", + "description": "Ange din Google API-nyckel ([hämta den här]({api_key_url})) och läs bästa praxis ([bästa praxis]({restricting_api_keys_url})). Välj din plats på kartan, uppdateringsintervallet (timmar) och språkkoden för API-svaret. Du kan också ange prognosdagar och omfånget för sensorer per dag (TYPER).", + "data": { + "api_key": "API-nyckel", + "name": "Namn", + "location": "Plats", + "update_interval": "Uppdateringsintervall (timmar)", + "language_code": "Språkkod för API-svar", + "forecast_days": "Prognosdagar (1–5)", + "create_forecast_sensors": "Omfång för sensorer per dag (TYPER)" + } + }, + "reauth_confirm": { + "title": "Autentisera Pollen Levels igen", + "description": "API-nyckeln för {latitude},{longitude} är inte längre giltig. Ange en ny nyckel för att återuppta uppdateringarna.", + "data": { + "api_key": "API-nyckel" + } + } }, "error": { - "cannot_connect": "Kan inte ansluta till tjänsten\n\n{error_message}", - "empty": "Detta fält får inte vara tomt", "invalid_auth": "Ogiltig API-nyckel\n\n{error_message}", - "invalid_coordinates": "Välj en giltig plats på kartan.", - "invalid_http_referrer": "Ogiltigt värde för HTTP Referer. Det får inte innehålla radbrytningar.", + "cannot_connect": "Kan inte ansluta till tjänsten\n\n{error_message}", + "quota_exceeded": "Kvoten har överskridits\n\n{error_message}", "invalid_language_format": "Använd en kanonisk BCP-47-kod som \"en\" eller \"es-ES\".", + "empty": "Detta fält får inte vara tomt", "invalid_option_combo": "Öka \"Prognosdagar\" för att täcka valda sensorer per dag.", - "quota_exceeded": "Kvoten har överskridits\n\n{error_message}", + "invalid_coordinates": "Välj en giltig plats på kartan.", "unknown": "Okänt fel", "invalid_update_interval": "Uppdateringsintervallet måste vara mellan 1 och 24 timmar.", "invalid_forecast_days": "Prognosdagar måste vara mellan 1 och 5." }, + "abort": { + "already_configured": "Den här platsen är redan konfigurerad.", + "reauth_successful": "Återautentiseringen lyckades.", + "reauth_failed": "Återautentiseringen misslyckades. Försök igen." + } + }, + "options": { "step": { - "reauth_confirm": { - "data": { - "api_key": "API-nyckel" - }, - "description": "API-nyckeln för {latitude},{longitude} är inte längre giltig. Ange en ny nyckel för att återuppta uppdateringarna.", - "title": "Autentisera Pollen Levels igen" - }, - "user": { + "init": { + "title": "Pollen Levels – Alternativ", + "description": "Ändra uppdateringsintervall, API-språk, prognosdagar och sensorer per dag för {title}.\nAlternativ för sensorer per dag (TYPER): Endast idag (none), Till och med i morgon (D+1), Till och med i övermorgon (D+1+2).", "data": { - "api_key": "API-nyckel", - "language_code": "Språkkod för API-svar", - "location": "Plats", - "name": "Namn", "update_interval": "Uppdateringsintervall (timmar)", - "http_referer": "HTTP Referer", + "language_code": "Språkkod för API-svar", "forecast_days": "Prognosdagar (1–5)", "create_forecast_sensors": "Omfång för sensorer per dag (TYPER)" - }, - "description": "Ange din Google API-nyckel ([hämta den här]({api_key_url})) och läs bästa praxis ([bästa praxis]({restricting_api_keys_url})). Välj din plats på kartan, uppdateringsintervallet (timmar) och språkkoden för API-svaret. Du kan också ange prognosdagar och omfånget för sensorer per dag (TYPER).", - "title": "Konfiguration av pollennivåer", - "sections": { - "api_key_options": { - "name": "Valfria API-nyckelalternativ" - } - }, - "data_description": { - "http_referer": "Endast nödvändigt om din API-nyckel har en [webbplatsbegränsning]({restricting_api_keys_url}) (HTTP Referer)." } } + }, + "error": { + "invalid_language_format": "Använd en kanonisk BCP-47-kod som \"en\" eller \"es-ES\".", + "empty": "Detta fält får inte vara tomt", + "invalid_option_combo": "Öka \"Prognosdagar\" för att täcka valda sensorer per dag.", + "unknown": "Okänt fel", + "invalid_update_interval": "Uppdateringsintervallet måste vara mellan 1 och 24 timmar.", + "invalid_forecast_days": "Prognosdagar måste vara mellan 1 och 5." } }, "device": { - "info": { - "name": "{title} - Polleninfo ({latitude},{longitude})" + "types": { + "name": "{title} - Pollentyper ({latitude},{longitude})" }, "plants": { "name": "{title} - Växter ({latitude},{longitude})" }, - "types": { - "name": "{title} - Pollentyper ({latitude},{longitude})" + "info": { + "name": "{title} - Polleninfo ({latitude},{longitude})" + } + }, + "services": { + "force_update": { + "name": "Tvinga uppdatering", + "description": "Uppdatera pollendata manuellt för alla konfigurerade platser." } }, "entity": { "sensor": { + "region": { + "name": "Region" + }, "date": { "name": "Datum" }, "last_updated": { "name": "Senaste uppdatering" - }, - "region": { - "name": "Region" - } - } - }, - "options": { - "error": { - "empty": "Detta fält får inte vara tomt", - "invalid_language_format": "Använd en kanonisk BCP-47-kod som \"en\" eller \"es-ES\".", - "invalid_option_combo": "Öka \"Prognosdagar\" för att täcka valda sensorer per dag.", - "unknown": "Okänt fel", - "invalid_update_interval": "Uppdateringsintervallet måste vara mellan 1 och 24 timmar.", - "invalid_forecast_days": "Prognosdagar måste vara mellan 1 och 5." - }, - "step": { - "init": { - "data": { - "create_forecast_sensors": "Omfång för sensorer per dag (TYPER)", - "forecast_days": "Prognosdagar (1–5)", - "language_code": "Språkkod för API-svar", - "update_interval": "Uppdateringsintervall (timmar)" - }, - "description": "Ändra uppdateringsintervall, API-språk, prognosdagar och sensorer per dag för {title}.\nAlternativ för sensorer per dag (TYPER): Endast idag (none), Till och med i morgon (D+1), Till och med i övermorgon (D+1+2).", - "title": "Pollen Levels – Alternativ" } } - }, - "services": { - "force_update": { - "description": "Uppdatera pollendata manuellt för alla konfigurerade platser.", - "name": "Tvinga uppdatering" - } } } diff --git a/custom_components/pollenlevels/translations/uk.json b/custom_components/pollenlevels/translations/uk.json index 8fbc54b1..60a3b972 100644 --- a/custom_components/pollenlevels/translations/uk.json +++ b/custom_components/pollenlevels/translations/uk.json @@ -1,105 +1,95 @@ { "config": { - "abort": { - "already_configured": "Це розташування вже налаштовано.", - "reauth_failed": "Повторна автентифікація не вдалася. Спробуйте ще раз.", - "reauth_successful": "Повторна автентифікація виконана успішно." + "step": { + "user": { + "title": "Налаштування рівнів пилку", + "description": "Введіть свій ключ Google API ([отримайте його тут]({api_key_url})) та ознайомтеся з найкращими практиками ([найкращі практики]({restricting_api_keys_url})). Виберіть місце на карті, інтервал оновлення (години) і код мови відповіді API. Ви також можете налаштувати дні прогнозу та діапазон денних датчиків (ТИПИ).", + "data": { + "api_key": "Ключ API", + "name": "Ім'я", + "location": "Місцезнаходження", + "update_interval": "Інтервал оновлення (у годинах)", + "language_code": "Код мови відповіді API", + "forecast_days": "Дні прогнозу (1–5)", + "create_forecast_sensors": "Діапазон денних датчиків (ТИПИ)" + } + }, + "reauth_confirm": { + "title": "Повторно автентифікуйте Pollen Levels", + "description": "Ключ API для {latitude},{longitude} більше не дійсний. Введіть новий ключ, щоб відновити оновлення.", + "data": { + "api_key": "Ключ API" + } + } }, "error": { - "cannot_connect": "Не вдається підключитися до сервісу\n\n{error_message}", - "empty": "Це поле не може бути порожнім", "invalid_auth": "Невірний ключ API\n\n{error_message}", - "invalid_coordinates": "Виберіть дійсне місце на карті.", - "invalid_http_referrer": "Неприпустиме значення для HTTP Referer. Воно не повинно містити символів нового рядка.", + "cannot_connect": "Не вдається підключитися до сервісу\n\n{error_message}", + "quota_exceeded": "Перевищено ліміт запитів\n\n{error_message}", "invalid_language_format": "Використовуйте канонічний код BCP-47, наприклад \"en\" або \"es-ES\".", + "empty": "Це поле не може бути порожнім", "invalid_option_combo": "Збільшіть «Дні прогнозу», щоб охопити вибрані денні датчики.", - "quota_exceeded": "Перевищено ліміт запитів\n\n{error_message}", + "invalid_coordinates": "Виберіть дійсне місце на карті.", "unknown": "Невідома помилка", "invalid_update_interval": "Інтервал оновлення має бути між 1 і 24 годинами.", "invalid_forecast_days": "Дні прогнозу мають бути від 1 до 5." }, + "abort": { + "already_configured": "Це розташування вже налаштовано.", + "reauth_successful": "Повторна автентифікація виконана успішно.", + "reauth_failed": "Повторна автентифікація не вдалася. Спробуйте ще раз." + } + }, + "options": { "step": { - "reauth_confirm": { - "data": { - "api_key": "Ключ API" - }, - "description": "Ключ API для {latitude},{longitude} більше не дійсний. Введіть новий ключ, щоб відновити оновлення.", - "title": "Повторно автентифікуйте Pollen Levels" - }, - "user": { + "init": { + "title": "Pollen Levels – Параметри", + "description": "Змініть інтервал оновлення, мову відповіді API, кількість днів прогнозу та денні датчики для ТИПІВ для {title}.\nПараметри денних датчиків (ТИПИ): Лише сьогодні (none), До завтра (D+1), До післязавтра (D+1+2).", "data": { - "api_key": "Ключ API", - "language_code": "Код мови відповіді API", - "location": "Місцезнаходження", - "name": "Ім'я", "update_interval": "Інтервал оновлення (у годинах)", - "http_referer": "HTTP Referer", + "language_code": "Код мови відповіді API", "forecast_days": "Дні прогнозу (1–5)", "create_forecast_sensors": "Діапазон денних датчиків (ТИПИ)" - }, - "description": "Введіть свій ключ Google API ([отримайте його тут]({api_key_url})) та ознайомтеся з найкращими практиками ([найкращі практики]({restricting_api_keys_url})). Виберіть місце на карті, інтервал оновлення (години) і код мови відповіді API. Ви також можете налаштувати дні прогнозу та діапазон денних датчиків (ТИПИ).", - "title": "Налаштування рівнів пилку", - "sections": { - "api_key_options": { - "name": "Додаткові параметри ключа API" - } - }, - "data_description": { - "http_referer": "Потрібно лише якщо ваш ключ API має [обмеження веб‑сайту]({restricting_api_keys_url}) (HTTP Referer)." } } + }, + "error": { + "invalid_language_format": "Використовуйте канонічний код BCP-47, наприклад \"en\" або \"es-ES\".", + "empty": "Це поле не може бути порожнім", + "invalid_option_combo": "Збільшіть «Дні прогнозу», щоб охопити вибрані денні датчики.", + "unknown": "Невідома помилка", + "invalid_update_interval": "Інтервал оновлення має бути між 1 і 24 годинами.", + "invalid_forecast_days": "Дні прогнозу мають бути від 1 до 5." } }, "device": { - "info": { - "name": "{title} - Інформація про пилок ({latitude},{longitude})" + "types": { + "name": "{title} - Типи пилку ({latitude},{longitude})" }, "plants": { "name": "{title} - Рослини ({latitude},{longitude})" }, - "types": { - "name": "{title} - Типи пилку ({latitude},{longitude})" + "info": { + "name": "{title} - Інформація про пилок ({latitude},{longitude})" + } + }, + "services": { + "force_update": { + "name": "Примусове оновлення", + "description": "Вручну оновити дані про пилок для всіх налаштованих місць." } }, "entity": { "sensor": { + "region": { + "name": "Регіон" + }, "date": { "name": "Дата" }, "last_updated": { "name": "Останнє оновлення" - }, - "region": { - "name": "Регіон" - } - } - }, - "options": { - "error": { - "empty": "Це поле не може бути порожнім", - "invalid_language_format": "Використовуйте канонічний код BCP-47, наприклад \"en\" або \"es-ES\".", - "invalid_option_combo": "Збільшіть «Дні прогнозу», щоб охопити вибрані денні датчики.", - "unknown": "Невідома помилка", - "invalid_update_interval": "Інтервал оновлення має бути між 1 і 24 годинами.", - "invalid_forecast_days": "Дні прогнозу мають бути від 1 до 5." - }, - "step": { - "init": { - "data": { - "create_forecast_sensors": "Діапазон денних датчиків (ТИПИ)", - "forecast_days": "Дні прогнозу (1–5)", - "language_code": "Код мови відповіді API", - "update_interval": "Інтервал оновлення (у годинах)" - }, - "description": "Змініть інтервал оновлення, мову відповіді API, кількість днів прогнозу та денні датчики для ТИПІВ для {title}.\nПараметри денних датчиків (ТИПИ): Лише сьогодні (none), До завтра (D+1), До післязавтра (D+1+2).", - "title": "Pollen Levels – Параметри" } } - }, - "services": { - "force_update": { - "description": "Вручну оновити дані про пилок для всіх налаштованих місць.", - "name": "Примусове оновлення" - } } } diff --git a/custom_components/pollenlevels/translations/zh-Hans.json b/custom_components/pollenlevels/translations/zh-Hans.json index 0d19ec81..27d34459 100644 --- a/custom_components/pollenlevels/translations/zh-Hans.json +++ b/custom_components/pollenlevels/translations/zh-Hans.json @@ -1,105 +1,95 @@ { "config": { - "abort": { - "already_configured": "该位置已配置。", - "reauth_failed": "重新验证失败。请重试。", - "reauth_successful": "重新验证已成功完成。" + "step": { + "user": { + "title": "花粉水平配置", + "description": "输入你的 Google API 密钥([在此获取]({api_key_url}))并查看最佳实践([最佳实践]({restricting_api_keys_url})). 在地图上选择位置、更新间隔(小时)以及 API 响应的语言代码。 你还可以设置预测天数以及逐日类型传感器的范围。", + "data": { + "api_key": "API 密钥", + "name": "名称", + "location": "位置", + "update_interval": "更新间隔(小时)", + "language_code": "API 响应语言代码", + "forecast_days": "预测天数(1–5)", + "create_forecast_sensors": "逐日类型传感器范围" + } + }, + "reauth_confirm": { + "title": "重新验证 Pollen Levels", + "description": "{latitude},{longitude} 的 API 密钥已失效。请输入新的密钥以恢复更新。", + "data": { + "api_key": "API 密钥" + } + } }, "error": { - "cannot_connect": "无法连接到服务\n\n{error_message}", - "empty": "此字段不能为空", "invalid_auth": "无效的 API 密钥\n\n{error_message}", - "invalid_coordinates": "请在地图上选择有效的位置。", - "invalid_http_referrer": "HTTP Referer 的值无效。它不能包含换行符。", + "cannot_connect": "无法连接到服务\n\n{error_message}", + "quota_exceeded": "配额已用尽\n\n{error_message}", "invalid_language_format": "请使用规范的 BCP-47 代码,例如 \"en\" 或 \"es-ES\"。", + "empty": "此字段不能为空", "invalid_option_combo": "请增加“预测天数”,以覆盖所选的逐日类型传感器。", - "quota_exceeded": "配额已用尽\n\n{error_message}", + "invalid_coordinates": "请在地图上选择有效的位置。", "unknown": "未知错误", "invalid_update_interval": "更新间隔必须在 1 到 24 小时之间。", "invalid_forecast_days": "预测天数必须在 1 到 5 之间。" }, + "abort": { + "already_configured": "该位置已配置。", + "reauth_successful": "重新验证已成功完成。", + "reauth_failed": "重新验证失败。请重试。" + } + }, + "options": { "step": { - "reauth_confirm": { - "data": { - "api_key": "API 密钥" - }, - "description": "{latitude},{longitude} 的 API 密钥已失效。请输入新的密钥以恢复更新。", - "title": "重新验证 Pollen Levels" - }, - "user": { + "init": { + "title": "Pollen Levels – 选项", + "description": "修改更新间隔、API 语言、预测天以及逐日类型传感器,适用于 {title}。\n逐日类型传感器选项:仅今日(none)、至明日(D+1)、至后日(D+1+2)。", "data": { - "api_key": "API 密钥", - "language_code": "API 响应语言代码", - "location": "位置", - "name": "名称", "update_interval": "更新间隔(小时)", - "http_referer": "HTTP Referer", + "language_code": "API 响应语言代码", "forecast_days": "预测天数(1–5)", "create_forecast_sensors": "逐日类型传感器范围" - }, - "description": "输入你的 Google API 密钥([在此获取]({api_key_url}))并查看最佳实践([最佳实践]({restricting_api_keys_url})). 在地图上选择位置、更新间隔(小时)以及 API 响应的语言代码。 你还可以设置预测天数以及逐日类型传感器的范围。", - "title": "花粉水平配置", - "sections": { - "api_key_options": { - "name": "可选的 API 密钥选项" - } - }, - "data_description": { - "http_referer": "仅在你的 API 密钥启用了[网站限制]({restricting_api_keys_url})(HTTP Referer)时需要。" } } + }, + "error": { + "invalid_language_format": "请使用规范的 BCP-47 代码,例如 \"en\" 或 \"es-ES\"。", + "empty": "此字段不能为空", + "invalid_option_combo": "请增加“预测天数”,以覆盖所选的逐日类型传感器。", + "unknown": "未知错误", + "invalid_update_interval": "更新间隔必须在 1 到 24 小时之间。", + "invalid_forecast_days": "预测天数必须在 1 到 5 之间。" } }, "device": { - "info": { - "name": "{title} - 花粉信息 ({latitude},{longitude})" + "types": { + "name": "{title} - 花粉类型 ({latitude},{longitude})" }, "plants": { "name": "{title} - 植物 ({latitude},{longitude})" }, - "types": { - "name": "{title} - 花粉类型 ({latitude},{longitude})" + "info": { + "name": "{title} - 花粉信息 ({latitude},{longitude})" + } + }, + "services": { + "force_update": { + "name": "强制更新", + "description": "为所有已配置的位置手动刷新花粉数据。" } }, "entity": { "sensor": { + "region": { + "name": "地区" + }, "date": { "name": "日期" }, "last_updated": { "name": "上次更新时间" - }, - "region": { - "name": "地区" - } - } - }, - "options": { - "error": { - "empty": "此字段不能为空", - "invalid_language_format": "请使用规范的 BCP-47 代码,例如 \"en\" 或 \"es-ES\"。", - "invalid_option_combo": "请增加“预测天数”,以覆盖所选的逐日类型传感器。", - "unknown": "未知错误", - "invalid_update_interval": "更新间隔必须在 1 到 24 小时之间。", - "invalid_forecast_days": "预测天数必须在 1 到 5 之间。" - }, - "step": { - "init": { - "data": { - "create_forecast_sensors": "逐日类型传感器范围", - "forecast_days": "预测天数(1–5)", - "language_code": "API 响应语言代码", - "update_interval": "更新间隔(小时)" - }, - "description": "修改更新间隔、API 语言、预测天以及逐日类型传感器,适用于 {title}。\n逐日类型传感器选项:仅今日(none)、至明日(D+1)、至后日(D+1+2)。", - "title": "Pollen Levels – 选项" } } - }, - "services": { - "force_update": { - "description": "为所有已配置的位置手动刷新花粉数据。", - "name": "强制更新" - } } } diff --git a/custom_components/pollenlevels/translations/zh-Hant.json b/custom_components/pollenlevels/translations/zh-Hant.json index f68ab221..3a0be116 100644 --- a/custom_components/pollenlevels/translations/zh-Hant.json +++ b/custom_components/pollenlevels/translations/zh-Hant.json @@ -1,105 +1,95 @@ { "config": { - "abort": { - "already_configured": "此位置已設定。", - "reauth_failed": "重新驗證失敗。請再試一次。", - "reauth_successful": "重新驗證已成功完成。" + "step": { + "user": { + "title": "花粉水平設定", + "description": "輸入你的 Google API 金鑰([在此取得]({api_key_url})) 並查看最佳實務([最佳實務]({restricting_api_keys_url})). 在地圖上選擇位置、更新間隔(小時)以及 API 回應的語言代碼。 你也可以設定預測天數與逐日類型感測器的範圍。", + "data": { + "api_key": "API 金鑰", + "name": "名稱", + "location": "位置", + "update_interval": "更新間隔(小時)", + "language_code": "API 回應語言代碼", + "forecast_days": "預測天數(1–5)", + "create_forecast_sensors": "逐日類型感測器範圍" + } + }, + "reauth_confirm": { + "title": "重新驗證 Pollen Levels", + "description": "{latitude},{longitude} 的 API 金鑰已失效。請輸入新的金鑰以恢復更新。", + "data": { + "api_key": "API 金鑰" + } + } }, "error": { - "cannot_connect": "無法連線到服務\n\n{error_message}", - "empty": "此欄位不得為空", "invalid_auth": "無效的 API 金鑰\n\n{error_message}", - "invalid_coordinates": "請在地圖上選擇有效的位置。", - "invalid_http_referrer": "HTTP Referer 的值無效。不得包含換行符。", + "cannot_connect": "無法連線到服務\n\n{error_message}", + "quota_exceeded": "超出配額\n\n{error_message}", "invalid_language_format": "請使用標準的 BCP-47 代碼,例如 \"en\" 或 \"es-ES\"。", + "empty": "此欄位不得為空", "invalid_option_combo": "請增加「預測天數」以涵蓋所選的逐日類型感測器。", - "quota_exceeded": "超出配額\n\n{error_message}", + "invalid_coordinates": "請在地圖上選擇有效的位置。", "unknown": "未知錯誤", "invalid_update_interval": "更新間隔必須在 1 到 24 小時之間。", "invalid_forecast_days": "預測天數必須在 1 到 5 之間。" }, + "abort": { + "already_configured": "此位置已設定。", + "reauth_successful": "重新驗證已成功完成。", + "reauth_failed": "重新驗證失敗。請再試一次。" + } + }, + "options": { "step": { - "reauth_confirm": { - "data": { - "api_key": "API 金鑰" - }, - "description": "{latitude},{longitude} 的 API 金鑰已失效。請輸入新的金鑰以恢復更新。", - "title": "重新驗證 Pollen Levels" - }, - "user": { + "init": { + "title": "Pollen Levels – 選項", + "description": "修改更新間隔、API 語言、預測天數與逐日類型感測器,適用於 {title}。\n逐日類型感測器選項:僅今日(none)、至明日(D+1)、至後日(D+1+2)。", "data": { - "api_key": "API 金鑰", - "language_code": "API 回應語言代碼", - "location": "位置", - "name": "名稱", "update_interval": "更新間隔(小時)", - "http_referer": "HTTP Referer", + "language_code": "API 回應語言代碼", "forecast_days": "預測天數(1–5)", "create_forecast_sensors": "逐日類型感測器範圍" - }, - "description": "輸入你的 Google API 金鑰([在此取得]({api_key_url})) 並查看最佳實務([最佳實務]({restricting_api_keys_url})). 在地圖上選擇位置、更新間隔(小時)以及 API 回應的語言代碼。 你也可以設定預測天數與逐日類型感測器的範圍。", - "title": "花粉水平設定", - "sections": { - "api_key_options": { - "name": "可選的 API 金鑰選項" - } - }, - "data_description": { - "http_referer": "僅在你的 API 金鑰啟用了[網站限制]({restricting_api_keys_url})(HTTP Referer)時需要。" } } + }, + "error": { + "invalid_language_format": "請使用標準的 BCP-47 代碼,例如 \"en\" 或 \"es-ES\"。", + "empty": "此欄位不得為空", + "invalid_option_combo": "請增加「預測天數」以涵蓋所選的逐日類型感測器。", + "unknown": "未知錯誤", + "invalid_update_interval": "更新間隔必須在 1 到 24 小時之間。", + "invalid_forecast_days": "預測天數必須在 1 到 5 之間。" } }, "device": { - "info": { - "name": "{title} - 花粉資訊 ({latitude},{longitude})" + "types": { + "name": "{title} - 花粉類型 ({latitude},{longitude})" }, "plants": { "name": "{title} - 植物 ({latitude},{longitude})" }, - "types": { - "name": "{title} - 花粉類型 ({latitude},{longitude})" + "info": { + "name": "{title} - 花粉資訊 ({latitude},{longitude})" + } + }, + "services": { + "force_update": { + "name": "強制更新", + "description": "為所有已設定的位置手動重新整理花粉資料。" } }, "entity": { "sensor": { + "region": { + "name": "地區" + }, "date": { "name": "日期" }, "last_updated": { "name": "上次更新時間" - }, - "region": { - "name": "地區" - } - } - }, - "options": { - "error": { - "empty": "此欄位不得為空", - "invalid_language_format": "請使用標準的 BCP-47 代碼,例如 \"en\" 或 \"es-ES\"。", - "invalid_option_combo": "請增加「預測天數」以涵蓋所選的逐日類型感測器。", - "unknown": "未知錯誤", - "invalid_update_interval": "更新間隔必須在 1 到 24 小時之間。", - "invalid_forecast_days": "預測天數必須在 1 到 5 之間。" - }, - "step": { - "init": { - "data": { - "create_forecast_sensors": "逐日類型感測器範圍", - "forecast_days": "預測天數(1–5)", - "language_code": "API 回應語言代碼", - "update_interval": "更新間隔(小時)" - }, - "description": "修改更新間隔、API 語言、預測天數與逐日類型感測器,適用於 {title}。\n逐日類型感測器選項:僅今日(none)、至明日(D+1)、至後日(D+1+2)。", - "title": "Pollen Levels – 選項" } } - }, - "services": { - "force_update": { - "description": "為所有已設定的位置手動重新整理花粉資料。", - "name": "強制更新" - } } } diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 7ed25245..0af91b9d 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -9,7 +9,6 @@ import sys from pathlib import Path from types import ModuleType, SimpleNamespace -from typing import Any import pytest @@ -47,22 +46,6 @@ def _force_module(name: str, module: ModuleType) -> None: config_entries_mod = ModuleType("homeassistant.config_entries") -data_entry_flow_mod = ModuleType("homeassistant.data_entry_flow") - - -class _SectionConfig: - def __init__(self, collapsed: bool | None = None): - self.collapsed = collapsed - - -def section(key: str, config: _SectionConfig): # noqa: ARG001 - return key - - -data_entry_flow_mod.SectionConfig = _SectionConfig -data_entry_flow_mod.section = section -_force_module("homeassistant.data_entry_flow", data_entry_flow_mod) - class _StubConfigFlow: def __init_subclass__(cls, **_kwargs): @@ -145,7 +128,6 @@ def _longitude(value=None): config_validation_mod.latitude = _latitude config_validation_mod.longitude = _longitude config_validation_mod.string = lambda value=None: value -config_validation_mod.custom_serializer = lambda *args, **kwargs: None _force_module("homeassistant.helpers.config_validation", config_validation_mod) aiohttp_client_mod = ModuleType("homeassistant.helpers.aiohttp_client") @@ -274,7 +256,6 @@ def __init__(self, config: _SelectSelectorConfig): ha_mod.helpers = helpers_mod ha_mod.config_entries = config_entries_mod -ha_mod.data_entry_flow = data_entry_flow_mod aiohttp_mod = ModuleType("aiohttp") @@ -316,10 +297,6 @@ def __init__(self, schema): vol_mod.In = lambda *args, **kwargs: None _force_module("voluptuous", vol_mod) -voluptuous_serialize_mod = ModuleType("voluptuous_serialize") -voluptuous_serialize_mod.convert = lambda *args, **kwargs: {} -_force_module("voluptuous_serialize", voluptuous_serialize_mod) - from homeassistant.const import ( CONF_LATITUDE, CONF_LOCATION, @@ -336,7 +313,6 @@ def __init__(self, schema): CONF_API_KEY, CONF_CREATE_FORECAST_SENSORS, CONF_FORECAST_DAYS, - CONF_HTTP_REFERER, CONF_LANGUAGE_CODE, CONF_UPDATE_INTERVAL, DEFAULT_ENTRY_TITLE, @@ -346,7 +322,6 @@ def __init__(self, schema): MAX_FORECAST_DAYS, MAX_UPDATE_INTERVAL_HOURS, MIN_FORECAST_DAYS, - normalize_http_referer, ) @@ -592,42 +567,6 @@ def _base_user_input() -> dict: } -def test_async_step_user_persists_http_referer() -> None: - """HTTP referrer should be trimmed and persisted when provided.""" - - flow = PollenLevelsConfigFlow() - flow.hass = SimpleNamespace( - config=SimpleNamespace(latitude=1.0, longitude=2.0, language="en") - ) - - async def fake_validate( - user_input, *, check_unique_id, description_placeholders=None - ): - http_referer = normalize_http_referer(user_input.get(CONF_HTTP_REFERER)) - assert http_referer == "https://example.com" - normalized = { - CONF_API_KEY: user_input[CONF_API_KEY], - CONF_LATITUDE: 1.0, - CONF_LONGITUDE: 2.0, - CONF_LANGUAGE_CODE: "en", - CONF_HTTP_REFERER: http_referer, - } - return {}, normalized - - flow._async_validate_input = fake_validate # type: ignore[assignment] - - user_input = { - **_base_user_input(), - CONF_UPDATE_INTERVAL: 6, - CONF_LANGUAGE_CODE: "en", - CONF_HTTP_REFERER: " https://example.com ", - } - - result = asyncio.run(flow.async_step_user(user_input)) - - assert result["data"][CONF_HTTP_REFERER] == "https://example.com" - - @pytest.mark.parametrize( ("raw_value", "expected"), [ @@ -657,7 +596,7 @@ def _capture_optional(key, **kwargs): ) cf._build_step_user_schema(hass, {CONF_UPDATE_INTERVAL: raw_value}) - assert captured_defaults == [expected, expected] + assert captured_defaults == [expected] @pytest.mark.parametrize( @@ -689,7 +628,7 @@ def _capture_optional(key, **kwargs): ) cf._build_step_user_schema(hass, {CONF_FORECAST_DAYS: raw_value}) - assert captured_defaults == [expected, expected] + assert captured_defaults == [expected] def test_setup_schema_sensor_mode_default_is_sanitized( @@ -711,93 +650,7 @@ def _capture_optional(key, **kwargs): ) cf._build_step_user_schema(hass, {CONF_CREATE_FORECAST_SENSORS: "bad"}) - assert captured_defaults == [FORECAST_SENSORS_CHOICES[0]] * 2 - - -def test_async_step_user_drops_blank_http_referer() -> None: - """Blank HTTP referrer values should not be persisted.""" - - flow = PollenLevelsConfigFlow() - flow.hass = SimpleNamespace( - config=SimpleNamespace(latitude=1.0, longitude=2.0, language="en") - ) - - async def fake_validate( - user_input, *, check_unique_id, description_placeholders=None - ): - http_referer = normalize_http_referer(user_input.get(CONF_HTTP_REFERER)) - assert http_referer is None - normalized = { - CONF_API_KEY: user_input[CONF_API_KEY], - CONF_LATITUDE: 1.0, - CONF_LONGITUDE: 2.0, - CONF_LANGUAGE_CODE: "en", - } - return {}, normalized - - flow._async_validate_input = fake_validate # type: ignore[assignment] - - user_input = { - **_base_user_input(), - CONF_UPDATE_INTERVAL: 6, - CONF_LANGUAGE_CODE: "en", - CONF_HTTP_REFERER: " ", - } - - result = asyncio.run(flow.async_step_user(user_input)) - - assert CONF_HTTP_REFERER not in result["data"] - - -def test_async_step_user_invalid_http_referer_sets_field_error() -> None: - """Newlines in HTTP referrer should surface a field-level error.""" - - flow = PollenLevelsConfigFlow() - flow.hass = SimpleNamespace( - config=SimpleNamespace(latitude=1.0, longitude=2.0, language="en") - ) - - captured: dict[str, Any] = {} - - def fake_show_form(*args, **kwargs): - captured.update(kwargs) - return kwargs - - flow.async_show_form = fake_show_form # type: ignore[assignment] - - user_input = { - **_base_user_input(), - CONF_UPDATE_INTERVAL: 6, - CONF_LANGUAGE_CODE: "en", - CONF_HTTP_REFERER: "http://example.com/\npath", - } - - asyncio.run(flow.async_step_user(user_input)) - - assert captured.get("errors") == {CONF_HTTP_REFERER: "invalid_http_referrer"} - - -def test_validate_input_sends_referer_header(monkeypatch: pytest.MonkeyPatch) -> None: - """Validation should forward the Referer header when provided.""" - - session = _patch_client_session( - monkeypatch, _StubResponse(200, b'{"dailyInfo": [{"indexInfo": []}]}') - ) - - flow = PollenLevelsConfigFlow() - flow.hass = SimpleNamespace() - - user_input = {**_base_user_input(), CONF_HTTP_REFERER: "https://example.com"} - - errors, normalized = asyncio.run( - flow._async_validate_input(user_input, check_unique_id=False) - ) - - assert errors == {} - assert normalized is not None - assert session.calls - _, kwargs = session.calls[0] - assert kwargs.get("headers") == {"Referer": "https://example.com"} + assert captured_defaults == [FORECAST_SENSORS_CHOICES[0]] def test_validate_input_update_interval_below_min_sets_error( diff --git a/tests/test_init.py b/tests/test_init.py index 0b0256e2..60133d69 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -265,6 +265,8 @@ async def async_unload_platforms(self, entry, platforms): return self._unload_result def async_update_entry(self, entry, **kwargs): + if "data" in kwargs: + entry.data = kwargs["data"] if "options" in kwargs: entry.options = kwargs["options"] if "version" in kwargs: @@ -385,10 +387,9 @@ def test_setup_entry_success_and_unload() -> None: entry = _FakeEntry() class _StubClient: - def __init__(self, _session, _api_key, _http_referer=None): + def __init__(self, _session, _api_key): self.session = _session self.api_key = _api_key - self.http_referer = _http_referer async def async_fetch_pollen_data(self, **_kwargs): return {"region": {"source": "meta"}, "dailyInfo": []} @@ -475,15 +476,18 @@ def test_migrate_entry_moves_mode_to_options() -> None: integration.CONF_LATITUDE: 1.0, integration.CONF_LONGITUDE: 2.0, integration.CONF_CREATE_FORECAST_SENSORS: "D+1", + "http_referer": "https://legacy.example.com", }, - options={}, + options={"http_referer": "https://legacy.example.com"}, version=1, ) hass = _FakeHass(entries=[entry]) assert asyncio.run(integration.async_migrate_entry(hass, entry)) is True assert entry.options[integration.CONF_CREATE_FORECAST_SENSORS] == "D+1" - assert entry.version == 2 + assert "http_referer" not in entry.data + assert "http_referer" not in entry.options + assert entry.version == 3 def test_migrate_entry_normalizes_invalid_mode() -> None: @@ -505,7 +509,7 @@ def test_migrate_entry_normalizes_invalid_mode() -> None: entry.options[integration.CONF_CREATE_FORECAST_SENSORS] == const.FORECAST_SENSORS_CHOICES[0] ) - assert entry.version == 2 + assert entry.version == 3 def test_migrate_entry_normalizes_invalid_mode_in_options() -> None: @@ -522,7 +526,7 @@ def test_migrate_entry_normalizes_invalid_mode_in_options() -> None: entry.options[integration.CONF_CREATE_FORECAST_SENSORS] == const.FORECAST_SENSORS_CHOICES[0] ) - assert entry.version == 2 + assert entry.version == 3 def test_migrate_entry_marks_version_when_no_changes() -> None: @@ -534,7 +538,7 @@ def test_migrate_entry_marks_version_when_no_changes() -> None: hass = _FakeHass(entries=[entry]) assert asyncio.run(integration.async_migrate_entry(hass, entry)) is True - assert entry.version == 2 + assert entry.version == 3 @pytest.mark.parametrize("version", [None, "x"]) @@ -544,4 +548,4 @@ def test_migrate_entry_handles_non_int_version(version: object) -> None: hass = _FakeHass(entries=[entry]) assert asyncio.run(integration.async_migrate_entry(hass, entry)) is True - assert entry.version == 2 + assert entry.version == 3 From 362b3034d0d7babdc7cf2e02b74665940c8ed367 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Tue, 23 Dec 2025 19:12:34 +0100 Subject: [PATCH 145/200] Remove forecast sensor mode from entry data --- custom_components/pollenlevels/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/pollenlevels/__init__.py b/custom_components/pollenlevels/__init__.py index 83947e96..8173f2c2 100644 --- a/custom_components/pollenlevels/__init__.py +++ b/custom_components/pollenlevels/__init__.py @@ -67,7 +67,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: new_options = dict(entry.options) mode = new_options.get(CONF_CREATE_FORECAST_SENSORS) if mode is None: - mode = entry.data.get(CONF_CREATE_FORECAST_SENSORS) + mode = new_data.pop(CONF_CREATE_FORECAST_SENSORS, None) if mode is not None: normalized_mode = normalize_sensor_mode(mode, _LOGGER) From 7973d8095b05ab4efd1cdecf6dbcff5f4ea5e4ac Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Wed, 24 Dec 2025 08:31:38 +0100 Subject: [PATCH 146/200] Make migration cleanup idempotent --- custom_components/pollenlevels/__init__.py | 12 +++++++--- .../pollenlevels/translations/zh-Hans.json | 2 +- .../pollenlevels/translations/zh-Hant.json | 2 +- tests/test_init.py | 23 +++++++++++++++++++ 4 files changed, 34 insertions(+), 5 deletions(-) diff --git a/custom_components/pollenlevels/__init__.py b/custom_components/pollenlevels/__init__.py index 8173f2c2..48f4eae6 100644 --- a/custom_components/pollenlevels/__init__.py +++ b/custom_components/pollenlevels/__init__.py @@ -60,14 +60,21 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: current_version = ( current_version_raw if isinstance(current_version_raw, int) else 1 ) - if current_version >= target_version: + legacy_key = "http_referer" + if ( + current_version >= target_version + and legacy_key not in entry.data + and legacy_key not in entry.options + and CONF_CREATE_FORECAST_SENSORS not in entry.data + ): return True new_data = dict(entry.data) new_options = dict(entry.options) mode = new_options.get(CONF_CREATE_FORECAST_SENSORS) if mode is None: - mode = new_data.pop(CONF_CREATE_FORECAST_SENSORS, None) + mode = new_data.get(CONF_CREATE_FORECAST_SENSORS) + new_data.pop(CONF_CREATE_FORECAST_SENSORS, None) if mode is not None: normalized_mode = normalize_sensor_mode(mode, _LOGGER) @@ -76,7 +83,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: elif CONF_CREATE_FORECAST_SENSORS in new_options: new_options.pop(CONF_CREATE_FORECAST_SENSORS) - legacy_key = "http_referer" new_data.pop(legacy_key, None) new_options.pop(legacy_key, None) diff --git a/custom_components/pollenlevels/translations/zh-Hans.json b/custom_components/pollenlevels/translations/zh-Hans.json index 27d34459..f4c6ff55 100644 --- a/custom_components/pollenlevels/translations/zh-Hans.json +++ b/custom_components/pollenlevels/translations/zh-Hans.json @@ -3,7 +3,7 @@ "step": { "user": { "title": "花粉水平配置", - "description": "输入你的 Google API 密钥([在此获取]({api_key_url}))并查看最佳实践([最佳实践]({restricting_api_keys_url})). 在地图上选择位置、更新间隔(小时)以及 API 响应的语言代码。 你还可以设置预测天数以及逐日类型传感器的范围。", + "description": "输入你的 Google API 密钥([在此获取]({api_key_url})并查看最佳实践([最佳实践]({restricting_api_keys_url}))。在地图上选择位置、更新间隔(小时)以及 API 响应的语言代码。你还可以设置预测天数以及逐日类型传感器的范围。", "data": { "api_key": "API 密钥", "name": "名称", diff --git a/custom_components/pollenlevels/translations/zh-Hant.json b/custom_components/pollenlevels/translations/zh-Hant.json index 3a0be116..57fe69b2 100644 --- a/custom_components/pollenlevels/translations/zh-Hant.json +++ b/custom_components/pollenlevels/translations/zh-Hant.json @@ -3,7 +3,7 @@ "step": { "user": { "title": "花粉水平設定", - "description": "輸入你的 Google API 金鑰([在此取得]({api_key_url})) 並查看最佳實務([最佳實務]({restricting_api_keys_url})). 在地圖上選擇位置、更新間隔(小時)以及 API 回應的語言代碼。 你也可以設定預測天數與逐日類型感測器的範圍。", + "description": "輸入你的 Google API 金鑰([在此取得]({api_key_url})並查看最佳實務([最佳實務]({restricting_api_keys_url}))。在地圖上選擇位置、更新間隔(小時)以及 API 回應的語言代碼。你也可以設定預測天數與逐日類型感測器的範圍。", "data": { "api_key": "API 金鑰", "name": "名稱", diff --git a/tests/test_init.py b/tests/test_init.py index 60133d69..25571806 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -485,6 +485,7 @@ def test_migrate_entry_moves_mode_to_options() -> None: assert asyncio.run(integration.async_migrate_entry(hass, entry)) is True assert entry.options[integration.CONF_CREATE_FORECAST_SENSORS] == "D+1" + assert integration.CONF_CREATE_FORECAST_SENSORS not in entry.data assert "http_referer" not in entry.data assert "http_referer" not in entry.options assert entry.version == 3 @@ -541,6 +542,28 @@ def test_migrate_entry_marks_version_when_no_changes() -> None: assert entry.version == 3 +def test_migrate_entry_cleans_legacy_keys_when_version_current() -> None: + """Migration should remove legacy keys even if already at target version.""" + entry = _FakeEntry( + data={ + integration.CONF_API_KEY: "key", + integration.CONF_LATITUDE: 1.0, + integration.CONF_LONGITUDE: 2.0, + integration.CONF_CREATE_FORECAST_SENSORS: "D+1", + "http_referer": "https://legacy.example.com", + }, + options={"http_referer": "https://legacy.example.com"}, + version=integration.TARGET_ENTRY_VERSION, + ) + hass = _FakeHass(entries=[entry]) + + assert asyncio.run(integration.async_migrate_entry(hass, entry)) is True + assert "http_referer" not in entry.data + assert "http_referer" not in entry.options + assert integration.CONF_CREATE_FORECAST_SENSORS not in entry.data + assert entry.version == integration.TARGET_ENTRY_VERSION + + @pytest.mark.parametrize("version", [None, "x"]) def test_migrate_entry_handles_non_int_version(version: object) -> None: """Migration should normalize non-integer versions before bumping.""" From be976700b2fe9a6431c2b419275c918e0480cbf5 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Wed, 24 Dec 2025 08:31:44 +0100 Subject: [PATCH 147/200] Update changelog for migration cleanup --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e32109d..efca1d16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,12 @@ # Changelog +## [1.9.0-alpha6] - 2025-12-31 +### Fixed +- Ensured migrations clean up legacy keys even when entries are already at the + target version. +- Always removed per-day sensor mode from entry data during migration to avoid + duplicated settings. +- Corrected Chinese setup description punctuation in zh-Hans and zh-Hant. + ## [1.9.0-alpha5] - 2025-12-30 ### Removed - Removed optional HTTP Referer (website restriction) support and related config UI. From 950091e28f3f52dcd3a3a638e56e5e9c2d9d48cc Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Wed, 24 Dec 2025 08:44:16 +0100 Subject: [PATCH 148/200] Release 1.9.0-rc1 changelog --- CHANGELOG.md | 168 +++++++------------ custom_components/pollenlevels/manifest.json | 2 +- pyproject.toml | 2 +- 3 files changed, 66 insertions(+), 106 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index efca1d16..efb78a7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,18 +1,61 @@ # Changelog -## [1.9.0-alpha6] - 2025-12-31 -### Fixed -- Ensured migrations clean up legacy keys even when entries are already at the - target version. -- Always removed per-day sensor mode from entry data during migration to avoid - duplicated settings. -- Corrected Chinese setup description punctuation in zh-Hans and zh-Hant. - -## [1.9.0-alpha5] - 2025-12-30 -### Removed -- Removed optional HTTP Referer (website restriction) support and related config UI. +## [1.9.0-rc1] - 2025-12-31 +### Changed +- Moved runtime state to config entry `runtime_data` with a shared + `GooglePollenApiClient` per entry while keeping existing sensor behaviour and + identifiers unchanged. +- Updated sensors, diagnostics, and the `pollenlevels.force_update` service to + read coordinators from runtime data so each entry reuses a single API client + for Google Pollen requests. +- Treated HTTP 401 responses like 403 to surface `invalid_auth` during setup + validation and runtime calls instead of generic connection errors. +- Restored API key validation during setup to raise `ConfigEntryAuthFailed` + when the key is missing instead of retrying endlessly. +- Centralized config entry title normalization during setup so the cleaned + device titles are reused across all sensors. +- Simplified metadata sensors by relying on inherited `unique_id` and + `device_info` properties instead of redefining them. +- Updated the `force_update` service to queue coordinator refreshes via + `async_request_refresh` and added service coverage for entries lacking + runtime data. +- Cleared config entry `runtime_data` after unload to drop stale coordinator + references and keep teardown tidy. +- Enabled forecast day count and per-day sensor mode selection during initial + setup using dropdown selectors shared with the options flow to keep + validation consistent. +- Enhanced setup validation to surface HTTP 401/403 API messages safely via the + form error placeholders without exposing secrets. +- Updated the options flow to use selectors while normalizing numeric fields to + integers and keeping existing validation rules and defaults intact. +- Runtime HTTP 403 responses now surface detailed messages without triggering + reauthentication, keeping setup and update behavior aligned. +- Deduplicated HTTP error message extraction into a shared helper used by + config validation and the runtime client to keep diagnostics consistent. +- Updated the options flow regression test to expect the new + `invalid_forecast_days` error code for out-of-range values. +- Consolidated numeric options validation in the options flow through a shared + helper to reduce duplication for interval and forecast day checks. +- Centralized the pollen client retry count into a shared `MAX_RETRIES` + constant to simplify future tuning without touching request logic. +- Tightened error extraction typing to expect `aiohttp.ClientResponse` while + guarding the import so environments without aiohttp can still run tests. +- Reduced debug log volume by summarizing coordinator refreshes and sensor + creation details instead of logging full payloads. +- Reformatted the codebase with Black and Ruff to keep imports and styling + consistent with repository standards. +- Expanded translation coverage tests to include section titles and service + metadata keys, ensuring locales stay aligned with `en.json`. +- Coordinator Module Extraction: The PollenDataUpdateCoordinator class and its + associated helper functions have been moved from sensor.py to a new, + dedicated coordinator.py module. This significantly improves modularity and + separation of concerns within the component. +- Documented the 1–24 update interval range in the README options list. -## [1.9.0-alpha4] - 2025-12-23 ### Fixed +- Avoid pre-filling the API key field when the form is re-displayed after + validation errors. +- Added a fallback error message when unexpected client exceptions are raised to + avoid empty UpdateFailed errors in the UI. - Fixed options flow to preserve the stored per-day sensor mode when no override is set in entry options, preventing unintended resets to "none". - Sanitized update interval defaults in setup and options forms to clamp @@ -28,16 +71,14 @@ prevent option resets after upgrades. - Centralized per-day sensor mode normalization to avoid duplicate validation logic across migration and options handling. -- Normalized invalid per-day sensor mode values already stored in entry options - during migration to keep options consistent. -- Versioned config entries to ensure the per-day sensor mode migration runs - once and is not repeated on every restart. +- Normalized invalid stored per-day sensor mode values already stored in entry + options during migration to keep options consistent. +- Versioned config entries to ensure the per-day sensor mode migration runs once + and is not repeated on every restart. - Ensured unversioned entries run the per-day sensor mode migration and that option presence is respected even when the stored value is None. - Moved the optional API key section directly below the API key field in the setup flow for improved visibility. -- Restored the HTTP Referer field when the setup schema falls back to the flat - layout and updated setup guidance to mention forecast configuration. - Hardened HTTP client timeout handling and normalized non-string per-day sensor mode values defensively. - Added entry context to migration failure logs for easier debugging. @@ -53,96 +94,15 @@ - Hardened numeric parsing to handle non-finite values without crashing setup. - Clamped update interval and forecast days in setup to supported ranges. - Limited update interval to a maximum of 24 hours in setup and options. -- Rejected HTTP Referer values containing whitespace to prevent invalid headers. - Clamped forecast day handling in sensor setup to the supported 1–5 range for consistent cleanup decisions. - Avoided treating empty indexInfo objects as valid forecast indices. - Added force_update service name/description for better UI discoverability. - -### Changed -- Documented the 1–24 update interval range in the README options list. - -## [1.9.0-alpha3] - 2025-12-20 -### Fixed -- Fixed config flow crash (500) caused by invalid section schema serialization; - HTTP Referer is now correctly placed inside a collapsed 'API key options' - section. -- Preserved empty HTTP Referer values when the form re-renders to avoid - accidentally overriding explicit empty input with section defaults. -- Avoid pre-filling the API key field when the form is re-displayed after - validation errors. -- Added a fallback error message when unexpected client exceptions are raised to - avoid empty UpdateFailed errors in the UI. - -## [1.9.0-alpha2] - 2025-12-16 -### Changed -- Enabled forecast day count and per-day sensor mode selection during initial - setup using dropdown selectors shared with the options flow to keep - validation consistent. -- Added stable constants for HTTP referrer support and API key helper URLs to - support upcoming flow updates. -- Modernized the config flow with selectors, API key guidance links, and a - collapsed API key options section including an optional HTTP referrer field - stored flat in entry data. -- Enhanced setup validation to surface HTTP 401/403 API messages safely via the - form error placeholders without exposing secrets. -- Updated the options flow to use selectors while normalizing numeric fields to - integers and keeping existing validation rules and defaults intact. -- Clarified HTTP referrer validation with a dedicated error to avoid confusing - connection-failure messaging when the input contains newline characters. -- Added optional HTTP referrer support that sends a sanitized `Referer` header - when configured while preserving the default behavior for unrestricted keys. -- Runtime HTTP 403 responses now surface detailed messages without triggering - reauthentication, keeping setup and update behavior aligned. -- Deduplicated HTTP error message extraction into a shared helper used by - config validation and the runtime client to keep diagnostics consistent. -- Standardized translation locales for HTTP Referer wording, quota-exceeded - details, and invalid update interval errors to keep UI feedback aligned. -- Simplified HTTP Referer handling during validation/runtime and finalized - locale wording, including corrected Chinese setup descriptions. -- Updated the options flow regression test to expect the new - `invalid_forecast_days` error code for out-of-range values. -- Consolidated numeric options validation in the options flow through a shared - helper to reduce duplication for interval and forecast day checks. -- Centralized the pollen client retry count into a shared `MAX_RETRIES` - constant to simplify future tuning without touching request logic. -- Tightened error extraction typing to expect `aiohttp.ClientResponse` while - guarding the import so environments without aiohttp can still run tests. -- Hardened the runtime pollen client with sanitized `Referer` headers, - normalized error parsing, and strict JSON validation to avoid leaking secrets - while surfacing consistent failures. -- Reduced debug log volume by summarizing coordinator refreshes and sensor - creation details instead of logging full payloads. -- Reformatted the codebase with Black and Ruff to keep imports and styling - consistent with repository standards. -- Expanded translation coverage tests to include section titles and service - metadata keys, ensuring locales stay aligned with `en.json`. -- Coordinator Module Extraction: The PollenDataUpdateCoordinator class and its - associated helper functions have been moved from sensor.py to a new, - dedicated coordinator.py module. This significantly improves modularity and - separation of concerns within the component. - -## [1.9.0-alpha1] - 2025-12-11 -### Changed -- Moved runtime state to config entry `runtime_data` with a shared - `GooglePollenApiClient` per entry while keeping existing sensor behaviour and - identifiers unchanged. -- Updated sensors, diagnostics, and the `pollenlevels.force_update` service to - read coordinators from runtime data so each entry reuses a single API client - for Google Pollen requests. -- Treated HTTP 401 responses like 403 to surface `invalid_auth` during setup - validation and runtime calls instead of generic connection errors. -- Restored API key validation during setup to raise `ConfigEntryAuthFailed` - when the key is missing instead of retrying endlessly. -- Centralized config entry title normalization during setup so the cleaned - device titles are reused across all sensors. -- Simplified metadata sensors by relying on inherited `unique_id` and - `device_info` properties instead of redefining them. -- Updated the `force_update` service to queue coordinator refreshes via - `async_request_refresh` and added service coverage for entries lacking - runtime data. -- Cleared config entry `runtime_data` after unload to drop stale coordinator - references and keep teardown tidy. +- Ensured migrations clean up legacy keys even when entries are already at the + target version. +- Always removed per-day sensor mode from entry data during migration to avoid + duplicated settings. +- Corrected Chinese setup description punctuation in zh-Hans and zh-Hant. ## [1.8.6] - 2025-12-09 ### Changed diff --git a/custom_components/pollenlevels/manifest.json b/custom_components/pollenlevels/manifest.json index 5719155c..22fc930a 100644 --- a/custom_components/pollenlevels/manifest.json +++ b/custom_components/pollenlevels/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "issue_tracker": "https://github.com/eXPerience83/pollenlevels/issues", - "version": "1.9.0-alpha4" + "version": "1.9.0-rc1" } diff --git a/pyproject.toml b/pyproject.toml index 3f1bb569..8e6932c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ [project] name = "pollenlevels" -version = "1.9.0-alpha4" +version = "1.9.0-rc1" # Enforce the runtime floor aligned with upcoming HA Python 3.14 images. requires-python = ">=3.14" From 4b8ba25d2d2fff1713b57ca8acdeb34540a88fe3 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Wed, 24 Dec 2025 09:11:56 +0100 Subject: [PATCH 149/200] Harden migration cleanup and zh links --- custom_components/pollenlevels/__init__.py | 19 +++++++++--------- .../pollenlevels/translations/zh-Hans.json | 2 +- .../pollenlevels/translations/zh-Hant.json | 2 +- tests/test_init.py | 20 +++++++++++++++++++ 4 files changed, 32 insertions(+), 11 deletions(-) diff --git a/custom_components/pollenlevels/__init__.py b/custom_components/pollenlevels/__init__.py index 48f4eae6..0ae9cb2c 100644 --- a/custom_components/pollenlevels/__init__.py +++ b/custom_components/pollenlevels/__init__.py @@ -61,16 +61,16 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: current_version_raw if isinstance(current_version_raw, int) else 1 ) legacy_key = "http_referer" - if ( - current_version >= target_version - and legacy_key not in entry.data - and legacy_key not in entry.options - and CONF_CREATE_FORECAST_SENSORS not in entry.data - ): + cleanup_needed = ( + legacy_key in entry.data + or legacy_key in entry.options + or CONF_CREATE_FORECAST_SENSORS in entry.data + ) + if current_version >= target_version and not cleanup_needed: return True new_data = dict(entry.data) - new_options = dict(entry.options) + new_options = dict(entry.options or {}) mode = new_options.get(CONF_CREATE_FORECAST_SENSORS) if mode is None: mode = new_data.get(CONF_CREATE_FORECAST_SENSORS) @@ -86,12 +86,13 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: new_data.pop(legacy_key, None) new_options.pop(legacy_key, None) + new_version = max(current_version, target_version) if new_data != entry.data or new_options != entry.options: hass.config_entries.async_update_entry( - entry, data=new_data, options=new_options, version=target_version + entry, data=new_data, options=new_options, version=new_version ) else: - hass.config_entries.async_update_entry(entry, version=target_version) + hass.config_entries.async_update_entry(entry, version=new_version) return True except asyncio.CancelledError: raise diff --git a/custom_components/pollenlevels/translations/zh-Hans.json b/custom_components/pollenlevels/translations/zh-Hans.json index f4c6ff55..6ed3935f 100644 --- a/custom_components/pollenlevels/translations/zh-Hans.json +++ b/custom_components/pollenlevels/translations/zh-Hans.json @@ -3,7 +3,7 @@ "step": { "user": { "title": "花粉水平配置", - "description": "输入你的 Google API 密钥([在此获取]({api_key_url})并查看最佳实践([最佳实践]({restricting_api_keys_url}))。在地图上选择位置、更新间隔(小时)以及 API 响应的语言代码。你还可以设置预测天数以及逐日类型传感器的范围。", + "description": "输入你的 Google API 密钥([在此获取]({api_key_url}))并查看最佳实践([最佳实践]({restricting_api_keys_url}))。在地图上选择位置、更新间隔(小时)以及 API 响应的语言代码。你还可以设置预测天数以及逐日类型传感器的范围。", "data": { "api_key": "API 密钥", "name": "名称", diff --git a/custom_components/pollenlevels/translations/zh-Hant.json b/custom_components/pollenlevels/translations/zh-Hant.json index 57fe69b2..948b61f8 100644 --- a/custom_components/pollenlevels/translations/zh-Hant.json +++ b/custom_components/pollenlevels/translations/zh-Hant.json @@ -3,7 +3,7 @@ "step": { "user": { "title": "花粉水平設定", - "description": "輸入你的 Google API 金鑰([在此取得]({api_key_url})並查看最佳實務([最佳實務]({restricting_api_keys_url}))。在地圖上選擇位置、更新間隔(小時)以及 API 回應的語言代碼。你也可以設定預測天數與逐日類型感測器的範圍。", + "description": "輸入你的 Google API 金鑰([在此取得]({api_key_url}))並查看最佳實務([最佳實務]({restricting_api_keys_url}))。在地圖上選擇位置、更新間隔(小時)以及 API 回應的語言代碼。你也可以設定預測天數與逐日類型感測器的範圍。", "data": { "api_key": "API 金鑰", "name": "名稱", diff --git a/tests/test_init.py b/tests/test_init.py index 25571806..171b663e 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -564,6 +564,26 @@ def test_migrate_entry_cleans_legacy_keys_when_version_current() -> None: assert entry.version == integration.TARGET_ENTRY_VERSION +def test_migrate_entry_does_not_downgrade_version() -> None: + """Migration should preserve versions newer than the target.""" + entry = _FakeEntry( + data={ + integration.CONF_API_KEY: "key", + integration.CONF_LATITUDE: 1.0, + integration.CONF_LONGITUDE: 2.0, + "http_referer": "https://legacy.example.com", + }, + options={"http_referer": "https://legacy.example.com"}, + version=integration.TARGET_ENTRY_VERSION + 1, + ) + hass = _FakeHass(entries=[entry]) + + assert asyncio.run(integration.async_migrate_entry(hass, entry)) is True + assert "http_referer" not in entry.data + assert "http_referer" not in entry.options + assert entry.version == integration.TARGET_ENTRY_VERSION + 1 + + @pytest.mark.parametrize("version", [None, "x"]) def test_migrate_entry_handles_non_int_version(version: object) -> None: """Migration should normalize non-integer versions before bumping.""" From 68af8733a9c8f8a4812ad992a07db510f1de32ca Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Wed, 24 Dec 2025 09:45:57 +0100 Subject: [PATCH 150/200] Add migration test for options mode --- tests/test_init.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/test_init.py b/tests/test_init.py index 171b663e..4764138d 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -584,6 +584,25 @@ def test_migrate_entry_does_not_downgrade_version() -> None: assert entry.version == integration.TARGET_ENTRY_VERSION + 1 +def test_migrate_entry_removes_mode_from_data_when_in_options() -> None: + """Migration should remove per-day sensor mode from data when already in options.""" + entry = _FakeEntry( + data={ + integration.CONF_API_KEY: "key", + integration.CONF_LATITUDE: 1.0, + integration.CONF_LONGITUDE: 2.0, + integration.CONF_CREATE_FORECAST_SENSORS: "D+1", + }, + options={integration.CONF_CREATE_FORECAST_SENSORS: "D+1"}, + version=1, + ) + hass = _FakeHass(entries=[entry]) + + assert asyncio.run(integration.async_migrate_entry(hass, entry)) is True + assert integration.CONF_CREATE_FORECAST_SENSORS not in entry.data + assert entry.options[integration.CONF_CREATE_FORECAST_SENSORS] == "D+1" + + @pytest.mark.parametrize("version", [None, "x"]) def test_migrate_entry_handles_non_int_version(version: object) -> None: """Migration should normalize non-integer versions before bumping.""" From 7e9bb4984616fa1d039494f9d999620d3082fae1 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Wed, 24 Dec 2025 09:57:01 +0100 Subject: [PATCH 151/200] Update __init__.py --- custom_components/pollenlevels/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/pollenlevels/__init__.py b/custom_components/pollenlevels/__init__.py index 0ae9cb2c..c51c1d58 100644 --- a/custom_components/pollenlevels/__init__.py +++ b/custom_components/pollenlevels/__init__.py @@ -63,7 +63,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: legacy_key = "http_referer" cleanup_needed = ( legacy_key in entry.data - or legacy_key in entry.options + or legacy_key in (entry.options or {}) or CONF_CREATE_FORECAST_SENSORS in entry.data ) if current_version >= target_version and not cleanup_needed: From 8829005683036f6b2b2209697057941194da8461 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Wed, 24 Dec 2025 13:27:25 +0100 Subject: [PATCH 152/200] Update __init__.py From b0acf82b8a3e5f1f993a216e8b326411c1c55fac Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Wed, 24 Dec 2025 13:40:51 +0100 Subject: [PATCH 153/200] Update __init__.py --- custom_components/pollenlevels/__init__.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/custom_components/pollenlevels/__init__.py b/custom_components/pollenlevels/__init__.py index c51c1d58..4c8113cc 100644 --- a/custom_components/pollenlevels/__init__.py +++ b/custom_components/pollenlevels/__init__.py @@ -61,11 +61,18 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: current_version_raw if isinstance(current_version_raw, int) else 1 ) legacy_key = "http_referer" + existing_options = entry.options or {} cleanup_needed = ( legacy_key in entry.data - or legacy_key in (entry.options or {}) + or legacy_key in existing_options or CONF_CREATE_FORECAST_SENSORS in entry.data ) + if not cleanup_needed and CONF_CREATE_FORECAST_SENSORS in existing_options: + stored_mode = existing_options.get(CONF_CREATE_FORECAST_SENSORS) + stored_mode_raw = getattr(stored_mode, "value", stored_mode) + cleanup_needed = ( + normalize_sensor_mode(stored_mode, _LOGGER) != stored_mode_raw + ) if current_version >= target_version and not cleanup_needed: return True @@ -87,7 +94,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: new_options.pop(legacy_key, None) new_version = max(current_version, target_version) - if new_data != entry.data or new_options != entry.options: + if new_data != entry.data or new_options != existing_options: hass.config_entries.async_update_entry( entry, data=new_data, options=new_options, version=new_version ) From 91ff711e0d79e405b112f3af0113228a975932ee Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Wed, 24 Dec 2025 13:44:02 +0100 Subject: [PATCH 154/200] Update CHANGELOG.md Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index efb78a7b..7ca6cf2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,7 @@ associated helper functions have been moved from sensor.py to a new, dedicated coordinator.py module. This significantly improves modularity and separation of concerns within the component. +- Removed optional HTTP Referer (website restriction) support to simplify configuration, as it is not suitable for server-side integrations. - Documented the 1–24 update interval range in the README options list. ### Fixed From 1c5e5648c01b335038e0ef83c27134983173a690 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Wed, 24 Dec 2025 13:51:31 +0100 Subject: [PATCH 155/200] Update custom_components/pollenlevels/__init__.py Co-authored-by: qodo-code-review[bot] <151058649+qodo-code-review[bot]@users.noreply.github.com> --- custom_components/pollenlevels/__init__.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/custom_components/pollenlevels/__init__.py b/custom_components/pollenlevels/__init__.py index 4c8113cc..a76dea11 100644 --- a/custom_components/pollenlevels/__init__.py +++ b/custom_components/pollenlevels/__init__.py @@ -70,9 +70,10 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if not cleanup_needed and CONF_CREATE_FORECAST_SENSORS in existing_options: stored_mode = existing_options.get(CONF_CREATE_FORECAST_SENSORS) stored_mode_raw = getattr(stored_mode, "value", stored_mode) - cleanup_needed = ( - normalize_sensor_mode(stored_mode, _LOGGER) != stored_mode_raw - ) + if stored_mode_raw is not None: + cleanup_needed = ( + normalize_sensor_mode(stored_mode_raw, _LOGGER) != stored_mode_raw + ) if current_version >= target_version and not cleanup_needed: return True From f10b4efe99086f5bcff216b359db53aa4dee10fa Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Wed, 24 Dec 2025 15:32:39 +0100 Subject: [PATCH 156/200] docs(changelog): clarify http_referer removal migration --- CHANGELOG.md | 4 +++- custom_components/pollenlevels/__init__.py | 22 +++++++++++++--------- tests/test_init.py | 20 ++++++++++++++++++++ 3 files changed, 36 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ca6cf2e..88ac332b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,7 +49,9 @@ associated helper functions have been moved from sensor.py to a new, dedicated coordinator.py module. This significantly improves modularity and separation of concerns within the component. -- Removed optional HTTP Referer (website restriction) support to simplify configuration, as it is not suitable for server-side integrations. +- Removed optional HTTP Referer (website restriction) support to simplify configuration, + as it is not suitable for server-side integrations (existing entries are + migrated to remove legacy `http_referer` values). - Documented the 1–24 update interval range in the README options list. ### Fixed diff --git a/custom_components/pollenlevels/__init__.py b/custom_components/pollenlevels/__init__.py index a76dea11..2b0d1f6d 100644 --- a/custom_components/pollenlevels/__init__.py +++ b/custom_components/pollenlevels/__init__.py @@ -61,41 +61,45 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: current_version_raw if isinstance(current_version_raw, int) else 1 ) legacy_key = "http_referer" + existing_data = entry.data or {} existing_options = entry.options or {} cleanup_needed = ( - legacy_key in entry.data + legacy_key in existing_data or legacy_key in existing_options - or CONF_CREATE_FORECAST_SENSORS in entry.data + or CONF_CREATE_FORECAST_SENSORS in existing_data ) if not cleanup_needed and CONF_CREATE_FORECAST_SENSORS in existing_options: stored_mode = existing_options.get(CONF_CREATE_FORECAST_SENSORS) stored_mode_raw = getattr(stored_mode, "value", stored_mode) if stored_mode_raw is not None: + stored_mode_raw = str(stored_mode_raw) cleanup_needed = ( normalize_sensor_mode(stored_mode_raw, _LOGGER) != stored_mode_raw ) if current_version >= target_version and not cleanup_needed: return True - new_data = dict(entry.data) - new_options = dict(entry.options or {}) + new_data = dict(existing_data) + new_options = dict(existing_options) mode = new_options.get(CONF_CREATE_FORECAST_SENSORS) if mode is None: mode = new_data.get(CONF_CREATE_FORECAST_SENSORS) new_data.pop(CONF_CREATE_FORECAST_SENSORS, None) - if mode is not None: - normalized_mode = normalize_sensor_mode(mode, _LOGGER) + mode_raw = getattr(mode, "value", mode) + if mode_raw is not None: + mode_raw = str(mode_raw) + normalized_mode = normalize_sensor_mode(mode_raw, _LOGGER) if new_options.get(CONF_CREATE_FORECAST_SENSORS) != normalized_mode: new_options[CONF_CREATE_FORECAST_SENSORS] = normalized_mode - elif CONF_CREATE_FORECAST_SENSORS in new_options: - new_options.pop(CONF_CREATE_FORECAST_SENSORS) + else: + new_options.pop(CONF_CREATE_FORECAST_SENSORS, None) new_data.pop(legacy_key, None) new_options.pop(legacy_key, None) new_version = max(current_version, target_version) - if new_data != entry.data or new_options != existing_options: + if new_data != existing_data or new_options != existing_options: hass.config_entries.async_update_entry( entry, data=new_data, options=new_options, version=new_version ) diff --git a/tests/test_init.py b/tests/test_init.py index 4764138d..d1d0807d 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -530,6 +530,26 @@ def test_migrate_entry_normalizes_invalid_mode_in_options() -> None: assert entry.version == 3 +def test_migrate_entry_normalizes_invalid_mode_in_options_when_version_current() -> ( + None +): + """Migration should normalize invalid mode values even at the target version.""" + entry = _FakeEntry( + data={ + integration.CONF_API_KEY: "key", + integration.CONF_LATITUDE: 1.0, + integration.CONF_LONGITUDE: 2.0, + }, + options={integration.CONF_CREATE_FORECAST_SENSORS: "invalid-value"}, + version=integration.TARGET_ENTRY_VERSION, + ) + hass = _FakeHass(entries=[entry]) + + assert asyncio.run(integration.async_migrate_entry(hass, entry)) is True + assert entry.options[integration.CONF_CREATE_FORECAST_SENSORS] == "none" + assert entry.version == integration.TARGET_ENTRY_VERSION + + def test_migrate_entry_marks_version_when_no_changes() -> None: """Migration should still bump the version when no changes are needed.""" entry = _FakeEntry( From 7211c9e77ca073cfd2838148930742a8ffdd268c Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sat, 7 Feb 2026 20:48:18 +0100 Subject: [PATCH 157/200] Update changelog for diagnostics redaction fix --- CHANGELOG.md | 17 +++ custom_components/pollenlevels/config_flow.py | 27 +++-- custom_components/pollenlevels/coordinator.py | 13 +- custom_components/pollenlevels/diagnostics.py | 19 ++- custom_components/pollenlevels/manifest.json | 2 +- custom_components/pollenlevels/sensor.py | 1 - pyproject.toml | 2 +- tests/test_config_flow.py | 113 ++++++++++++++++++ tests/test_diagnostics.py | 104 ++++++++++++++++ tests/test_init.py | 6 +- tests/test_sensor.py | 91 ++++++++++++++ tests/test_translations.py | 50 ++++++++ 12 files changed, 415 insertions(+), 30 deletions(-) create mode 100644 tests/test_diagnostics.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 88ac332b..10075563 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,21 @@ # Changelog +## [1.9.1] - 2026-01-10 +### Fixed +- Preserved the last successful coordinator data when the API response omits + `dailyInfo`, avoiding empty entities after transient API glitches. +- Redacted API keys from config flow error placeholders derived from API responses + to prevent secrets from appearing in setup errors. +- Cleared stale setup error placeholders when per-day sensor options are + incompatible with the selected forecast days. +- Ensured diagnostics redaction is awaited so exports return the final redacted + payload rather than a coroutine. +- Trimmed diagnostics payloads to avoid listing all data keys and to hide precise + coordinates while keeping rounded location context for support. + +### Changed +- **Breaking change:** removed the `color_raw` attribute from pollen sensors to + reduce state size; use `color_hex` or `color_rgb` instead. + ## [1.9.0-rc1] - 2025-12-31 ### Changed - Moved runtime state to config entry `runtime_data` with a shared diff --git a/custom_components/pollenlevels/config_flow.py b/custom_components/pollenlevels/config_flow.py index fd04e3e9..7b3cd4d5 100644 --- a/custom_components/pollenlevels/config_flow.py +++ b/custom_components/pollenlevels/config_flow.py @@ -315,6 +315,7 @@ async def _async_validate_input( normalized[CONF_UPDATE_INTERVAL] = interval_value if interval_error: errors[CONF_UPDATE_INTERVAL] = interval_error + placeholders.pop("error_message", None) return errors, None forecast_days, days_error = _parse_int_option( @@ -327,6 +328,7 @@ async def _async_validate_input( normalized[CONF_FORECAST_DAYS] = forecast_days if days_error: errors[CONF_FORECAST_DAYS] = days_error + placeholders.pop("error_message", None) return errors, None mode = normalized.get(CONF_CREATE_FORECAST_SENSORS, FORECAST_SENSORS_CHOICES[0]) @@ -336,6 +338,7 @@ async def _async_validate_input( needed = {"D+1": 2, "D+1+2": 3}.get(mode, 1) if forecast_days < needed: errors[CONF_CREATE_FORECAST_SENSORS] = "invalid_option_combo" + placeholders.pop("error_message", None) return errors, None normalized[CONF_CREATE_FORECAST_SENSORS] = mode @@ -347,6 +350,7 @@ async def _async_validate_input( "Invalid coordinates provided (values redacted): parsing failed" ) errors[CONF_LOCATION] = "invalid_coordinates" + placeholders.pop("error_message", None) return errors, None else: try: @@ -358,6 +362,7 @@ async def _async_validate_input( "Invalid coordinates provided (values redacted): parsing failed" ) errors["base"] = "invalid_coordinates" + placeholders.pop("error_message", None) return errors, None lat, lon = latlon @@ -407,29 +412,26 @@ async def _async_validate_input( if status == 401: _LOGGER.debug("Validation HTTP 401 (body omitted)") errors["base"] = "invalid_auth" - placeholders["error_message"] = await extract_error_message( - resp, "HTTP 401" - ) + raw_msg = await extract_error_message(resp, "HTTP 401") + placeholders["error_message"] = redact_api_key(raw_msg, api_key) elif status == 403: _LOGGER.debug("Validation HTTP 403 (body omitted)") - error_message = await extract_error_message(resp, "HTTP 403") - if is_invalid_api_key_message(error_message): + raw_msg = await extract_error_message(resp, "HTTP 403") + if is_invalid_api_key_message(raw_msg): errors["base"] = "invalid_auth" else: errors["base"] = "cannot_connect" - placeholders["error_message"] = error_message + placeholders["error_message"] = redact_api_key(raw_msg, api_key) elif status == 429: _LOGGER.debug("Validation HTTP 429 (body omitted)") errors["base"] = "quota_exceeded" - placeholders["error_message"] = await extract_error_message( - resp, "HTTP 429" - ) + raw_msg = await extract_error_message(resp, "HTTP 429") + placeholders["error_message"] = redact_api_key(raw_msg, api_key) elif status != 200: _LOGGER.debug("Validation HTTP %s (body omitted)", status) errors["base"] = "cannot_connect" - placeholders["error_message"] = await extract_error_message( - resp, f"HTTP {status}" - ) + raw_msg = await extract_error_message(resp, f"HTTP {status}") + placeholders["error_message"] = redact_api_key(raw_msg, api_key) else: raw = await resp.read() try: @@ -465,6 +467,7 @@ async def _async_validate_input( ve, ) errors[CONF_LANGUAGE_CODE] = _language_error_to_form_key(ve) + placeholders.pop("error_message", None) except TimeoutError as err: _LOGGER.warning( "Validation timeout (%ss): %s", diff --git a/custom_components/pollenlevels/coordinator.py b/custom_components/pollenlevels/coordinator.py index db464f1c..51eb7c37 100644 --- a/custom_components/pollenlevels/coordinator.py +++ b/custom_components/pollenlevels/coordinator.py @@ -125,6 +125,7 @@ def __init__( self.create_d1 = create_d1 self.create_d2 = create_d2 self._client = client + self._missing_dailyinfo_warned = False self.data: dict[str, dict] = {} self.last_updated = None @@ -224,9 +225,15 @@ async def _async_update_data(self): daily: list[dict] = payload.get("dailyInfo") or [] if not daily: - self.data = new_data - self.last_updated = dt_util.utcnow() - return self.data + if self.data: + if not self._missing_dailyinfo_warned: + _LOGGER.warning( + "API response missing dailyInfo; keeping last successful data" + ) + self._missing_dailyinfo_warned = True + return self.data + raise UpdateFailed("API response missing dailyInfo") + self._missing_dailyinfo_warned = False # date (today) first_day = daily[0] diff --git a/custom_components/pollenlevels/diagnostics.py b/custom_components/pollenlevels/diagnostics.py index 3eaf6fb3..623e57fb 100644 --- a/custom_components/pollenlevels/diagnostics.py +++ b/custom_components/pollenlevels/diagnostics.py @@ -31,14 +31,10 @@ from .util import redact_api_key # Redact potentially sensitive values from diagnostics. -# NOTE: Also redact the "location.*" variants used in the request example to avoid -# leaking coordinates in exported diagnostics. TO_REDACT = { CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, - "location.latitude", - "location.longitude", } @@ -96,8 +92,9 @@ def _rounded(value: Any) -> float | None: params_example: dict[str, Any] = { # Explicitly mask the API key example "key": redact_api_key(data.get(CONF_API_KEY), data.get(CONF_API_KEY)) or "***", - "location.latitude": data.get(CONF_LATITUDE), - "location.longitude": data.get(CONF_LONGITUDE), + # Use rounded coordinates to avoid exposing precise location data. + "location.latitude": _rounded(data.get(CONF_LATITUDE)), + "location.longitude": _rounded(data.get(CONF_LONGITUDE)), "days": days_effective, } lang = options.get(CONF_LANGUAGE_CODE, data.get(CONF_LANGUAGE_CODE)) @@ -116,8 +113,12 @@ def _rounded(value: Any) -> float | None: "create_d1": getattr(coordinator, "create_d1", None), "create_d2": getattr(coordinator, "create_d2", None), "last_updated": _iso_or_none(getattr(coordinator, "last_updated", None)), - "data_keys": list((getattr(coordinator, "data", {}) or {}).keys()), + "data_keys_total": 0, + "data_keys": [], } + all_keys = list((getattr(coordinator, "data", {}) or {}).keys()) + coord_info["data_keys_total"] = len(all_keys) + coord_info["data_keys"] = all_keys[:50] # ---------- Forecast summaries (TYPES & PLANTS) ---------- data_map: dict[str, Any] = getattr(coordinator, "data", {}) or {} @@ -184,8 +185,6 @@ def _rounded(value: Any) -> float | None: CONF_CREATE_FORECAST_SENSORS: options.get(CONF_CREATE_FORECAST_SENSORS), }, "data": { - CONF_LATITUDE: data.get(CONF_LATITUDE), - CONF_LONGITUDE: data.get(CONF_LONGITUDE), CONF_LANGUAGE_CODE: data.get(CONF_LANGUAGE_CODE), }, }, @@ -196,4 +195,4 @@ def _rounded(value: Any) -> float | None: } # Redact secrets and return - return async_redact_data(diag, TO_REDACT) + return await async_redact_data(diag, TO_REDACT) diff --git a/custom_components/pollenlevels/manifest.json b/custom_components/pollenlevels/manifest.json index 22fc930a..d5cf46f2 100644 --- a/custom_components/pollenlevels/manifest.json +++ b/custom_components/pollenlevels/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "issue_tracker": "https://github.com/eXPerience83/pollenlevels/issues", - "version": "1.9.0-rc1" + "version": "1.9.1" } diff --git a/custom_components/pollenlevels/sensor.py b/custom_components/pollenlevels/sensor.py index 4332d41d..c7b0bb64 100644 --- a/custom_components/pollenlevels/sensor.py +++ b/custom_components/pollenlevels/sensor.py @@ -272,7 +272,6 @@ def extra_state_attributes(self): "advice", "color_hex", "color_rgb", - "color_raw", "date", "has_index", ): diff --git a/pyproject.toml b/pyproject.toml index 8e6932c8..1c3a3a6c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ [project] name = "pollenlevels" -version = "1.9.0-rc1" +version = "1.9.1" # Enforce the runtime floor aligned with upcoming HA Python 3.14 images. requires-python = ">=3.14" diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 0af91b9d..34e4c984 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -808,6 +808,88 @@ def test_validate_input_http_500_sets_error_message_placeholder( assert placeholders.get("error_message") +def test_validate_input_clears_error_message_placeholder_on_validation_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Field-level validation errors should clear stale error_message placeholders.""" + + session = _patch_client_session(monkeypatch, _StubResponse(500)) + + flow = PollenLevelsConfigFlow() + flow.hass = SimpleNamespace() + placeholders: dict[str, str] = {} + + errors, normalized = asyncio.run( + flow._async_validate_input( + _base_user_input(), + check_unique_id=False, + description_placeholders=placeholders, + ) + ) + + assert session.calls + assert errors == {"base": "cannot_connect"} + assert normalized is None + assert placeholders.get("error_message") + + errors, normalized = asyncio.run( + flow._async_validate_input( + {**_base_user_input(), CONF_LANGUAGE_CODE: "bad code"}, + check_unique_id=False, + description_placeholders=placeholders, + ) + ) + + assert errors == {CONF_LANGUAGE_CODE: "invalid_language_format"} + assert normalized is None + assert "error_message" not in placeholders + + +def test_validate_input_invalid_option_combo_clears_error_message_placeholder( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """invalid_option_combo should clear stale error_message placeholders.""" + + session = _patch_client_session(monkeypatch, _StubResponse(500)) + + flow = PollenLevelsConfigFlow() + flow.hass = SimpleNamespace() + placeholders: dict[str, str] = {} + + errors, normalized = asyncio.run( + flow._async_validate_input( + _base_user_input(), + check_unique_id=False, + description_placeholders=placeholders, + ) + ) + + assert session.calls + assert errors == {"base": "cannot_connect"} + assert normalized is None + assert placeholders.get("error_message") + + _patch_client_session( + monkeypatch, _StubResponse(200, b'{"dailyInfo": [{"day": "D0"}]}') + ) + + errors, normalized = asyncio.run( + flow._async_validate_input( + { + **_base_user_input(), + CONF_FORECAST_DAYS: 1, + CONF_CREATE_FORECAST_SENSORS: "D+1", + }, + check_unique_id=False, + description_placeholders=placeholders, + ) + ) + + assert errors == {CONF_CREATE_FORECAST_SENSORS: "invalid_option_combo"} + assert normalized is None + assert "error_message" not in placeholders + + def test_validate_input_http_403_sets_error_message_placeholder( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -860,6 +942,37 @@ def test_validate_input_http_403_invalid_key_maps_to_invalid_auth( assert "api key not valid" in placeholders.get("error_message", "").lower() +def test_validate_input_redacts_api_key_in_error_message( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Error placeholders should redact API keys returned by the service.""" + + body = b'{"error": {"message": "API key test-key not valid"}}' + session = _patch_client_session(monkeypatch, _StubResponse(status=401, body=body)) + + flow = PollenLevelsConfigFlow() + flow.hass = SimpleNamespace() + placeholders: dict[str, str] = {} + + user_input = _base_user_input() + user_input[cf.CONF_API_KEY] = "test-key" + + errors, normalized = asyncio.run( + flow._async_validate_input( + user_input, + check_unique_id=False, + description_placeholders=placeholders, + ) + ) + + assert session.calls + assert errors == {"base": "invalid_auth"} + assert normalized is None + error_message = placeholders.get("error_message", "") + assert "test-key" not in error_message + assert "***" in error_message + + def test_validate_input_unexpected_exception_sets_unknown( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py new file mode 100644 index 00000000..24c422d5 --- /dev/null +++ b/tests/test_diagnostics.py @@ -0,0 +1,104 @@ +"""Diagnostics tests for privacy and payload sizing.""" + +from __future__ import annotations + +import datetime as dt +import sys +from types import ModuleType, SimpleNamespace +from typing import Any + +import pytest + + +def _force_module(name: str, module: ModuleType) -> None: + sys.modules[name] = module + + +components_mod = ModuleType("homeassistant.components") +diagnostics_mod = ModuleType("homeassistant.components.diagnostics") + + +async def _async_redact_data(data: dict[str, Any], _redact: set[str]) -> dict[str, Any]: + return data + + +diagnostics_mod.async_redact_data = _async_redact_data +_force_module("homeassistant.components", components_mod) +_force_module("homeassistant.components.diagnostics", diagnostics_mod) + +config_entries_mod = ModuleType("homeassistant.config_entries") + + +class _ConfigEntry: + def __init__( + self, + *, + data: dict[str, Any], + options: dict[str, Any], + entry_id: str, + title: str, + ) -> None: + self.data = data + self.options = options + self.entry_id = entry_id + self.title = title + self.runtime_data = None + + +config_entries_mod.ConfigEntry = _ConfigEntry +_force_module("homeassistant.config_entries", config_entries_mod) + +core_mod = ModuleType("homeassistant.core") + + +class _HomeAssistant: + pass + + +core_mod.HomeAssistant = _HomeAssistant +_force_module("homeassistant.core", core_mod) + +from custom_components.pollenlevels import diagnostics as diag # noqa: E402 +from custom_components.pollenlevels.const import ( # noqa: E402 + CONF_FORECAST_DAYS, + CONF_LANGUAGE_CODE, + CONF_LATITUDE, + CONF_LONGITUDE, +) +from custom_components.pollenlevels.runtime import PollenLevelsRuntimeData # noqa: E402 + + +@pytest.mark.asyncio +async def test_diagnostics_rounds_coordinates_and_truncates_keys() -> None: + """Diagnostics should use rounded coordinates and limit data_keys length.""" + + data = { + CONF_LATITUDE: 12.3456, + CONF_LONGITUDE: 78.9876, + CONF_LANGUAGE_CODE: "en", + } + options = {CONF_FORECAST_DAYS: 3} + + entry = _ConfigEntry(data=data, options=options, entry_id="entry", title="Home") + + coordinator = SimpleNamespace( + entry_id="entry", + forecast_days=3, + language="en", + create_d1=True, + create_d2=False, + last_updated=dt.datetime(2025, 1, 1, tzinfo=dt.UTC), + data={f"type_{idx}": {} for idx in range(60)}, + ) + entry.runtime_data = PollenLevelsRuntimeData( + coordinator=coordinator, client=object() + ) + + diagnostics = await diag.async_get_config_entry_diagnostics(None, entry) + + assert CONF_LATITUDE not in diagnostics["entry"]["data"] + assert CONF_LONGITUDE not in diagnostics["entry"]["data"] + assert diagnostics["request_params_example"]["location.latitude"] == 12.3 + assert diagnostics["request_params_example"]["location.longitude"] == 79.0 + assert diagnostics["coordinator"]["data_keys_total"] == 60 + assert len(diagnostics["coordinator"]["data_keys"]) == 50 diff --git a/tests/test_init.py b/tests/test_init.py index d1d0807d..b78d19a7 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -20,7 +20,9 @@ # Provide the additional stubs required by __init__. sys.modules.setdefault("homeassistant", types.ModuleType("homeassistant")) -core_mod = types.ModuleType("homeassistant.core") +core_mod = sys.modules.get("homeassistant.core") or types.ModuleType( + "homeassistant.core" +) class _StubHomeAssistant: # pragma: no cover - structure only @@ -33,7 +35,7 @@ class _StubServiceCall: # pragma: no cover - structure only core_mod.HomeAssistant = _StubHomeAssistant core_mod.ServiceCall = _StubServiceCall -sys.modules.setdefault("homeassistant.core", core_mod) +sys.modules["homeassistant.core"] = core_mod ha_components_mod = sys.modules.get("homeassistant.components") or types.ModuleType( "homeassistant.components" diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 1b6c13fa..148ad7e8 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -479,6 +479,66 @@ def test_type_sensor_preserves_source_with_single_day( assert entry["forecast"] == [] assert entry["tomorrow_has_index"] is False assert entry["tomorrow_value"] is None + assert entry["color_raw"] == {"red": 30, "green": 160, "blue": 40} + + +def test_coordinator_preserves_last_data_when_dailyinfo_missing() -> None: + """Missing dailyInfo keeps the last successful data instead of clearing.""" + + payload = { + "regionCode": "us_ca_san_francisco", + "dailyInfo": [ + { + "date": {"year": 2025, "month": 5, "day": 9}, + "pollenTypeInfo": [ + { + "code": "GRASS", + "displayName": "Grass", + "indexInfo": { + "value": 2, + "category": "LOW", + "indexDescription": "Low", + }, + } + ], + } + ], + } + + session = SequenceSession( + [ + ResponseSpec(status=200, payload=payload), + ResponseSpec(status=200, payload={}), + ] + ) + client = client_mod.GooglePollenApiClient(session, "test") + + loop = asyncio.new_event_loop() + hass = DummyHass(loop) + coordinator = coordinator_mod.PollenDataUpdateCoordinator( + hass=hass, + api_key="test", + lat=1.0, + lon=2.0, + hours=12, + language=None, + entry_id="entry", + forecast_days=1, + create_d1=False, + create_d2=False, + client=client, + ) + + try: + first_data = loop.run_until_complete(coordinator._async_update_data()) + first_updated = coordinator.last_updated + second_data = loop.run_until_complete(coordinator._async_update_data()) + finally: + loop.close() + + assert first_data["type_grass"]["value"] == 2 + assert second_data == first_data + assert coordinator.last_updated == first_updated def test_coordinator_clamps_forecast_days_low() -> None: @@ -508,6 +568,37 @@ def test_coordinator_clamps_forecast_days_low() -> None: assert coordinator.forecast_days == const.MIN_FORECAST_DAYS +def test_coordinator_first_refresh_missing_dailyinfo_raises() -> None: + """Missing dailyInfo on the first refresh should raise UpdateFailed.""" + + session = SequenceSession([ResponseSpec(status=200, payload={})]) + client = client_mod.GooglePollenApiClient(session, "test") + + loop = asyncio.new_event_loop() + hass = DummyHass(loop) + coordinator = coordinator_mod.PollenDataUpdateCoordinator( + hass=hass, + api_key="test", + lat=1.0, + lon=2.0, + hours=12, + language=None, + entry_id="entry", + forecast_days=1, + create_d1=False, + create_d2=False, + client=client, + ) + + try: + with pytest.raises(client_mod.UpdateFailed, match="dailyInfo"): + loop.run_until_complete(coordinator._async_update_data()) + finally: + loop.close() + + assert coordinator.data == {} + + def test_coordinator_clamps_forecast_days_negative() -> None: """Negative forecast days are clamped to minimum.""" diff --git a/tests/test_translations.py b/tests/test_translations.py index a07c1ca4..1b1c944a 100644 --- a/tests/test_translations.py +++ b/tests/test_translations.py @@ -84,6 +84,34 @@ def _extract_services_from_services_yaml() -> set[str]: return services +def _extract_service_labels_from_services_yaml() -> dict[str, dict[str, str]]: + """Extract service name/description values from services.yaml without PyYAML.""" + + if not SERVICES_YAML_PATH.is_file(): + return {} + + services: dict[str, dict[str, str]] = {} + current: str | None = None + for line in SERVICES_YAML_PATH.read_text(encoding="utf-8").splitlines(): + raw = line.rstrip("\n") + if not raw or raw.lstrip().startswith("#"): + continue + if not raw.startswith(" "): + match = re.match(r"^([a-zA-Z0-9_]+):\s*$", raw) + current = match.group(1) if match else None + if current is not None: + services.setdefault(current, {}) + continue + if current is None: + continue + match = re.match(r"^\s+(name|description):\s*(.+)\s*$", raw) + if match: + key, value = match.groups() + services[current][key] = value.strip().strip('"').strip("'") + + return services + + def _extract_sensor_translation_key_usage() -> tuple[set[str], set[str]]: """Extract translation keys referenced by sensor entities and devices. @@ -267,6 +295,28 @@ def test_services_translation_keys_present() -> None: ) +def test_services_yaml_labels_match_translations() -> None: + """Ensure services.yaml labels match en.json translations.""" + + services = _extract_service_labels_from_services_yaml() + if not services: + return + + en_data = _load_translation(TRANSLATIONS_DIR / "en.json") + en_services = en_data.get("services", {}) + + for service_name, labels in services.items(): + translations = en_services.get(service_name, {}) + for key in ("name", "description"): + value = labels.get(key) + if value is None: + continue + expected = translations.get(key) + assert ( + value == expected + ), f"Service {service_name} {key} mismatch: {value!r} != {expected!r}" + + def _extract_constant_assignments(tree: ast.AST) -> dict[str, str]: """Collect string literal assignments from an AST. From d6d295f4f608cae29adce68762cde4d6c1b78f28 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sat, 7 Feb 2026 22:02:33 +0100 Subject: [PATCH 158/200] Fix diagnostics redaction sync behavior --- custom_components/pollenlevels/diagnostics.py | 2 +- tests/test_diagnostics.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/pollenlevels/diagnostics.py b/custom_components/pollenlevels/diagnostics.py index 623e57fb..fc75832f 100644 --- a/custom_components/pollenlevels/diagnostics.py +++ b/custom_components/pollenlevels/diagnostics.py @@ -195,4 +195,4 @@ def _rounded(value: Any) -> float | None: } # Redact secrets and return - return await async_redact_data(diag, TO_REDACT) + return async_redact_data(diag, TO_REDACT) diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index 24c422d5..a791a378 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -18,7 +18,7 @@ def _force_module(name: str, module: ModuleType) -> None: diagnostics_mod = ModuleType("homeassistant.components.diagnostics") -async def _async_redact_data(data: dict[str, Any], _redact: set[str]) -> dict[str, Any]: +def _async_redact_data(data: dict[str, Any], _redact: set[str]) -> dict[str, Any]: return data From bdac367c3725100ca1ca5ce6c039edf6c97d169f Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sat, 7 Feb 2026 22:06:04 +0100 Subject: [PATCH 159/200] Deduplicate config flow HTTP error handling --- custom_components/pollenlevels/config_flow.py | 30 +++++++------------ 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/custom_components/pollenlevels/config_flow.py b/custom_components/pollenlevels/config_flow.py index 7b3cd4d5..9ea91d6d 100644 --- a/custom_components/pollenlevels/config_flow.py +++ b/custom_components/pollenlevels/config_flow.py @@ -409,29 +409,21 @@ async def _async_validate_input( timeout=aiohttp.ClientTimeout(total=POLLEN_API_TIMEOUT), ) as resp: status = resp.status - if status == 401: - _LOGGER.debug("Validation HTTP 401 (body omitted)") - errors["base"] = "invalid_auth" - raw_msg = await extract_error_message(resp, "HTTP 401") + if status != 200: + _LOGGER.debug("Validation HTTP %s (body omitted)", status) + raw_msg = await extract_error_message(resp, f"HTTP {status}") placeholders["error_message"] = redact_api_key(raw_msg, api_key) - elif status == 403: - _LOGGER.debug("Validation HTTP 403 (body omitted)") - raw_msg = await extract_error_message(resp, "HTTP 403") - if is_invalid_api_key_message(raw_msg): + if status == 401: errors["base"] = "invalid_auth" + elif status == 403: + if is_invalid_api_key_message(raw_msg): + errors["base"] = "invalid_auth" + else: + errors["base"] = "cannot_connect" + elif status == 429: + errors["base"] = "quota_exceeded" else: errors["base"] = "cannot_connect" - placeholders["error_message"] = redact_api_key(raw_msg, api_key) - elif status == 429: - _LOGGER.debug("Validation HTTP 429 (body omitted)") - errors["base"] = "quota_exceeded" - raw_msg = await extract_error_message(resp, "HTTP 429") - placeholders["error_message"] = redact_api_key(raw_msg, api_key) - elif status != 200: - _LOGGER.debug("Validation HTTP %s (body omitted)", status) - errors["base"] = "cannot_connect" - raw_msg = await extract_error_message(resp, f"HTTP {status}") - placeholders["error_message"] = redact_api_key(raw_msg, api_key) else: raw = await resp.read() try: From a62c7de1116cd9f00eedbc9878aaef49eb6cf249 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sat, 7 Feb 2026 22:12:32 +0100 Subject: [PATCH 160/200] Update changelog for config flow refactor --- CHANGELOG.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 10075563..1797f463 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,14 +7,16 @@ to prevent secrets from appearing in setup errors. - Cleared stale setup error placeholders when per-day sensor options are incompatible with the selected forecast days. -- Ensured diagnostics redaction is awaited so exports return the final redacted - payload rather than a coroutine. +- Ensured diagnostics redaction returns the final redacted payload without + attempting to await the synchronous helper. - Trimmed diagnostics payloads to avoid listing all data keys and to hide precise coordinates while keeping rounded location context for support. ### Changed - **Breaking change:** removed the `color_raw` attribute from pollen sensors to reduce state size; use `color_hex` or `color_rgb` instead. +- Refactored config flow HTTP validation to reduce duplicated error handling + logic without changing behavior. ## [1.9.0-rc1] - 2025-12-31 ### Changed From 977f9324797caaaaf5b6d2d67f9ffc6b253239b8 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Mon, 9 Feb 2026 21:47:26 +0100 Subject: [PATCH 161/200] Align config flow version with entry migration --- custom_components/pollenlevels/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/pollenlevels/config_flow.py b/custom_components/pollenlevels/config_flow.py index 9ea91d6d..be14e58e 100644 --- a/custom_components/pollenlevels/config_flow.py +++ b/custom_components/pollenlevels/config_flow.py @@ -274,7 +274,7 @@ def _sanitize_forecast_mode_for_default(raw_value: Any) -> str: class PollenLevelsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Config flow for Pollen Levels.""" - VERSION = 2 + VERSION = 3 def __init__(self) -> None: """Initialize the config flow state.""" From 2f834459f7c647ed572c8e9726620c415a80ea48 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Mon, 9 Feb 2026 21:47:32 +0100 Subject: [PATCH 162/200] Update README to remove color_raw attribute --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 220d3c04..d0e837a5 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,8 @@ Get sensors for **grass**, **tree**, **weed** pollen, plus individual plants lik - **Configurable updates** — Change update interval, language, forecast days, and per-day sensors without reinstalling. - **Manual refresh** — Call `pollenlevels.force_update` to trigger an immediate update and reset the timer. - **Last Updated sensor** — Shows timestamp of last successful update. -- **Rich attributes** — Includes `inSeason`, index `description`, health `advice`, `color_hex`, `color_rgb`, `color_raw`, and plant details. +- **Rich attributes** — Includes `inSeason`, index `description`, health `advice`, + `color_hex`, `color_rgb`, and plant details. - **Resilient startup** — Retries setup automatically when the first API response lacks daily pollen info (`dailyInfo` types/plants), ensuring entities appear once data is ready. --- From d29c98fae8aca78b5ad36796e0ca0032a5a501a7 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Mon, 9 Feb 2026 21:47:38 +0100 Subject: [PATCH 163/200] Update changelog for README attribute change --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1797f463..bff221b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ reduce state size; use `color_hex` or `color_rgb` instead. - Refactored config flow HTTP validation to reduce duplicated error handling logic without changing behavior. +- Updated README attributes list to remove `color_raw` now that it is no longer + exposed by sensors. ## [1.9.0-rc1] - 2025-12-31 ### Changed From 97fd50c27c83e7655047d0e80291a20246d83099 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Mon, 9 Feb 2026 22:12:19 +0100 Subject: [PATCH 164/200] Update force_update to await async_request_refresh --- CHANGELOG.md | 2 ++ custom_components/pollenlevels/__init__.py | 33 +++++++++++++--------- tests/test_init.py | 9 +++--- 3 files changed, 26 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bff221b9..cfc13f75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ attempting to await the synchronous helper. - Trimmed diagnostics payloads to avoid listing all data keys and to hide precise coordinates while keeping rounded location context for support. +- Updated the `pollenlevels.force_update` service to request a coordinator + refresh and wait for completion before returning. ### Changed - **Breaking change:** removed the `color_raw` attribute from pollen sensors to diff --git a/custom_components/pollenlevels/__init__.py b/custom_components/pollenlevels/__init__.py index 2b0d1f6d..b92abdb2 100644 --- a/custom_components/pollenlevels/__init__.py +++ b/custom_components/pollenlevels/__init__.py @@ -124,8 +124,7 @@ async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool: async def handle_force_update_service(call: ServiceCall) -> None: """Refresh pollen data for all entries.""" - # Added: top-level log to confirm manual trigger for easier debugging. - _LOGGER.info("Executing force_update service for all Pollen Levels entries") + _LOGGER.debug("Executing force_update service for all Pollen Levels entries") entries = list(hass.config_entries.async_entries(DOMAIN)) tasks: list[Awaitable[None]] = [] task_entries: list[ConfigEntry] = [] @@ -133,20 +132,26 @@ async def handle_force_update_service(call: ServiceCall) -> None: runtime = getattr(entry, "runtime_data", None) coordinator = getattr(runtime, "coordinator", None) if coordinator: - _LOGGER.info("Trigger manual refresh for entry %s", entry.entry_id) - refresh_coro = coordinator.async_refresh() - tasks.append(refresh_coro) + tasks.append(coordinator.async_request_refresh()) task_entries.append(entry) + else: + _LOGGER.debug( + "Skipping force_update for entry %s (no coordinator)", + entry.entry_id, + ) - if tasks: - results = await asyncio.gather(*tasks, return_exceptions=True) - for entry, result in zip(task_entries, results, strict=False): - if isinstance(result, Exception): - _LOGGER.warning( - "Manual refresh failed for entry %s: %r", - entry.entry_id, - result, - ) + if not tasks: + _LOGGER.debug("No coordinators available for force_update") + return + + results = await asyncio.gather(*tasks, return_exceptions=True) + for entry, result in zip(task_entries, results, strict=False): + if isinstance(result, Exception): + _LOGGER.warning( + "Manual refresh failed for entry %s: %r", + entry.entry_id, + result, + ) # Enforce empty payload for the service; reject unknown fields for clearer errors. hass.services.async_register( diff --git a/tests/test_init.py b/tests/test_init.py index b78d19a7..f8c9e1ae 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -442,16 +442,15 @@ def test_force_update_requests_refresh_per_entry() -> None: class _StubCoordinator: def __init__(self): self.calls: list[str] = [] + self.done = asyncio.Event() async def _mark(self): self.calls.append("refresh") + self.done.set() - async def async_refresh(self): + async def async_request_refresh(self): await self._mark() - def async_request_refresh(self): - return asyncio.create_task(self._mark()) - entry1 = _FakeEntry(entry_id="entry-1") entry1.runtime_data = types.SimpleNamespace(coordinator=_StubCoordinator()) entry2 = _FakeEntry(entry_id="entry-2") @@ -468,6 +467,8 @@ def async_request_refresh(self): assert entry1.runtime_data.coordinator.calls == ["refresh"] assert entry2.runtime_data.coordinator.calls == ["refresh"] + assert entry1.runtime_data.coordinator.done.is_set() + assert entry2.runtime_data.coordinator.done.is_set() def test_migrate_entry_moves_mode_to_options() -> None: From ec9df2c20451b367f1cef68c9b4ba7eb8e677d1b Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Tue, 10 Feb 2026 08:37:26 +0100 Subject: [PATCH 165/200] Re-raise CancelledError in coordinator updates --- custom_components/pollenlevels/coordinator.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/custom_components/pollenlevels/coordinator.py b/custom_components/pollenlevels/coordinator.py index 51eb7c37..e0a665ec 100644 --- a/custom_components/pollenlevels/coordinator.py +++ b/custom_components/pollenlevels/coordinator.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import logging from datetime import timedelta from typing import TYPE_CHECKING, Any @@ -212,6 +213,8 @@ async def _async_update_data(self): raise except UpdateFailed: raise + except asyncio.CancelledError: + raise except Exception as err: # Keep previous behavior for unexpected errors msg = redact_api_key(err, self.api_key) _LOGGER.error("Pollen API error: %s", msg) From 0636f0b951d8f73ad3f7c5771ff7c39765b7b673 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Tue, 10 Feb 2026 08:37:31 +0100 Subject: [PATCH 166/200] Sanitize force_update failure logging --- CHANGELOG.md | 2 ++ custom_components/pollenlevels/__init__.py | 9 ++++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cfc13f75..d66c304a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ coordinates while keeping rounded location context for support. - Updated the `pollenlevels.force_update` service to request a coordinator refresh and wait for completion before returning. +- Sanitized `pollenlevels.force_update` failure logging to avoid exposing raw + exception details in warnings. ### Changed - **Breaking change:** removed the `color_raw` attribute from pollen sensors to diff --git a/custom_components/pollenlevels/__init__.py b/custom_components/pollenlevels/__init__.py index b92abdb2..3ca05f7f 100644 --- a/custom_components/pollenlevels/__init__.py +++ b/custom_components/pollenlevels/__init__.py @@ -41,7 +41,7 @@ from .coordinator import PollenDataUpdateCoordinator from .runtime import PollenLevelsConfigEntry, PollenLevelsRuntimeData from .sensor import ForecastSensorMode -from .util import normalize_sensor_mode +from .util import normalize_sensor_mode, redact_api_key # Ensure YAML config is entry-only for this domain (no YAML schema). CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -147,10 +147,13 @@ async def handle_force_update_service(call: ServiceCall) -> None: results = await asyncio.gather(*tasks, return_exceptions=True) for entry, result in zip(task_entries, results, strict=False): if isinstance(result, Exception): + api_key = (entry.data or {}).get(CONF_API_KEY) + safe_message = redact_api_key(result, api_key) _LOGGER.warning( - "Manual refresh failed for entry %s: %r", + "Manual refresh failed for entry %s (%s): %s", entry.entry_id, - result, + type(result).__name__, + safe_message or "no error details", ) # Enforce empty payload for the service; reject unknown fields for clearer errors. From b5940d0569a6f7e3bfd0eee96028f9357c66502e Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Tue, 10 Feb 2026 09:47:16 +0100 Subject: [PATCH 167/200] Clarify optional runtime coordinator access in diagnostics --- custom_components/pollenlevels/diagnostics.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/pollenlevels/diagnostics.py b/custom_components/pollenlevels/diagnostics.py index fc75832f..38ab6ce4 100644 --- a/custom_components/pollenlevels/diagnostics.py +++ b/custom_components/pollenlevels/diagnostics.py @@ -54,7 +54,7 @@ async def async_get_config_entry_diagnostics( NOTE: This function must not perform any network I/O. """ runtime = cast(PollenLevelsRuntimeData | None, getattr(entry, "runtime_data", None)) - coordinator = getattr(runtime, "coordinator", None) + coordinator = getattr(runtime, "coordinator", None) if runtime else None options: dict[str, Any] = dict(entry.options or {}) data: dict[str, Any] = dict(entry.data or {}) From 1ed986182a27861b32244972949ad7f274128206 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Tue, 10 Feb 2026 09:47:24 +0100 Subject: [PATCH 168/200] Update changelog for recent coordinator and diagnostics tweaks --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d66c304a..91f27a45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ refresh and wait for completion before returning. - Sanitized `pollenlevels.force_update` failure logging to avoid exposing raw exception details in warnings. +- Re-raised coordinator `CancelledError` during updates so shutdown/reload + cancellations are not wrapped as `UpdateFailed`. ### Changed - **Breaking change:** removed the `color_raw` attribute from pollen sensors to @@ -23,6 +25,8 @@ logic without changing behavior. - Updated README attributes list to remove `color_raw` now that it is no longer exposed by sensors. +- Clarified diagnostics coordinator access when `runtime_data` is missing, + without changing diagnostics output. ## [1.9.0-rc1] - 2025-12-31 ### Changed From 6cc8cac7e6710d4d0d2f2bef868f9b1fdd7b6d95 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Tue, 10 Feb 2026 09:47:31 +0100 Subject: [PATCH 169/200] Fix __init__ note to match force_update log level --- custom_components/pollenlevels/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/pollenlevels/__init__.py b/custom_components/pollenlevels/__init__.py index 3ca05f7f..406d6363 100644 --- a/custom_components/pollenlevels/__init__.py +++ b/custom_components/pollenlevels/__init__.py @@ -1,7 +1,7 @@ """Initialize Pollen Levels integration. Notes: -- Adds a top-level INFO log when the force_update service is invoked to aid debugging. +- Adds a top-level DEBUG log when the force_update service is invoked to aid debugging. - Registers an options update listener to reload the entry so interval/language changes take effect immediately without reinstalling. """ From 9f3ec4244acfa01b6a283e4bc8f2b0f200ec8fa5 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Tue, 10 Feb 2026 14:25:07 +0100 Subject: [PATCH 170/200] Handle force_update cancellations and drop internal color_raw --- CHANGELOG.md | 4 ++++ custom_components/pollenlevels/__init__.py | 6 ++++++ custom_components/pollenlevels/coordinator.py | 18 ------------------ tests/test_sensor.py | 2 +- 4 files changed, 11 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91f27a45..44c1bab6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ refresh and wait for completion before returning. - Sanitized `pollenlevels.force_update` failure logging to avoid exposing raw exception details in warnings. +- Handled cancelled `force_update` refresh results explicitly to keep service + logging and control flow consistent during shutdown/reload paths. - Re-raised coordinator `CancelledError` during updates so shutdown/reload cancellations are not wrapped as `UpdateFailed`. @@ -25,6 +27,8 @@ logic without changing behavior. - Updated README attributes list to remove `color_raw` now that it is no longer exposed by sensors. +- Removed unused internal `color_raw` coordinator payload fields to reduce + update payload size while keeping `color_hex`/`color_rgb` behavior unchanged. - Clarified diagnostics coordinator access when `runtime_data` is missing, without changing diagnostics output. diff --git a/custom_components/pollenlevels/__init__.py b/custom_components/pollenlevels/__init__.py index 406d6363..7a648835 100644 --- a/custom_components/pollenlevels/__init__.py +++ b/custom_components/pollenlevels/__init__.py @@ -146,6 +146,12 @@ async def handle_force_update_service(call: ServiceCall) -> None: results = await asyncio.gather(*tasks, return_exceptions=True) for entry, result in zip(task_entries, results, strict=False): + if isinstance(result, asyncio.CancelledError): + _LOGGER.debug( + "Manual refresh cancelled for entry %s", + entry.entry_id, + ) + continue if isinstance(result, Exception): api_key = (entry.data or {}).get(CONF_API_KEY) safe_message = redact_api_key(result, api_key) diff --git a/custom_components/pollenlevels/coordinator.py b/custom_components/pollenlevels/coordinator.py index e0a665ec..34643cee 100644 --- a/custom_components/pollenlevels/coordinator.py +++ b/custom_components/pollenlevels/coordinator.py @@ -292,9 +292,6 @@ def _find_plant(day: dict, code: str) -> dict | None: "advice": titem.get("healthRecommendations"), "color_hex": _rgb_to_hex_triplet(rgb), "color_rgb": list(rgb) if rgb is not None else None, - "color_raw": ( - idx.get("color") if isinstance(idx.get("color"), dict) else None - ), } # Current-day PLANTS @@ -327,9 +324,6 @@ def _find_plant(day: dict, code: str) -> dict | None: "advice": pitem.get("healthRecommendations"), "color_hex": _rgb_to_hex_triplet(rgb), "color_rgb": list(rgb) if rgb is not None else None, - "color_raw": ( - idx.get("color") if isinstance(idx.get("color"), dict) else None - ), "picture": desc.get("picture"), "picture_closeup": desc.get("pictureCloseup"), } @@ -362,7 +356,6 @@ def _extract_day_info(day: dict) -> tuple[str | None, dict | None]: "description": None, "color_hex": None, "color_rgb": None, - "color_raw": None, } candidate = None @@ -397,11 +390,6 @@ def _extract_day_info(day: dict) -> tuple[str | None, dict | None]: "color_rgb": ( list(rgb) if (has_index and rgb is not None) else None ), - "color_raw": ( - idx.get("color") - if has_index and isinstance(idx.get("color"), dict) - else None - ), } ) # Attach common forecast attributes (convenience, trend, expected_peak) @@ -448,7 +436,6 @@ def _add_day_sensor( "advice": day_advice, "color_hex": f.get("color_hex"), "color_rgb": f.get("color_rgb"), - "color_raw": f.get("color_raw"), "date": f.get("date"), "has_index": f.get("has_index"), } @@ -491,11 +478,6 @@ def _add_day_sensor( "color_rgb": ( list(rgb) if (has_index and rgb is not None) else None ), - "color_raw": ( - idx.get("color") - if has_index and isinstance(idx.get("color"), dict) - else None - ), } ) diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 148ad7e8..5a0b81d4 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -479,7 +479,7 @@ def test_type_sensor_preserves_source_with_single_day( assert entry["forecast"] == [] assert entry["tomorrow_has_index"] is False assert entry["tomorrow_value"] is None - assert entry["color_raw"] == {"red": 30, "green": 160, "blue": 40} + assert "color_raw" not in entry def test_coordinator_preserves_last_data_when_dailyinfo_missing() -> None: From b52a762739fbb43a359011878d0337efa6e52df5 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Tue, 10 Feb 2026 15:13:51 +0100 Subject: [PATCH 171/200] Fix dailyInfo-missing preservation test setup --- tests/test_sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 5a0b81d4..b30f18fb 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -531,14 +531,14 @@ def test_coordinator_preserves_last_data_when_dailyinfo_missing() -> None: try: first_data = loop.run_until_complete(coordinator._async_update_data()) - first_updated = coordinator.last_updated + coordinator.data = first_data second_data = loop.run_until_complete(coordinator._async_update_data()) finally: loop.close() assert first_data["type_grass"]["value"] == 2 assert second_data == first_data - assert coordinator.last_updated == first_updated + assert second_data == coordinator.data def test_coordinator_clamps_forecast_days_low() -> None: From aeae36ade5e6b2ba097bf21ecdab0268d5a48eb1 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Tue, 10 Feb 2026 15:13:56 +0100 Subject: [PATCH 172/200] Handle refresh cancellations and validate setup coordinates --- custom_components/pollenlevels/__init__.py | 18 ++++++++-- custom_components/pollenlevels/coordinator.py | 35 ++++++++++--------- tests/test_init.py | 16 +++++++++ 3 files changed, 50 insertions(+), 19 deletions(-) diff --git a/custom_components/pollenlevels/__init__.py b/custom_components/pollenlevels/__init__.py index 7a648835..25b15705 100644 --- a/custom_components/pollenlevels/__init__.py +++ b/custom_components/pollenlevels/__init__.py @@ -11,7 +11,7 @@ import asyncio import logging from collections.abc import Awaitable -from typing import Any, cast +from typing import Any import homeassistant.helpers.config_validation as cv import voluptuous as vol # Service schema validation @@ -222,6 +222,18 @@ def _safe_int(value: Any, default: int) -> int: if not api_key: raise ConfigEntryAuthFailed("Missing API key") + raw_lat = entry.data.get(CONF_LATITUDE) + raw_lon = entry.data.get(CONF_LONGITUDE) + try: + lat = float(raw_lat) + lon = float(raw_lon) + except (TypeError, ValueError) as err: + _LOGGER.warning( + "Invalid config entry coordinates for entry %s", + entry.entry_id, + ) + raise ConfigEntryNotReady from err + raw_title = entry.title or "" clean_title = raw_title.strip() or DEFAULT_ENTRY_TITLE @@ -231,8 +243,8 @@ def _safe_int(value: Any, default: int) -> int: coordinator = PollenDataUpdateCoordinator( hass=hass, api_key=api_key, - lat=cast(float, entry.data[CONF_LATITUDE]), - lon=cast(float, entry.data[CONF_LONGITUDE]), + lat=lat, + lon=lon, hours=hours, language=language, entry_id=entry.entry_id, diff --git a/custom_components/pollenlevels/coordinator.py b/custom_components/pollenlevels/coordinator.py index 34643cee..fec304f0 100644 --- a/custom_components/pollenlevels/coordinator.py +++ b/custom_components/pollenlevels/coordinator.py @@ -257,27 +257,30 @@ async def _async_update_data(self): if code: type_codes.add(code) - def _find_type(day: dict, code: str) -> dict | None: - """Find a pollen TYPE entry by code inside a day's 'pollenTypeInfo'.""" + type_by_day_code: list[dict[str, dict[str, Any]]] = [] + plant_by_day_code: list[dict[str, dict[str, Any]]] = [] + for day in daily: + day_types: dict[str, dict[str, Any]] = {} for item in day.get("pollenTypeInfo", []) or []: if not isinstance(item, dict): continue - if (item.get("code") or "").upper() == code: - return item - return None + code = (item.get("code") or "").upper() + if code: + day_types[code] = item + type_by_day_code.append(day_types) - def _find_plant(day: dict, code: str) -> dict | None: - """Find a PLANT entry by code inside a day's 'plantInfo'.""" + day_plants: dict[str, dict[str, Any]] = {} for item in day.get("plantInfo", []) or []: if not isinstance(item, dict): continue - if (item.get("code") or "") == code: - return item - return None + code = item.get("code") or "" + if code: + day_plants[code] = item + plant_by_day_code.append(day_plants) # Current-day TYPES for tcode in type_codes: - titem = _find_type(first_day, tcode) or {} + titem = type_by_day_code[0].get(tcode) or {} idx_raw = titem.get("indexInfo") idx = idx_raw if isinstance(idx_raw, dict) else {} rgb = _rgb_from_api(idx.get("color")) @@ -359,8 +362,8 @@ def _extract_day_info(day: dict) -> tuple[str | None, dict | None]: } candidate = None - for day_data in daily: - candidate = _find_type(day_data, tcode) + for day_idx, _day_data in enumerate(daily): + candidate = type_by_day_code[day_idx].get(tcode) if isinstance(candidate, dict): base["displayName"] = candidate.get("displayName", tcode) base["inSeason"] = candidate.get("inSeason") @@ -371,7 +374,7 @@ def _extract_day_info(day: dict) -> tuple[str | None, dict | None]: if offset >= self.forecast_days: break date_str, _ = _extract_day_info(day) - item = _find_type(day, tcode) or {} + item = type_by_day_code[offset].get(tcode) or {} idx_raw = item.get("indexInfo") idx = idx_raw if isinstance(idx_raw, dict) else None has_index = isinstance(idx_raw, dict) and bool(idx_raw) @@ -415,7 +418,7 @@ def _add_day_sensor( day_obj = daily[off] except (IndexError, TypeError): day_obj = None - day_item = _find_type(day_obj, _tcode) if day_obj else None + day_item = type_by_day_code[off].get(_tcode) if day_obj else None day_in_season = ( day_item.get("inSeason") if isinstance(day_item, dict) else None ) @@ -459,7 +462,7 @@ def _add_day_sensor( if offset >= self.forecast_days: break date_str, _ = _extract_day_info(day) - item = _find_plant(day, pcode) or {} + item = plant_by_day_code[offset].get(pcode) or {} idx_raw = item.get("indexInfo") idx = idx_raw if isinstance(idx_raw, dict) else None has_index = isinstance(idx_raw, dict) and bool(idx_raw) diff --git a/tests/test_init.py b/tests/test_init.py index f8c9e1ae..b590fbeb 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -369,6 +369,22 @@ def test_setup_entry_missing_api_key_raises_auth_failed() -> None: asyncio.run(integration.async_setup_entry(hass, entry)) +def test_setup_entry_invalid_coordinates_raise_not_ready() -> None: + """Invalid coordinates should trigger ConfigEntryNotReady.""" + + hass = _FakeHass() + entry = _FakeEntry( + data={ + integration.CONF_API_KEY: "key", + integration.CONF_LATITUDE: "not-a-number", + integration.CONF_LONGITUDE: 2.0, + } + ) + + with pytest.raises(integration.ConfigEntryNotReady): + asyncio.run(integration.async_setup_entry(hass, entry)) + + def test_setup_entry_wraps_generic_error() -> None: """Unexpected errors convert to ConfigEntryNotReady for retries.""" From 8f961093b989bdbfe13bf70725f48f977457c1ad Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Fri, 13 Feb 2026 23:58:36 +0100 Subject: [PATCH 173/200] Release version 1.9.2 --- CHANGELOG.md | 5 +++++ custom_components/pollenlevels/manifest.json | 2 +- pyproject.toml | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 44c1bab6..a2dc7a58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,9 @@ # Changelog +## [1.9.2] - 2026-02-13 +### Fixed +- Re-raised `asyncio.CancelledError` during coordinator updates to avoid wrapping + shutdown/reload cancellations as `UpdateFailed`. + ## [1.9.1] - 2026-01-10 ### Fixed - Preserved the last successful coordinator data when the API response omits diff --git a/custom_components/pollenlevels/manifest.json b/custom_components/pollenlevels/manifest.json index d5cf46f2..3a9a3da3 100644 --- a/custom_components/pollenlevels/manifest.json +++ b/custom_components/pollenlevels/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "issue_tracker": "https://github.com/eXPerience83/pollenlevels/issues", - "version": "1.9.1" + "version": "1.9.2" } diff --git a/pyproject.toml b/pyproject.toml index 1c3a3a6c..d4161e61 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ [project] name = "pollenlevels" -version = "1.9.1" +version = "1.9.2" # Enforce the runtime floor aligned with upcoming HA Python 3.14 images. requires-python = ">=3.14" From 88ee1d3e84698f8b0f9b2729dfae1d143e7c5095 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Fri, 13 Feb 2026 23:58:40 +0100 Subject: [PATCH 174/200] Add boundary coordinate setup regression test --- CHANGELOG.md | 6 +++ custom_components/pollenlevels/__init__.py | 13 ++++++ custom_components/pollenlevels/coordinator.py | 10 +---- tests/test_init.py | 40 +++++++++++++++++++ 4 files changed, 60 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a2dc7a58..6d858364 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ ### Fixed - Re-raised `asyncio.CancelledError` during coordinator updates to avoid wrapping shutdown/reload cancellations as `UpdateFailed`. +- Validated config-entry coordinates as finite and in-range values before setup + to avoid malformed requests and retry with `ConfigEntryNotReady` when invalid. + +### Changed +- Reduced coordinator parsing overhead by building per-day type/plant lookup maps + in a single pass and reusing them across forecast extraction. ## [1.9.1] - 2026-01-10 ### Fixed diff --git a/custom_components/pollenlevels/__init__.py b/custom_components/pollenlevels/__init__.py index 25b15705..4efdccdb 100644 --- a/custom_components/pollenlevels/__init__.py +++ b/custom_components/pollenlevels/__init__.py @@ -10,6 +10,7 @@ import asyncio import logging +import math from collections.abc import Awaitable from typing import Any @@ -234,6 +235,18 @@ def _safe_int(value: Any, default: int) -> int: ) raise ConfigEntryNotReady from err + if ( + not math.isfinite(lat) + or not math.isfinite(lon) + or not (-90.0 <= lat <= 90.0) + or not (-180.0 <= lon <= 180.0) + ): + _LOGGER.warning( + "Out-of-range or non-finite coordinates for entry %s", + entry.entry_id, + ) + raise ConfigEntryNotReady + raw_title = entry.title or "" clean_title = raw_title.strip() or DEFAULT_ENTRY_TITLE diff --git a/custom_components/pollenlevels/coordinator.py b/custom_components/pollenlevels/coordinator.py index fec304f0..ccf8f1ae 100644 --- a/custom_components/pollenlevels/coordinator.py +++ b/custom_components/pollenlevels/coordinator.py @@ -247,16 +247,7 @@ async def _async_update_data(self): "value": f"{date_obj['year']:04d}-{date_obj['month']:02d}-{date_obj['day']:02d}", } - # collect type codes found in any day type_codes: set[str] = set() - for day in daily: - for item in day.get("pollenTypeInfo", []) or []: - if not isinstance(item, dict): - continue - code = (item.get("code") or "").upper() - if code: - type_codes.add(code) - type_by_day_code: list[dict[str, dict[str, Any]]] = [] plant_by_day_code: list[dict[str, dict[str, Any]]] = [] for day in daily: @@ -267,6 +258,7 @@ async def _async_update_data(self): code = (item.get("code") or "").upper() if code: day_types[code] = item + type_codes.add(code) type_by_day_code.append(day_types) day_plants: dict[str, dict[str, Any]] = {} diff --git a/tests/test_init.py b/tests/test_init.py index b590fbeb..d0da219e 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -385,6 +385,46 @@ def test_setup_entry_invalid_coordinates_raise_not_ready() -> None: asyncio.run(integration.async_setup_entry(hass, entry)) +def test_setup_entry_nonfinite_or_out_of_range_coordinates_raise_not_ready() -> None: + """Non-finite or out-of-range coordinates should trigger ConfigEntryNotReady.""" + + bad_pairs = [ + (float("inf"), 2.0), + (1.0, float("nan")), + (91.0, 2.0), + (1.0, 181.0), + ] + + for lat, lon in bad_pairs: + hass = _FakeHass() + entry = _FakeEntry( + data={ + integration.CONF_API_KEY: "key", + integration.CONF_LATITUDE: lat, + integration.CONF_LONGITUDE: lon, + } + ) + + with pytest.raises(integration.ConfigEntryNotReady): + asyncio.run(integration.async_setup_entry(hass, entry)) + + +def test_setup_entry_boundary_coordinates_are_allowed() -> None: + """Coordinate values on valid boundaries should still set up successfully.""" + + for lat, lon in [(-90.0, -180.0), (90.0, 180.0)]: + hass = _FakeHass() + entry = _FakeEntry( + data={ + integration.CONF_API_KEY: "key", + integration.CONF_LATITUDE: lat, + integration.CONF_LONGITUDE: lon, + } + ) + + assert asyncio.run(integration.async_setup_entry(hass, entry)) is True + + def test_setup_entry_wraps_generic_error() -> None: """Unexpected errors convert to ConfigEntryNotReady for retries.""" From d6bbc966bb63a3ce2762908e74e2466b3b0b53c1 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Fri, 13 Feb 2026 23:59:11 +0100 Subject: [PATCH 175/200] Optimize coordinator day-0 plant parsing and stabilize type ordering --- CHANGELOG.md | 2 + custom_components/pollenlevels/coordinator.py | 9 +-- tests/test_sensor.py | 57 +++++++++++++++++++ 3 files changed, 62 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d858364..86e48f87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ ### Changed - Reduced coordinator parsing overhead by building per-day type/plant lookup maps in a single pass and reusing them across forecast extraction. +- Reused cached day-0 plant maps for current-day plant sensors and sorted type-code + processing for deterministic sensor key ordering. ## [1.9.1] - 2026-01-10 ### Fixed diff --git a/custom_components/pollenlevels/coordinator.py b/custom_components/pollenlevels/coordinator.py index ccf8f1ae..ca6dbd5e 100644 --- a/custom_components/pollenlevels/coordinator.py +++ b/custom_components/pollenlevels/coordinator.py @@ -271,7 +271,7 @@ async def _async_update_data(self): plant_by_day_code.append(day_plants) # Current-day TYPES - for tcode in type_codes: + for tcode in sorted(type_codes): titem = type_by_day_code[0].get(tcode) or {} idx_raw = titem.get("indexInfo") idx = idx_raw if isinstance(idx_raw, dict) else {} @@ -290,10 +290,7 @@ async def _async_update_data(self): } # Current-day PLANTS - for pitem in first_day.get("plantInfo", []) or []: - if not isinstance(pitem, dict): - continue - code = pitem.get("code") + for code, pitem in plant_by_day_code[0].items(): # Safety: skip plants without a stable 'code' to avoid duplicate 'plants_' keys # and silent overwrites. This is robust and avoids creating unstable entities. if not code: @@ -330,7 +327,7 @@ def _extract_day_info(day: dict) -> tuple[str | None, dict | None]: return None, None return f"{d['year']:04d}-{d['month']:02d}-{d['day']:02d}", d - for tcode in type_codes: + for tcode in sorted(type_codes): type_key = f"type_{tcode.lower()}" existing = new_data.get(type_key) needs_skeleton = not existing or ( diff --git a/tests/test_sensor.py b/tests/test_sensor.py index b30f18fb..3d015427 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -883,6 +883,63 @@ def test_plant_sensor_includes_forecast_attributes( assert entry["expected_peak"]["value"] == 4 +def test_coordinator_type_keys_are_deterministic_sorted() -> None: + """Type sensor keys are emitted in stable sorted order.""" + + payload = { + "dailyInfo": [ + { + "date": {"year": 2025, "month": 7, "day": 1}, + "pollenTypeInfo": [ + { + "code": "WEED", + "displayName": "Weed", + "indexInfo": {"value": 2, "category": "LOW"}, + }, + { + "code": "GRASS", + "displayName": "Grass", + "indexInfo": {"value": 1, "category": "LOW"}, + }, + ], + } + ] + } + + fake_session = FakeSession(payload) + client = client_mod.GooglePollenApiClient(fake_session, "test") + + loop = asyncio.new_event_loop() + hass = DummyHass(loop) + coordinator = coordinator_mod.PollenDataUpdateCoordinator( + hass=hass, + api_key="test", + lat=1.0, + lon=2.0, + hours=12, + language=None, + entry_id="entry", + forecast_days=1, + create_d1=False, + create_d2=False, + client=client, + ) + + try: + data = loop.run_until_complete(coordinator._async_update_data()) + finally: + loop.close() + + type_keys = [ + k + for k, v in data.items() + if isinstance(v, dict) + and v.get("source") == "type" + and not k.endswith(("_d1", "_d2")) + ] + assert type_keys == sorted(type_keys) + + @pytest.mark.parametrize( ( "allow_d1", From 81b99074717016e99ce38affa19d7262fa4cf780 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sat, 14 Feb 2026 00:00:26 +0100 Subject: [PATCH 176/200] Harden plant forecast matching and diagnostics day clamping --- CHANGELOG.md | 4 ++ custom_components/pollenlevels/coordinator.py | 16 +++-- custom_components/pollenlevels/diagnostics.py | 21 ++++--- tests/test_diagnostics.py | 44 ++++++++++++++ tests/test_sensor.py | 58 +++++++++++++++++++ 5 files changed, 130 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 86e48f87..6571b068 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ in a single pass and reusing them across forecast extraction. - Reused cached day-0 plant maps for current-day plant sensors and sorted type-code processing for deterministic sensor key ordering. +- Normalized plant-code matching across days to keep plant forecast attributes + populated even when API casing differs between days. +- Clamped diagnostics `request_params_example.days` to supported forecast ranges + and handled non-finite values defensively. ## [1.9.1] - 2026-01-10 ### Fixed diff --git a/custom_components/pollenlevels/coordinator.py b/custom_components/pollenlevels/coordinator.py index ca6dbd5e..20d2e01e 100644 --- a/custom_components/pollenlevels/coordinator.py +++ b/custom_components/pollenlevels/coordinator.py @@ -81,6 +81,13 @@ def _rgb_to_hex_triplet(rgb: tuple[int, int, int] | None) -> str | None: return f"#{r:02X}{g:02X}{b:02X}" +def _normalize_plant_code(code: Any) -> str: + """Normalize plant code for cross-day map lookups.""" + if code is None: + return "" + return str(code).strip().upper() + + class PollenDataUpdateCoordinator(DataUpdateCoordinator): """Coordinate pollen data fetch with forecast support for TYPES and PLANTS.""" @@ -265,7 +272,7 @@ async def _async_update_data(self): for item in day.get("plantInfo", []) or []: if not isinstance(item, dict): continue - code = item.get("code") or "" + code = _normalize_plant_code(item.get("code")) if code: day_plants[code] = item plant_by_day_code.append(day_plants) @@ -290,16 +297,17 @@ async def _async_update_data(self): } # Current-day PLANTS - for code, pitem in plant_by_day_code[0].items(): + for norm_code, pitem in plant_by_day_code[0].items(): # Safety: skip plants without a stable 'code' to avoid duplicate 'plants_' keys # and silent overwrites. This is robust and avoids creating unstable entities. - if not code: + if not norm_code: continue idx_raw = pitem.get("indexInfo") idx = idx_raw if isinstance(idx_raw, dict) else {} desc_raw = pitem.get("plantDescription") desc = desc_raw if isinstance(desc_raw, dict) else {} rgb = _rgb_from_api(idx.get("color")) + code = _normalize_plant_code(pitem.get("code")) or norm_code key = f"plants_{code.lower()}" new_data[key] = { "source": "plant", @@ -441,7 +449,7 @@ def _add_day_sensor( for key, base in list(new_data.items()): if base.get("source") != "plant": continue - pcode = base.get("code") + pcode = _normalize_plant_code(base.get("code")) if not pcode: # Safety: skip if for some reason code is missing continue diff --git a/custom_components/pollenlevels/diagnostics.py b/custom_components/pollenlevels/diagnostics.py index 38ab6ce4..8ee0407c 100644 --- a/custom_components/pollenlevels/diagnostics.py +++ b/custom_components/pollenlevels/diagnostics.py @@ -11,6 +11,7 @@ from __future__ import annotations +import math from typing import Any, cast from homeassistant.components.diagnostics import async_redact_data @@ -26,6 +27,8 @@ CONF_LONGITUDE, CONF_UPDATE_INTERVAL, DEFAULT_FORECAST_DAYS, # use constant instead of magic number + MAX_FORECAST_DAYS, + MIN_FORECAST_DAYS, ) from .runtime import PollenLevelsRuntimeData from .util import redact_api_key @@ -75,19 +78,19 @@ def _rounded(value: Any) -> float | None: # --- Build a safe params example (no network I/O) ---------------------- # Use DEFAULT_FORECAST_DAYS from const.py to avoid config drift. try: - days_effective = int( - options.get( - CONF_FORECAST_DAYS, - data.get(CONF_FORECAST_DAYS, DEFAULT_FORECAST_DAYS), - ) + days_raw = options.get( + CONF_FORECAST_DAYS, + data.get(CONF_FORECAST_DAYS, DEFAULT_FORECAST_DAYS), ) - except Exception: + days_float = float(days_raw) + if not math.isfinite(days_float): + raise ValueError + days_effective = int(days_float) + except (TypeError, ValueError, OverflowError): # Defensive fallback days_effective = DEFAULT_FORECAST_DAYS - # Clamp days to a sensible minimum (avoid 0 or negative in diagnostics) - if days_effective < 1: - days_effective = 1 + days_effective = max(MIN_FORECAST_DAYS, min(MAX_FORECAST_DAYS, days_effective)) params_example: dict[str, Any] = { # Explicitly mask the API key example diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index a791a378..b00fa75a 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -64,6 +64,9 @@ class _HomeAssistant: CONF_LANGUAGE_CODE, CONF_LATITUDE, CONF_LONGITUDE, + DEFAULT_FORECAST_DAYS, + MAX_FORECAST_DAYS, + MIN_FORECAST_DAYS, ) from custom_components.pollenlevels.runtime import PollenLevelsRuntimeData # noqa: E402 @@ -102,3 +105,44 @@ async def test_diagnostics_rounds_coordinates_and_truncates_keys() -> None: assert diagnostics["request_params_example"]["location.longitude"] == 79.0 assert diagnostics["coordinator"]["data_keys_total"] == 60 assert len(diagnostics["coordinator"]["data_keys"]) == 50 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("raw_days", "expected_days"), + [ + (999, MAX_FORECAST_DAYS), + (-3, MIN_FORECAST_DAYS), + ("nan", DEFAULT_FORECAST_DAYS), + ], +) +async def test_diagnostics_clamps_request_days( + raw_days: Any, expected_days: int +) -> None: + """Diagnostics request params should always show a supported day count.""" + + data = { + CONF_LATITUDE: 12.3, + CONF_LONGITUDE: 45.6, + CONF_LANGUAGE_CODE: "en", + } + options = {CONF_FORECAST_DAYS: raw_days} + + entry = _ConfigEntry(data=data, options=options, entry_id="entry", title="Home") + + coordinator = SimpleNamespace( + entry_id="entry", + forecast_days=3, + language="en", + create_d1=True, + create_d2=False, + last_updated=dt.datetime(2025, 1, 1, tzinfo=dt.UTC), + data={"type_grass": {"source": "type"}}, + ) + entry.runtime_data = PollenLevelsRuntimeData( + coordinator=coordinator, client=object() + ) + + diagnostics = await diag.async_get_config_entry_diagnostics(None, entry) + + assert diagnostics["request_params_example"]["days"] == expected_days diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 3d015427..e57902e4 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -883,6 +883,64 @@ def test_plant_sensor_includes_forecast_attributes( assert entry["expected_peak"]["value"] == 4 +def test_plant_forecast_matches_codes_case_insensitively() -> None: + """Plant forecast should match even when code casing varies by day.""" + + payload = { + "dailyInfo": [ + { + "date": {"year": 2025, "month": 6, "day": 1}, + "plantInfo": [ + { + "code": "ragweed", + "displayName": "Ragweed", + "indexInfo": {"value": 2, "category": "LOW"}, + } + ], + }, + { + "date": {"year": 2025, "month": 6, "day": 2}, + "plantInfo": [ + { + "code": "RAGWEED", + "displayName": "Ragweed", + "indexInfo": {"value": 4, "category": "HIGH"}, + } + ], + }, + ] + } + + fake_session = FakeSession(payload) + client = client_mod.GooglePollenApiClient(fake_session, "test") + + loop = asyncio.new_event_loop() + hass = DummyHass(loop) + coordinator = coordinator_mod.PollenDataUpdateCoordinator( + hass=hass, + api_key="test", + lat=1.0, + lon=2.0, + hours=12, + language=None, + entry_id="entry", + forecast_days=3, + create_d1=False, + create_d2=False, + client=client, + ) + + try: + data = loop.run_until_complete(coordinator._async_update_data()) + finally: + loop.close() + + entry = data["plants_ragweed"] + assert entry["code"] == "RAGWEED" + assert entry["tomorrow_has_index"] is True + assert entry["tomorrow_value"] == 4 + + def test_coordinator_type_keys_are_deterministic_sorted() -> None: """Type sensor keys are emitted in stable sorted order.""" From befaca1684f8ba9a992044c58a762480c75d2869 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sat, 14 Feb 2026 00:03:16 +0100 Subject: [PATCH 177/200] Harden non-finite numeric handling across flows and diagnostics --- CHANGELOG.md | 4 ++ custom_components/pollenlevels/config_flow.py | 8 ++- custom_components/pollenlevels/coordinator.py | 5 +- custom_components/pollenlevels/diagnostics.py | 7 ++- tests/test_config_flow.py | 16 ++++++ tests/test_diagnostics.py | 34 +++++++++++++ tests/test_sensor.py | 50 +++++++++++++++++++ 7 files changed, 119 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6571b068..95eba20c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ populated even when API casing differs between days. - Clamped diagnostics `request_params_example.days` to supported forecast ranges and handled non-finite values defensively. +- Hardened numeric parsing guards for config/options inputs and color channels + to safely reject non-finite values without raising runtime errors. +- Dropped non-finite rounded coordinates in diagnostics request examples to keep + support payloads consistent and safe. ## [1.9.1] - 2026-01-10 ### Fixed diff --git a/custom_components/pollenlevels/config_flow.py b/custom_components/pollenlevels/config_flow.py index be14e58e..ce4f3615 100644 --- a/custom_components/pollenlevels/config_flow.py +++ b/custom_components/pollenlevels/config_flow.py @@ -15,6 +15,7 @@ import json import logging +import math import re from typing import Any @@ -220,8 +221,11 @@ def _parse_int_option( ) -> tuple[int, str | None]: """Parse a numeric option to int and enforce bounds.""" try: - parsed = int(float(value if value is not None else default)) - except (TypeError, ValueError): + parsed_float = float(value if value is not None else default) + if not math.isfinite(parsed_float): + return default, error_key + parsed = int(parsed_float) + except (TypeError, ValueError, OverflowError): return default, error_key if min_value is not None and parsed < min_value: diff --git a/custom_components/pollenlevels/coordinator.py b/custom_components/pollenlevels/coordinator.py index 20d2e01e..6f2778cd 100644 --- a/custom_components/pollenlevels/coordinator.py +++ b/custom_components/pollenlevels/coordinator.py @@ -4,6 +4,7 @@ import asyncio import logging +import math from datetime import timedelta from typing import TYPE_CHECKING, Any @@ -34,7 +35,9 @@ def _normalize_channel(v: Any) -> int | None: """ try: f = float(v) - except (TypeError, ValueError): + except (TypeError, ValueError, OverflowError): + return None + if not math.isfinite(f): return None if 0.0 <= f <= 1.0: f *= 255.0 diff --git a/custom_components/pollenlevels/diagnostics.py b/custom_components/pollenlevels/diagnostics.py index 8ee0407c..ca6dc8ed 100644 --- a/custom_components/pollenlevels/diagnostics.py +++ b/custom_components/pollenlevels/diagnostics.py @@ -65,9 +65,12 @@ async def async_get_config_entry_diagnostics( # coordinates. This should not be redacted. def _rounded(value: Any) -> float | None: try: - return round(float(value), 1) - except (TypeError, ValueError): + f = float(value) + except (TypeError, ValueError, OverflowError): return None + if not math.isfinite(f): + return None + return round(f, 1) approx_location = { "label": "approximate_location (rounded)", diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 34e4c984..7d5de7cb 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -1171,3 +1171,19 @@ async def fake_validate( assert result["title"] == DEFAULT_ENTRY_TITLE assert result["data"] == normalized + + +@pytest.mark.parametrize("raw", ["inf", "-inf", "nan"]) +def test_parse_int_option_non_finite_returns_error(raw: str) -> None: + """Non-finite numeric values should be rejected safely.""" + + parsed, err = cf._parse_int_option( + raw, + default=cf.DEFAULT_UPDATE_INTERVAL, + min_value=cf.MIN_UPDATE_INTERVAL_HOURS, + max_value=cf.MAX_UPDATE_INTERVAL_HOURS, + error_key="invalid_update_interval", + ) + + assert parsed == cf.DEFAULT_UPDATE_INTERVAL + assert err == "invalid_update_interval" diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index b00fa75a..4ee83f94 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -146,3 +146,37 @@ async def test_diagnostics_clamps_request_days( diagnostics = await diag.async_get_config_entry_diagnostics(None, entry) assert diagnostics["request_params_example"]["days"] == expected_days + + +@pytest.mark.asyncio +async def test_diagnostics_nonfinite_coordinates_are_omitted_in_examples() -> None: + """Rounded coordinate helpers should drop non-finite values.""" + + data = { + CONF_LATITUDE: "nan", + CONF_LONGITUDE: float("inf"), + CONF_LANGUAGE_CODE: "en", + } + options = {CONF_FORECAST_DAYS: 2} + + entry = _ConfigEntry(data=data, options=options, entry_id="entry", title="Home") + + coordinator = SimpleNamespace( + entry_id="entry", + forecast_days=2, + language="en", + create_d1=True, + create_d2=False, + last_updated=dt.datetime(2025, 1, 1, tzinfo=dt.UTC), + data={"type_grass": {"source": "type"}}, + ) + entry.runtime_data = PollenLevelsRuntimeData( + coordinator=coordinator, client=object() + ) + + diagnostics = await diag.async_get_config_entry_diagnostics(None, entry) + + assert diagnostics["approximate_location"]["latitude_rounded"] is None + assert diagnostics["approximate_location"]["longitude_rounded"] is None + assert diagnostics["request_params_example"]["location.latitude"] is None + assert diagnostics["request_params_example"]["location.longitude"] is None diff --git a/tests/test_sensor.py b/tests/test_sensor.py index e57902e4..f77381d8 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -941,6 +941,56 @@ def test_plant_forecast_matches_codes_case_insensitively() -> None: assert entry["tomorrow_value"] == 4 +def test_coordinator_ignores_nonfinite_color_channels() -> None: + """Non-finite color channel values should not crash or emit invalid colors.""" + + payload = { + "dailyInfo": [ + { + "date": {"year": 2025, "month": 7, "day": 1}, + "pollenTypeInfo": [ + { + "code": "GRASS", + "displayName": "Grass", + "indexInfo": { + "value": 1, + "category": "LOW", + "color": {"red": float("inf"), "green": float("nan")}, + }, + } + ], + } + ] + } + + fake_session = FakeSession(payload) + client = client_mod.GooglePollenApiClient(fake_session, "test") + + loop = asyncio.new_event_loop() + hass = DummyHass(loop) + coordinator = coordinator_mod.PollenDataUpdateCoordinator( + hass=hass, + api_key="test", + lat=1.0, + lon=2.0, + hours=12, + language=None, + entry_id="entry", + forecast_days=1, + create_d1=False, + create_d2=False, + client=client, + ) + + try: + data = loop.run_until_complete(coordinator._async_update_data()) + finally: + loop.close() + + assert data["type_grass"]["color_hex"] is None + assert data["type_grass"]["color_rgb"] is None + + def test_coordinator_type_keys_are_deterministic_sorted() -> None: """Type sensor keys are emitted in stable sorted order.""" From 752aed3624f579882868afee638a690d9f99d31f Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sat, 14 Feb 2026 00:04:12 +0100 Subject: [PATCH 178/200] Refine code stability for plant codes, integer parsing, and diagnostics redaction --- CHANGELOG.md | 6 ++++++ custom_components/pollenlevels/config_flow.py | 2 ++ custom_components/pollenlevels/coordinator.py | 5 ++++- tests/test_config_flow.py | 16 ++++++++++++++++ tests/test_diagnostics.py | 15 ++++++++++++++- tests/test_sensor.py | 2 +- 6 files changed, 43 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95eba20c..b11c0e04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,12 @@ to safely reject non-finite values without raising runtime errors. - Dropped non-finite rounded coordinates in diagnostics request examples to keep support payloads consistent and safe. +- Kept plant `code` attributes in their day-0 API form while using normalized + keys internally for cross-day forecast matching stability. +- Enforced integer-only parsing for numeric config/options fields to reject + decimal inputs consistently. +- Strengthened diagnostics tests with active redaction behavior checks for secret + fields in support payloads. ## [1.9.1] - 2026-01-10 ### Fixed diff --git a/custom_components/pollenlevels/config_flow.py b/custom_components/pollenlevels/config_flow.py index ce4f3615..006e400d 100644 --- a/custom_components/pollenlevels/config_flow.py +++ b/custom_components/pollenlevels/config_flow.py @@ -224,6 +224,8 @@ def _parse_int_option( parsed_float = float(value if value is not None else default) if not math.isfinite(parsed_float): return default, error_key + if not parsed_float.is_integer(): + return default, error_key parsed = int(parsed_float) except (TypeError, ValueError, OverflowError): return default, error_key diff --git a/custom_components/pollenlevels/coordinator.py b/custom_components/pollenlevels/coordinator.py index 6f2778cd..c836ca65 100644 --- a/custom_components/pollenlevels/coordinator.py +++ b/custom_components/pollenlevels/coordinator.py @@ -310,7 +310,10 @@ async def _async_update_data(self): desc_raw = pitem.get("plantDescription") desc = desc_raw if isinstance(desc_raw, dict) else {} rgb = _rgb_from_api(idx.get("color")) - code = _normalize_plant_code(pitem.get("code")) or norm_code + raw_code = pitem.get("code") + code = str(raw_code).strip() if raw_code is not None else "" + if not code: + code = norm_code key = f"plants_{code.lower()}" new_data[key] = { "source": "plant", diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 7d5de7cb..00c750d1 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -1187,3 +1187,19 @@ def test_parse_int_option_non_finite_returns_error(raw: str) -> None: assert parsed == cf.DEFAULT_UPDATE_INTERVAL assert err == "invalid_update_interval" + + +@pytest.mark.parametrize("raw", ["2.9", 2.1]) +def test_parse_int_option_decimal_returns_error(raw: object) -> None: + """Decimal values should be rejected for integer-only options.""" + + parsed, err = cf._parse_int_option( + raw, + default=cf.DEFAULT_UPDATE_INTERVAL, + min_value=cf.MIN_UPDATE_INTERVAL_HOURS, + max_value=cf.MAX_UPDATE_INTERVAL_HOURS, + error_key="invalid_update_interval", + ) + + assert parsed == cf.DEFAULT_UPDATE_INTERVAL + assert err == "invalid_update_interval" diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index 4ee83f94..47a7070d 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -19,7 +19,17 @@ def _force_module(name: str, module: ModuleType) -> None: def _async_redact_data(data: dict[str, Any], _redact: set[str]) -> dict[str, Any]: - return data + def _walk(value): + if isinstance(value, dict): + return { + k: ("**REDACTED**" if k in _redact else _walk(v)) + for k, v in value.items() + } + if isinstance(value, list): + return [_walk(v) for v in value] + return value + + return _walk(data) diagnostics_mod.async_redact_data = _async_redact_data @@ -60,6 +70,7 @@ class _HomeAssistant: from custom_components.pollenlevels import diagnostics as diag # noqa: E402 from custom_components.pollenlevels.const import ( # noqa: E402 + CONF_API_KEY, CONF_FORECAST_DAYS, CONF_LANGUAGE_CODE, CONF_LATITUDE, @@ -76,6 +87,7 @@ async def test_diagnostics_rounds_coordinates_and_truncates_keys() -> None: """Diagnostics should use rounded coordinates and limit data_keys length.""" data = { + CONF_API_KEY: "secret-token", CONF_LATITUDE: 12.3456, CONF_LONGITUDE: 78.9876, CONF_LANGUAGE_CODE: "en", @@ -99,6 +111,7 @@ async def test_diagnostics_rounds_coordinates_and_truncates_keys() -> None: diagnostics = await diag.async_get_config_entry_diagnostics(None, entry) + assert diagnostics["request_params_example"]["key"] == "***" assert CONF_LATITUDE not in diagnostics["entry"]["data"] assert CONF_LONGITUDE not in diagnostics["entry"]["data"] assert diagnostics["request_params_example"]["location.latitude"] == 12.3 diff --git a/tests/test_sensor.py b/tests/test_sensor.py index f77381d8..6a854d31 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -936,7 +936,7 @@ def test_plant_forecast_matches_codes_case_insensitively() -> None: loop.close() entry = data["plants_ragweed"] - assert entry["code"] == "RAGWEED" + assert entry["code"] == "ragweed" assert entry["tomorrow_has_index"] is True assert entry["tomorrow_value"] == 4 From 5dffa0f05ce2f3e44ea3e6885d317b1ae80e4f41 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sat, 14 Feb 2026 00:05:01 +0100 Subject: [PATCH 179/200] Align runtime parsing rules and optimize coordinator forecast paths --- CHANGELOG.md | 3 ++ custom_components/pollenlevels/__init__.py | 3 +- custom_components/pollenlevels/coordinator.py | 11 ++++-- tests/test_init.py | 38 +++++++++++++++++++ 4 files changed, 50 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b11c0e04..1ad7723e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,9 @@ decimal inputs consistently. - Strengthened diagnostics tests with active redaction behavior checks for secret fields in support payloads. +- Aligned runtime integer parsing with config-flow rules (reject non-integer + numeric values) and reduced coordinator forecast overhead by reusing offset + maps and plant-key lists during attribute enrichment. ## [1.9.1] - 2026-01-10 ### Fixed diff --git a/custom_components/pollenlevels/__init__.py b/custom_components/pollenlevels/__init__.py index 4efdccdb..012ea03b 100644 --- a/custom_components/pollenlevels/__init__.py +++ b/custom_components/pollenlevels/__init__.py @@ -183,9 +183,10 @@ async def async_setup_entry( options = entry.options or {} def _safe_int(value: Any, default: int) -> int: + """Parse integer settings defensively, rejecting non-finite/decimal input.""" try: val = float(value if value is not None else default) - if val != val or val in (float("inf"), float("-inf")): + if not math.isfinite(val) or not val.is_integer(): return default return int(val) except (TypeError, ValueError, OverflowError): diff --git a/custom_components/pollenlevels/coordinator.py b/custom_components/pollenlevels/coordinator.py index c836ca65..e6dbccdf 100644 --- a/custom_components/pollenlevels/coordinator.py +++ b/custom_components/pollenlevels/coordinator.py @@ -159,9 +159,10 @@ def _process_forecast_attributes( Does NOT touch per-day TYPE sensor creation (kept elsewhere). """ base["forecast"] = forecast_list + forecast_by_offset = {item.get("offset"): item for item in forecast_list} def _set_convenience(prefix: str, off: int) -> None: - f = next((d for d in forecast_list if d["offset"] == off), None) + f = forecast_by_offset.get(off) base[f"{prefix}_has_index"] = f.get("has_index") if f else False base[f"{prefix}_value"] = ( f.get("value") if f and f.get("has_index") else None @@ -299,6 +300,8 @@ async def _async_update_data(self): "color_rgb": list(rgb) if rgb is not None else None, } + plant_keys: list[str] = [] + # Current-day PLANTS for norm_code, pitem in plant_by_day_code[0].items(): # Safety: skip plants without a stable 'code' to avoid duplicate 'plants_' keys @@ -333,6 +336,7 @@ async def _async_update_data(self): "picture": desc.get("picture"), "picture_closeup": desc.get("pictureCloseup"), } + plant_keys.append(key) # Forecast for TYPES def _extract_day_info(day: dict) -> tuple[str | None, dict | None]: @@ -452,9 +456,8 @@ def _add_day_sensor( _add_day_sensor(2) # Forecast for PLANTS (attributes only; no per-day plant sensors) - for key, base in list(new_data.items()): - if base.get("source") != "plant": - continue + for key in plant_keys: + base = new_data.get(key) or {} pcode = _normalize_plant_code(base.get("code")) if not pcode: # Safety: skip if for some reason code is missing diff --git a/tests/test_init.py b/tests/test_init.py index d0da219e..edeacbf7 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -425,6 +425,44 @@ def test_setup_entry_boundary_coordinates_are_allowed() -> None: assert asyncio.run(integration.async_setup_entry(hass, entry)) is True +def test_setup_entry_decimal_numeric_options_fallback_to_defaults() -> None: + """Decimal options should not be truncated silently during setup.""" + + hass = _FakeHass() + entry = _FakeEntry( + data={ + integration.CONF_API_KEY: "key", + integration.CONF_LATITUDE: 1.0, + integration.CONF_LONGITUDE: 2.0, + }, + options={ + integration.CONF_UPDATE_INTERVAL: 2.5, + integration.CONF_FORECAST_DAYS: 3.1, + }, + ) + + seen: dict[str, int] = {} + + class _StubCoordinator(update_coordinator_mod.DataUpdateCoordinator): + def __init__(self, *args, **kwargs): + seen["hours"] = kwargs["hours"] + seen["forecast_days"] = kwargs["forecast_days"] + self.data = {"region": {"source": "meta"}, "date": {"source": "meta"}} + + async def async_config_entry_first_refresh(self): + return None + + orig_coordinator = integration.PollenDataUpdateCoordinator + integration.PollenDataUpdateCoordinator = _StubCoordinator + + try: + assert asyncio.run(integration.async_setup_entry(hass, entry)) is True + assert seen["hours"] == integration.DEFAULT_UPDATE_INTERVAL + assert seen["forecast_days"] == integration.DEFAULT_FORECAST_DAYS + finally: + integration.PollenDataUpdateCoordinator = orig_coordinator + + def test_setup_entry_wraps_generic_error() -> None: """Unexpected errors convert to ConfigEntryNotReady for retries.""" From 8e7bbc947811df24e3a85b195817bc974443155b Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sat, 14 Feb 2026 00:34:36 +0100 Subject: [PATCH 180/200] Clarify async_redact_data sync behavior in diagnostics --- CHANGELOG.md | 5 +++-- custom_components/pollenlevels/diagnostics.py | 2 ++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ad7723e..9ce3be59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,8 +37,9 @@ to prevent secrets from appearing in setup errors. - Cleared stale setup error placeholders when per-day sensor options are incompatible with the selected forecast days. -- Ensured diagnostics redaction returns the final redacted payload without - attempting to await the synchronous helper. +- Clarified diagnostics redaction flow: `async_redact_data` is a synchronous + helper in HA Core (despite the name), ensuring diagnostics return the final + redacted payload. - Trimmed diagnostics payloads to avoid listing all data keys and to hide precise coordinates while keeping rounded location context for support. - Updated the `pollenlevels.force_update` service to request a coordinator diff --git a/custom_components/pollenlevels/diagnostics.py b/custom_components/pollenlevels/diagnostics.py index ca6dc8ed..9284e253 100644 --- a/custom_components/pollenlevels/diagnostics.py +++ b/custom_components/pollenlevels/diagnostics.py @@ -200,5 +200,7 @@ def _rounded(value: Any) -> float | None: "request_params_example": params_example, } + # NOTE: Home Assistant's `async_redact_data` is a synchronous callback helper + # despite its `async_` prefix. Do not `await` it. # Redact secrets and return return async_redact_data(diag, TO_REDACT) From 98441e8d1c65e847a751aaf91e8e7c1d669fcb91 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sat, 14 Feb 2026 16:03:50 +0100 Subject: [PATCH 181/200] Update custom_components/pollenlevels/coordinator.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- custom_components/pollenlevels/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/pollenlevels/coordinator.py b/custom_components/pollenlevels/coordinator.py index e6dbccdf..349a66ad 100644 --- a/custom_components/pollenlevels/coordinator.py +++ b/custom_components/pollenlevels/coordinator.py @@ -303,7 +303,7 @@ async def _async_update_data(self): plant_keys: list[str] = [] # Current-day PLANTS - for norm_code, pitem in plant_by_day_code[0].items(): + for norm_code, pitem in sorted(plant_by_day_code[0].items()): # Safety: skip plants without a stable 'code' to avoid duplicate 'plants_' keys # and silent overwrites. This is robust and avoids creating unstable entities. if not norm_code: From 0e401945e375ca4bd9899e5baf08303a9c1bf687 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sat, 14 Feb 2026 16:04:04 +0100 Subject: [PATCH 182/200] Update custom_components/pollenlevels/coordinator.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- custom_components/pollenlevels/coordinator.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/custom_components/pollenlevels/coordinator.py b/custom_components/pollenlevels/coordinator.py index 349a66ad..05e65ae2 100644 --- a/custom_components/pollenlevels/coordinator.py +++ b/custom_components/pollenlevels/coordinator.py @@ -306,8 +306,6 @@ async def _async_update_data(self): for norm_code, pitem in sorted(plant_by_day_code[0].items()): # Safety: skip plants without a stable 'code' to avoid duplicate 'plants_' keys # and silent overwrites. This is robust and avoids creating unstable entities. - if not norm_code: - continue idx_raw = pitem.get("indexInfo") idx = idx_raw if isinstance(idx_raw, dict) else {} desc_raw = pitem.get("plantDescription") @@ -315,8 +313,6 @@ async def _async_update_data(self): rgb = _rgb_from_api(idx.get("color")) raw_code = pitem.get("code") code = str(raw_code).strip() if raw_code is not None else "" - if not code: - code = norm_code key = f"plants_{code.lower()}" new_data[key] = { "source": "plant", From cd0316b9741b7a06dd7c8278901288d8a5b14192 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sat, 14 Feb 2026 16:17:43 +0100 Subject: [PATCH 183/200] Update coordinator.py --- custom_components/pollenlevels/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/pollenlevels/coordinator.py b/custom_components/pollenlevels/coordinator.py index 05e65ae2..5170ca32 100644 --- a/custom_components/pollenlevels/coordinator.py +++ b/custom_components/pollenlevels/coordinator.py @@ -303,7 +303,7 @@ async def _async_update_data(self): plant_keys: list[str] = [] # Current-day PLANTS - for norm_code, pitem in sorted(plant_by_day_code[0].items()): + for _norm_code, pitem in sorted(plant_by_day_code[0].items()): # Safety: skip plants without a stable 'code' to avoid duplicate 'plants_' keys # and silent overwrites. This is robust and avoids creating unstable entities. idx_raw = pitem.get("indexInfo") From 4bcd9e85501abce91b3920a2addebeb216d03d9f Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sat, 14 Feb 2026 16:18:25 +0100 Subject: [PATCH 184/200] Update manifest.json --- custom_components/pollenlevels/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/pollenlevels/manifest.json b/custom_components/pollenlevels/manifest.json index 3a9a3da3..8a6d4eb7 100644 --- a/custom_components/pollenlevels/manifest.json +++ b/custom_components/pollenlevels/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "issue_tracker": "https://github.com/eXPerience83/pollenlevels/issues", - "version": "1.9.2" + "version": "1.9.3" } From 4a55dbc4134ceb8f6ece0d35b01030b471658cd8 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sat, 14 Feb 2026 16:19:48 +0100 Subject: [PATCH 185/200] Update pyproject.toml --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index d4161e61..c29fb16d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ [project] name = "pollenlevels" -version = "1.9.2" +version = "1.9.3" # Enforce the runtime floor aligned with upcoming HA Python 3.14 images. requires-python = ">=3.14" From ab945b5caa75dc491cadbf294e3ba66b399b0c1c Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sat, 14 Feb 2026 16:22:13 +0100 Subject: [PATCH 186/200] Update CHANGELOG.md --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ce3be59..f3651ec9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,11 @@ # Changelog +## [1.9.3] - 2026-02-14 +### Fixed +- Ensured deterministic current-day plant sensor creation by sorting plant codes. + +### Changed +- Simplified plant parsing by removing redundant code checks (non-empty by construction). +- ## [1.9.2] - 2026-02-13 ### Fixed - Re-raised `asyncio.CancelledError` during coordinator updates to avoid wrapping From 1bced01d049201d082e432547c87b1799be24a98 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sat, 14 Feb 2026 17:58:45 +0100 Subject: [PATCH 187/200] Update CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f3651ec9..c6b75cae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ ### Changed - Simplified plant parsing by removing redundant code checks (non-empty by construction). -- + ## [1.9.2] - 2026-02-13 ### Fixed - Re-raised `asyncio.CancelledError` during coordinator updates to avoid wrapping From aee21137e3052d99ff48f47fdf7461ac5549c8fa Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Mon, 16 Feb 2026 16:49:32 +0100 Subject: [PATCH 188/200] Update coordinator.py --- custom_components/pollenlevels/coordinator.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/custom_components/pollenlevels/coordinator.py b/custom_components/pollenlevels/coordinator.py index 5170ca32..390211df 100644 --- a/custom_components/pollenlevels/coordinator.py +++ b/custom_components/pollenlevels/coordinator.py @@ -304,8 +304,10 @@ async def _async_update_data(self): # Current-day PLANTS for _norm_code, pitem in sorted(plant_by_day_code[0].items()): - # Safety: skip plants without a stable 'code' to avoid duplicate 'plants_' keys - # and silent overwrites. This is robust and avoids creating unstable entities. + # NOTE: plant_by_day_code[0] is built using normalized, non-empty plant codes as keys, + # so `_norm_code` is guaranteed to be a stable non-empty identifier. + # We still derive `code` from the raw API field (stripped) for attributes, while + # using lowercased `code` for the sensor key to keep entity creation deterministic. idx_raw = pitem.get("indexInfo") idx = idx_raw if isinstance(idx_raw, dict) else {} desc_raw = pitem.get("plantDescription") From 0502228a46b43a9f2a1cdf09cefc486d21649ba0 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Tue, 17 Feb 2026 11:00:07 +0100 Subject: [PATCH 189/200] Update changelog for integer parsing deduplication --- CHANGELOG.md | 2 ++ custom_components/pollenlevels/__init__.py | 28 ++++++++----------- custom_components/pollenlevels/config_flow.py | 18 ++++++------ custom_components/pollenlevels/diagnostics.py | 20 ++++++------- custom_components/pollenlevels/util.py | 18 ++++++++++++ tests/test_util.py | 24 +++++++++++++++- 6 files changed, 71 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c6b75cae..0cd99b73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ ### Changed - Simplified plant parsing by removing redundant code checks (non-empty by construction). +- Deduplicated defensive integer parsing into a shared utility and aligned diagnostics + with runtime/config-flow rules to reject non-finite or decimal values consistently. ## [1.9.2] - 2026-02-13 ### Fixed diff --git a/custom_components/pollenlevels/__init__.py b/custom_components/pollenlevels/__init__.py index 012ea03b..0dfd58a0 100644 --- a/custom_components/pollenlevels/__init__.py +++ b/custom_components/pollenlevels/__init__.py @@ -42,7 +42,7 @@ from .coordinator import PollenDataUpdateCoordinator from .runtime import PollenLevelsConfigEntry, PollenLevelsRuntimeData from .sensor import ForecastSensorMode -from .util import normalize_sensor_mode, redact_api_key +from .util import normalize_sensor_mode, redact_api_key, safe_parse_int # Ensure YAML config is entry-only for this domain (no YAML schema). CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -182,30 +182,24 @@ async def async_setup_entry( options = entry.options or {} - def _safe_int(value: Any, default: int) -> int: - """Parse integer settings defensively, rejecting non-finite/decimal input.""" - try: - val = float(value if value is not None else default) - if not math.isfinite(val) or not val.is_integer(): - return default - return int(val) - except (TypeError, ValueError, OverflowError): - return default - - hours = _safe_int( + parsed_hours = safe_parse_int( options.get( CONF_UPDATE_INTERVAL, entry.data.get(CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL), - ), - DEFAULT_UPDATE_INTERVAL, + ) ) + hours = parsed_hours if parsed_hours is not None else DEFAULT_UPDATE_INTERVAL hours = max(MIN_UPDATE_INTERVAL_HOURS, min(MAX_UPDATE_INTERVAL_HOURS, hours)) - forecast_days = _safe_int( + parsed_forecast_days = safe_parse_int( options.get( CONF_FORECAST_DAYS, entry.data.get(CONF_FORECAST_DAYS, DEFAULT_FORECAST_DAYS), - ), - DEFAULT_FORECAST_DAYS, + ) + ) + forecast_days = ( + parsed_forecast_days + if parsed_forecast_days is not None + else DEFAULT_FORECAST_DAYS ) forecast_days = max(MIN_FORECAST_DAYS, min(MAX_FORECAST_DAYS, forecast_days)) language = options.get(CONF_LANGUAGE_CODE, entry.data.get(CONF_LANGUAGE_CODE)) diff --git a/custom_components/pollenlevels/config_flow.py b/custom_components/pollenlevels/config_flow.py index 006e400d..df2c1082 100644 --- a/custom_components/pollenlevels/config_flow.py +++ b/custom_components/pollenlevels/config_flow.py @@ -15,7 +15,6 @@ import json import logging -import math import re from typing import Any @@ -59,7 +58,12 @@ RESTRICTING_API_KEYS_URL, is_invalid_api_key_message, ) -from .util import extract_error_message, normalize_sensor_mode, redact_api_key +from .util import ( + extract_error_message, + normalize_sensor_mode, + redact_api_key, + safe_parse_int, +) _LOGGER = logging.getLogger(__name__) @@ -220,14 +224,8 @@ def _parse_int_option( error_key: str | None = None, ) -> tuple[int, str | None]: """Parse a numeric option to int and enforce bounds.""" - try: - parsed_float = float(value if value is not None else default) - if not math.isfinite(parsed_float): - return default, error_key - if not parsed_float.is_integer(): - return default, error_key - parsed = int(parsed_float) - except (TypeError, ValueError, OverflowError): + parsed = safe_parse_int(value if value is not None else default) + if parsed is None: return default, error_key if min_value is not None and parsed < min_value: diff --git a/custom_components/pollenlevels/diagnostics.py b/custom_components/pollenlevels/diagnostics.py index 9284e253..fccf0fa3 100644 --- a/custom_components/pollenlevels/diagnostics.py +++ b/custom_components/pollenlevels/diagnostics.py @@ -31,7 +31,7 @@ MIN_FORECAST_DAYS, ) from .runtime import PollenLevelsRuntimeData -from .util import redact_api_key +from .util import redact_api_key, safe_parse_int # Redact potentially sensitive values from diagnostics. TO_REDACT = { @@ -80,18 +80,16 @@ def _rounded(value: Any) -> float | None: # --- Build a safe params example (no network I/O) ---------------------- # Use DEFAULT_FORECAST_DAYS from const.py to avoid config drift. - try: - days_raw = options.get( - CONF_FORECAST_DAYS, - data.get(CONF_FORECAST_DAYS, DEFAULT_FORECAST_DAYS), - ) - days_float = float(days_raw) - if not math.isfinite(days_float): - raise ValueError - days_effective = int(days_float) - except (TypeError, ValueError, OverflowError): + days_raw = options.get( + CONF_FORECAST_DAYS, + data.get(CONF_FORECAST_DAYS, DEFAULT_FORECAST_DAYS), + ) + parsed_days = safe_parse_int(days_raw) + if parsed_days is None: # Defensive fallback days_effective = DEFAULT_FORECAST_DAYS + else: + days_effective = parsed_days days_effective = max(MIN_FORECAST_DAYS, min(MAX_FORECAST_DAYS, days_effective)) diff --git a/custom_components/pollenlevels/util.py b/custom_components/pollenlevels/util.py index d3763e62..24885afb 100644 --- a/custom_components/pollenlevels/util.py +++ b/custom_components/pollenlevels/util.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +import math from typing import TYPE_CHECKING, Any from .const import FORECAST_SENSORS_CHOICES @@ -92,6 +93,22 @@ def normalize_sensor_mode(mode: Any, logger: logging.Logger) -> str: return default_mode +def safe_parse_int(value: Any) -> int | None: + """Parse an integer-like value, rejecting non-finite and decimal numbers.""" + if value is None: + return None + + try: + parsed_float = float(value) + except (TypeError, ValueError, OverflowError): + return None + + if not math.isfinite(parsed_float) or not parsed_float.is_integer(): + return None + + return int(parsed_float) + + # Backwards-compatible alias for modules that still import the private helper name. _redact_api_key = redact_api_key @@ -99,5 +116,6 @@ def normalize_sensor_mode(mode: Any, logger: logging.Logger) -> str: "extract_error_message", "normalize_sensor_mode", "redact_api_key", + "safe_parse_int", "_redact_api_key", ] diff --git a/tests/test_util.py b/tests/test_util.py index 17184d1e..ddd1f3d8 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -1,6 +1,8 @@ """Tests for shared utilities.""" -from custom_components.pollenlevels.util import redact_api_key +import pytest + +from custom_components.pollenlevels.util import redact_api_key, safe_parse_int def test_redact_api_key_handles_non_utf8_bytes(): @@ -21,3 +23,23 @@ def test_redact_api_key_returns_empty_string_for_none(): """None inputs should yield an empty string.""" assert redact_api_key(None, "anything") == "" + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + (3, 3), + (3.0, 3), + ("3", 3), + ("3.0", 3), + (None, None), + ("3.5", None), + (3.5, None), + ("nan", None), + ("inf", None), + ], +) +def test_safe_parse_int(value, expected): + """safe_parse_int accepts integer-like values and rejects invalid input.""" + + assert safe_parse_int(value) == expected From fea43e5137da1b2a6ce630d1e4cbb4af9465aad4 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Tue, 17 Feb 2026 23:02:21 +0100 Subject: [PATCH 190/200] Harden API key validation and mask config flow input --- CHANGELOG.md | 2 + custom_components/pollenlevels/__init__.py | 3 +- custom_components/pollenlevels/config_flow.py | 8 +-- tests/test_config_flow.py | 51 +++++++++++++++++++ tests/test_init.py | 16 ++++++ 5 files changed, 76 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cd99b73..6d6bf947 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## [1.9.3] - 2026-02-14 ### Fixed - Ensured deterministic current-day plant sensor creation by sorting plant codes. +- Reject whitespace-only API keys at setup (defensive validation). +- Mask API key input fields in config flow (password selector). ### Changed - Simplified plant parsing by removing redundant code checks (non-empty by construction). diff --git a/custom_components/pollenlevels/__init__.py b/custom_components/pollenlevels/__init__.py index 0dfd58a0..31160f18 100644 --- a/custom_components/pollenlevels/__init__.py +++ b/custom_components/pollenlevels/__init__.py @@ -215,8 +215,9 @@ async def async_setup_entry( create_d2 = mode == ForecastSensorMode.D1_D2 api_key = entry.data.get(CONF_API_KEY) - if not api_key: + if not isinstance(api_key, str) or not api_key.strip(): raise ConfigEntryAuthFailed("Missing API key") + api_key = api_key.strip() raw_lat = entry.data.get(CONF_LATITUDE) raw_lon = entry.data.get(CONF_LONGITUDE) diff --git a/custom_components/pollenlevels/config_flow.py b/custom_components/pollenlevels/config_flow.py index df2c1082..150df6a7 100644 --- a/custom_components/pollenlevels/config_flow.py +++ b/custom_components/pollenlevels/config_flow.py @@ -149,7 +149,9 @@ def _build_step_user_schema(hass: Any, user_input: dict[str, Any] | None) -> vol schema = vol.Schema( { - vol.Required(CONF_API_KEY): str, + vol.Required(CONF_API_KEY): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), vol.Required(CONF_NAME, default=default_name): str, location_field: LocationSelector(LocationSelectorConfig(radius=False)), vol.Optional( @@ -563,8 +565,8 @@ async def async_step_reauth_confirm(self, user_input: dict[str, Any] | None = No { vol.Required( CONF_API_KEY, - default=self._reauth_entry.data.get(CONF_API_KEY, ""), - ): str + default="", + ): TextSelector(TextSelectorConfig(type=TextSelectorType.PASSWORD)) } ) diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 00c750d1..e1e78324 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -218,6 +218,7 @@ def __init__(self, *, type: str | None = None): # noqa: A003 class _TextSelectorType: TEXT = "TEXT" + PASSWORD = "PASSWORD" class _TextSelector: @@ -653,6 +654,56 @@ def _capture_optional(key, **kwargs): assert captured_defaults == [FORECAST_SENSORS_CHOICES[0]] +def test_step_user_schema_masks_api_key_field() -> None: + """Initial setup form should render API key as a password selector.""" + + hass = SimpleNamespace( + config=SimpleNamespace(latitude=1.0, longitude=2.0, language="en") + ) + + schema = cf._build_step_user_schema(hass, {}) + api_selector = schema.schema[CONF_API_KEY] + + assert isinstance(api_selector, cf.TextSelector) + assert api_selector.config.type == cf.TextSelectorType.PASSWORD + + +def test_reauth_confirm_schema_masks_api_key_and_uses_blank_default() -> None: + """Reauth form should mask API key input and avoid prefilling secrets.""" + + entry = cf.config_entries.ConfigEntry( + data={ + CONF_API_KEY: "old-key", + CONF_LATITUDE: 1.0, + CONF_LONGITUDE: 2.0, + }, + entry_id="entry-id", + ) + + flow = PollenLevelsConfigFlow() + flow.hass = SimpleNamespace(config_entries=SimpleNamespace()) + flow.context = {"entry_id": "entry-id"} + flow._reauth_entry = entry + + captured: dict[str, object] = {} + + def _capture_show_form(*, step_id=None, data_schema=None, **kwargs): + captured["step_id"] = step_id + captured["schema"] = data_schema + return {"step_id": step_id} + + flow.async_show_form = _capture_show_form # type: ignore[method-assign] + + result = asyncio.run(flow.async_step_reauth_confirm()) + + assert result == {"step_id": "reauth_confirm"} + schema = captured["schema"] + assert hasattr(schema, "schema") + api_selector = schema.schema[CONF_API_KEY] + assert isinstance(api_selector, cf.TextSelector) + assert api_selector.config.type == cf.TextSelectorType.PASSWORD + + def test_validate_input_update_interval_below_min_sets_error( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/test_init.py b/tests/test_init.py index edeacbf7..f7431fdc 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -369,6 +369,22 @@ def test_setup_entry_missing_api_key_raises_auth_failed() -> None: asyncio.run(integration.async_setup_entry(hass, entry)) +def test_setup_entry_whitespace_api_key_raises_auth_failed() -> None: + """Whitespace-only API key should trigger ConfigEntryAuthFailed.""" + + hass = _FakeHass() + entry = _FakeEntry( + data={ + integration.CONF_API_KEY: " ", + integration.CONF_LATITUDE: 1.0, + integration.CONF_LONGITUDE: 2.0, + } + ) + + with pytest.raises(integration.ConfigEntryAuthFailed): + asyncio.run(integration.async_setup_entry(hass, entry)) + + def test_setup_entry_invalid_coordinates_raise_not_ready() -> None: """Invalid coordinates should trigger ConfigEntryNotReady.""" From 76e3ddb96bd5daacfadadab8e12673044489a20f Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Wed, 18 Feb 2026 09:26:49 +0100 Subject: [PATCH 191/200] Refine API key auth message and reauth schema assertion --- custom_components/pollenlevels/__init__.py | 2 +- tests/test_config_flow.py | 15 ++++++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/custom_components/pollenlevels/__init__.py b/custom_components/pollenlevels/__init__.py index 31160f18..a249c2ab 100644 --- a/custom_components/pollenlevels/__init__.py +++ b/custom_components/pollenlevels/__init__.py @@ -216,7 +216,7 @@ async def async_setup_entry( api_key = entry.data.get(CONF_API_KEY) if not isinstance(api_key, str) or not api_key.strip(): - raise ConfigEntryAuthFailed("Missing API key") + raise ConfigEntryAuthFailed("Invalid API key") api_key = api_key.strip() raw_lat = entry.data.get(CONF_LATITUDE) diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index e1e78324..6b04f7f3 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -668,9 +668,21 @@ def test_step_user_schema_masks_api_key_field() -> None: assert api_selector.config.type == cf.TextSelectorType.PASSWORD -def test_reauth_confirm_schema_masks_api_key_and_uses_blank_default() -> None: +def test_reauth_confirm_schema_masks_api_key_and_uses_blank_default( + monkeypatch: pytest.MonkeyPatch, +) -> None: """Reauth form should mask API key input and avoid prefilling secrets.""" + captured_default: dict[str, object] = {} + orig_required = cf.vol.Required + + def _capture_required(key, **kwargs): + if key == CONF_API_KEY: + captured_default["api_key"] = kwargs.get("default") + return orig_required(key, **kwargs) + + monkeypatch.setattr(cf.vol, "Required", _capture_required) + entry = cf.config_entries.ConfigEntry( data={ CONF_API_KEY: "old-key", @@ -697,6 +709,7 @@ def _capture_show_form(*, step_id=None, data_schema=None, **kwargs): result = asyncio.run(flow.async_step_reauth_confirm()) assert result == {"step_id": "reauth_confirm"} + assert captured_default["api_key"] == "" schema = captured["schema"] assert hasattr(schema, "schema") api_selector = schema.schema[CONF_API_KEY] From f14c925584db55ac97709f1f16bd36001e56d684 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Thu, 19 Feb 2026 07:51:44 +0100 Subject: [PATCH 192/200] Localize D+2 wording in non-English translations --- custom_components/pollenlevels/__init__.py | 3 ++ custom_components/pollenlevels/coordinator.py | 9 +++-- custom_components/pollenlevels/sensor.py | 11 ++---- .../pollenlevels/translations/ca.json | 2 +- .../pollenlevels/translations/cs.json | 2 +- .../pollenlevels/translations/da.json | 2 +- .../pollenlevels/translations/de.json | 2 +- .../pollenlevels/translations/en.json | 2 +- .../pollenlevels/translations/es.json | 2 +- .../pollenlevels/translations/fi.json | 2 +- .../pollenlevels/translations/fr.json | 2 +- .../pollenlevels/translations/hu.json | 2 +- .../pollenlevels/translations/it.json | 2 +- .../pollenlevels/translations/nb.json | 2 +- .../pollenlevels/translations/nl.json | 2 +- .../pollenlevels/translations/pl.json | 2 +- .../pollenlevels/translations/pt-BR.json | 2 +- .../pollenlevels/translations/pt-PT.json | 2 +- .../pollenlevels/translations/ro.json | 2 +- .../pollenlevels/translations/ru.json | 2 +- .../pollenlevels/translations/sv.json | 2 +- .../pollenlevels/translations/uk.json | 2 +- .../pollenlevels/translations/zh-Hans.json | 2 +- .../pollenlevels/translations/zh-Hant.json | 2 +- custom_components/pollenlevels/util.py | 2 +- tests/test_init.py | 39 +++++++++++++++++++ tests/test_sensor.py | 15 ++++--- tests/test_util.py | 2 + 28 files changed, 85 insertions(+), 38 deletions(-) diff --git a/custom_components/pollenlevels/__init__.py b/custom_components/pollenlevels/__init__.py index a249c2ab..d5458758 100644 --- a/custom_components/pollenlevels/__init__.py +++ b/custom_components/pollenlevels/__init__.py @@ -279,10 +279,13 @@ async def async_setup_entry( try: await hass.config_entries.async_forward_entry_setups(entry, ["sensor"]) except ConfigEntryAuthFailed: + entry.runtime_data = None raise except ConfigEntryNotReady: + entry.runtime_data = None raise except Exception as err: + entry.runtime_data = None _LOGGER.exception("Error forwarding entry setups: %s", err) raise ConfigEntryNotReady from err diff --git a/custom_components/pollenlevels/coordinator.py b/custom_components/pollenlevels/coordinator.py index 390211df..5770aa6c 100644 --- a/custom_components/pollenlevels/coordinator.py +++ b/custom_components/pollenlevels/coordinator.py @@ -19,7 +19,7 @@ MAX_FORECAST_DAYS, MIN_FORECAST_DAYS, ) -from .util import redact_api_key +from .util import redact_api_key, safe_parse_int if TYPE_CHECKING: from homeassistant.core import HomeAssistant @@ -130,9 +130,10 @@ def __init__( self.entry_id = entry_id self.entry_title = entry_title or DEFAULT_ENTRY_TITLE # Clamp defensively for legacy/manual entries to supported range. - self.forecast_days = max( - MIN_FORECAST_DAYS, min(MAX_FORECAST_DAYS, int(forecast_days)) - ) + parsed_days = safe_parse_int(forecast_days) + if parsed_days is None: + parsed_days = MIN_FORECAST_DAYS + self.forecast_days = max(MIN_FORECAST_DAYS, min(MAX_FORECAST_DAYS, parsed_days)) self.create_d1 = create_d1 self.create_d2 = create_d2 self._client = client diff --git a/custom_components/pollenlevels/sensor.py b/custom_components/pollenlevels/sensor.py index c7b0bb64..ced3a5cd 100644 --- a/custom_components/pollenlevels/sensor.py +++ b/custom_components/pollenlevels/sensor.py @@ -52,6 +52,7 @@ ) from .coordinator import PollenDataUpdateCoordinator from .runtime import PollenLevelsConfigEntry, PollenLevelsRuntimeData +from .util import safe_parse_int _LOGGER = logging.getLogger(__name__) @@ -156,13 +157,9 @@ async def async_setup_entry( coordinator = runtime.coordinator opts = config_entry.options or {} - try: - val = float(opts.get(CONF_FORECAST_DAYS, coordinator.forecast_days)) - if val != val or val in (float("inf"), float("-inf")): - raise ValueError - forecast_days = int(val) - except (TypeError, ValueError, OverflowError): - forecast_days = coordinator.forecast_days + raw_days = opts.get(CONF_FORECAST_DAYS, coordinator.forecast_days) + parsed = safe_parse_int(raw_days) + forecast_days = parsed if parsed is not None else coordinator.forecast_days forecast_days = max(MIN_FORECAST_DAYS, min(MAX_FORECAST_DAYS, forecast_days)) create_d1 = coordinator.create_d1 create_d2 = coordinator.create_d2 diff --git a/custom_components/pollenlevels/translations/ca.json b/custom_components/pollenlevels/translations/ca.json index cb3252e4..4b3f97d2 100644 --- a/custom_components/pollenlevels/translations/ca.json +++ b/custom_components/pollenlevels/translations/ca.json @@ -44,7 +44,7 @@ "step": { "init": { "title": "Pollen Levels – Opcions", - "description": "Canvia l’interval d’actualització, l’idioma de resposta de l’API, els dies de previsió i els sensors per dia per a {title}.\nOpcions de sensors per dia (TIPUS): Només avui (none), Fins demà (D+1), Fins demà passat (D+1+2).", + "description": "Canvia l’interval d’actualització, l’idioma de resposta de l’API, els dies de previsió i els sensors per dia per a {title}.\nOpcions de sensors per dia (TIPUS): Només avui (none), Fins demà (D+1), Fins demà passat (D+2; crea tant els sensors D+1 com D+2).", "data": { "update_interval": "Interval d’actualització (hores)", "language_code": "Codi d’idioma de la resposta de l’API", diff --git a/custom_components/pollenlevels/translations/cs.json b/custom_components/pollenlevels/translations/cs.json index b5ef666f..1d451730 100644 --- a/custom_components/pollenlevels/translations/cs.json +++ b/custom_components/pollenlevels/translations/cs.json @@ -44,7 +44,7 @@ "step": { "init": { "title": "Pollen Levels – Možnosti", - "description": "Změňte interval aktualizace, jazyk API, dny předpovědi a senzory po dnech pro {title}.\nMožnosti senzorů po dnech (TYPY): Pouze dnes (none), Do zítra (D+1), Do pozítří (D+1+2).", + "description": "Změňte interval aktualizace, jazyk API, dny předpovědi a senzory po dnech pro {title}.\nMožnosti senzorů po dnech (TYPY): Pouze dnes (none), Do zítra (D+1), Do pozítří (D+2; vytvoří senzory D+1 i D+2).", "data": { "update_interval": "Interval aktualizace (hodiny)", "language_code": "Kód jazyka odpovědi API", diff --git a/custom_components/pollenlevels/translations/da.json b/custom_components/pollenlevels/translations/da.json index 548c3c69..2ddc2d76 100644 --- a/custom_components/pollenlevels/translations/da.json +++ b/custom_components/pollenlevels/translations/da.json @@ -44,7 +44,7 @@ "step": { "init": { "title": "Pollen Levels – Indstillinger", - "description": "Skift opdateringsinterval, API-sprog, prognosedage og sensorer pr. dag for {title}.\nIndstillinger for sensorer pr. dag (TYPER): Kun i dag (none), Til og med i morgen (D+1), Til og med overmorgen (D+1+2).", + "description": "Skift opdateringsinterval, API-sprog, prognosedage og sensorer pr. dag for {title}.\nIndstillinger for sensorer pr. dag (TYPER): Kun i dag (none), Til og med i morgen (D+1), Til og med overmorgen (D+2; opretter både D+1- og D+2-sensorer).", "data": { "update_interval": "Opdateringsinterval (timer)", "language_code": "Sprogkode for API-svar", diff --git a/custom_components/pollenlevels/translations/de.json b/custom_components/pollenlevels/translations/de.json index a0975cc8..70b8a0d2 100644 --- a/custom_components/pollenlevels/translations/de.json +++ b/custom_components/pollenlevels/translations/de.json @@ -44,7 +44,7 @@ "step": { "init": { "title": "Pollen Levels – Optionen", - "description": "Ändere Aktualisierungsintervall, API-Sprache, Vorhersagetage und Tagessensoren für {title}.\nOptionen für Tagessensoren (TYPEN): Nur heute (none), Bis morgen (D+1), Bis übermorgen (D+1+2).", + "description": "Ändere Aktualisierungsintervall, API-Sprache, Vorhersagetage und Tagessensoren für {title}.\nOptionen für Tagessensoren (TYPEN): Nur heute (none), Bis morgen (D+1), Bis übermorgen (D+2; erstellt sowohl D+1- als auch D+2-Sensoren).", "data": { "update_interval": "Aktualisierungsintervall (Stunden)", "language_code": "Sprachcode für die API-Antwort", diff --git a/custom_components/pollenlevels/translations/en.json b/custom_components/pollenlevels/translations/en.json index 9a6630bd..a4750573 100644 --- a/custom_components/pollenlevels/translations/en.json +++ b/custom_components/pollenlevels/translations/en.json @@ -44,7 +44,7 @@ "step": { "init": { "title": "Pollen Levels – Options", - "description": "Change the update interval, API language, forecast days and per-day TYPE sensors for {title}.\nOptions for per-day TYPE sensors: Only today (none), Through tomorrow (D+1), Through day after tomorrow (D+1+2).", + "description": "Change the update interval, API language, forecast days and per-day TYPE sensors for {title}.\nOptions for per-day TYPE sensors: Only today (none), Through tomorrow (D+1), Through day after tomorrow (D+2; creates both D+1 and D+2 sensors).", "data": { "update_interval": "Update interval (hours)", "language_code": "API response language code", diff --git a/custom_components/pollenlevels/translations/es.json b/custom_components/pollenlevels/translations/es.json index afadb970..6dc960d0 100644 --- a/custom_components/pollenlevels/translations/es.json +++ b/custom_components/pollenlevels/translations/es.json @@ -44,7 +44,7 @@ "step": { "init": { "title": "Pollen Levels – Opciones", - "description": "Cambia el intervalo de actualización, el idioma de respuesta de la API, los días de previsión y los sensores por día para {title}.\nOpciones de sensores por día (TIPOS): Solo hoy (none), Hasta mañana (D+1), Hasta pasado mañana (D+1+2).", + "description": "Cambia el intervalo de actualización, el idioma de respuesta de la API, los días de previsión y los sensores por día para {title}.\nOpciones de sensores por día (TIPOS): Solo hoy (none), Hasta mañana (D+1), Hasta pasado mañana (D+2; crea sensores D+1 y D+2).", "data": { "update_interval": "Intervalo de actualización (horas)", "language_code": "Código de idioma de la respuesta de la API", diff --git a/custom_components/pollenlevels/translations/fi.json b/custom_components/pollenlevels/translations/fi.json index 09ca3740..d1d396ac 100644 --- a/custom_components/pollenlevels/translations/fi.json +++ b/custom_components/pollenlevels/translations/fi.json @@ -44,7 +44,7 @@ "step": { "init": { "title": "Pollen Levels – Asetukset", - "description": "Muuta päivitysväliä, API-kieltä, ennustepäiviä ja päiväsensoreita TYYPEILLE kohteelle {title}.\nPäiväsensorien vaihtoehdot (TYYPIT): Vain tänään (none), Huomiseen asti (D+1), Ylihuomiseen asti (D+1+2).", + "description": "Muuta päivitysväliä, API-kieltä, ennustepäiviä ja päiväsensoreita TYYPEILLE kohteelle {title}.\nPäiväsensorien vaihtoehdot (TYYPIT): Vain tänään (none), Huomiseen asti (D+1), Ylihuomiseen asti (D+2; luo sekä D+1- että D+2-sensorit).", "data": { "update_interval": "Päivitysväli (tunnit)", "language_code": "API-vastauksen kielikoodi", diff --git a/custom_components/pollenlevels/translations/fr.json b/custom_components/pollenlevels/translations/fr.json index e0371aa6..2e31d471 100644 --- a/custom_components/pollenlevels/translations/fr.json +++ b/custom_components/pollenlevels/translations/fr.json @@ -44,7 +44,7 @@ "step": { "init": { "title": "Pollen Levels – Options", - "description": "Modifiez l’intervalle de mise à jour, la langue de l’API, les jours de prévision et les capteurs par jour pour {title}.\nOptions des capteurs par jour (TYPES) : Aujourd’hui uniquement (none), Jusqu’à demain (D+1), Jusqu’au surlendemain (D+1+2).", + "description": "Modifiez l’intervalle de mise à jour, la langue de l’API, les jours de prévision et les capteurs par jour pour {title}.\nOptions des capteurs par jour (TYPES) : Aujourd’hui uniquement (none), Jusqu’à demain (D+1), Jusqu’au surlendemain (D+2 ; crée les capteurs D+1 et D+2).", "data": { "update_interval": "Intervalle de mise à jour (heures)", "language_code": "Code de langue pour la réponse de l’API", diff --git a/custom_components/pollenlevels/translations/hu.json b/custom_components/pollenlevels/translations/hu.json index 82322558..c6ac1d86 100644 --- a/custom_components/pollenlevels/translations/hu.json +++ b/custom_components/pollenlevels/translations/hu.json @@ -44,7 +44,7 @@ "step": { "init": { "title": "Pollen Levels – Beállítások", - "description": "Módosítsd a frissítési időközt, az API nyelvét, az előrejelzési napokat és a napi TÍPUS szenzorokat a(z) {title} bejegyzéshez.\nNapi TÍPUS szenzorok: Csak ma (none), Holnapig (D+1), Holnaputánig (D+1+2).", + "description": "Módosítsd a frissítési időközt, az API nyelvét, az előrejelzési napokat és a napi TÍPUS szenzorokat a(z) {title} bejegyzéshez.\nNapi TÍPUS szenzorok: Csak ma (none), Holnapig (D+1), Holnaputánig (D+2; létrehozza a D+1 és D+2 szenzorokat is).", "data": { "update_interval": "Frissítési időköz (óra)", "language_code": "API-válasz nyelvi kódja", diff --git a/custom_components/pollenlevels/translations/it.json b/custom_components/pollenlevels/translations/it.json index 8654dc4b..09ab015d 100644 --- a/custom_components/pollenlevels/translations/it.json +++ b/custom_components/pollenlevels/translations/it.json @@ -44,7 +44,7 @@ "step": { "init": { "title": "Pollen Levels – Opzioni", - "description": "Modifica l’intervallo di aggiornamento, la lingua della risposta dell’API, i giorni di previsione e i sensori giornalieri per i TIPI per {title}.\nOpzioni dei sensori giornalieri (TIPI): Solo oggi (none), Fino a domani (D+1), Fino a dopodomani (D+1+2).", + "description": "Modifica l’intervallo di aggiornamento, la lingua della risposta dell’API, i giorni di previsione e i sensori giornalieri per i TIPI per {title}.\nOpzioni dei sensori giornalieri (TIPI): Solo oggi (none), Fino a domani (D+1), Fino a dopodomani (D+2; crea sia i sensori D+1 che D+2).", "data": { "update_interval": "Intervallo di aggiornamento (ore)", "language_code": "Codice lingua per la risposta dell'API", diff --git a/custom_components/pollenlevels/translations/nb.json b/custom_components/pollenlevels/translations/nb.json index e89453a2..e082548f 100644 --- a/custom_components/pollenlevels/translations/nb.json +++ b/custom_components/pollenlevels/translations/nb.json @@ -44,7 +44,7 @@ "step": { "init": { "title": "Pollen Levels – Innstillinger", - "description": "Endre oppdateringsintervall, API-språk, prognosedager og sensorer per dag for {title}.\nAlternativer for sensorer per dag (TYPER): Kun i dag (none), Til og med i morgen (D+1), Til og med i overmorgen (D+1+2).", + "description": "Endre oppdateringsintervall, API-språk, prognosedager og sensorer per dag for {title}.\nAlternativer for sensorer per dag (TYPER): Kun i dag (none), Til og med i morgen (D+1), Til og med i overmorgen (D+2; oppretter både D+1- og D+2-sensorer).", "data": { "update_interval": "Oppdateringsintervall (timer)", "language_code": "Språkkode for API-svar", diff --git a/custom_components/pollenlevels/translations/nl.json b/custom_components/pollenlevels/translations/nl.json index fe0e6045..9759950a 100644 --- a/custom_components/pollenlevels/translations/nl.json +++ b/custom_components/pollenlevels/translations/nl.json @@ -44,7 +44,7 @@ "step": { "init": { "title": "Pollen Levels – Opties", - "description": "Wijzig het update-interval, de API-taal, het aantal voorspellingsdagen en de per-dag TYPE-sensoren voor {title}.\nOpties voor per-dag TYPE-sensoren: Alleen vandaag (none), Tot en met morgen (D+1), Tot en met overmorgen (D+1+2).", + "description": "Wijzig het update-interval, de API-taal, het aantal voorspellingsdagen en de per-dag TYPE-sensoren voor {title}.\nOpties voor per-dag TYPE-sensoren: Alleen vandaag (none), Tot en met morgen (D+1), Tot en met overmorgen (D+2; maakt zowel D+1- als D+2-sensoren aan).", "data": { "update_interval": "Update-interval (uren)", "language_code": "Taalcode voor API-respons", diff --git a/custom_components/pollenlevels/translations/pl.json b/custom_components/pollenlevels/translations/pl.json index 4f71da9b..de2d5e11 100644 --- a/custom_components/pollenlevels/translations/pl.json +++ b/custom_components/pollenlevels/translations/pl.json @@ -44,7 +44,7 @@ "step": { "init": { "title": "Pollen Levels – Opcje", - "description": "Zmień interwał aktualizacji, język odpowiedzi API, liczbę dni prognozy oraz czujniki dzienne dla TYPÓW dla {title}.\nOpcje czujników dziennych (TYPY): Tylko dziś (none), Do jutra (D+1), Do pojutrza (D+1+2).", + "description": "Zmień interwał aktualizacji, język odpowiedzi API, liczbę dni prognozy oraz czujniki dzienne dla TYPÓW dla {title}.\nOpcje czujników dziennych (TYPY): Tylko dziś (none), Do jutra (D+1), Do pojutrza (D+2; tworzy czujniki D+1 i D+2).", "data": { "update_interval": "Interwał aktualizacji (godziny)", "language_code": "Kod języka odpowiedzi API", diff --git a/custom_components/pollenlevels/translations/pt-BR.json b/custom_components/pollenlevels/translations/pt-BR.json index 6eb7e74f..31e98645 100644 --- a/custom_components/pollenlevels/translations/pt-BR.json +++ b/custom_components/pollenlevels/translations/pt-BR.json @@ -44,7 +44,7 @@ "step": { "init": { "title": "Pollen Levels – Opções", - "description": "Altere o intervalo de atualização, o idioma da API, os dias de previsão e os sensores por dia para {title}.\nOpções de sensores por dia (TIPOS): Apenas hoje (none), Até amanhã (D+1), Até depois de amanhã (D+1+2).", + "description": "Altere o intervalo de atualização, o idioma da API, os dias de previsão e os sensores por dia para {title}.\nOpções de sensores por dia (TIPOS): Apenas hoje (none), Até amanhã (D+1), Até depois de amanhã (D+2; cria sensores D+1 e D+2).", "data": { "update_interval": "Intervalo de atualização (horas)", "language_code": "Código de idioma da resposta da API", diff --git a/custom_components/pollenlevels/translations/pt-PT.json b/custom_components/pollenlevels/translations/pt-PT.json index 0b0c5170..254a6d0d 100644 --- a/custom_components/pollenlevels/translations/pt-PT.json +++ b/custom_components/pollenlevels/translations/pt-PT.json @@ -44,7 +44,7 @@ "step": { "init": { "title": "Pollen Levels – Opções", - "description": "Altere o intervalo de atualização, o idioma da API, os dias de previsão e os sensores por dia para {title}.\nOpções de sensores por dia (TIPOS): Apenas hoje (none), Até amanhã (D+1), Até depois de amanhã (D+1+2).", + "description": "Altere o intervalo de atualização, o idioma da API, os dias de previsão e os sensores por dia para {title}.\nOpções de sensores por dia (TIPOS): Apenas hoje (none), Até amanhã (D+1), Até depois de amanhã (D+2; cria sensores D+1 e D+2).", "data": { "update_interval": "Intervalo de atualização (horas)", "language_code": "Código de idioma da resposta da API", diff --git a/custom_components/pollenlevels/translations/ro.json b/custom_components/pollenlevels/translations/ro.json index 15905946..ad78a630 100644 --- a/custom_components/pollenlevels/translations/ro.json +++ b/custom_components/pollenlevels/translations/ro.json @@ -44,7 +44,7 @@ "step": { "init": { "title": "Pollen Levels – Opțiuni", - "description": "Modificați intervalul de actualizare, limba API, zilele de prognoză și senzorii pe zile pentru {title}.\nOpțiuni pentru senzorii pe zile (TIPURI): Doar azi (none), Până mâine (D+1), Până poimâine (D+1+2).", + "description": "Modificați intervalul de actualizare, limba API, zilele de prognoză și senzorii pe zile pentru {title}.\nOpțiuni pentru senzorii pe zile (TIPURI): Doar azi (none), Până mâine (D+1), Până poimâine (D+2; creează atât senzori D+1, cât și D+2).", "data": { "update_interval": "Interval de actualizare (ore)", "language_code": "Codul limbii pentru răspunsul API", diff --git a/custom_components/pollenlevels/translations/ru.json b/custom_components/pollenlevels/translations/ru.json index 93d0bb32..3f4332e1 100644 --- a/custom_components/pollenlevels/translations/ru.json +++ b/custom_components/pollenlevels/translations/ru.json @@ -44,7 +44,7 @@ "step": { "init": { "title": "Pollen Levels – Параметры", - "description": "Измените интервал обновления, язык ответа API, дни прогноза и дневные датчики для ТИПОВ для {title}.\nВарианты дневных датчиков (ТИПЫ): Только сегодня (none), До завтра (D+1), До послезавтра (D+1+2).", + "description": "Измените интервал обновления, язык ответа API, дни прогноза и дневные датчики для ТИПОВ для {title}.\nВарианты дневных датчиков (ТИПЫ): Только сегодня (none), До завтра (D+1), До послезавтра (D+2; создаёт датчики D+1 и D+2).", "data": { "update_interval": "Интервал обновления (в часах)", "language_code": "Код языка ответа API", diff --git a/custom_components/pollenlevels/translations/sv.json b/custom_components/pollenlevels/translations/sv.json index 3be5fd4b..5d1868fd 100644 --- a/custom_components/pollenlevels/translations/sv.json +++ b/custom_components/pollenlevels/translations/sv.json @@ -44,7 +44,7 @@ "step": { "init": { "title": "Pollen Levels – Alternativ", - "description": "Ändra uppdateringsintervall, API-språk, prognosdagar och sensorer per dag för {title}.\nAlternativ för sensorer per dag (TYPER): Endast idag (none), Till och med i morgon (D+1), Till och med i övermorgon (D+1+2).", + "description": "Ändra uppdateringsintervall, API-språk, prognosdagar och sensorer per dag för {title}.\nAlternativ för sensorer per dag (TYPER): Endast idag (none), Till och med i morgon (D+1), Till och med i övermorgon (D+2; skapar både D+1- och D+2-sensorer).", "data": { "update_interval": "Uppdateringsintervall (timmar)", "language_code": "Språkkod för API-svar", diff --git a/custom_components/pollenlevels/translations/uk.json b/custom_components/pollenlevels/translations/uk.json index 60a3b972..0d90cba4 100644 --- a/custom_components/pollenlevels/translations/uk.json +++ b/custom_components/pollenlevels/translations/uk.json @@ -44,7 +44,7 @@ "step": { "init": { "title": "Pollen Levels – Параметри", - "description": "Змініть інтервал оновлення, мову відповіді API, кількість днів прогнозу та денні датчики для ТИПІВ для {title}.\nПараметри денних датчиків (ТИПИ): Лише сьогодні (none), До завтра (D+1), До післязавтра (D+1+2).", + "description": "Змініть інтервал оновлення, мову відповіді API, кількість днів прогнозу та денні датчики для ТИПІВ для {title}.\nПараметри денних датчиків (ТИПИ): Лише сьогодні (none), До завтра (D+1), До післязавтра (D+2; створює датчики D+1 і D+2).", "data": { "update_interval": "Інтервал оновлення (у годинах)", "language_code": "Код мови відповіді API", diff --git a/custom_components/pollenlevels/translations/zh-Hans.json b/custom_components/pollenlevels/translations/zh-Hans.json index 6ed3935f..e639bff4 100644 --- a/custom_components/pollenlevels/translations/zh-Hans.json +++ b/custom_components/pollenlevels/translations/zh-Hans.json @@ -44,7 +44,7 @@ "step": { "init": { "title": "Pollen Levels – 选项", - "description": "修改更新间隔、API 语言、预测天以及逐日类型传感器,适用于 {title}。\n逐日类型传感器选项:仅今日(none)、至明日(D+1)、至后日(D+1+2)。", + "description": "修改更新间隔、API 语言、预测天以及逐日类型传感器,适用于 {title}。\n逐日类型传感器选项:仅今日(none)、至明日(D+1)、至后日(D+2;会同时创建 D+1 和 D+2 传感器)。", "data": { "update_interval": "更新间隔(小时)", "language_code": "API 响应语言代码", diff --git a/custom_components/pollenlevels/translations/zh-Hant.json b/custom_components/pollenlevels/translations/zh-Hant.json index 948b61f8..10f54599 100644 --- a/custom_components/pollenlevels/translations/zh-Hant.json +++ b/custom_components/pollenlevels/translations/zh-Hant.json @@ -44,7 +44,7 @@ "step": { "init": { "title": "Pollen Levels – 選項", - "description": "修改更新間隔、API 語言、預測天數與逐日類型感測器,適用於 {title}。\n逐日類型感測器選項:僅今日(none)、至明日(D+1)、至後日(D+1+2)。", + "description": "修改更新間隔、API 語言、預測天數與逐日類型感測器,適用於 {title}。\n逐日類型感測器選項:僅今日(none)、至明日(D+1)、至後日(D+2;會同時建立 D+1 與 D+2 感測器)。", "data": { "update_interval": "更新間隔(小時)", "language_code": "API 回應語言代碼", diff --git a/custom_components/pollenlevels/util.py b/custom_components/pollenlevels/util.py index 24885afb..d99218b3 100644 --- a/custom_components/pollenlevels/util.py +++ b/custom_components/pollenlevels/util.py @@ -95,7 +95,7 @@ def normalize_sensor_mode(mode: Any, logger: logging.Logger) -> str: def safe_parse_int(value: Any) -> int | None: """Parse an integer-like value, rejecting non-finite and decimal numbers.""" - if value is None: + if value is None or isinstance(value, bool): return None try: diff --git a/tests/test_init.py b/tests/test_init.py index f7431fdc..082a5be5 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -354,6 +354,45 @@ def test_setup_entry_propagates_auth_failed() -> None: asyncio.run(integration.async_setup_entry(hass, entry)) +def test_setup_entry_clears_runtime_data_on_forward_auth_failed() -> None: + """runtime_data is cleared when forwarding raises ConfigEntryAuthFailed.""" + + hass = _FakeHass(forward_exception=integration.ConfigEntryAuthFailed("bad key")) + entry = _FakeEntry() + + with pytest.raises(integration.ConfigEntryAuthFailed): + asyncio.run(integration.async_setup_entry(hass, entry)) + + assert entry.runtime_data is None + + +def test_setup_entry_clears_runtime_data_on_forward_not_ready() -> None: + """runtime_data is cleared when forwarding raises ConfigEntryNotReady.""" + + hass = _FakeHass(forward_exception=integration.ConfigEntryNotReady("retry")) + entry = _FakeEntry() + + with pytest.raises(integration.ConfigEntryNotReady): + asyncio.run(integration.async_setup_entry(hass, entry)) + + assert entry.runtime_data is None + + +def test_setup_entry_clears_runtime_data_on_forward_generic_error() -> None: + """runtime_data is cleared when forwarding raises an unexpected exception.""" + + class _Boom(Exception): + pass + + hass = _FakeHass(forward_exception=_Boom("boom")) + entry = _FakeEntry() + + with pytest.raises(integration.ConfigEntryNotReady): + asyncio.run(integration.async_setup_entry(hass, entry)) + + assert entry.runtime_data is None + + def test_setup_entry_missing_api_key_raises_auth_failed() -> None: """Missing API key should trigger ConfigEntryAuthFailed.""" diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 6a854d31..f6ae122f 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -228,7 +228,8 @@ def _stub_parse_http_date(value: str | None): # pragma: no cover - stub only util_mod.dt = dt_mod sys.modules.setdefault("homeassistant.util", util_mod) -aiohttp_mod = sys.modules.get("aiohttp") or types.ModuleType("aiohttp") +aiohttp_existing = sys.modules.get("aiohttp") +aiohttp_mod = aiohttp_existing or types.ModuleType("aiohttp") class _StubClientError(Exception): @@ -244,10 +245,14 @@ def __init__(self, total: float | None = None): self.total = total -aiohttp_mod.ClientError = _StubClientError -aiohttp_mod.ClientSession = _StubClientSession -aiohttp_mod.ClientTimeout = _StubClientTimeout -sys.modules["aiohttp"] = aiohttp_mod +if not hasattr(aiohttp_mod, "ClientError"): + aiohttp_mod.ClientError = _StubClientError +if not hasattr(aiohttp_mod, "ClientSession"): + aiohttp_mod.ClientSession = _StubClientSession +if not hasattr(aiohttp_mod, "ClientTimeout"): + aiohttp_mod.ClientTimeout = _StubClientTimeout +if aiohttp_existing is None: + sys.modules["aiohttp"] = aiohttp_mod def _load_module(module_name: str, relative_path: str): diff --git a/tests/test_util.py b/tests/test_util.py index ddd1f3d8..e1415886 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -32,6 +32,8 @@ def test_redact_api_key_returns_empty_string_for_none(): (3.0, 3), ("3", 3), ("3.0", 3), + (True, None), + (False, None), (None, None), ("3.5", None), (3.5, None), From d386a803e9dffc9410635a5db9582b321a2cf724 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Thu, 19 Feb 2026 08:32:43 +0100 Subject: [PATCH 193/200] Warn on invalid forecast_days and dedupe cleanup --- custom_components/pollenlevels/__init__.py | 5 +---- custom_components/pollenlevels/sensor.py | 7 +++++++ 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/custom_components/pollenlevels/__init__.py b/custom_components/pollenlevels/__init__.py index d5458758..4b83e4b2 100644 --- a/custom_components/pollenlevels/__init__.py +++ b/custom_components/pollenlevels/__init__.py @@ -278,10 +278,7 @@ async def async_setup_entry( try: await hass.config_entries.async_forward_entry_setups(entry, ["sensor"]) - except ConfigEntryAuthFailed: - entry.runtime_data = None - raise - except ConfigEntryNotReady: + except (ConfigEntryAuthFailed, ConfigEntryNotReady): entry.runtime_data = None raise except Exception as err: diff --git a/custom_components/pollenlevels/sensor.py b/custom_components/pollenlevels/sensor.py index ced3a5cd..97fbbcb7 100644 --- a/custom_components/pollenlevels/sensor.py +++ b/custom_components/pollenlevels/sensor.py @@ -159,6 +159,13 @@ async def async_setup_entry( opts = config_entry.options or {} raw_days = opts.get(CONF_FORECAST_DAYS, coordinator.forecast_days) parsed = safe_parse_int(raw_days) + if parsed is None: + _LOGGER.warning( + "Invalid forecast_days '%s' for entry %s; defaulting to %s", + raw_days, + config_entry.entry_id, + coordinator.forecast_days, + ) forecast_days = parsed if parsed is not None else coordinator.forecast_days forecast_days = max(MIN_FORECAST_DAYS, min(MAX_FORECAST_DAYS, forecast_days)) create_d1 = coordinator.create_d1 From fc5cfdc8ce0a023e803a70b87fc6450e133ece56 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Thu, 19 Feb 2026 16:02:10 +0100 Subject: [PATCH 194/200] Update CHANGELOG.md --- CHANGELOG.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d6bf947..d86d65cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,14 +1,17 @@ -# Changelog ## [1.9.3] - 2026-02-14 ### Fixed - Ensured deterministic current-day plant sensor creation by sorting plant codes. -- Reject whitespace-only API keys at setup (defensive validation). +- Reject whitespace-only API keys at setup (defensive validation) and raise `ConfigEntryAuthFailed` with a clearer "Invalid API key" message. - Mask API key input fields in config flow (password selector). +- Cleared entry runtime data when platform forwarding fails to avoid leaving a partially initialized state. +- Hardened `forecast_days` parsing during coordinator and sensor setup to tolerate malformed stored values without crashing. +- Improved test isolation by avoiding unconditional replacement of the global `aiohttp` module stub. ### Changed - Simplified plant parsing by removing redundant code checks (non-empty by construction). - Deduplicated defensive integer parsing into a shared utility and aligned diagnostics with runtime/config-flow rules to reject non-finite or decimal values consistently. +- Clarified the per-day TYPE sensor range option text (D+2 creates both D+1 and D+2 sensors) across translations. ## [1.9.2] - 2026-02-13 ### Fixed From 6a73370ef235e01339aed24714e86df5a2199aa3 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sat, 21 Feb 2026 08:07:28 +0100 Subject: [PATCH 195/200] Align setup validation and tighten test isolation --- CHANGELOG.md | 18 ++ custom_components/pollenlevels/__init__.py | 9 +- custom_components/pollenlevels/config_flow.py | 18 +- custom_components/pollenlevels/coordinator.py | 13 +- custom_components/pollenlevels/sensor.py | 6 +- tests/test_config_flow.py | 94 +++++++- tests/test_init.py | 107 ++++++++- tests/test_sensor.py | 208 ++++++++++++++++++ 8 files changed, 449 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d86d65cd..9032f2f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ ## [1.9.3] - 2026-02-14 ### Fixed +- Aligned config-flow API validation with runtime parsing by requiring `dailyInfo` + to be a non-empty list of objects during setup validation. +- Prevented test cross-contamination in setup tests by using scoped monkeypatching + for coordinator/client stubs instead of persistent module reassignment. +- Prevented disabled per-day sensors from being re-created during sensor setup by + skipping `*_d1`/`*_d2` keys when effective forecast options disable them. +- Hardened coordinator parsing for malformed `dailyInfo` payloads by treating + non-list/non-dict structures as invalid and preserving the last successful + dataset when available. +- Normalized stored forecast sensor mode values during integration setup so + legacy or whitespace-padded values no longer degrade silently to `none`. - Ensured deterministic current-day plant sensor creation by sorting plant codes. - Reject whitespace-only API keys at setup (defensive validation) and raise `ConfigEntryAuthFailed` with a clearer "Invalid API key" message. - Mask API key input fields in config flow (password selector). @@ -8,6 +19,13 @@ - Improved test isolation by avoiding unconditional replacement of the global `aiohttp` module stub. ### Changed +- Switched sensor setup iteration to use a validated local data snapshot for + clearer and more consistent entity creation flow. +- Increased config-flow coordinate unique-id precision from 4 to 6 decimals to + reduce accidental collisions for nearby locations. +- Expanded regression coverage for disabled per-day sensor creation, malformed + `dailyInfo` handling, setup mode normalization, and nearby-coordinate + unique-id behavior. - Simplified plant parsing by removing redundant code checks (non-empty by construction). - Deduplicated defensive integer parsing into a shared utility and aligned diagnostics with runtime/config-flow rules to reject non-finite or decimal values consistently. diff --git a/custom_components/pollenlevels/__init__.py b/custom_components/pollenlevels/__init__.py index 4b83e4b2..bd930a67 100644 --- a/custom_components/pollenlevels/__init__.py +++ b/custom_components/pollenlevels/__init__.py @@ -207,12 +207,15 @@ async def async_setup_entry( CONF_CREATE_FORECAST_SENSORS, entry.data.get(CONF_CREATE_FORECAST_SENSORS, ForecastSensorMode.NONE), ) + normalized_mode = normalize_sensor_mode(raw_mode, _LOGGER) try: - mode = ForecastSensorMode(raw_mode) + mode = ForecastSensorMode(normalized_mode) except (ValueError, TypeError): mode = ForecastSensorMode.NONE - create_d1 = mode in (ForecastSensorMode.D1, ForecastSensorMode.D1_D2) - create_d2 = mode == ForecastSensorMode.D1_D2 + create_d1 = ( + mode in (ForecastSensorMode.D1, ForecastSensorMode.D1_D2) and forecast_days >= 2 + ) + create_d2 = mode == ForecastSensorMode.D1_D2 and forecast_days >= 3 api_key = entry.data.get(CONF_API_KEY) if not isinstance(api_key, str) or not api_key.strip(): diff --git a/custom_components/pollenlevels/config_flow.py b/custom_components/pollenlevels/config_flow.py index 150df6a7..4cca2477 100644 --- a/custom_components/pollenlevels/config_flow.py +++ b/custom_components/pollenlevels/config_flow.py @@ -376,7 +376,9 @@ async def _async_validate_input( normalized[CONF_LONGITUDE] = lon if check_unique_id: - uid = f"{lat:.4f}_{lon:.4f}" + # Keep a stable coordinate-based unique_id with enough precision to + # reduce accidental collisions for nearby locations. + uid = f"{lat:.6f}_{lon:.6f}" try: await self.async_set_unique_id(uid, raise_on_progress=False) self._abort_if_unique_id_configured() @@ -445,8 +447,18 @@ async def _async_validate_input( data = json.loads(body_str) if body_str else {} except Exception: data = {} - if not data.get("dailyInfo"): - _LOGGER.warning("Validation: 'dailyInfo' missing") + + daily_info = ( + data.get("dailyInfo") if isinstance(data, dict) else None + ) + daily_is_valid = isinstance(daily_info, list) and bool(daily_info) + if daily_is_valid: + daily_is_valid = all( + isinstance(item, dict) for item in daily_info + ) + + if not daily_is_valid: + _LOGGER.warning("Validation: 'dailyInfo' missing or invalid") errors["base"] = "cannot_connect" placeholders["error_message"] = ( "API response missing expected pollen forecast information." diff --git a/custom_components/pollenlevels/coordinator.py b/custom_components/pollenlevels/coordinator.py index 5770aa6c..8f7051c8 100644 --- a/custom_components/pollenlevels/coordinator.py +++ b/custom_components/pollenlevels/coordinator.py @@ -238,16 +238,23 @@ async def _async_update_data(self): if region := payload.get("regionCode"): new_data["region"] = {"source": "meta", "value": region} - daily: list[dict] = payload.get("dailyInfo") or [] + daily_raw = payload.get("dailyInfo") + daily = daily_raw if isinstance(daily_raw, list) else None + # Keep day offsets stable: if any element is invalid, treat the payload as + # malformed instead of compacting/reindexing list positions. + if daily is not None and any(not isinstance(item, dict) for item in daily): + daily = None + if not daily: if self.data: if not self._missing_dailyinfo_warned: _LOGGER.warning( - "API response missing dailyInfo; keeping last successful data" + "API response missing or invalid dailyInfo; " + "keeping last successful data" ) self._missing_dailyinfo_warned = True return self.data - raise UpdateFailed("API response missing dailyInfo") + raise UpdateFailed("API response missing or invalid dailyInfo") self._missing_dailyinfo_warned = False # date (today) diff --git a/custom_components/pollenlevels/sensor.py b/custom_components/pollenlevels/sensor.py index 97fbbcb7..c2a99272 100644 --- a/custom_components/pollenlevels/sensor.py +++ b/custom_components/pollenlevels/sensor.py @@ -189,9 +189,13 @@ async def async_setup_entry( ) sensors: list[CoordinatorEntity] = [] - for code in coordinator.data: + for code in data: if code in ("region", "date"): continue + if code.endswith("_d1") and not allow_d1: + continue + if code.endswith("_d2") and not allow_d2: + continue sensors.append(PollenSensor(coordinator, code)) sensors.extend( diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 6b04f7f3..408187eb 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -1037,6 +1037,46 @@ def test_validate_input_redacts_api_key_in_error_message( assert "***" in error_message +def test_validate_input_http_200_non_list_dailyinfo_sets_cannot_connect( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A non-list dailyInfo in HTTP 200 should be treated as invalid.""" + + body = b'{"dailyInfo": "invalid"}' + session = _patch_client_session(monkeypatch, _StubResponse(status=200, body=body)) + + flow = PollenLevelsConfigFlow() + flow.hass = SimpleNamespace() + + errors, normalized = asyncio.run( + flow._async_validate_input(_base_user_input(), check_unique_id=False) + ) + + assert session.calls + assert errors == {"base": "cannot_connect"} + assert normalized is None + + +def test_validate_input_http_200_dailyinfo_with_non_dict_sets_cannot_connect( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A dailyInfo list with non-dict items should be treated as invalid.""" + + body = b'{"dailyInfo": ["invalid-item"]}' + session = _patch_client_session(monkeypatch, _StubResponse(status=200, body=body)) + + flow = PollenLevelsConfigFlow() + flow.hass = SimpleNamespace() + + errors, normalized = asyncio.run( + flow._async_validate_input(_base_user_input(), check_unique_id=False) + ) + + assert session.calls + assert errors == {"base": "cannot_connect"} + assert normalized is None + + def test_validate_input_unexpected_exception_sets_unknown( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -1064,7 +1104,8 @@ def test_validate_input_happy_path_sets_unique_id_and_normalizes( """Successful validation should normalize data and set unique ID.""" body = b'{"dailyInfo": [{"day": "D0"}]}' - session = _patch_client_session(monkeypatch, _StubResponse(200, body)) + session = _SequenceSession([_StubResponse(200, body), _StubResponse(200, body)]) + monkeypatch.setattr(cf, "async_get_clientsession", lambda hass: session) class _TrackingFlow(PollenLevelsConfigFlow): def __init__(self) -> None: @@ -1100,10 +1141,59 @@ def _abort_if_unique_id_configured(self): assert normalized[CONF_LATITUDE] == pytest.approx(1.0) assert normalized[CONF_LONGITUDE] == pytest.approx(2.0) assert normalized[CONF_LANGUAGE_CODE] == "es" - assert flow.unique_ids == ["1.0000_2.0000"] + assert flow.unique_ids == ["1.000000_2.000000"] assert flow.abort_calls == 1 +def test_validate_input_unique_id_distinguishes_nearby_locations( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Unique-id precision should not collapse nearby distinct coordinates.""" + + body = b'{"dailyInfo": [{"day": "D0"}]}' + session = _SequenceSession([_StubResponse(200, body), _StubResponse(200, body)]) + monkeypatch.setattr(cf, "async_get_clientsession", lambda hass: session) + + class _TrackingFlow(PollenLevelsConfigFlow): + def __init__(self) -> None: + super().__init__() + self.unique_ids: list[str] = [] + + async def async_set_unique_id(self, uid: str, raise_on_progress: bool = False): + self.unique_ids.append(uid) + return None + + def _abort_if_unique_id_configured(self): + return None + + flow = _TrackingFlow() + flow.hass = SimpleNamespace(config=SimpleNamespace()) + + first = { + **_base_user_input(), + CONF_LOCATION: {CONF_LATITUDE: "1.0000044", CONF_LONGITUDE: "2.0000044"}, + } + second = { + **_base_user_input(), + CONF_LOCATION: {CONF_LATITUDE: "1.0000046", CONF_LONGITUDE: "2.0000046"}, + } + + first_errors, first_normalized = asyncio.run( + flow._async_validate_input(first, check_unique_id=True) + ) + second_errors, second_normalized = asyncio.run( + flow._async_validate_input(second, check_unique_id=True) + ) + + assert session.calls + assert first_errors == {} + assert second_errors == {} + assert first_normalized is not None + assert second_normalized is not None + assert len(flow.unique_ids) == 2 + assert flow.unique_ids[0] != flow.unique_ids[1] + + def test_reauth_confirm_updates_and_reloads_entry() -> None: """Re-auth confirmation should update stored credentials and reload the entry.""" diff --git a/tests/test_init.py b/tests/test_init.py index 082a5be5..40788381 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -480,7 +480,9 @@ def test_setup_entry_boundary_coordinates_are_allowed() -> None: assert asyncio.run(integration.async_setup_entry(hass, entry)) is True -def test_setup_entry_decimal_numeric_options_fallback_to_defaults() -> None: +def test_setup_entry_decimal_numeric_options_fallback_to_defaults( + monkeypatch: pytest.MonkeyPatch, +) -> None: """Decimal options should not be truncated silently during setup.""" hass = _FakeHass() @@ -507,15 +509,11 @@ def __init__(self, *args, **kwargs): async def async_config_entry_first_refresh(self): return None - orig_coordinator = integration.PollenDataUpdateCoordinator - integration.PollenDataUpdateCoordinator = _StubCoordinator + monkeypatch.setattr(integration, "PollenDataUpdateCoordinator", _StubCoordinator) - try: - assert asyncio.run(integration.async_setup_entry(hass, entry)) is True - assert seen["hours"] == integration.DEFAULT_UPDATE_INTERVAL - assert seen["forecast_days"] == integration.DEFAULT_FORECAST_DAYS - finally: - integration.PollenDataUpdateCoordinator = orig_coordinator + assert asyncio.run(integration.async_setup_entry(hass, entry)) is True + assert seen["hours"] == integration.DEFAULT_UPDATE_INTERVAL + assert seen["forecast_days"] == integration.DEFAULT_FORECAST_DAYS def test_setup_entry_wraps_generic_error() -> None: @@ -531,7 +529,9 @@ class _Boom(Exception): asyncio.run(integration.async_setup_entry(hass, entry)) -def test_setup_entry_success_and_unload() -> None: +def test_setup_entry_success_and_unload( + monkeypatch: pytest.MonkeyPatch, +) -> None: """Happy path should forward setup, register listener, and unload cleanly.""" hass = _FakeHass() @@ -565,8 +565,8 @@ async def async_config_entry_first_refresh(self): async def async_refresh(self): return None - integration.GooglePollenApiClient = _StubClient - integration.PollenDataUpdateCoordinator = _StubCoordinator + monkeypatch.setattr(integration, "GooglePollenApiClient", _StubClient) + monkeypatch.setattr(integration, "PollenDataUpdateCoordinator", _StubCoordinator) assert asyncio.run(integration.async_setup_entry(hass, entry)) is True @@ -585,6 +585,89 @@ async def async_refresh(self): assert entry.runtime_data is None +def test_setup_entry_normalizes_forecast_sensor_mode( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Setup should normalize stored forecast mode values before coordinator flags.""" + + hass = _FakeHass() + entry = _FakeEntry(options={integration.CONF_CREATE_FORECAST_SENSORS: " D+1 "}) + + class _StubClient: + def __init__(self, _session, _api_key): + self.session = _session + self.api_key = _api_key + + async def async_fetch_pollen_data(self, **_kwargs): + return {"region": {"source": "meta"}, "dailyInfo": []} + + class _StubCoordinator(update_coordinator_mod.DataUpdateCoordinator): + def __init__(self, *args, **kwargs): + self.create_d1 = kwargs["create_d1"] + self.create_d2 = kwargs["create_d2"] + self.entry_id = kwargs["entry_id"] + self.entry_title = kwargs.get("entry_title") + self.lat = kwargs["lat"] + self.lon = kwargs["lon"] + self.last_updated = None + self.data = {"region": {"source": "meta"}, "date": {"source": "meta"}} + + async def async_config_entry_first_refresh(self): + return None + + monkeypatch.setattr(integration, "GooglePollenApiClient", _StubClient) + monkeypatch.setattr(integration, "PollenDataUpdateCoordinator", _StubCoordinator) + + assert asyncio.run(integration.async_setup_entry(hass, entry)) is True + assert entry.runtime_data is not None + assert entry.runtime_data.coordinator.create_d1 is True + assert entry.runtime_data.coordinator.create_d2 is False + + +def test_setup_entry_disables_d1_when_forecast_days_is_one( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Setup should disable D+1/D+2 creation when forecast days disallow them.""" + + hass = _FakeHass() + entry = _FakeEntry( + options={ + integration.CONF_CREATE_FORECAST_SENSORS: "D+1+2", + integration.CONF_FORECAST_DAYS: 1, + } + ) + + class _StubClient: + def __init__(self, _session, _api_key): + self.session = _session + self.api_key = _api_key + + async def async_fetch_pollen_data(self, **_kwargs): + return {"region": {"source": "meta"}, "dailyInfo": []} + + class _StubCoordinator(update_coordinator_mod.DataUpdateCoordinator): + def __init__(self, *args, **kwargs): + self.create_d1 = kwargs["create_d1"] + self.create_d2 = kwargs["create_d2"] + self.entry_id = kwargs["entry_id"] + self.entry_title = kwargs.get("entry_title") + self.lat = kwargs["lat"] + self.lon = kwargs["lon"] + self.last_updated = None + self.data = {"region": {"source": "meta"}, "date": {"source": "meta"}} + + async def async_config_entry_first_refresh(self): + return None + + monkeypatch.setattr(integration, "GooglePollenApiClient", _StubClient) + monkeypatch.setattr(integration, "PollenDataUpdateCoordinator", _StubCoordinator) + + assert asyncio.run(integration.async_setup_entry(hass, entry)) is True + assert entry.runtime_data is not None + assert entry.runtime_data.coordinator.create_d1 is False + assert entry.runtime_data.coordinator.create_d2 is False + + def test_force_update_requests_refresh_per_entry() -> None: """force_update should queue refresh via runtime_data coordinators and skip missing runtime data.""" diff --git a/tests/test_sensor.py b/tests/test_sensor.py index f6ae122f..570aaeca 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -604,6 +604,153 @@ def test_coordinator_first_refresh_missing_dailyinfo_raises() -> None: assert coordinator.data == {} +def test_coordinator_first_refresh_invalid_dailyinfo_type_raises() -> None: + """Non-list dailyInfo payload should raise UpdateFailed on first refresh.""" + + session = SequenceSession([ResponseSpec(status=200, payload={"dailyInfo": {}})]) + client = client_mod.GooglePollenApiClient(session, "test") + + loop = asyncio.new_event_loop() + hass = DummyHass(loop) + coordinator = coordinator_mod.PollenDataUpdateCoordinator( + hass=hass, + api_key="test", + lat=1.0, + lon=2.0, + hours=12, + language=None, + entry_id="entry", + forecast_days=1, + create_d1=False, + create_d2=False, + client=client, + ) + + try: + with pytest.raises(client_mod.UpdateFailed, match="dailyInfo"): + loop.run_until_complete(coordinator._async_update_data()) + finally: + loop.close() + + +def test_coordinator_invalid_dailyinfo_items_keep_last_data() -> None: + """Invalid dailyInfo items should preserve previous successful coordinator data.""" + + session = SequenceSession( + [ + ResponseSpec( + status=200, + payload={ + "dailyInfo": [ + { + "date": {"year": 2025, "month": 5, "day": 9}, + "pollenTypeInfo": [ + { + "code": "GRASS", + "displayName": "Grass", + "indexInfo": {"value": 2, "category": "LOW"}, + } + ], + } + ] + }, + ), + ResponseSpec(status=200, payload={"dailyInfo": ["bad-item"]}), + ] + ) + client = client_mod.GooglePollenApiClient(session, "test") + + loop = asyncio.new_event_loop() + hass = DummyHass(loop) + coordinator = coordinator_mod.PollenDataUpdateCoordinator( + hass=hass, + api_key="test", + lat=1.0, + lon=2.0, + hours=12, + language=None, + entry_id="entry", + forecast_days=1, + create_d1=False, + create_d2=False, + client=client, + ) + + try: + first_data = loop.run_until_complete(coordinator._async_update_data()) + coordinator.data = first_data + second_data = loop.run_until_complete(coordinator._async_update_data()) + finally: + loop.close() + + assert first_data["type_grass"]["value"] == 2 + assert second_data == first_data + + +def test_coordinator_mixed_dailyinfo_items_keep_last_data() -> None: + """Mixed valid/invalid dailyInfo items are treated as invalid payload.""" + + session = SequenceSession( + [ + ResponseSpec( + status=200, + payload={ + "dailyInfo": [ + { + "date": {"year": 2025, "month": 5, "day": 9}, + "pollenTypeInfo": [ + { + "code": "GRASS", + "displayName": "Grass", + "indexInfo": {"value": 2, "category": "LOW"}, + } + ], + } + ] + }, + ), + ResponseSpec( + status=200, + payload={ + "dailyInfo": [ + { + "date": {"year": 2025, "month": 5, "day": 10}, + "pollenTypeInfo": [], + }, + "bad-item", + ] + }, + ), + ] + ) + client = client_mod.GooglePollenApiClient(session, "test") + + loop = asyncio.new_event_loop() + hass = DummyHass(loop) + coordinator = coordinator_mod.PollenDataUpdateCoordinator( + hass=hass, + api_key="test", + lat=1.0, + lon=2.0, + hours=12, + language=None, + entry_id="entry", + forecast_days=2, + create_d1=False, + create_d2=False, + client=client, + ) + + try: + first_data = loop.run_until_complete(coordinator._async_update_data()) + coordinator.data = first_data + second_data = loop.run_until_complete(coordinator._async_update_data()) + finally: + loop.close() + + assert second_data == first_data + + def test_coordinator_clamps_forecast_days_negative() -> None: """Negative forecast days are clamped to minimum.""" @@ -1472,6 +1619,67 @@ async def _noop_add_entities(_entities, _update_before_add=False): loop.close() +@pytest.mark.asyncio +async def test_async_setup_entry_skips_disabled_d1_d2_sensors() -> None: + """Setup does not recreate D+1/D+2 sensors when forecast days disable them.""" + + hass = DummyHass(asyncio.get_running_loop()) + config_entry = FakeConfigEntry( + data={ + sensor.CONF_API_KEY: "key", + sensor.CONF_LATITUDE: 1.0, + sensor.CONF_LONGITUDE: 2.0, + sensor.CONF_UPDATE_INTERVAL: sensor.DEFAULT_UPDATE_INTERVAL, + sensor.CONF_FORECAST_DAYS: sensor.DEFAULT_FORECAST_DAYS, + }, + options={sensor.CONF_FORECAST_DAYS: 1}, + entry_id="entry", + ) + + client = client_mod.GooglePollenApiClient(FakeSession({}), "key") + coordinator = coordinator_mod.PollenDataUpdateCoordinator( + hass=hass, + api_key="key", + lat=1.0, + lon=2.0, + hours=sensor.DEFAULT_UPDATE_INTERVAL, + language=None, + entry_id="entry", + entry_title=sensor.DEFAULT_ENTRY_TITLE, + forecast_days=3, + create_d1=True, + create_d2=True, + client=client, + ) + coordinator.data = { + "date": {"source": "meta"}, + "region": {"source": "meta"}, + "type_grass": {"source": "type", "name": "Grass"}, + "type_grass_d1": {"source": "type", "name": "Grass D+1"}, + "type_grass_d2": {"source": "type", "name": "Grass D+2"}, + } + config_entry.runtime_data = sensor.PollenLevelsRuntimeData( + coordinator=coordinator, client=client + ) + + captured: list[Any] = [] + + def _capture_entities(entities, _update_before_add=False): + captured.extend(entities) + + await sensor.async_setup_entry(hass, config_entry, _capture_entities) + + unique_ids = { + entity.unique_id + for entity in captured + if getattr(entity, "unique_id", None) is not None + } + + assert "entry_type_grass" in unique_ids + assert all(not uid.endswith("_d1") for uid in unique_ids) + assert all(not uid.endswith("_d2") for uid in unique_ids) + + @pytest.mark.asyncio async def test_device_info_uses_default_title_when_blank( monkeypatch: pytest.MonkeyPatch, From 7273e5ca2e4cc0dc85b3d6fb113858e09a58598f Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sat, 21 Feb 2026 08:59:21 +0100 Subject: [PATCH 196/200] Revert coordinate unique_id precision to legacy format --- CHANGELOG.md | 8 ++++---- custom_components/pollenlevels/config_flow.py | 6 +++--- tests/test_config_flow.py | 8 ++++---- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9032f2f8..f95f7015 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,11 +21,11 @@ ### Changed - Switched sensor setup iteration to use a validated local data snapshot for clearer and more consistent entity creation flow. -- Increased config-flow coordinate unique-id precision from 4 to 6 decimals to - reduce accidental collisions for nearby locations. +- Preserved legacy 4-decimal coordinate unique-id formatting to keep existing + duplicate-location detection behavior stable across upgrades. - Expanded regression coverage for disabled per-day sensor creation, malformed - `dailyInfo` handling, setup mode normalization, and nearby-coordinate - unique-id behavior. + `dailyInfo` handling, setup mode normalization, and legacy duplicate + detection behavior for coordinate-based unique IDs. - Simplified plant parsing by removing redundant code checks (non-empty by construction). - Deduplicated defensive integer parsing into a shared utility and aligned diagnostics with runtime/config-flow rules to reject non-finite or decimal values consistently. diff --git a/custom_components/pollenlevels/config_flow.py b/custom_components/pollenlevels/config_flow.py index 4cca2477..d0723b49 100644 --- a/custom_components/pollenlevels/config_flow.py +++ b/custom_components/pollenlevels/config_flow.py @@ -376,9 +376,9 @@ async def _async_validate_input( normalized[CONF_LONGITUDE] = lon if check_unique_id: - # Keep a stable coordinate-based unique_id with enough precision to - # reduce accidental collisions for nearby locations. - uid = f"{lat:.6f}_{lon:.6f}" + # Keep unique_id formatting aligned with legacy entries for + # duplicate detection compatibility across upgrades. + uid = f"{lat:.4f}_{lon:.4f}" try: await self.async_set_unique_id(uid, raise_on_progress=False) self._abort_if_unique_id_configured() diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 408187eb..af1f78dc 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -1141,14 +1141,14 @@ def _abort_if_unique_id_configured(self): assert normalized[CONF_LATITUDE] == pytest.approx(1.0) assert normalized[CONF_LONGITUDE] == pytest.approx(2.0) assert normalized[CONF_LANGUAGE_CODE] == "es" - assert flow.unique_ids == ["1.000000_2.000000"] + assert flow.unique_ids == ["1.0000_2.0000"] assert flow.abort_calls == 1 -def test_validate_input_unique_id_distinguishes_nearby_locations( +def test_validate_input_unique_id_collapses_nearby_locations_legacy_compat( monkeypatch: pytest.MonkeyPatch, ) -> None: - """Unique-id precision should not collapse nearby distinct coordinates.""" + """Unique-id format should match legacy 4-decimal duplicate detection.""" body = b'{"dailyInfo": [{"day": "D0"}]}' session = _SequenceSession([_StubResponse(200, body), _StubResponse(200, body)]) @@ -1191,7 +1191,7 @@ def _abort_if_unique_id_configured(self): assert first_normalized is not None assert second_normalized is not None assert len(flow.unique_ids) == 2 - assert flow.unique_ids[0] != flow.unique_ids[1] + assert flow.unique_ids[0] == flow.unique_ids[1] == "1.0000_2.0000" def test_reauth_confirm_updates_and_reloads_entry() -> None: From 57796fec51573c7fc04151d1190c042c59d4852c Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sat, 21 Feb 2026 17:22:55 +0100 Subject: [PATCH 197/200] Update tests/test_sensor.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- tests/test_sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 570aaeca..9dacf338 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -399,7 +399,7 @@ def async_entries_for_config_entry(self, _registry, entry_id: str): for e in self._entries ] - async def async_remove(self, entity_id: str) -> None: + def async_remove(self, entity_id: str) -> None: self.removals.append(entity_id) From 47af711458eff0fba86b00ad7a582706dbf7a603 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Mon, 23 Feb 2026 08:02:38 +0100 Subject: [PATCH 198/200] Update 1.9.3 changelog with Retry-After hardening note --- CHANGELOG.md | 5 + custom_components/pollenlevels/client.py | 11 +- custom_components/pollenlevels/coordinator.py | 11 +- tests/test_sensor.py | 175 ++++++++++++++++++ 4 files changed, 190 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f95f7015..8df88078 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,11 @@ - Cleared entry runtime data when platform forwarding fails to avoid leaving a partially initialized state. - Hardened `forecast_days` parsing during coordinator and sensor setup to tolerate malformed stored values without crashing. - Improved test isolation by avoiding unconditional replacement of the global `aiohttp` module stub. +- Accepted numeric-string RGB channels from API color payloads by relying on shared + channel normalization, while still ignoring non-numeric strings. +- Hardened HTTP 429 backoff by validating `Retry-After` values (rejecting non-finite, + negative, and stale date-based delays) and clamping retry sleep to a safe bounded + range. ### Changed - Switched sensor setup iteration to use a validated local data snapshot for diff --git a/custom_components/pollenlevels/client.py b/custom_components/pollenlevels/client.py index 2363c092..f03cb6f3 100644 --- a/custom_components/pollenlevels/client.py +++ b/custom_components/pollenlevels/client.py @@ -2,6 +2,7 @@ import asyncio import logging +import math import random from typing import Any @@ -40,12 +41,15 @@ def _parse_retry_after(self, retry_after_raw: str) -> float: """Translate a Retry-After header into a delay in seconds.""" try: - return float(retry_after_raw) + parsed = float(retry_after_raw) + if math.isfinite(parsed) and parsed > 0: + return parsed + return 2.0 except (TypeError, ValueError): retry_at = dt_util.parse_http_date(retry_after_raw) if retry_at is not None: delay = (retry_at - dt_util.utcnow()).total_seconds() - if delay > 0: + if math.isfinite(delay) and delay > 0: return delay return 2.0 @@ -118,7 +122,8 @@ async def async_fetch_pollen_data( delay = 2.0 if retry_after_raw: delay = self._parse_retry_after(retry_after_raw) - delay = min(delay, 5.0) + random.uniform(0.0, 0.4) + delay = delay + random.uniform(0.0, 0.4) + delay = max(0.0, min(delay, 5.0)) _LOGGER.warning( "Pollen API 429 — retrying in %.2fs (attempt %d/%d)", delay, diff --git a/custom_components/pollenlevels/coordinator.py b/custom_components/pollenlevels/coordinator.py index 8f7051c8..8e160bf3 100644 --- a/custom_components/pollenlevels/coordinator.py +++ b/custom_components/pollenlevels/coordinator.py @@ -48,8 +48,8 @@ def _rgb_from_api(color: dict[str, Any] | None) -> tuple[int, int, int] | None: """Build an (R, G, B) tuple from API color dict. Rules: - - If color is not a dict, or an empty dict, or has no numeric channels at all, - return None (meaning "no color provided by API"). + - If color is not a dict, or an empty dict, return None + (meaning "no color provided by API"). - If only some channels are present, missing ones are treated as 0 (black baseline) but ONLY when at least one channel exists. This preserves partial colors like {green, blue} without inventing a color for {}. @@ -57,13 +57,6 @@ def _rgb_from_api(color: dict[str, Any] | None) -> tuple[int, int, int] | None: if not isinstance(color, dict) or not color: return None - # Check if any of the channels is actually provided as numeric - has_any_channel = any( - isinstance(color.get(k), (int, float)) for k in ("red", "green", "blue") - ) - if not has_any_channel: - return None - r = _normalize_channel(color.get("red")) g = _normalize_channel(color.get("green")) b = _normalize_channel(color.get("blue")) diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 9dacf338..82b10c1e 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -1093,6 +1093,106 @@ def test_plant_forecast_matches_codes_case_insensitively() -> None: assert entry["tomorrow_value"] == 4 +def test_coordinator_accepts_numeric_string_color_channels() -> None: + """Numeric string channels should be normalized into RGB/hex values.""" + + payload = { + "dailyInfo": [ + { + "date": {"year": 2025, "month": 7, "day": 1}, + "pollenTypeInfo": [ + { + "code": "GRASS", + "displayName": "Grass", + "indexInfo": { + "value": 1, + "category": "LOW", + "color": {"red": "1", "green": "0", "blue": "0"}, + }, + } + ], + } + ] + } + + fake_session = FakeSession(payload) + client = client_mod.GooglePollenApiClient(fake_session, "test") + + loop = asyncio.new_event_loop() + hass = DummyHass(loop) + coordinator = coordinator_mod.PollenDataUpdateCoordinator( + hass=hass, + api_key="test", + lat=1.0, + lon=2.0, + hours=12, + language=None, + entry_id="entry", + forecast_days=1, + create_d1=False, + create_d2=False, + client=client, + ) + + try: + data = loop.run_until_complete(coordinator._async_update_data()) + finally: + loop.close() + + assert data["type_grass"]["color_hex"] == "#FF0000" + assert data["type_grass"]["color_rgb"] == [255, 0, 0] + + +def test_coordinator_ignores_invalid_string_color_channels() -> None: + """Non-numeric string channels should not emit RGB/hex values.""" + + payload = { + "dailyInfo": [ + { + "date": {"year": 2025, "month": 7, "day": 1}, + "pollenTypeInfo": [ + { + "code": "GRASS", + "displayName": "Grass", + "indexInfo": { + "value": 1, + "category": "LOW", + "color": {"red": "foo"}, + }, + } + ], + } + ] + } + + fake_session = FakeSession(payload) + client = client_mod.GooglePollenApiClient(fake_session, "test") + + loop = asyncio.new_event_loop() + hass = DummyHass(loop) + coordinator = coordinator_mod.PollenDataUpdateCoordinator( + hass=hass, + api_key="test", + lat=1.0, + lon=2.0, + hours=12, + language=None, + entry_id="entry", + forecast_days=1, + create_d1=False, + create_d2=False, + client=client, + ) + + try: + data = loop.run_until_complete(coordinator._async_update_data()) + finally: + loop.close() + + assert data["type_grass"]["color_hex"] is None + assert data["type_grass"]["color_rgb"] is None + + def test_coordinator_ignores_nonfinite_color_channels() -> None: """Non-finite color channel values should not crash or emit invalid colors.""" @@ -1460,6 +1560,81 @@ async def _fast_sleep(delay: float) -> None: assert delays == [5.0] +@pytest.mark.parametrize( + ("retry_after", "now"), + [ + ("-10", None), + ("nan", None), + ("inf", None), + ( + "Wed, 10 Dec 2025 12:00:00 GMT", + datetime.datetime(2025, 12, 10, 12, 0, 5, tzinfo=datetime.UTC), + ), + ], +) +def test_coordinator_retry_after_invalid_values_use_safe_default( + monkeypatch: pytest.MonkeyPatch, + retry_after: str, + now: datetime.datetime | None, +) -> None: + """Invalid Retry-After values should fall back to a safe finite delay.""" + + session = SequenceSession( + [ + ResponseSpec( + status=429, + payload={"error": {"message": "Quota exceeded"}}, + headers={"Retry-After": retry_after}, + ), + ResponseSpec( + status=429, + payload={"error": {"message": "Quota exceeded"}}, + headers={"Retry-After": retry_after}, + ), + ] + ) + delays: list[float] = [] + + async def _fast_sleep(delay: float) -> None: + assert isinstance(delay, float) + assert delay == delay + assert delay != float("inf") + assert delay != float("-inf") + delays.append(delay) + + monkeypatch.setattr(client_mod.asyncio, "sleep", _fast_sleep) + monkeypatch.setattr(client_mod.random, "uniform", lambda *_args, **_kwargs: 0.0) + if now is not None: + monkeypatch.setattr(client_mod.dt_util, "utcnow", lambda: now) + + client = client_mod.GooglePollenApiClient(session, "test") + + loop = asyncio.new_event_loop() + hass = DummyHass(loop) + coordinator = coordinator_mod.PollenDataUpdateCoordinator( + hass=hass, + api_key="test", + lat=1.0, + lon=2.0, + hours=12, + language=None, + entry_id="entry", + forecast_days=1, + create_d1=False, + create_d2=False, + client=client, + ) + + try: + with pytest.raises(client_mod.UpdateFailed, match="Quota exceeded"): + loop.run_until_complete(coordinator._async_update_data()) + finally: + loop.close() + + assert session.calls == 2 + assert delays == [2.0] + + def test_coordinator_retries_then_raises_on_server_errors( monkeypatch: pytest.MonkeyPatch, ) -> None: From 41890d0f751fde06c76e0c90d0c43fea697e8d78 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Mon, 23 Feb 2026 08:23:12 +0100 Subject: [PATCH 199/200] Update README.md --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 64f694bb..e5946f86 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,14 @@ severity: ### 🧩 Custom cards (for real dynamic color binding) +**Pollen dashboard card (recommended): pollenprognos-card** + +If you want a dedicated pollen Lovelace card with forecast visualizations and a visual editor UI, +**pollenprognos-card** supports this integration since **v2.9.0**. + +- Repo: https://github.com/krissen/pollenprognos-card +- Install: HACS → Frontend + If you need the icon/badge to follow the **exact** API color (`color_hex`): **Mushroom (mushroom-template-card)** From 2f938607427dd1ce2d70341f8d37aa5b031cf9d9 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Mon, 23 Feb 2026 08:24:49 +0100 Subject: [PATCH 200/200] Update README.md Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e5946f86..145ad6c0 100644 --- a/README.md +++ b/README.md @@ -142,7 +142,7 @@ severity: If you want a dedicated pollen Lovelace card with forecast visualizations and a visual editor UI, **pollenprognos-card** supports this integration since **v2.9.0**. -- Repo: https://github.com/krissen/pollenprognos-card +- Repo: [pollenprognos-card](https://github.com/krissen/pollenprognos-card) - Install: HACS → Frontend If you need the icon/badge to follow the **exact** API color (`color_hex`):