Skip to content
Merged
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
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,12 @@ Get sensors for **grass**, **tree**, **weed** pollen, plus individual plants lik

- **Multi-language support** — UI in 9 languages (EN, ES, CA, DE, FR, IT, PL, RU, UK) + API responses in any language.
- **Dynamic sensors** — Auto-creates sensors for all pollen types found in your location.
- **Multi-day forecast for TYPES** —
- `forecast` list with `{offset, date, has_index, value, category, description, color_*}`
- Convenience: `tomorrow_*` and `d2_*`
- Derived: `trend` and `expected_peak`
- **Optional** per-day sensors via unified option.
- **Multi-day forecast for TYPES & PLANTS** —
- `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`).
**PLANTS** expose forecast **as attributes only** (no extra entities).
- **Smart grouping** — Organizes sensors into:
- **Pollen Types** (Grass / Tree / Weed)
- **Plants** (Oak, Pine, Birch, etc.)
Expand Down
22 changes: 22 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,25 @@
# Changelog

## [1.7.2] – 2025-09-01
### Fixed
- **Diagnostics**: redact `location.latitude`/`location.longitude` inside the request example to avoid leaking coordinates in exports.

## [1.7.1] – 2025-09-01
### Changed
- Internal refactor: centralize forecast attribute building for TYPES & PLANTS into a single helper to reduce duplication and ensure parity.
- Logging: add a top-level INFO when `pollenlevels.force_update` is invoked.

### Notes
- No behavior changes; entities, attributes, and options remain identical.

## [1.7.0] – 2025-08-31
### Fixed
- TYPE per-day sensors (D+1/D+2) now use the **correct day's** `inSeason` and `advice` instead of inheriting today's values.
### Added
- **Plant forecast (attributes only):** plant sensors now include `forecast`, `tomorrow_*`, `d2_*`, `trend`, and `expected_peak`, mirroring TYPE sensors.
### Notes
- No new plant entities are created; forecast is available via attributes to keep entity count low.

## [1.6.5] – 2025-08-26
### Fixed
- Timeouts: catch built-in **`TimeoutError`** in Config Flow and Coordinator.
Expand Down
17 changes: 13 additions & 4 deletions custom_components/pollenlevels/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
"""Initialize Pollen Levels integration."""
"""Initialize Pollen Levels integration.

Notes:
- Adds a top-level INFO 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 __future__ import annotations

Expand All @@ -12,6 +18,7 @@

from .const import DOMAIN

# Ensure YAML config is entry-only for this domain (no YAML schema).
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)

_LOGGER = logging.getLogger(__name__)
Expand All @@ -25,11 +32,13 @@ 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")
for entry in hass.config_entries.async_entries(DOMAIN):
coordinator = hass.data.get(DOMAIN, {}).get(entry.entry_id)
if coordinator:
_LOGGER.info("Trigger manual refresh for entry %s", entry.entry_id)
# Wait until the update completes
# Wait until the update completes to surface errors in logs.
await coordinator.async_refresh()

hass.services.async_register(
Expand All @@ -50,10 +59,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await hass.config_entries.async_forward_entry_setups(entry, ["sensor"])
except Exception as err:
_LOGGER.error("Error forwarding entry setups: %s", err)
# Surfaced as ConfigEntryNotReady so HA can retry later.
raise ConfigEntryNotReady from err

# Register an update listener so that options changes trigger a reload.
# This ensures the coordinator picks up the new interval/language immediately.
# Ensure options updates (interval/language/forecast settings) trigger reload.
entry.async_on_unload(entry.add_update_listener(_update_listener))

_LOGGER.info("PollenLevels integration loaded successfully")
Expand Down
88 changes: 84 additions & 4 deletions custom_components/pollenlevels/diagnostics.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
This exposes non-sensitive runtime details useful for support:
- Entry data/options (with API key and location redacted)
- Coordinator snapshot (last_updated, forecast_days, language, flags)
- Forecast summaries for TYPES & PLANTS (attributes-only for plants)
- A sample of the request params with the API key redacted

No network I/O is performed.
Expand All @@ -27,40 +28,117 @@
DOMAIN,
)

TO_REDACT = {CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE}
# 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",
}


def _iso_or_none(dt_obj) -> str | None:
"""Return UTC ISO8601 string for datetimes, else None."""
try:
return dt_obj.isoformat() if dt_obj is not None else None
except Exception:
return None


async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: ConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry with secrets redacted."""
"""Return diagnostics for a config entry with secrets redacted.

NOTE: This function must not perform any network I/O.
"""
coordinator = hass.data.get(DOMAIN, {}).get(entry.entry_id)
options = dict(entry.options or {})
data = dict(entry.data or {})

# Build a safe params example (no network I/O)
days_effective = int(
options.get(CONF_FORECAST_DAYS, data.get(CONF_FORECAST_DAYS, 2))
)
params_example = {
"key": "***",
"location.latitude": data.get(CONF_LATITUDE),
"location.longitude": data.get(CONF_LONGITUDE),
"days": options.get(CONF_FORECAST_DAYS, data.get(CONF_FORECAST_DAYS, 2)),
"days": days_effective,
}
lang = options.get(CONF_LANGUAGE_CODE, data.get(CONF_LANGUAGE_CODE))
if lang:
params_example["languageCode"] = lang

# Coordinator snapshot
coord_info: dict[str, Any] = {}
forecast_summary: dict[str, Any] = {}
if coordinator is not None:
# Base coordinator info
coord_info = {
"entry_id": getattr(coordinator, "entry_id", None),
"forecast_days": getattr(coordinator, "forecast_days", None),
"language": getattr(coordinator, "language", None),
"create_d1": getattr(coordinator, "create_d1", None),
"create_d2": getattr(coordinator, "create_d2", None),
"last_updated": getattr(coordinator, "last_updated", None),
"last_updated": _iso_or_none(getattr(coordinator, "last_updated", None)),
"data_keys": list(getattr(coordinator, "data", {}).keys()),
}

# ---------- Forecast summaries (TYPES & PLANTS) ----------
data_map: dict[str, Any] = getattr(coordinator, "data", {}) or {}

# TYPES
type_main_keys = [
k
for k, v in data_map.items()
if isinstance(v, dict)
and v.get("source") == "type"
and not k.endswith(("_d1", "_d2"))
]
type_perday_keys = [
k
for k, v in data_map.items()
if isinstance(v, dict)
and v.get("source") == "type"
and k.endswith(("_d1", "_d2"))
]
type_codes = sorted(
{k.split("_", 1)[1].split("_d", 1)[0].upper() for k in type_main_keys}
)
forecast_summary["type"] = {
"total_main": len(type_main_keys),
"total_per_day": len(type_perday_keys),
"create_d1": getattr(coordinator, "create_d1", None),
"create_d2": getattr(coordinator, "create_d2", None),
"codes": type_codes,
}

# PLANTS (attributes-only)
plant_items = [
v
for v in data_map.values()
if isinstance(v, dict) and v.get("source") == "plant"
]
plant_codes = sorted([v.get("code") for v in plant_items if v.get("code")])
plants_with_attr = [v for v in plant_items if "forecast" in v]
plants_with_nonempty = [v for v in plant_items if v.get("forecast") or []]
plants_with_trend = [v for v in plant_items if v.get("trend") is not None]

forecast_summary["plant"] = {
# Enabled if at least tomorrow is requested (2+ days)
"enabled": bool(getattr(coordinator, "forecast_days", 1) >= 2),
"days": getattr(coordinator, "forecast_days", None),
"total": len(plant_items),
"with_attr": len(plants_with_attr),
"with_nonempty": len(plants_with_nonempty),
"with_trend": len(plants_with_trend),
"codes": plant_codes,
}

diag = {
"entry": {
"entry_id": entry.entry_id,
Expand All @@ -78,7 +156,9 @@ async def async_get_config_entry_diagnostics(
},
},
"coordinator": coord_info,
"forecast_summary": forecast_summary,
"request_params_example": params_example,
}

# Redact secrets and return
return async_redact_data(diag, TO_REDACT)
2 changes: 1 addition & 1 deletion custom_components/pollenlevels/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,5 @@
"documentation": "https://github.com/eXPerience83/pollenlevels",
"iot_class": "cloud_polling",
"issue_tracker": "https://github.com/eXPerience83/pollenlevels/issues",
"version": "1.6.5"
"version": "1.7.2"
}
Loading