"""Entity analyzer for discovering numeric attributes and fetching recorder statistics.""" from __future__ import annotations import logging import statistics as py_stats from dataclasses import dataclass, field from datetime import timedelta from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) _STATISTICS_LOOKBACK_DAYS = 90 @dataclass class AttributeInfo: """Information about a numeric attribute.""" name: str current_value: float unit: str | None = None @dataclass class StatisticsInfo: """Historical statistics for an entity from the HA recorder.""" has_data: bool = False period_days: int = 0 mean: float | None = None minimum: float | None = None maximum: float | None = None std_dev: float | None = None percentile_10: float | None = None percentile_90: float | None = None recent_trend: str | None = None # "rising" | "falling" | "stable" @dataclass class EntityAnalysis: """Result of analyzing an entity for trigger suitability.""" entity_id: str domain: str = "" device_class: str | None = None unit_of_measurement: str | None = None is_numeric_state: bool = False numeric_attributes: dict[str, AttributeInfo] = field(default_factory=dict) current_state: str = "" statistics: StatisticsInfo | None = None class EntityAnalyzer: """Analyzes HA entities for maintenance trigger configuration.""" def __init__(self, hass: HomeAssistant) -> None: """Initialize the analyzer.""" self.hass = hass async def async_analyze_entity(self, entity_id: str) -> EntityAnalysis | None: """Analyze an entity and return its properties including recorder statistics.""" state = self.hass.states.get(entity_id) if state is None: return None domain = entity_id.split(".")[0] device_class = state.attributes.get("device_class") unit = state.attributes.get("unit_of_measurement") # Check if state is numeric is_numeric = False try: float(state.state) is_numeric = True except (ValueError, TypeError): pass # Find numeric attributes numeric_attrs: dict[str, AttributeInfo] = {} for attr_name, attr_value in state.attributes.items(): if attr_name.startswith("_"): continue try: val = float(attr_value) numeric_attrs[attr_name] = AttributeInfo( name=attr_name, current_value=val, unit=None, ) except (ValueError, TypeError): continue # Fetch recorder statistics stats_info = await self._async_fetch_statistics(entity_id) return EntityAnalysis( entity_id=entity_id, domain=domain, device_class=device_class, unit_of_measurement=unit, is_numeric_state=is_numeric, numeric_attributes=numeric_attrs, current_state=state.state, statistics=stats_info, ) async def _async_fetch_statistics(self, entity_id: str) -> StatisticsInfo | None: """Fetch long-term statistics from the HA recorder.""" try: from homeassistant.components.recorder import ( # type: ignore[attr-defined] get_instance, ) from homeassistant.components.recorder.statistics import ( statistics_during_period, ) except ImportError: _LOGGER.debug("Recorder statistics module not available") return None start_time = dt_util.utcnow() - timedelta(days=_STATISTICS_LOOKBACK_DAYS) try: result = await get_instance(self.hass).async_add_executor_job( lambda: statistics_during_period( self.hass, start_time, None, # end_time = now {entity_id}, "day", None, # units {"mean", "min", "max"}, ) ) except (HomeAssistantError, ValueError, TypeError): _LOGGER.debug("Failed to fetch statistics for %s", entity_id, exc_info=True) return None rows = result.get(entity_id, []) if not rows: return StatisticsInfo(has_data=False) # Extract daily values means: list[float] = [] mins: list[float] = [] maxs: list[float] = [] for row in rows: m = row.get("mean") if m is not None: means.append(m) mn = row.get("min") if mn is not None: mins.append(mn) mx = row.get("max") if mx is not None: maxs.append(mx) if not means and not mins and not maxs: return StatisticsInfo(has_data=False) # Use whichever series has data (means preferred, fall back to mins) values = means if means else mins if mins else maxs info = StatisticsInfo( has_data=True, period_days=len(rows), ) if means: info.mean = round(py_stats.mean(means), 3) if mins: info.minimum = round(min(mins), 3) if maxs: info.maximum = round(max(maxs), 3) # Standard deviation if len(values) >= 2: info.std_dev = round(py_stats.stdev(values), 3) # Percentiles if len(values) >= 5: sorted_vals = sorted(values) n = len(sorted_vals) idx_10 = (n - 1) * 0.1 lo, hi = int(idx_10), min(int(idx_10) + 1, n - 1) frac = idx_10 - lo info.percentile_10 = round(sorted_vals[lo] + frac * (sorted_vals[hi] - sorted_vals[lo]), 3) idx_90 = (n - 1) * 0.9 lo, hi = int(idx_90), min(int(idx_90) + 1, n - 1) frac = idx_90 - lo info.percentile_90 = round(sorted_vals[lo] + frac * (sorted_vals[hi] - sorted_vals[lo]), 3) # Recent trend (last 7 days vs previous 7 days) if len(values) >= 14: recent = values[-7:] previous = values[-14:-7] recent_avg = py_stats.mean(recent) prev_avg = py_stats.mean(previous) if prev_avg != 0: change_pct = (recent_avg - prev_avg) / abs(prev_avg) * 100 if change_pct > 5: info.recent_trend = "rising" elif change_pct < -5: info.recent_trend = "falling" else: info.recent_trend = "stable" return info