From f26f7994e31dadbbb0af069f07ec950d88ff4f42 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sun, 31 Aug 2025 08:58:00 +0200 Subject: [PATCH 01/19] Update sensor.py --- custom_components/pollenlevels/sensor.py | 144 +++++++++++++++++++++-- 1 file changed, 136 insertions(+), 8 deletions(-) diff --git a/custom_components/pollenlevels/sensor.py b/custom_components/pollenlevels/sensor.py index 24a81221..b2f6562c 100644 --- a/custom_components/pollenlevels/sensor.py +++ b/custom_components/pollenlevels/sensor.py @@ -203,7 +203,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, @@ -394,11 +394,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 +462,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 @@ -525,9 +533,7 @@ def _set_convenience( # 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 isinstance(now_val, (int, float)) and isinstance(tomorrow_val, (int, float)): if tomorrow_val > now_val: base["trend"] = "up" elif tomorrow_val < now_val: @@ -540,7 +546,7 @@ def _set_convenience( # Expected peak (excluding today) peak = None for f in forecast_list: - if f.get("has_index") and isinstance(f.get("value"), int | float): + 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"] = ( @@ -590,6 +596,107 @@ 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 + ), + } + ) + + base["forecast"] = forecast_list + + # Convenience attributes (tomorrow / d2) + def _set_convenience_plant( + prefix: str, + off: int, + *, + _forecast_list=forecast_list, + _base=base, + ) -> None: + """Set convenience attributes for plant forecasts.""" + 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_plant("tomorrow", 1) + _set_convenience_plant("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 + ) + + new_data[key] = base + self.data = new_data self.last_updated = dt_util.utcnow() _LOGGER.debug("Updated data: %s", self.data) @@ -657,7 +764,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 +786,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 +801,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 From 861a6292618dd9331cf100b67e61b782dca38537 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sun, 31 Aug 2025 09:02:11 +0200 Subject: [PATCH 02/19] Update changelog.md --- changelog.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/changelog.md b/changelog.md index 46a74a26..c42ff868 100644 --- a/changelog.md +++ b/changelog.md @@ -1,3 +1,11 @@ +# Changelog + +## [1.7.0] – 2025-08-31 +### 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. From 94eee09c605a416e0d93b6940773d087701bda8a Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sun, 31 Aug 2025 09:02:30 +0200 Subject: [PATCH 03/19] 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 41b09704..38a92ab6 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.0" } From 8414328376add59e785a4ecd6a9f30c15f571b56 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sun, 31 Aug 2025 09:03:28 +0200 Subject: [PATCH 04/19] Update README.md --- README.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) 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.) From f5a504c2fdb2c57a77bf4dcb722f29b08f35f135 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sun, 31 Aug 2025 09:09:33 +0200 Subject: [PATCH 05/19] Update diagnostics.py --- custom_components/pollenlevels/diagnostics.py | 66 ++++++++++++++++++- 1 file changed, 63 insertions(+), 3 deletions(-) diff --git a/custom_components/pollenlevels/diagnostics.py b/custom_components/pollenlevels/diagnostics.py index 6561d137..9687f775 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,97 @@ DOMAIN, ) +# Redact potentially sensitive values from diagnostics TO_REDACT = {CONF_API_KEY, CONF_LATITUDE, CONF_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 +136,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) From c42afccabb029ffa67a45766428a397bcd83838f Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Sun, 31 Aug 2025 09:19:12 +0200 Subject: [PATCH 06/19] Update sensor.py --- custom_components/pollenlevels/sensor.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/custom_components/pollenlevels/sensor.py b/custom_components/pollenlevels/sensor.py index b2f6562c..a7c63dae 100644 --- a/custom_components/pollenlevels/sensor.py +++ b/custom_components/pollenlevels/sensor.py @@ -533,7 +533,7 @@ def _set_convenience( # 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 isinstance(now_val, int | float) and isinstance(tomorrow_val, int | float): if tomorrow_val > now_val: base["trend"] = "up" elif tomorrow_val < now_val: @@ -546,7 +546,7 @@ def _set_convenience( # Expected peak (excluding today) peak = None for f in forecast_list: - if f.get("has_index") and isinstance(f.get("value"), (int, float)): + 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"] = ( @@ -668,7 +668,7 @@ def _set_convenience_plant( # 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 isinstance(now_val, int | float) and isinstance(tomorrow_val, int | float): if tomorrow_val > now_val: base["trend"] = "up" elif tomorrow_val < now_val: @@ -681,7 +681,7 @@ def _set_convenience_plant( # Expected peak (excluding today) peak = None for f in forecast_list: - if f.get("has_index") and isinstance(f.get("value"), (int, float)): + 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"] = ( From 7b42b17baa12171d29eeb4f0f5dec663b1d0cfbf Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 31 Aug 2025 07:22:21 +0000 Subject: [PATCH 07/19] style: apply Ruff fixes and Black formatting --- custom_components/pollenlevels/diagnostics.py | 20 +++++++++++++++---- custom_components/pollenlevels/sensor.py | 8 ++++++-- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/custom_components/pollenlevels/diagnostics.py b/custom_components/pollenlevels/diagnostics.py index 9687f775..6b4cd10a 100644 --- a/custom_components/pollenlevels/diagnostics.py +++ b/custom_components/pollenlevels/diagnostics.py @@ -52,7 +52,9 @@ async def async_get_config_entry_diagnostics( 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))) + days_effective = int( + options.get(CONF_FORECAST_DAYS, data.get(CONF_FORECAST_DAYS, 2)) + ) params_example = { "key": "***", "location.latitude": data.get(CONF_LATITUDE), @@ -83,10 +85,18 @@ async def async_get_config_entry_diagnostics( # 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")) + 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")) + 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} @@ -101,7 +111,9 @@ async def async_get_config_entry_diagnostics( # PLANTS (attributes-only) plant_items = [ - v for v in data_map.values() if isinstance(v, dict) and v.get("source") == "plant" + 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] diff --git a/custom_components/pollenlevels/sensor.py b/custom_components/pollenlevels/sensor.py index a7c63dae..5fa2dbbb 100644 --- a/custom_components/pollenlevels/sensor.py +++ b/custom_components/pollenlevels/sensor.py @@ -533,7 +533,9 @@ def _set_convenience( # 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 isinstance(now_val, int | float) and isinstance( + tomorrow_val, int | float + ): if tomorrow_val > now_val: base["trend"] = "up" elif tomorrow_val < now_val: @@ -668,7 +670,9 @@ def _set_convenience_plant( # 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 isinstance(now_val, int | float) and isinstance( + tomorrow_val, int | float + ): if tomorrow_val > now_val: base["trend"] = "up" elif tomorrow_val < now_val: From b376c9486b1cbd5bcd97922983c0f6c391238d5f Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Mon, 1 Sep 2025 08:27:50 +0200 Subject: [PATCH 08/19] Update sensor.py fix(sensor): use day-specific inSeason/advice for TYPE D+1/D+2 --- custom_components/pollenlevels/sensor.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/custom_components/pollenlevels/sensor.py b/custom_components/pollenlevels/sensor.py index 5fa2dbbb..0c2a0cc6 100644 --- a/custom_components/pollenlevels/sensor.py +++ b/custom_components/pollenlevels/sensor.py @@ -577,6 +577,20 @@ def _add_day_sensor( f = next((d for d in _forecast_list if d["offset"] == off), None) if not f: return + + # FIX: Use day-specific 'inSeason' and 'advice' from the forecast day + # instead of inheriting from today's base. That guarantees the per-day + # TYPE sensors reflect the correct day's metadata. + 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", @@ -584,8 +598,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"), From 5aeed952ccfe946769f54046f2585099e56f87d4 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Mon, 1 Sep 2025 08:29:15 +0200 Subject: [PATCH 09/19] Update changelog.md --- changelog.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/changelog.md b/changelog.md index c42ff868..4ce732bb 100644 --- a/changelog.md +++ b/changelog.md @@ -1,6 +1,8 @@ # Changelog ## [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 From 7ad69c76b3837f9c8e64e0b90587c80d81024360 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 1 Sep 2025 06:30:18 +0000 Subject: [PATCH 10/19] style: apply Ruff fixes and Black formatting --- custom_components/pollenlevels/sensor.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/custom_components/pollenlevels/sensor.py b/custom_components/pollenlevels/sensor.py index 0c2a0cc6..1cca724e 100644 --- a/custom_components/pollenlevels/sensor.py +++ b/custom_components/pollenlevels/sensor.py @@ -586,9 +586,13 @@ def _add_day_sensor( 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_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 + day_item.get("healthRecommendations") + if isinstance(day_item, dict) + else None ) dname = f"{_base.get('displayName', _tcode)} (D+{off})" From b5a27eaddf60d06a7131d3a707b8f16d8c5b2dc9 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Mon, 1 Sep 2025 11:55:23 +0200 Subject: [PATCH 11/19] Update sensor.py --- custom_components/pollenlevels/sensor.py | 199 +++++++++-------------- 1 file changed, 74 insertions(+), 125 deletions(-) diff --git a/custom_components/pollenlevels/sensor.py b/custom_components/pollenlevels/sensor.py index 1cca724e..de5fcb39 100644 --- a/custom_components/pollenlevels/sensor.py +++ b/custom_components/pollenlevels/sensor.py @@ -245,6 +245,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" @@ -501,67 +570,8 @@ def _extract_day_info(day: dict) -> tuple[str | None, dict | 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) @@ -578,9 +588,7 @@ def _add_day_sensor( if not f: return - # FIX: Use day-specific 'inSeason' and 'advice' from the forecast day - # instead of inheriting from today's base. That guarantees the per-day - # TYPE sensors reflect the correct day's metadata. + # Use day-specific 'inSeason' and 'advice' from the forecast day. try: day_obj = daily[off] except (IndexError, TypeError): @@ -656,67 +664,8 @@ def _add_day_sensor( } ) - base["forecast"] = forecast_list - - # Convenience attributes (tomorrow / d2) - def _set_convenience_plant( - prefix: str, - off: int, - *, - _forecast_list=forecast_list, - _base=base, - ) -> None: - """Set convenience attributes for plant forecasts.""" - 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_plant("tomorrow", 1) - _set_convenience_plant("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 - ) - + # Attach common forecast attributes (convenience, trend, expected_peak) + base = self._process_forecast_attributes(base, forecast_list) new_data[key] = base self.data = new_data From ac4687258e69a9ac82e0dbe293390a4a28bad346 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Mon, 1 Sep 2025 11:55:42 +0200 Subject: [PATCH 12/19] Update __init__.py --- custom_components/pollenlevels/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/custom_components/pollenlevels/__init__.py b/custom_components/pollenlevels/__init__.py index 88b09882..6e114903 100644 --- a/custom_components/pollenlevels/__init__.py +++ b/custom_components/pollenlevels/__init__.py @@ -25,6 +25,8 @@ 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 + _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: From 5a14918e5597438f7bc34c29bfc700afbb57b4c0 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Mon, 1 Sep 2025 11:56:22 +0200 Subject: [PATCH 13/19] Update changelog.md --- changelog.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/changelog.md b/changelog.md index 4ce732bb..ed5eca42 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,13 @@ # Changelog +## [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. From 3ea9e22c4d92ed1ae57369cbcded9613b7357ded Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Mon, 1 Sep 2025 11:56:39 +0200 Subject: [PATCH 14/19] 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 38a92ab6..b4b44ce5 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.7.0" + "version": "1.7.1" } From a1dcf5189175ece71fc4828103285277db545930 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Mon, 1 Sep 2025 12:08:41 +0200 Subject: [PATCH 15/19] Update __init__.py --- custom_components/pollenlevels/__init__.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/custom_components/pollenlevels/__init__.py b/custom_components/pollenlevels/__init__.py index 6e114903..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,13 +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 + # 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( @@ -52,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") From 0bb1b7b1e0e6f731c8de793ee7fb1a6b9294aa1b Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Mon, 1 Sep 2025 12:10:23 +0200 Subject: [PATCH 16/19] Update sensor.py --- custom_components/pollenlevels/sensor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/custom_components/pollenlevels/sensor.py b/custom_components/pollenlevels/sensor.py index de5fcb39..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" From 708879e8de92e2e7c88ec1f7dada4433353e26f2 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Mon, 1 Sep 2025 15:28:39 +0200 Subject: [PATCH 17/19] Update diagnostics.py fix(diagnostics): redact request_params_example location.* to avoid leaking coordinates --- custom_components/pollenlevels/diagnostics.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/custom_components/pollenlevels/diagnostics.py b/custom_components/pollenlevels/diagnostics.py index 6b4cd10a..86d9fd40 100644 --- a/custom_components/pollenlevels/diagnostics.py +++ b/custom_components/pollenlevels/diagnostics.py @@ -28,8 +28,16 @@ DOMAIN, ) -# Redact potentially sensitive values from diagnostics -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: From 6b0962ff67661923baa2b601c53f4c7bc12f60b2 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Mon, 1 Sep 2025 15:29:19 +0200 Subject: [PATCH 18/19] 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 b4b44ce5..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.7.1" + "version": "1.7.2" } From 10527ce3f1a6a04df33a61bd7a3b4bb84a17c475 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Mon, 1 Sep 2025 15:30:00 +0200 Subject: [PATCH 19/19] Update changelog.md --- changelog.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/changelog.md b/changelog.md index ed5eca42..7dc47654 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,9 @@ # 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.