diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d7d619..09acbd6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,61 @@ Das Format orientiert sich an [Keep a Changelog](https://keepachangelog.com/de/1 die Versionsnummerierung folgt grob [Semantic Versioning](https://semver.org/lang/de/) mit optionaler vierter Build-Komponente. +## [2.6.1] — 2026-09-26 + +Laufzeit-Korrekturen aus dem Code-Review. Anders als 2.6.0.7 ändert dieses +Release sichtbares Verhalten — siehe „Hinweis" unten. + +### Behoben +- **Jede Entity wurde doppelt aktualisiert.** `should_poll` gab hart `True` + zurück; das spätere `_attr_should_poll = False` war dadurch wirkungslos. + HA pollte jede Entity zusätzlich alle 30 s (Sensor-Default) neben dem + eigenen Timer — Stundenkalender rechneten alle 30 s neu, und bei den + Plugins mit synchronem `update()` konnten beide Läufe gleichzeitig in zwei + Executor-Threads auf derselben Instanz laufen. `should_poll` ist jetzt + `False`; der Timer ist die einzige Quelle. Zusätzlich überspringt der Timer + einen Tick, solange der vorherige noch läuft (relevant bei `ut1`, dessen + 10-s-Timeout länger ist als sein 1-s-Intervall). +- **Optionsänderungen griffen erst nach Neustart.** Es war kein + `update_listener` registriert; der 2.6.0.6-Options-Dialog speicherte also + korrekt, ohne dass etwas passierte. Jetzt lädt HA den Eintrag nach dem + Speichern automatisch neu. Der Options-Flow reicht `entry.options` + unverändert durch, damit kein zweiter Reload ausgelöst wird. +- **Fehlgeschlagene Updates waren unsichtbar.** Exceptions aus `update()` + wurden nur auf DEBUG geloggt; der State blieb stumm stehen. Jetzt: erster + Fehler einer Serie auf WARNING (Traceback auf DEBUG), Folgefehler auf DEBUG, + Erholung auf INFO. Nach 3 aufeinanderfolgenden Fehlern wird die Entity als + **nicht verfügbar** markiert, beim nächsten Erfolg wieder verfügbar. +- **`ut1` konnte die IERS-API fluten.** Bei Ausfall der API versuchte das + Plugin jede Sekunde erneut (mit je 10 s Timeout, serialisiert über den + Lock) und schrieb jedes Mal eine WARNING. Jetzt: HA-weite geteilte + `aiohttp`-Session statt einer neuen Session pro Aufruf, exponentielles + Backoff 60 s → 120 s → … → 3600 s (Cap), WARNING nur beim ersten Fehler + einer Serie und einmal pro Stunde am Cap, INFO bei Erholung. +- `_CONFIG_ENTRIES` in `sensor.py` wurde nie geleert — entfernte Einträge + blieben bis zum Prozessende referenziert. Wird beim Unload jetzt bereinigt. +- `__init__.py` fing Fehler beim Platform-Setup mit einem breiten `except` + und gab `False` zurück — der Eintrag erschien als „geladen" ohne Entities, + die Ursache war nur im Log zu ahnen. Fehler propagieren jetzt an HA. + +### Entfernt +- `async_reload_entry` in `__init__.py`: rief Unload und Setup direkt auf + (am HA-Zustandsautomaten vorbei) und hatte keinen Aufrufer. + +### Hinweis +- **Entities können jetzt „nicht verfügbar" werden**, wenn ein Plugin + dreimal hintereinander scheitert — bisher zeigten sie stattdessen den + letzten alten Wert. Wer Automationen auf diese Sensoren hat, sollte das + wissen; die Ursache steht ab dem ersten Fehler als WARNING im Log. +- **Nach dem Speichern von Optionen wird der Eintrag neu geladen** — die + Entities verschwinden dabei für einen Moment und kommen zurück. +- Stundenkalender ändern `last_updated` jetzt tatsächlich nur stündlich, + nicht mehr alle 30 s. +- Die sekündlichen `Updated …`-DEBUG-Zeilen der 1-s-Plugins sind unverändert; + sie erscheinen nur mit `logger: custom_components.alternative_time: debug` + in der `configuration.yaml`. Wer das aus einer früheren Fehlersuche noch + aktiv hat, sollte es auf `info` zurücksetzen. + ## [2.6.0.7] — 2026-09-26 Hotfix-Release nach einem vollständigen Code-Review. Nur isolierte Korrekturen, diff --git a/custom_components/alternative_time/__init__.py b/custom_components/alternative_time/__init__.py index a681694..76a3824 100644 --- a/custom_components/alternative_time/__init__.py +++ b/custom_components/alternative_time/__init__.py @@ -41,18 +41,26 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Store config entry data hass.data[DOMAIN][entry.entry_id] = entry.data - # Forward setup to sensor platform - # This will look for sensor.py in the same directory as __init__.py - try: - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - _LOGGER.info(f"Successfully set up Alternative Time integration for {entry.title}") - except Exception as e: - _LOGGER.error(f"Error setting up platforms: {e}") - return False + # Reload the entry whenever its data/options change (options flow). + # Without this, edits made under "Configure" only took effect after a + # Home Assistant restart. The listener is unregistered on unload. + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) + + # Forward setup to sensor platform. Let failures propagate: HA records + # the error and the entry state; swallowing it here (as older versions + # did) hid the real cause and showed a "loaded" entry with no entities. + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + _LOGGER.info(f"Successfully set up Alternative Time integration for {entry.title}") return True +async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle config entry updates by reloading the entry.""" + _LOGGER.debug(f"Config entry {entry.title} updated — reloading") + await hass.config_entries.async_reload(entry.entry_id) + + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" _LOGGER.debug(f"Unloading Alternative Time integration for {entry.title}") @@ -64,14 +72,13 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Remove config entry from hass.data hass.data[DOMAIN].pop(entry.entry_id, None) + # Drop it from the sensor platform's entry registry as well, otherwise + # removed entries stay referenced for the lifetime of the process. + from .sensor import forget_config_entry + forget_config_entry(entry.entry_id) + # Clean up domain if no more entries if not hass.data[DOMAIN]: hass.data.pop(DOMAIN) return unload_ok - - -async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Reload config entry.""" - await async_unload_entry(hass, entry) - await async_setup_entry(hass, entry) diff --git a/custom_components/alternative_time/calendars/ut1.py b/custom_components/alternative_time/calendars/ut1.py index f5154f2..873ba7f 100644 --- a/custom_components/alternative_time/calendars/ut1.py +++ b/custom_components/alternative_time/calendars/ut1.py @@ -10,6 +10,7 @@ from typing import Any, Dict, Optional from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession try: import aiohttp @@ -34,6 +35,12 @@ # Cache duration for IERS data (in seconds) - DUT1 changes slowly IERS_CACHE_DURATION = 3600 # 1 hour +# Backoff after a failed IERS fetch: first retry after 60 s, doubling each +# failure, capped at IERS_CACHE_DURATION. Without this the 1-second update +# interval retried (and warned) every second for as long as the API was down. +IERS_BACKOFF_INITIAL = 60 +IERS_BACKOFF_MAX = IERS_CACHE_DURATION + # Fallback DUT1 value if API is unavailable (updated periodically) # As of July 2025, DUT1 is approximately +0.1s FALLBACK_DUT1 = 0.1 @@ -450,6 +457,9 @@ def __init__(self, base_name: str, hass: HomeAssistant) -> None: self._dut1_last_fetch: Optional[datetime] = None self._dut1_source: str = "fallback" # "iers_api", "fallback", "cached" self._iers_fetch_lock = asyncio.Lock() + # Exponential backoff state for failed IERS fetches + self._iers_failures: int = 0 + self._iers_next_attempt: Optional[datetime] = None # Initialize state self._state = None @@ -537,67 +547,99 @@ async def _async_fetch_iers_data(self) -> bool: _LOGGER.warning("aiohttp not available, using fallback DUT1 value") return False - # Check if cache is still valid + now = datetime.now(timezone.utc) + + # Cache still valid? if self._dut1_last_fetch: - cache_age = (datetime.now(timezone.utc) - self._dut1_last_fetch).total_seconds() + cache_age = (now - self._dut1_last_fetch).total_seconds() if cache_age < self._cache_duration: - _LOGGER.debug(f"Using cached DUT1 value (age: {cache_age:.0f}s)") self._dut1_source = "cached" return True + # In backoff after a failure? Stay quiet — this runs every second. + if self._iers_next_attempt and now < self._iers_next_attempt: + return False + # Use lock to prevent multiple simultaneous fetches async with self._iers_fetch_lock: - # Double-check cache after acquiring lock + # Double-check both conditions after acquiring the lock + now = datetime.now(timezone.utc) if self._dut1_last_fetch: - cache_age = (datetime.now(timezone.utc) - self._dut1_last_fetch).total_seconds() + cache_age = (now - self._dut1_last_fetch).total_seconds() if cache_age < self._cache_duration: return True + if self._iers_next_attempt and now < self._iers_next_attempt: + return False + failure_reason: Optional[str] = None try: # Build API URL with proper URL encoding import urllib.parse - now = datetime.now(timezone.utc) datetime_str = now.strftime("%Y-%m-%d %H:%M:%S") datetime_encoded = urllib.parse.quote(datetime_str) url = f"{IERS_API_BASE}?param=UT1-UTC&datetime={datetime_encoded}" _LOGGER.debug(f"Fetching IERS data from: {url}") - # Create timeout + # HA's shared session — never create a ClientSession per call + session = async_get_clientsession(self.hass) timeout = aiohttp.ClientTimeout(total=10) - async with aiohttp.ClientSession(timeout=timeout) as session: - async with session.get(url, headers={"Accept": "application/json"}) as response: - if response.status == 200: - # Try to parse JSON response - try: - data = await response.json() - # The IERS API returns value in seconds - if "value" in data: - self._dut1_value = float(data["value"]) - self._dut1_last_fetch = datetime.now(timezone.utc) - self._dut1_source = "iers_api" + async with session.get( + url, headers={"Accept": "application/json"}, timeout=timeout + ) as response: + if response.status == 200: + try: + data = await response.json() + # The IERS API returns value in seconds + if "value" in data: + self._dut1_value = float(data["value"]) + self._dut1_last_fetch = datetime.now(timezone.utc) + self._dut1_source = "iers_api" + if self._iers_failures: + _LOGGER.info( + f"IERS API reachable again after {self._iers_failures} failed attempt(s); " + f"DUT1 = {self._dut1_value}s" + ) + else: _LOGGER.info(f"Successfully fetched DUT1 from IERS: {self._dut1_value}s") - return True - except (ValueError, KeyError) as e: - _LOGGER.warning(f"Failed to parse IERS JSON response: {e}") - # Try text parsing as fallback - text = await response.text() - _LOGGER.debug(f"IERS response text: {text[:200]}") - else: - _LOGGER.warning(f"IERS API returned status {response.status}") + self._iers_failures = 0 + self._iers_next_attempt = None + return True + failure_reason = f"no 'value' in response: {str(data)[:120]}" + except (ValueError, KeyError) as e: + text = await response.text() + failure_reason = f"unparsable JSON ({e}): {text[:120]!r}" + else: + failure_reason = f"HTTP {response.status}" except asyncio.TimeoutError: - _LOGGER.warning("IERS API request timed out") + failure_reason = "request timed out" except aiohttp.ClientError as e: - _LOGGER.warning(f"IERS API request failed: {e}") - except Exception as e: - _LOGGER.error(f"Unexpected error fetching IERS data: {e}", exc_info=True) - - # If we get here, fetch failed - use fallback - if self._dut1_source != "cached": - self._dut1_source = "fallback" - _LOGGER.info(f"Using fallback DUT1 value: {self._dut1_value}s") + failure_reason = f"request failed: {e}" + except Exception as e: # noqa: BLE001 + failure_reason = f"unexpected error: {e!r}" + _LOGGER.debug("Traceback:", exc_info=True) + + # ---- failure: schedule backoff, log once per streak ---- + self._iers_failures += 1 + delay = min(IERS_BACKOFF_MAX, IERS_BACKOFF_INITIAL * 2 ** (self._iers_failures - 1)) + self._iers_next_attempt = datetime.now(timezone.utc) + timedelta(seconds=delay) + if self._dut1_source != "cached": + self._dut1_source = "fallback" + if self._iers_failures == 1: + _LOGGER.warning( + f"IERS API fetch failed ({failure_reason}); using DUT1 = {self._dut1_value}s, " + f"next attempt in {delay}s" + ) + elif delay >= IERS_BACKOFF_MAX: + # Reached the cap: remind once per hour, not every second + _LOGGER.warning( + f"IERS API still unreachable after {self._iers_failures} attempts ({failure_reason}); " + f"retrying every {delay}s" + ) + else: + _LOGGER.debug(f"IERS fetch failed ({failure_reason}), attempt {self._iers_failures}, next in {delay}s") return False diff --git a/custom_components/alternative_time/config_flow.py b/custom_components/alternative_time/config_flow.py index 1372a9b..dd3fad4 100644 --- a/custom_components/alternative_time/config_flow.py +++ b/custom_components/alternative_time/config_flow.py @@ -730,9 +730,10 @@ async def async_step_configure_calendar( ) _LOGGER.info(f"OptionsFlow saved new options for {cid}: {normalized}") - # async_create_entry on an OptionsFlow must return — we use a - # blank options dict because the actual data lives in entry.data. - return self.async_create_entry(title="", data={}) + # The real data lives in entry.data (written above, which fires the + # update listener -> reload). Pass entry.options through unchanged so + # HA sees no options diff and does not trigger a second reload. + return self.async_create_entry(title="", data=dict(self.config_entry.options)) # Build schema (duplicated from ConfigFlow.async_step_plugin_options # to keep this fix self-contained) diff --git a/custom_components/alternative_time/manifest.json b/custom_components/alternative_time/manifest.json index 6521516..db78a67 100644 --- a/custom_components/alternative_time/manifest.json +++ b/custom_components/alternative_time/manifest.json @@ -15,5 +15,5 @@ "Pillow>=10.0.0", "tzlocal>=5.0" ], - "version": "2.6.0.7" + "version": "2.6.1" } diff --git a/custom_components/alternative_time/sensor.py b/custom_components/alternative_time/sensor.py index ee1f595..c848756 100644 --- a/custom_components/alternative_time/sensor.py +++ b/custom_components/alternative_time/sensor.py @@ -350,6 +350,11 @@ def get_config_entry(entry_id: str) -> Optional[ConfigEntry]: return _CONFIG_ENTRIES.get(entry_id) +def forget_config_entry(entry_id: str) -> None: + """Drop a config entry from the module-level registry (called on unload).""" + _CONFIG_ENTRIES.pop(entry_id, None) + + # RECORDER EXCLUSION - Deaktiviert wegen Kompatibilitätsproblemen async def register_recorder_exclusion(hass: HomeAssistant, entities_to_exclude: List[str]) -> None: """Register entities to be excluded from recorder. @@ -386,7 +391,10 @@ def __init__(self, base_name: str, hass: HomeAssistant) -> None: self._base_name = base_name self._hass = hass self._state = None - self._attr_should_poll = True + # Scheduling is owned by our own timer (see async_added_to_hass); + # should_poll is False so HA's platform poller stays out of it. + self._tick_running = False + self._consecutive_failures = 0 self._calendar_id = None # Will be set by async_setup_entry self._config_entry_id = None # Will be set by async_setup_entry @@ -449,13 +457,17 @@ def update_interval(self) -> int: @property def should_poll(self) -> bool: - """Return True if entity has to be polled for state.""" - return True + """Never let the platform poll us. - @property - def available(self) -> bool: - """Return True if entity is available.""" - return True + Each entity runs its own timer at CALENDAR_INFO["update_interval"]. + Returning True here (as older versions did) made HA additionally poll + every entity at the sensor default of 30 s, so every plugin updated + twice and sync update() could run concurrently in two executor threads. + """ + return False + + # `available` is inherited from HA's Entity and driven by _attr_available, + # which _async_timer_tick flips after repeated failures. def _translate(self, key: str, default: str = "") -> str: """Translate a CALENDAR_INFO text block for the user's language. @@ -541,9 +553,6 @@ async def async_added_to_hass(self) -> None: _LOGGER.debug(f"{self._attr_name} will update every {seconds} seconds") - # Avoid platform-wide polling - self._attr_should_poll = False - # Start scheduler self._unsub_timer = async_track_time_interval( self._hass, self._async_timer_tick, timedelta(seconds=seconds) @@ -564,18 +573,51 @@ async def async_will_remove_from_hass(self) -> None: pass self._unsub_timer = None + # After this many consecutive failed updates the entity is marked + # unavailable instead of silently showing a stale state. + UNAVAILABLE_AFTER_FAILURES = 3 + async def _async_timer_tick(self, _now) -> None: - """Call plugin update without blocking the event loop.""" + """Call plugin update without blocking the event loop. + + - Skips the tick if the previous one is still running (a slow + update must not pile up on a short interval). + - Logs the first failure of a streak at WARNING (traceback at DEBUG), + further consecutive failures at DEBUG only, recovery at INFO. + - Flips ``_attr_available`` after UNAVAILABLE_AFTER_FAILURES. + """ + if self._tick_running: + _LOGGER.debug(f"{self.entity_id or self.name}: previous update still running, skipping tick") + return + self._tick_running = True + label = self.entity_id or self.name try: # Prefer plugin's async_update if available if hasattr(self, "async_update") and callable(getattr(self, "async_update")): await getattr(self, "async_update")() else: await self._hass.async_add_executor_job(getattr(self, "update")) - except Exception as exc: - _LOGGER.debug(f"Scheduled update failed for {self.name}: {exc}") + except Exception as exc: # noqa: BLE001 — plugin code, must never kill the timer + self._consecutive_failures += 1 + n = self._consecutive_failures + if n == 1: + _LOGGER.warning(f"Update of {label} ({self._calendar_id}) failed: {exc!r}") + _LOGGER.debug("Traceback:", exc_info=True) + else: + _LOGGER.debug(f"Update of {label} failed again ({n}x): {exc!r}") + if n == self.UNAVAILABLE_AFTER_FAILURES: + self._attr_available = False + _LOGGER.warning(f"{label} marked unavailable after {n} consecutive failed updates") + else: + if self._consecutive_failures: + _LOGGER.info(f"{label} recovered after {self._consecutive_failures} failed update(s)") + self._consecutive_failures = 0 + self._attr_available = True finally: - try: - self.async_write_ha_state() - except Exception: - pass + self._tick_running = False + # Entity may have been removed while the update was running + if self.hass is not None: + try: + self.async_write_ha_state() + except Exception as exc: # noqa: BLE001 + _LOGGER.debug(f"{label}: writing state failed: {exc!r}") diff --git a/readme.md b/readme.md index cbe695f..2e69308 100644 --- a/readme.md +++ b/readme.md @@ -7,7 +7,7 @@ [![GitHub Release](https://img.shields.io/github/release/Lexorius/alternative_time.svg)](https://github.com/Lexorius/alternative_time/releases) [![GitHub Activity](https://img.shields.io/github/commit-activity/y/Lexorius/alternative_time.svg)](https://github.com/Lexorius/alternative_time/commits/main) [![License](https://img.shields.io/github/license/Lexorius/alternative_time.svg)](LICENSE) -[![Version](https://img.shields.io/badge/version-2.6.0.7-blue)](https://github.com/Lexorius/alternative_time) +[![Version](https://img.shields.io/badge/version-2.6.1-blue)](https://github.com/Lexorius/alternative_time) A comprehensive Home Assistant integration providing **30+ alternative time systems** from science, science fiction, fantasy, history, religion, and various cultures. @@ -383,7 +383,14 @@ Each calendar follows the unified `CALENDAR_INFO` structure: ## 📈 Version History -### v2.6.0.7 (Current) +### v2.6.1 (Current) +- 🐛 **Fix: every entity was updated twice** — `should_poll` returned `True`, so HA polled every sensor every 30 s on top of the plugin's own timer. Now `False`; the timer is the only scheduler, and a tick is skipped while the previous one is still running. +- ✨ **Options changes apply immediately** — an `update_listener` now reloads the entry after saving under *Configure* (previously required a restart). +- 👁️ **Failed updates are visible** — first failure of a streak logs a WARNING, entity becomes *unavailable* after 3 consecutive failures and recovers automatically. +- 🌐 **`ut1`: shared aiohttp session + exponential backoff** (60 s → 1 h) instead of retrying the IERS API every second while it is down. +- 🧹 `_CONFIG_ENTRIES` cleaned on unload; `__init__.py` no longer swallows platform setup errors; dead `async_reload_entry` removed. + +### v2.6.0.7 - 🐛 **Fix: three calendars never created a sensor** — `julian_date`, `minguo_taiwan`, `suriyakati_thai` were selectable but silently skipped at setup (id ≠ file name). Discovery now records the module name per id. - 🐛 **Fix: `sensor.alternative_time_*` entity_id prefix actually works now.** The 2.6.0.4 change used a non-existent `_attr_`; replaced by a proper `suggested_object_id` property override. Still applies to newly registered entities only. - 🐛 **Fix: options of `dtg`, `german_rescue_dtg`, `julian_date` now appear in the setup wizard** (wrong `CALENDAR_INFO` key). Two malformed selects in those plugins repaired.