diff --git a/README.md b/README.md index 2491a983..908e0e76 100644 --- a/README.md +++ b/README.md @@ -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.) diff --git a/changelog.md b/changelog.md index 46a74a26..7dc47654 100644 --- a/changelog.md +++ b/changelog.md @@ -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. diff --git a/custom_components/pollenlevels/__init__.py b/custom_components/pollenlevels/__init__.py index 88b09882..ea08a5df 100644 --- a/custom_components/pollenlevels/__init__.py +++ b/custom_components/pollenlevels/__init__.py @@ -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 @@ -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__) @@ -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( @@ -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") diff --git a/custom_components/pollenlevels/diagnostics.py b/custom_components/pollenlevels/diagnostics.py index 6561d137..86d9fd40 100644 --- a/custom_components/pollenlevels/diagnostics.py +++ b/custom_components/pollenlevels/diagnostics.py @@ -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. @@ -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, @@ -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) diff --git a/custom_components/pollenlevels/manifest.json b/custom_components/pollenlevels/manifest.json index 41b09704..874e97a2 100644 --- a/custom_components/pollenlevels/manifest.json +++ b/custom_components/pollenlevels/manifest.json @@ -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" } diff --git a/custom_components/pollenlevels/sensor.py b/custom_components/pollenlevels/sensor.py index 24a81221..296f6d52 100644 --- a/custom_components/pollenlevels/sensor.py +++ b/custom_components/pollenlevels/sensor.py @@ -51,6 +51,7 @@ "TREE": "mdi:tree", "WEED": "mdi:flower-tulip", } +# Plants reuse the same icon mapping by type. PLANT_TYPE_ICONS = TYPE_ICONS DEFAULT_ICON = "mdi:flower-pollen" @@ -203,7 +204,7 @@ async def async_setup_entry(hass, entry, async_add_entities): class PollenDataUpdateCoordinator(DataUpdateCoordinator): - """Coordinate pollen data fetch with forecast support for TYPES.""" + """Coordinate pollen data fetch with forecast support for TYPES and PLANTS.""" def __init__( self, @@ -245,6 +246,75 @@ def __init__( self.last_updated = None self._session = async_get_clientsession(hass) + # ------------------------------ + # 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.""" url = "https://pollen.googleapis.com/v1/forecast:lookup" @@ -394,11 +464,19 @@ async def _async_update_data(self): 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 {} @@ -454,7 +532,7 @@ def _find_type(day: dict, code: str) -> dict | None: } # Forecast for TYPES - def _extract_day_info(day: dict) -> tuple[str | None, str | None]: + 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 @@ -493,67 +571,8 @@ def _extract_day_info(day: dict) -> tuple[str | None, str | None]: ), } ) - base["forecast"] = forecast_list - - # Convenience for tomorrow (1) and d2 (2) - def _set_convenience( - prefix: str, - off: int, - *, - _forecast_list=forecast_list, - _base=base, - ) -> None: - """Set convenience attributes for a given offset using bound snapshot values.""" - 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 - 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 - ) - + # 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) @@ -569,6 +588,22 @@ def _add_day_sensor( 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", @@ -576,8 +611,8 @@ def _add_day_sensor( "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": _base.get("inSeason"), - "advice": _base.get("advice"), + "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"), @@ -590,6 +625,50 @@ def _add_day_sensor( 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) @@ -657,7 +736,9 @@ def extra_state_attributes(self): if info.get(k) is not None: attrs[k] = info.get(k) - # Forecast-related attributes only on main type sensors (not per-day) + # Forecast-related attributes: + # - For TYPE sensors: include on main sensors only (not per-day _d1/_d2) + # - For PLANT sensors: always include (there are no per-day plant sensors) if info.get("source") == "type" and not self.code.endswith(("_d1", "_d2")): for k in ( "forecast", @@ -677,8 +758,8 @@ def extra_state_attributes(self): if info.get(k) is not None: attrs[k] = info.get(k) - # Plant-specific attributes if info.get("source") == "plant": + # Plant-specific metadata plant_attrs = { "code": info.get("code"), "type": info.get("type"), @@ -692,6 +773,25 @@ def extra_state_attributes(self): if v is not None: attrs[k] = v + # Plant forecast attributes (attributes-only, no per-day plant sensors) + for k in ( + "forecast", + "tomorrow_has_index", + "tomorrow_value", + "tomorrow_category", + "tomorrow_description", + "tomorrow_color_hex", + "d2_has_index", + "d2_value", + "d2_category", + "d2_description", + "d2_color_hex", + "trend", + "expected_peak", + ): + if info.get(k) is not None: + attrs[k] = info.get(k) + return attrs @property