Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,15 @@
## [1.9.5] - 2026-02-27
### Changed
- Aligned config-flow validation with runtime client semantics by using
`GooglePollenApiClient` for setup checks, mapping auth/quota/connectivity
errors consistently, and removing duplicate HTTP/JSON parsing from the flow.
- Expanded config-flow regression coverage to validate client-based setup
behavior, including normalized request arguments and timeout/client-error
handling parity.
- Bumped GitHub Actions artifact upload to `actions/upload-artifact@v7`.
- Replaced fragile text-based HTTP 429 detection in setup validation with a
dedicated client quota exception so `quota_exceeded` mapping remains stable.

## [1.9.4] - 2026-02-24
### Changed
- Updated release packaging automation for HACS `zip_release` by validating the
Expand Down
6 changes: 5 additions & 1 deletion custom_components/pollenlevels/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ def _format_http_message(status: int, raw_message: str | None) -> str:
return f"HTTP {status}"


class PollenQuotaExceededError(UpdateFailed):
"""Raised when Google Pollen API quota limits are exceeded (HTTP 429)."""


class GooglePollenApiClient:
"""Thin async client wrapper for the Google Pollen API."""

Expand Down Expand Up @@ -136,7 +140,7 @@ async def async_fetch_pollen_data(
await extract_error_message(resp, default=""), self._api_key
)
message = _format_http_message(resp.status, raw_message or None)
raise UpdateFailed(message)
raise PollenQuotaExceededError(message)

if 500 <= resp.status <= 599:
if attempt < max_retries:
Expand Down
107 changes: 38 additions & 69 deletions custom_components/pollenlevels/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,16 @@

from __future__ import annotations

import json
import logging
import re
from typing import Any

import aiohttp
import homeassistant.helpers.config_validation as cv
import voluptuous as vol
from aiohttp import ClientError
from homeassistant import config_entries
from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, CONF_NAME
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import (
LocationSelector,
Expand All @@ -37,7 +37,9 @@
TextSelectorConfig,
TextSelectorType,
)
from homeassistant.helpers.update_coordinator import UpdateFailed

from .client import GooglePollenApiClient, PollenQuotaExceededError
from .const import (
CONF_API_KEY,
CONF_CREATE_FORECAST_SENSORS,
Expand All @@ -56,10 +58,8 @@
POLLEN_API_KEY_URL,
POLLEN_API_TIMEOUT,
RESTRICTING_API_KEYS_URL,
is_invalid_api_key_message,
)
from .util import (
extract_error_message,
normalize_sensor_mode,
redact_api_key,
safe_parse_int,
Expand Down Expand Up @@ -398,71 +398,25 @@ async def _async_validate_input(
lang = is_valid_language_code(lang)

session = async_get_clientsession(self.hass)
params = {
"key": api_key,
"location.latitude": f"{lat:.6f}",
"location.longitude": f"{lon:.6f}",
"days": 1,
}
if lang:
params["languageCode"] = lang

url = "https://pollen.googleapis.com/v1/forecast:lookup"

_LOGGER.debug("Validating Pollen API (days=%s, lang_set=%s)", 1, bool(lang))

async with session.get(
url,
params=params,
timeout=aiohttp.ClientTimeout(total=POLLEN_API_TIMEOUT),
) as resp:
status = resp.status
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)
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"
else:
raw = await resp.read()
try:
body_str = raw.decode()
except Exception:
body_str = str(raw)
_LOGGER.debug(
"Validation HTTP %s — %s",
status,
redact_api_key(body_str, api_key),
)
try:
data = json.loads(body_str) if body_str else {}
except Exception:
data = {}
client = GooglePollenApiClient(session, api_key)
data = await client.async_fetch_pollen_data(
latitude=lat,
longitude=lon,
days=1,
language_code=lang or None,
)

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."
)
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."
)

if errors:
return errors, None
Expand All @@ -478,6 +432,21 @@ async def _async_validate_input(
)
errors[CONF_LANGUAGE_CODE] = _language_error_to_form_key(ve)
placeholders.pop("error_message", None)
except ConfigEntryAuthFailed as err:
errors["base"] = "invalid_auth"
redacted = redact_api_key(err, api_key)
if redacted:
placeholders["error_message"] = redacted
except PollenQuotaExceededError as err:
redacted = redact_api_key(err, api_key)
if redacted:
placeholders["error_message"] = redacted
errors["base"] = "quota_exceeded"
except UpdateFailed as err:
redacted = redact_api_key(err, api_key)
if redacted:
placeholders["error_message"] = redacted
errors["base"] = "cannot_connect"
Comment on lines +435 to +449
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To reduce code duplication and improve conciseness, you can use assignment expressions (the walrus operator :=) to both check for and assign the redacted error message. This is supported since your project requires Python >= 3.14.

Suggested change
except ConfigEntryAuthFailed as err:
errors["base"] = "invalid_auth"
redacted = redact_api_key(err, api_key)
if redacted:
placeholders["error_message"] = redacted
except PollenQuotaExceededError as err:
redacted = redact_api_key(err, api_key)
if redacted:
placeholders["error_message"] = redacted
errors["base"] = "quota_exceeded"
except UpdateFailed as err:
redacted = redact_api_key(err, api_key)
if redacted:
placeholders["error_message"] = redacted
errors["base"] = "cannot_connect"
except ConfigEntryAuthFailed as err:
errors["base"] = "invalid_auth"
if redacted := redact_api_key(err, api_key):
placeholders["error_message"] = redacted
except PollenQuotaExceededError as err:
errors["base"] = "quota_exceeded"
if redacted := redact_api_key(err, api_key):
placeholders["error_message"] = redacted
except UpdateFailed as err:
errors["base"] = "cannot_connect"
if redacted := redact_api_key(err, api_key):
placeholders["error_message"] = redacted

except TimeoutError as err:
_LOGGER.warning(
"Validation timeout (%ss): %s",
Expand All @@ -490,7 +459,7 @@ async def _async_validate_input(
redacted
or f"Validation request timed out ({POLLEN_API_TIMEOUT} seconds)."
)
except aiohttp.ClientError as err:
except ClientError as err:
_LOGGER.error(
"Connection error: %s",
redact_api_key(err, api_key),
Expand Down
2 changes: 1 addition & 1 deletion custom_components/pollenlevels/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"issue_tracker": "https://github.com/eXPerience83/pollenlevels/issues",
"version": "1.9.4"
"version": "1.9.5"
}
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

[project]
name = "pollenlevels"
version = "1.9.4"
version = "1.9.5"
# Enforce the runtime floor aligned with upcoming HA Python 3.14 images.
requires-python = ">=3.14"

Expand Down
Loading
Loading