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
55 changes: 55 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
35 changes: 21 additions & 14 deletions custom_components/alternative_time/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand All @@ -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)
112 changes: 77 additions & 35 deletions custom_components/alternative_time/calendars/ut1.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
7 changes: 4 additions & 3 deletions custom_components/alternative_time/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion custom_components/alternative_time/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,5 @@
"Pillow>=10.0.0",
"tzlocal>=5.0"
],
"version": "2.6.0.7"
"version": "2.6.1"
}
Loading
Loading