Add stall sensor detection.

SchiZzA 2026-06-01 17:47:30 +02:00
parent 0142c5412b
commit 2f7d96f8d3
No known key found for this signature in database
6 changed files with 1025 additions and 914 deletions

View File

@ -28,14 +28,16 @@ period where no entities are subscribed, causing stale states until another full
from __future__ import annotations
from datetime import datetime, timedelta
import logging
from typing import Any
from py_typecheck import checked, checked_or
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady, PlatformNotReady
from homeassistant.helpers.event import async_track_time_interval
from .const import (
DEFAULT_URL,
@ -53,6 +55,7 @@ from .data import SWSConfigEntry, SWSRuntimeData
from .health_coordinator import HealthCoordinator
from .legacy import update_legacy_battery_issue
from .routes import Routes
from .staleness import update_stale_sensors_issue
_LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.BINARY_SENSOR]
@ -181,6 +184,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: SWSConfigEntry) -> bool:
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@callback
def _check_stale(_now: datetime) -> None:
update_stale_sensors_issue(hass, entry)
entry.async_on_unload(async_track_time_interval(hass, _check_stale, timedelta(hours=1)))
entry.async_on_unload(entry.add_update_listener(update_listener))
update_legacy_battery_issue(hass, entry)

View File

@ -26,6 +26,7 @@ from py_typecheck import checked, checked_or
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import InvalidStateError
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.util import dt as dt_util
from .binary_sensor import add_new_binary_sensors
from .const import (
@ -45,6 +46,7 @@ from .ecowitt import EcowittBridge
from .health_coordinator import HealthCoordinator
from .pocasti_cz import PocasiPush
from .sensor import add_new_sensors
from .staleness import update_stale_sensors_issue
from .utils import (
anonymize,
check_disabled,
@ -139,6 +141,10 @@ class WeatherDataUpdateCoordinator(DataUpdateCoordinator):
add_new_binary_sensors(self.hass, self.config, newly_discovered)
add_new_sensors(self.hass, self.config, newly_discovered)
self.async_set_updated_data(mapped_data)
now = dt_util.utcnow()
for key in mapped_data:
self.config.runtime_data.last_seen[key] = now
update_stale_sensors_issue(self.hass, self.config)
if health:
health.update_ingress_result(
@ -299,6 +305,12 @@ class WeatherDataUpdateCoordinator(DataUpdateCoordinator):
add_new_binary_sensors(self.hass, self.config, newly_discovered)
self.async_set_updated_data(remaped_items)
now = dt_util.utcnow()
for key in remaped_items:
self.config.runtime_data.last_seen[key] = now
update_stale_sensors_issue(self.hass, self.config)
if health:
health.update_ingress_result(
webdata,

View File

@ -8,10 +8,12 @@ hass.data[DOMAIN]["routes"] because it must outlive a single entry reload.
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from typing import TYPE_CHECKING, Any
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import dt as dt_util
if TYPE_CHECKING:
from homeassistant.components.binary_sensor import BinarySensorEntityDescription
@ -45,6 +47,10 @@ class SWSRuntimeData:
# Health data cache for diagnostics - refreshed by `HealthCoordinator` on each tick.
health_data: dict[str, Any] | None = None
# Staleness tracking - in-memory, resets on reload.
started_at: datetime = field(default_factory=dt_util.utcnow)
last_seen: dict[str, datetime] = field(default_factory=dict)
# Type alias for typed ConfigEntry
type SWSConfigEntry = ConfigEntry[SWSRuntimeData]

View File

@ -0,0 +1,76 @@
"""Stale sensor detection.
If a sensor key is in SENSORS_TO_LOAD but its station never reported it (or stopped reporting it
for an extended period), the corresponding HA entity becomes Unavailable indefinitely.
This module raises a Repairs issue listing such stale keys so users can decide whether to remove them.
Warmup avoids false-positives at integration startup before any payload arrives.
The check is in-memory only restart/reload resets last_seen, but the 1h warmup covers re-population
from incoming webhooks.
"""
from __future__ import annotations
from datetime import timedelta
from typing import Final
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.issue_registry import IssueSeverity
from homeassistant.util import dt as dt_util
from .const import DOMAIN, SENSORS_TO_LOAD
from .data import SWSConfigEntry
STALE_THRESHOLD: Final = timedelta(hours=24)
WARMUP_PERIOD: Final = timedelta(hours=1)
def _stale_sensor_issue_id(entry: SWSConfigEntry) -> str:
"""Return Repairs issue id for this config entry."""
return f"stale_sensors_{entry.entry_id}"
@callback
def _find_stale_keys(entry: SWSConfigEntry) -> list[str]:
"""Return keys in SENSORS_TO_LOAD that haven't been seen recently."""
runtime = entry.runtime_data
now = dt_util.utcnow()
if (now - runtime.started_at) < WARMUP_PERIOD:
return []
loaded = entry.options.get(SENSORS_TO_LOAD, [])
last_seen = runtime.last_seen
stale: list[str] = []
for key in loaded:
seen_at = last_seen.get(key)
if seen_at is None or (now - seen_at) > STALE_THRESHOLD:
stale.append(key)
return stale
@callback
def update_stale_sensors_issue(hass: HomeAssistant, entry: SWSConfigEntry) -> None:
"""Create or clear a Repairs issue for stale sensor keys."""
issue_id = _stale_sensor_issue_id(entry)
stale = _find_stale_keys(entry)
if stale:
ir.async_create_issue(
hass,
DOMAIN,
issue_id=issue_id,
is_persistent=True,
is_fixable=False,
severity=IssueSeverity.WARNING,
translation_key="stale_sensors_detected",
translation_placeholders={
"sensors": ", ".join(sorted(stale)),
},
)
else:
ir.async_delete_issue(hass, DOMAIN, issue_id=issue_id)

View File

@ -426,6 +426,10 @@
"legacy_battery_sensor_deprecated": {
"title": "Detekovány zastaralé senzory baterií.",
"description": "V registru entit byly nalezeny staré senzory baterií ({entities}). Byly nahrazeny binárními senzory baterií a budou odstraněny ve verzi {remove_version}. Smažte prosím staré entity ručně přes Nastavení -> Zařízení a služby -> SWS-12500."
},
"stale_sensors_detected": {
"title": "Detekovány nečinné sensory",
"description": "Tyto senzory jsou nastavené, ale nereportují data: {sensors}. V HA budou viditelné jako Nedostupné. Pokud už nejsou přítomné, můžeš je odstranit přes Nastavení > Zařízení a Služby > SWS 12500."
}
},
"notify": {

View File

@ -479,6 +479,10 @@
"legacy_battery_sensor_deprecated": {
"title": "Legacy battery sensor detected",
"description": "Old battery sensor entities ({entities}) were found in the entity registry. They have been replaced by binary battery sensors and will be removed in version {remove_version}. Please delete the legacy entities manually via Settings Old battery sensor entities ({entities}) were found in the entity registry. They have been replaced by binary battery sensors and will be removed in version {remove_version}. Please delete the legacy entities manually via Settings -> Devices & Services -> SWS 12500."
},
"stale_sensors_detected": {
"title": "Stale sensor detected",
"description": "These sensors are configured but haven't reported data recently: {sensors}. They will appear as Unavailable in HA. If they are no longer connected, you can remove them via Settings -> Devices & Services -> SWS 12500."
}
},
"notify": {