New apps Added
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user