Files
HomeAssistance/custom_components/ai_automation_suggester/sensor.py
T
2026-06-05 22:34:31 -04:00

460 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# custom_components/ai_automation_suggester/sensor.py
"""Sensor platform for AI Automation Suggester."""
from __future__ import annotations
import logging
from typing import cast
from homeassistant.components.sensor import (
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_UNKNOWN, EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
)
from .const import (
DOMAIN,
INTEGRATION_NAME,
CONF_PROVIDER,
PROVIDER_STATUS_CONNECTED,
PROVIDER_STATUS_DISCONNECTED,
PROVIDER_STATUS_ERROR,
PROVIDER_STATUS_INITIALIZING,
CONF_MAX_INPUT_TOKENS,
DEFAULT_MAX_INPUT_TOKENS,
CONF_MAX_OUTPUT_TOKENS,
DEFAULT_MAX_OUTPUT_TOKENS,
# Model configuration keys (used to display current model)
CONF_OPENAI_MODEL,
CONF_ANTHROPIC_MODEL,
CONF_GOOGLE_MODEL,
CONF_GROQ_MODEL,
CONF_LOCALAI_MODEL,
CONF_OLLAMA_MODEL,
CONF_CUSTOM_OPENAI_MODEL,
CONF_MISTRAL_MODEL,
CONF_PERPLEXITY_MODEL,
CONF_OPENROUTER_MODEL,
CONF_OPENAI_AZURE_DEPLOYMENT_ID,
CONF_GENERIC_OPENAI_MODEL,
DEFAULT_MODELS,
# Sensor Keys from const.py
SENSOR_KEY_SUGGESTIONS,
SENSOR_KEY_STATUS,
SENSOR_KEY_INPUT_TOKENS,
SENSOR_KEY_OUTPUT_TOKENS,
SENSOR_KEY_MODEL,
SENSOR_KEY_LAST_ERROR,
SENSOR_KEY_HISTORY_COUNT,
)
_LOGGER = logging.getLogger(__name__)
PROVIDER_TO_MODEL_KEY_MAP: dict[str, str] = {
"OpenAI": CONF_OPENAI_MODEL,
"Anthropic": CONF_ANTHROPIC_MODEL,
"Google": CONF_GOOGLE_MODEL,
"Groq": CONF_GROQ_MODEL,
"LocalAI": CONF_LOCALAI_MODEL,
"Ollama": CONF_OLLAMA_MODEL,
"Custom OpenAI": CONF_CUSTOM_OPENAI_MODEL,
"Mistral AI": CONF_MISTRAL_MODEL,
"Perplexity AI": CONF_PERPLEXITY_MODEL,
"OpenRouter": CONF_OPENROUTER_MODEL,
"OpenAI Azure": CONF_OPENAI_AZURE_DEPLOYMENT_ID,
"Generic OpenAI": CONF_GENERIC_OPENAI_MODEL,
}
SENSOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
key=SENSOR_KEY_SUGGESTIONS,
name="AI Automation Suggestions",
icon="mdi:robot-happy-outline",
),
SensorEntityDescription(
key=SENSOR_KEY_STATUS,
name="AI Provider Status",
icon="mdi:lan-check",
entity_category=EntityCategory.DIAGNOSTIC,
),
SensorEntityDescription(
key=SENSOR_KEY_INPUT_TOKENS,
name="Max Input Tokens",
icon="mdi:format-letter-starts-with",
entity_category=EntityCategory.DIAGNOSTIC,
native_unit_of_measurement="tokens",
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=SENSOR_KEY_OUTPUT_TOKENS,
name="Max Output Tokens",
icon="mdi:format-letter-ends-with",
entity_category=EntityCategory.DIAGNOSTIC,
native_unit_of_measurement="tokens",
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=SENSOR_KEY_MODEL,
name="AI Model In Use",
icon="mdi:brain",
entity_category=EntityCategory.DIAGNOSTIC,
),
SensorEntityDescription(
key=SENSOR_KEY_LAST_ERROR,
name="Last Error Message",
icon="mdi:alert-circle-outline",
entity_category=EntityCategory.DIAGNOSTIC,
),
SensorEntityDescription(
key=SENSOR_KEY_HISTORY_COUNT,
name="Suggestion History Count",
icon="mdi:history",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up AI Automation Suggester sensors from a config entry."""
coordinator = cast(DataUpdateCoordinator, hass.data[DOMAIN][entry.entry_id])
provider_name = entry.data.get(CONF_PROVIDER, "Unknown Provider")
entities: list[SensorEntity] = []
for description in SENSOR_DESCRIPTIONS:
formatted_name = f"{description.name} ({provider_name})"
specific_description = SensorEntityDescription(
key=description.key,
name=formatted_name,
icon=description.icon,
entity_category=description.entity_category,
native_unit_of_measurement=description.native_unit_of_measurement,
state_class=description.state_class,
device_class=description.device_class,
)
if description.key == SENSOR_KEY_SUGGESTIONS:
entities.append(AISuggestionsSensor(coordinator, entry, specific_description))
elif description.key == SENSOR_KEY_STATUS:
entities.append(AIProviderStatusSensor(coordinator, entry, specific_description))
elif description.key == SENSOR_KEY_INPUT_TOKENS:
entities.append(MaxInputTokensSensor(coordinator, entry, specific_description))
elif description.key == SENSOR_KEY_OUTPUT_TOKENS:
entities.append(MaxOutputTokensSensor(coordinator, entry, specific_description))
elif description.key == SENSOR_KEY_MODEL:
entities.append(AIModelSensor(coordinator, entry, specific_description))
elif description.key == SENSOR_KEY_LAST_ERROR:
entities.append(AILastErrorSensor(coordinator, entry, specific_description))
elif description.key == SENSOR_KEY_HISTORY_COUNT:
entities.append(AIHistoryCountSensor(coordinator, entry, specific_description))
else:
entities.append(AIBaseSensor(coordinator, entry, specific_description))
# update_before_add must stay False: in HA 2025.x+, passing True calls
# CoordinatorEntity.async_update() during platform setup, which triggers a
# full LLM inference and exceeds the setup timeout (issue #166). Entities
# populate from coordinator data and refresh on generate_suggestions.
async_add_entities(entities, False)
_LOGGER.debug("Sensor platform setup complete for provider: %s", provider_name)
# ─────────────────────────────────────────────────────────────
# Base sensor
# ─────────────────────────────────────────────────────────────
class AIBaseSensor(CoordinatorEntity[DataUpdateCoordinator], SensorEntity):
"""Base class for AI Automation Suggester sensors."""
def __init__(
self,
coordinator: DataUpdateCoordinator,
entry: ConfigEntry,
description: SensorEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{entry.entry_id}_{description.key}"
self._entry = entry
self._provider_name = entry.data.get(CONF_PROVIDER, "Unknown Provider")
# Common device info for all sensors of this config entry
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, entry.entry_id)},
name=f"{INTEGRATION_NAME} ({self._provider_name})",
manufacturer="Community",
model=self._provider_name,
sw_version=str(entry.version) if entry.version else "N/A",
configuration_url=None, # Link Github?
)
@property
def available(self) -> bool:
"""Return True if coordinator is available and has data."""
return super().available and self.coordinator.last_update_success
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
if self.coordinator.last_update_success:
self._update_state_and_attributes()
super()._handle_coordinator_update()
def _update_state_and_attributes(self) -> None:
"""Update the sensor's state and attributes based on coordinator data.
This method should be overridden by subclasses.
"""
self._attr_native_value = STATE_UNKNOWN
_LOGGER.debug(
"Sensor %s._update_state_and_attributes not fully implemented for key %s",
self.__class__.__name__,
self.entity_description.key
)
# ─────────────────────────────────────────────────────────────
# Suggestions sensor
# ─────────────────────────────────────────────────────────────
class AISuggestionsSensor(AIBaseSensor):
"""Shows the availability of new AI suggestions."""
_attr_should_poll = False
def __init__(
self,
coordinator: DataUpdateCoordinator,
entry: ConfigEntry,
description: SensorEntityDescription,
) -> None:
super().__init__(coordinator, entry, description)
self._previous_suggestions_timestamp: float | None = None
# Initialize state with default values
self._attr_native_value = "No Suggestions"
self._attr_extra_state_attributes = {
"suggestions": "No suggestions yet",
"description": None,
"yaml_block": None,
"last_update": None,
"entities_processed": [],
"provider": self._entry.data.get(CONF_PROVIDER, "unknown"),
"entities_processed_count": 0,
"suggestion_count": 0,
"warnings": [],
}
async def async_added_to_hass(self) -> None:
"""Handle added to Hass."""
await super().async_added_to_hass()
# Update initial state from coordinator if data exists
if self.coordinator.data:
self._update_state_and_attributes()
def _update_state_and_attributes(self) -> None:
"""Update sensor state and attributes."""
data = self.coordinator.data or {}
suggestions = data.get("suggestions")
last_update_timestamp = data.get("last_update")
if suggestions and suggestions not in ("No suggestions available", "No suggestions yet"):
if last_update_timestamp and (self._previous_suggestions_timestamp is None or last_update_timestamp > self._previous_suggestions_timestamp):
self._attr_native_value = "New Suggestions Available"
self._previous_suggestions_timestamp = last_update_timestamp
else:
self._attr_native_value = "Suggestions Available"
else:
self._attr_native_value = "No Suggestions"
self._attr_extra_state_attributes = {
"suggestions": suggestions,
"description": data.get("description"),
"yaml_block": data.get("yaml_block"),
"last_update": data.get("last_update"),
"entities_processed": data.get("entities_processed", []),
"provider": self._entry.data.get(CONF_PROVIDER, "unknown"),
"model": data.get("model"),
"entities_processed_count": len(data.get("entities_processed", [])),
"suggestion": data.get("suggestion"),
"suggestion_count": data.get("suggestion_count", 0),
"warnings": data.get("warnings", []),
}
# ─────────────────────────────────────────────────────────────
# Providerstatus sensor
# ─────────────────────────────────────────────────────────────
class AIProviderStatusSensor(AIBaseSensor):
"""Indicates whether the configured provider is reachable."""
_attr_should_poll = False
def __init__(
self,
coordinator: DataUpdateCoordinator,
entry: ConfigEntry,
description: SensorEntityDescription,
) -> None:
super().__init__(coordinator, entry, description)
self._update_state_and_attributes()
def _update_state_and_attributes(self) -> None:
"""Update sensor state and attributes."""
data = self.coordinator.data or {}
if not self.coordinator.last_update_success:
self._attr_native_value = PROVIDER_STATUS_ERROR
elif not data:
self._attr_native_value = PROVIDER_STATUS_INITIALIZING
elif data.get("last_error"):
self._attr_native_value = PROVIDER_STATUS_ERROR
elif "suggestions" in data:
self._attr_native_value = PROVIDER_STATUS_CONNECTED
else:
self._attr_native_value = PROVIDER_STATUS_DISCONNECTED
self._attr_extra_state_attributes = {
"last_error_message": data.get("last_error", None),
"last_attempted_update": data.get("last_update"),
"provider": data.get("provider", self._provider_name),
"model": data.get("model"),
"warnings": data.get("warnings", []),
"response_metadata": data.get("response_metadata", {}),
}
# ─────────────────────────────────────────────────────────────
# Max Input Token Sensors
# ─────────────────────────────────────────────────────────────
class MaxInputTokensSensor(AIBaseSensor):
"""Shows the configured maximum input tokens."""
_attr_should_poll = False
def __init__(
self,
coordinator: DataUpdateCoordinator,
entry: ConfigEntry,
description: SensorEntityDescription,
) -> None:
super().__init__(coordinator, entry, description)
self._update_state_and_attributes() # Initial update
def _update_state_and_attributes(self) -> None:
"""Update sensor state from config entry options or data."""
self._attr_native_value = self._entry.options.get(
CONF_MAX_INPUT_TOKENS,
self._entry.data.get(CONF_MAX_INPUT_TOKENS, DEFAULT_MAX_INPUT_TOKENS)
)
# ─────────────────────────────────────────────────────────────
# Max Output Token Sensors
# ─────────────────────────────────────────────────────────────
class MaxOutputTokensSensor(AIBaseSensor):
"""Shows the configured maximum output tokens."""
_attr_should_poll = False
def __init__(
self,
coordinator: DataUpdateCoordinator,
entry: ConfigEntry,
description: SensorEntityDescription,
) -> None:
super().__init__(coordinator, entry, description)
self._update_state_and_attributes() # Initial update
def _update_state_and_attributes(self) -> None:
"""Update sensor state from config entry options or data."""
self._attr_native_value = self._entry.options.get(
CONF_MAX_OUTPUT_TOKENS,
self._entry.data.get(CONF_MAX_OUTPUT_TOKENS, DEFAULT_MAX_OUTPUT_TOKENS)
)
# ─────────────────────────────────────────────────────────────
# Model Sensor
# ─────────────────────────────────────────────────────────────
class AIModelSensor(AIBaseSensor):
"""Shows the currently configured AI model."""
_attr_should_poll = False
def __init__(
self,
coordinator: DataUpdateCoordinator,
entry: ConfigEntry,
description: SensorEntityDescription,
) -> None:
super().__init__(coordinator, entry, description)
self._update_state_and_attributes()
def _update_state_and_attributes(self) -> None:
"""Update sensor state with the configured model."""
provider = self._entry.data.get(CONF_PROVIDER)
if not provider:
self._attr_native_value = STATE_UNKNOWN
return
model_key = PROVIDER_TO_MODEL_KEY_MAP.get(provider)
if not model_key:
self._attr_native_value = "Unknown Model Key"
_LOGGER.warning("No model key found for provider: %s", provider)
return
self._attr_native_value = self._entry.options.get(
model_key,
self._entry.data.get(model_key, DEFAULT_MODELS.get(provider, "unknown"))
) if model_key else "unknown"
# ─────────────────────────────────────────────────────────────
# Last Error sensor
# ─────────────────────────────────────────────────────────────
class AILastErrorSensor(AIBaseSensor):
"""Shows the last error message from the AI provider."""
_attr_should_poll = False
def __init__(
self,
coordinator: DataUpdateCoordinator,
entry: ConfigEntry,
description: SensorEntityDescription,
) -> None:
super().__init__(coordinator, entry, description)
self._update_state_and_attributes() # Initial update
def _update_state_and_attributes(self) -> None:
"""Update sensor state with the last error message."""
data = self.coordinator.data or {}
last_error = data.get("last_error")
self._attr_native_value = str(last_error) if last_error else "No Error"
self._attr_extra_state_attributes = {
"last_error_timestamp": data.get("last_update") if last_error else None,
}
class AIHistoryCountSensor(AIBaseSensor):
"""Shows how many suggestions are retained in history."""
_attr_should_poll = False
def __init__(
self,
coordinator: DataUpdateCoordinator,
entry: ConfigEntry,
description: SensorEntityDescription,
) -> None:
super().__init__(coordinator, entry, description)
self._update_state_and_attributes()
def _update_state_and_attributes(self) -> None:
"""Update state from coordinator history data."""
data = self.coordinator.data or {}
self._attr_native_value = data.get("suggestion_count", 0)
self._attr_extra_state_attributes = {
"latest_suggestion_id": (data.get("suggestion") or {}).get("id"),
"latest_suggestion_status": (data.get("suggestion") or {}).get("status"),
}