348 files

This commit is contained in:
Home Assistant Version Control
2026-07-23 19:02:12 +00:00
parent 0ed1687503
commit 016af2e013
348 changed files with 12368 additions and 11689 deletions
-426
View File
@@ -1,426 +0,0 @@
"""The AI Agent HA integration."""
from __future__ import annotations
import logging
import voluptuous as vol
from homeassistant.components.frontend import async_register_built_in_panel
from homeassistant.components.http import StaticPathConfig
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .agent import AiAgentHaAgent
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
# Config schema - this integration only supports config entries
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
# Define service schema to accept a custom prompt
SERVICE_SCHEMA = vol.Schema(
{
vol.Optional("prompt"): cv.string,
}
)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the AI Agent HA component."""
return True
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Migrate old config entries to new version."""
_LOGGER.debug("Migrating config entry from version %s", entry.version)
if entry.version == 1:
# No migration needed for version 1
return True
# Future migrations would go here
# if entry.version < 2:
# # Migrate from version 1 to 2
# new_data = dict(entry.data)
# # Add migration logic here
# hass.config_entries.async_update_entry(entry, data=new_data, version=2)
_LOGGER.info("Migration to version %s successful", entry.version)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up AI Agent HA from a config entry."""
try:
# Handle version compatibility
if not hasattr(entry, "version") or entry.version != 1:
_LOGGER.warning(
"Config entry has version %s, expected 1. Attempting compatibility mode.",
getattr(entry, "version", "unknown"),
)
# Convert ConfigEntry to dict and ensure all required keys exist
config_data = dict(entry.data)
# Ensure backward compatibility - check for required keys
if "ai_provider" not in config_data:
_LOGGER.error(
"Config entry missing required 'ai_provider' key. Entry data: %s",
config_data,
)
raise ConfigEntryNotReady("Config entry missing required 'ai_provider' key")
if DOMAIN not in hass.data:
hass.data[DOMAIN] = {"agents": {}, "configs": {}}
provider = config_data["ai_provider"]
# Validate provider
if provider not in [
"llama",
"openai",
"gemini",
"openrouter",
"anthropic",
"alter",
"zai",
"local_ollama",
"openai_compatible",
]:
_LOGGER.error("Unknown AI provider: %s", provider)
raise ConfigEntryNotReady(f"Unknown AI provider: {provider}")
# Store config for this provider
hass.data[DOMAIN]["configs"][provider] = config_data
# Create agent for this provider
_LOGGER.debug(
"Creating AI agent for provider %s with config: %s",
provider,
{
k: v
for k, v in config_data.items()
if k
not in [
"llama_token",
"openai_token",
"gemini_token",
"openrouter_token",
"anthropic_token",
"zai_token",
]
},
)
hass.data[DOMAIN]["agents"][provider] = AiAgentHaAgent(hass, config_data)
_LOGGER.info("Successfully set up AI Agent HA for provider: %s", provider)
except KeyError as err:
_LOGGER.error("Missing required configuration key: %s", err)
raise ConfigEntryNotReady(f"Missing required configuration key: {err}")
except Exception as err:
_LOGGER.exception("Unexpected error setting up AI Agent HA")
raise ConfigEntryNotReady(f"Error setting up AI Agent HA: {err}")
# Modify the query service handler to use the correct provider
async def async_handle_query(call):
"""Handle the query service call."""
try:
# Check if agents are available
if DOMAIN not in hass.data or not hass.data[DOMAIN].get("agents"):
_LOGGER.error(
"No AI agents available. Please configure the integration first."
)
result = {"error": "No AI agents configured"}
hass.bus.async_fire("ai_agent_ha_response", result)
return
provider = call.data.get("provider")
if provider not in hass.data[DOMAIN]["agents"]:
# Get the first available provider
available_providers = list(hass.data[DOMAIN]["agents"].keys())
if not available_providers:
_LOGGER.error("No AI agents available")
result = {"error": "No AI agents configured"}
hass.bus.async_fire("ai_agent_ha_response", result)
return
provider = available_providers[0]
_LOGGER.debug(f"Using fallback provider: {provider}")
agent = hass.data[DOMAIN]["agents"][provider]
result = await agent.process_query(
call.data.get("prompt", ""),
provider=provider,
debug=call.data.get("debug", False),
)
hass.bus.async_fire("ai_agent_ha_response", result)
except Exception as e:
_LOGGER.error(f"Error processing query: {e}")
result = {"error": str(e)}
hass.bus.async_fire("ai_agent_ha_response", result)
async def async_handle_create_automation(call):
"""Handle the create_automation service call."""
try:
# Check if agents are available
if DOMAIN not in hass.data or not hass.data[DOMAIN].get("agents"):
_LOGGER.error(
"No AI agents available. Please configure the integration first."
)
return {"error": "No AI agents configured"}
provider = call.data.get("provider")
if provider not in hass.data[DOMAIN]["agents"]:
# Get the first available provider
available_providers = list(hass.data[DOMAIN]["agents"].keys())
if not available_providers:
_LOGGER.error("No AI agents available")
return {"error": "No AI agents configured"}
provider = available_providers[0]
_LOGGER.debug(f"Using fallback provider: {provider}")
agent = hass.data[DOMAIN]["agents"][provider]
result = await agent.create_automation(call.data.get("automation", {}))
return result
except Exception as e:
_LOGGER.error(f"Error creating automation: {e}")
return {"error": str(e)}
async def async_handle_save_prompt_history(call):
"""Handle the save_prompt_history service call."""
try:
# Check if agents are available
if DOMAIN not in hass.data or not hass.data[DOMAIN].get("agents"):
_LOGGER.error(
"No AI agents available. Please configure the integration first."
)
return {"error": "No AI agents configured"}
provider = call.data.get("provider")
if provider not in hass.data[DOMAIN]["agents"]:
# Get the first available provider
available_providers = list(hass.data[DOMAIN]["agents"].keys())
if not available_providers:
_LOGGER.error("No AI agents available")
return {"error": "No AI agents configured"}
provider = available_providers[0]
_LOGGER.debug(f"Using fallback provider: {provider}")
agent = hass.data[DOMAIN]["agents"][provider]
user_id = call.context.user_id if call.context.user_id else "default"
result = await agent.save_user_prompt_history(
user_id, call.data.get("history", [])
)
return result
except Exception as e:
_LOGGER.error(f"Error saving prompt history: {e}")
return {"error": str(e)}
async def async_handle_load_prompt_history(call):
"""Handle the load_prompt_history service call."""
try:
# Check if agents are available
if DOMAIN not in hass.data or not hass.data[DOMAIN].get("agents"):
_LOGGER.error(
"No AI agents available. Please configure the integration first."
)
return {"error": "No AI agents configured"}
provider = call.data.get("provider")
if provider not in hass.data[DOMAIN]["agents"]:
# Get the first available provider
available_providers = list(hass.data[DOMAIN]["agents"].keys())
if not available_providers:
_LOGGER.error("No AI agents available")
return {"error": "No AI agents configured"}
provider = available_providers[0]
_LOGGER.debug(f"Using fallback provider: {provider}")
agent = hass.data[DOMAIN]["agents"][provider]
user_id = call.context.user_id if call.context.user_id else "default"
result = await agent.load_user_prompt_history(user_id)
_LOGGER.debug("Load prompt history result: %s", result)
return result
except Exception as e:
_LOGGER.error(f"Error loading prompt history: {e}")
return {"error": str(e)}
async def async_handle_create_dashboard(call):
"""Handle the create_dashboard service call."""
try:
# Check if agents are available
if DOMAIN not in hass.data or not hass.data[DOMAIN].get("agents"):
_LOGGER.error(
"No AI agents available. Please configure the integration first."
)
return {"error": "No AI agents configured"}
provider = call.data.get("provider")
if provider not in hass.data[DOMAIN]["agents"]:
# Get the first available provider
available_providers = list(hass.data[DOMAIN]["agents"].keys())
if not available_providers:
_LOGGER.error("No AI agents available")
return {"error": "No AI agents configured"}
provider = available_providers[0]
_LOGGER.debug(f"Using fallback provider: {provider}")
agent = hass.data[DOMAIN]["agents"][provider]
# Parse dashboard config if it's a string
dashboard_config = call.data.get("dashboard_config", {})
if isinstance(dashboard_config, str):
try:
import json
dashboard_config = json.loads(dashboard_config)
except json.JSONDecodeError as e:
_LOGGER.error(f"Invalid JSON in dashboard_config: {e}")
return {"error": f"Invalid JSON in dashboard_config: {e}"}
result = await agent.create_dashboard(dashboard_config)
return result
except Exception as e:
_LOGGER.error(f"Error creating dashboard: {e}")
return {"error": str(e)}
async def async_handle_update_dashboard(call):
"""Handle the update_dashboard service call."""
try:
# Check if agents are available
if DOMAIN not in hass.data or not hass.data[DOMAIN].get("agents"):
_LOGGER.error(
"No AI agents available. Please configure the integration first."
)
return {"error": "No AI agents configured"}
provider = call.data.get("provider")
if provider not in hass.data[DOMAIN]["agents"]:
# Get the first available provider
available_providers = list(hass.data[DOMAIN]["agents"].keys())
if not available_providers:
_LOGGER.error("No AI agents available")
return {"error": "No AI agents configured"}
provider = available_providers[0]
_LOGGER.debug(f"Using fallback provider: {provider}")
agent = hass.data[DOMAIN]["agents"][provider]
# Parse dashboard config if it's a string
dashboard_config = call.data.get("dashboard_config", {})
if isinstance(dashboard_config, str):
try:
import json
dashboard_config = json.loads(dashboard_config)
except json.JSONDecodeError as e:
_LOGGER.error(f"Invalid JSON in dashboard_config: {e}")
return {"error": f"Invalid JSON in dashboard_config: {e}"}
dashboard_url = call.data.get("dashboard_url", "")
if not dashboard_url:
return {"error": "Dashboard URL is required"}
result = await agent.update_dashboard(dashboard_url, dashboard_config)
return result
except Exception as e:
_LOGGER.error(f"Error updating dashboard: {e}")
return {"error": str(e)}
# Register services
hass.services.async_register(DOMAIN, "query", async_handle_query)
hass.services.async_register(
DOMAIN, "create_automation", async_handle_create_automation
)
hass.services.async_register(
DOMAIN, "save_prompt_history", async_handle_save_prompt_history
)
hass.services.async_register(
DOMAIN, "load_prompt_history", async_handle_load_prompt_history
)
hass.services.async_register(
DOMAIN, "create_dashboard", async_handle_create_dashboard
)
hass.services.async_register(
DOMAIN, "update_dashboard", async_handle_update_dashboard
)
# Register static path for frontend
await hass.http.async_register_static_paths(
[
StaticPathConfig(
"/frontend/ai_agent_ha",
hass.config.path("custom_components/ai_agent_ha/frontend"),
False,
)
]
)
# Panel registration with proper error handling
panel_name = "ai_agent_ha"
try:
if await _panel_exists(hass, panel_name):
_LOGGER.debug("AI Agent HA panel already exists, skipping registration")
return True
_LOGGER.debug("Registering AI Agent HA panel")
async_register_built_in_panel(
hass,
component_name="custom",
sidebar_title="AI Agent HA",
sidebar_icon="mdi:robot",
frontend_url_path=panel_name,
require_admin=False,
config={
"_panel_custom": {
"name": "ai_agent_ha-panel",
"module_url": "/frontend/ai_agent_ha/ai_agent_ha-panel.js",
"embed_iframe": False,
}
},
)
_LOGGER.debug("AI Agent HA panel registered successfully")
except Exception as e:
_LOGGER.warning("Panel registration error: %s", str(e))
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
if await _panel_exists(hass, "ai_agent_ha"):
try:
from homeassistant.components.frontend import async_remove_panel
async_remove_panel(hass, "ai_agent_ha")
_LOGGER.debug("AI Agent HA panel removed successfully")
except Exception as e:
_LOGGER.debug("Error removing panel: %s", str(e))
# Remove services
hass.services.async_remove(DOMAIN, "query")
hass.services.async_remove(DOMAIN, "create_automation")
hass.services.async_remove(DOMAIN, "save_prompt_history")
hass.services.async_remove(DOMAIN, "load_prompt_history")
hass.services.async_remove(DOMAIN, "create_dashboard")
hass.services.async_remove(DOMAIN, "update_dashboard")
# Remove data
if DOMAIN in hass.data:
hass.data.pop(DOMAIN)
return True
async def _panel_exists(hass: HomeAssistant, panel_name: str) -> bool:
"""Check if a panel already exists."""
try:
return hasattr(hass.data, "frontend_panels") and panel_name in hass.data.get(
"frontend_panels", {}
)
except Exception as e:
_LOGGER.debug("Error checking panel existence: %s", str(e))
return False
File diff suppressed because it is too large Load Diff
@@ -1,917 +0,0 @@
"""Config flow for AI Agent HA integration."""
from __future__ import annotations
import logging
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.selector import (
SelectSelector,
SelectSelectorConfig,
TextSelector,
TextSelectorConfig,
)
from .agent import (
fetch_gemini_models,
fetch_openai_compatible_models,
fetch_openai_models,
)
from .const import (
CONF_LOCAL_OLLAMA_URL,
CONF_OPENAI_BASE_URL,
CONF_OPENAI_COMPATIBLE_URL,
DOMAIN,
)
_LOGGER = logging.getLogger(__name__)
PROVIDERS = {
"llama": "Llama",
"openai": "OpenAI",
"gemini": "Google Gemini",
"openrouter": "OpenRouter",
"anthropic": "Anthropic (Claude)",
"alter": "Alter",
"zai": "z.ai",
"local_ollama": "Local Ollama",
"openai_compatible": "Local OpenAI-Compatible (e.g. LM Studio, vLLM)",
}
TOKEN_FIELD_NAMES = {
"llama": "llama_token",
"openai": "openai_token",
"gemini": "gemini_token",
"openrouter": "openrouter_token",
"anthropic": "anthropic_token",
"alter": "alter_token",
"zai": "zai_token",
"zai_endpoint": "zai_endpoint",
"local_ollama": CONF_LOCAL_OLLAMA_URL, # For local Ollama models, we use URL instead of token
"openai_compatible": CONF_OPENAI_COMPATIBLE_URL, # For OpenAI-compatible endpoints
}
TOKEN_LABELS = {
"llama": "Llama API Token",
"openai": "OpenAI API Key",
"gemini": "Google Gemini API Key",
"openrouter": "OpenRouter API Key",
"anthropic": "Anthropic API Key",
"alter": "Alter API Key",
"zai": "z.ai API Key",
"zai_endpoint": "z.ai API Endpoint Type",
"local_ollama": "Local Ollama API URL (e.g., http://localhost:11434/api/generate)",
"openai_compatible": "OpenAI-Compatible URL (e.g., http://example.com/v1/ or http://localhost:8080/v1/). Must end with /v1/",
}
DEFAULT_MODELS = {
"llama": "Llama-4-Maverick-17B-128E-Instruct-FP8",
"openai": "gpt-5",
"gemini": "gemini-2.5-flash",
"openrouter": "openai/gpt-4o",
"anthropic": "claude-sonnet-4-5-20250929",
"alter": "", # User enters custom model
"zai": "glm-4.7", # Z.ai's latest flagship model
"local_ollama": "llama3.2", # Updated to use llama3.2 as default for local Ollama
"openai_compatible": "", # User enters custom model for OpenAI-compatible endpoint
}
AVAILABLE_MODELS = {
"openai": [
"gpt-5",
"gpt-5-mini",
"gpt-5-nano",
"gpt-4.1",
"gpt-4.1-mini",
"gpt-4.1-nano",
"gpt-4o",
"gpt-4o-mini",
"o3",
"o3-mini",
"o4-mini",
"o1",
"o1-preview",
"o1-mini",
"gpt-4-turbo",
"gpt-4",
"gpt-3.5-turbo",
],
"gemini": [
"gemini-2.5-flash",
"gemini-2.5-pro",
"gemini-2.0-flash",
"gemini-2.0-flash-exp",
"gemini-2.5-flash-preview",
"gemini-2.5-pro-preview",
],
"openrouter": [
"openai/gpt-4o",
"openai/gpt-4-turbo",
"openai/gpt-3.5-turbo",
"anthropic/claude-3.5-sonnet",
"anthropic/claude-3-sonnet",
"anthropic/claude-3-haiku",
"meta-llama/llama-3.1-70b-instruct",
"meta-llama/llama-3.2-90b-instruct",
"google/gemini-pro",
"mistralai/mixtral-8x7b-instruct",
"deepseek/deepseek-r1",
],
"anthropic": [
"claude-opus-4-7",
"claude-sonnet-4-6",
"claude-haiku-4-5",
"claude-sonnet-4-5-20250929",
"claude-sonnet-4-20250514",
"claude-3-5-sonnet-20241022",
"claude-3-5-haiku-20241022",
"claude-3-opus-20240229",
"claude-3-sonnet-20240229",
"claude-3-haiku-20240307",
],
"llama": [
"Llama-4-Maverick-17B-128E-Instruct-FP8",
"Llama-3.1-70B-Instruct",
"Llama-3.1-8B-Instruct",
"Llama-3.2-90B-Instruct",
],
# Alter - user enters custom model name only
"alter": [
"Custom...",
],
# z.ai - available models
"zai": [
"glm-4.7",
"glm-4.6",
"glm-4.5",
"glm-4.5-air",
"glm-4.5-x",
"glm-4.5-airx",
"glm-4.5-flash",
"glm-4-32b-0414-128k",
"Custom...",
],
# For local Ollama models, provide common models with llama3.2 as the default
"local_ollama": [
"llama3.2",
"llama3",
"llama3.1",
"mistral",
"mixtral",
"deepseek-coder",
"Custom...",
],
# For OpenAI-compatible endpoints, user should specify their model
"openai_compatible": [
"Custom...",
],
}
DEFAULT_PROVIDER = "openai"
class AiAgentHaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # type: ignore[call-arg,misc]
"""Handle a config flow for AI Agent HA."""
VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
@staticmethod
@callback
def async_get_options_flow(config_entry):
"""Get the options flow for this handler."""
try:
return AiAgentHaOptionsFlowHandler()
except Exception as e:
_LOGGER.error("Error creating options flow: %s", e)
return None
async def async_step_user(self, user_input=None):
"""Handle the initial step."""
errors = {}
if user_input is not None:
# Check if this provider is already configured
await self.async_set_unique_id(f"ai_agent_ha_{user_input['ai_provider']}")
self._abort_if_unique_id_configured()
self.config_data = {"ai_provider": user_input["ai_provider"]}
return await self.async_step_configure()
# Show provider selection form
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required("ai_provider"): SelectSelector(
SelectSelectorConfig(
options=[
{"value": k, "label": v} for k, v in PROVIDERS.items()
]
)
),
}
),
)
async def async_step_configure(self, user_input=None):
"""Handle the configuration step for the selected provider."""
errors = {}
provider = self.config_data["ai_provider"]
token_field = TOKEN_FIELD_NAMES[provider]
token_label = TOKEN_LABELS[provider]
default_model = DEFAULT_MODELS[provider]
# For Alter provider, default to "Custom..." for the dropdown since model is user-provided
dropdown_default = "Custom..." if provider == "alter" else default_model
available_models = AVAILABLE_MODELS.get(provider, [default_model])
if user_input is not None:
try:
# Validate the token
token_value = user_input.get(token_field)
if not token_value:
errors[token_field] = "required"
raise InvalidApiKey
# Store the configuration data
self.config_data[token_field] = token_value
# For z.ai, store endpoint type
if provider == "zai":
endpoint_type = user_input.get("zai_endpoint", "general")
self.config_data["zai_endpoint"] = endpoint_type
# For OpenAI, store Base URL (defaults to official endpoint if unchanged)
if provider == "openai":
base_url = (user_input.get(CONF_OPENAI_BASE_URL) or "").strip()
self.config_data[CONF_OPENAI_BASE_URL] = (
base_url or "https://api.openai.com/v1"
)
# For OpenAI, move to next step to select model from dynamic list
return await self.async_step_configure_openai_models()
# For OpenAI-Compatible, store Base URL + optional API key, then move on
if provider == "openai_compatible":
base_url = (
user_input.get(CONF_OPENAI_COMPATIBLE_URL) or ""
).strip()
self.config_data[CONF_OPENAI_COMPATIBLE_URL] = base_url
api_key = (
user_input.get("openai_compatible_api_key") or ""
).strip()
self.config_data["openai_compatible_api_key"] = api_key
# Move to next step to select model from dynamic list
return await self.async_step_configure_openai_compatible_models()
# Add model configuration if provided
selected_model = user_input.get("model")
custom_model = user_input.get("custom_model")
_LOGGER.debug(
f"Config flow - Provider: {provider}, Selected model: {selected_model}, Custom model: {custom_model}"
)
# Initialize models dict if it doesn't exist
if "models" not in self.config_data:
self.config_data["models"] = {}
if custom_model and custom_model.strip():
# Use custom model if provided and not empty
self.config_data["models"][provider] = custom_model.strip()
elif selected_model and selected_model != "Custom...":
# Use selected model if it's not the "Custom..." option
self.config_data["models"][provider] = selected_model
else:
# For local_ollama, openai_compatible, alter, and zai providers, allow empty model name
if provider in (
"local_ollama",
"openai_compatible",
"alter",
"zai",
):
self.config_data["models"][provider] = ""
else:
# Fallback to default model for other providers
self.config_data["models"][provider] = default_model
return self.async_create_entry(
title=f"AI Agent HA ({PROVIDERS[provider]})",
data=self.config_data,
)
except InvalidApiKey:
errors["base"] = "invalid_api_key"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
if provider == "zai":
# For z.ai provider, we need token, endpoint type, and optional model name
model_options = AVAILABLE_MODELS.get("zai", ["Custom..."])
schema_dict = {
vol.Required(token_field): TextSelector(
TextSelectorConfig(type="password")
),
vol.Optional("zai_endpoint", default="general"): SelectSelector(
SelectSelectorConfig(
options=[
{"value": "general", "label": "General Purpose"},
{"value": "coding", "label": "Coding (3× usage, 1/7 cost)"},
]
)
),
vol.Optional("model", default="glm-4.7"): SelectSelector(
SelectSelectorConfig(options=model_options)
),
vol.Optional("custom_model"): TextSelector(
TextSelectorConfig(type="text")
),
}
return self.async_show_form(
step_id="configure",
data_schema=vol.Schema(schema_dict),
errors=errors,
description_placeholders={
"token_label": token_label,
"provider": PROVIDERS[provider],
},
)
if provider == "local_ollama":
# For local_ollama provider, we need both URL and optional model name
schema_dict = {
vol.Required(CONF_LOCAL_OLLAMA_URL): TextSelector(
TextSelectorConfig(type="text")
),
}
# Add model selection
model_options = AVAILABLE_MODELS.get("local_ollama", ["Custom..."])
schema_dict[vol.Optional("model", default="Custom...")] = SelectSelector(
SelectSelectorConfig(options=model_options)
)
schema_dict[vol.Optional("custom_model")] = TextSelector(
TextSelectorConfig(type="text")
)
return self.async_show_form(
step_id="configure",
data_schema=vol.Schema(schema_dict),
errors=errors,
description_placeholders={
"token_label": "Local Ollama API URL", # nosec B105 - UI label string shown next to the URL field, not a credential
"provider": PROVIDERS[provider],
},
)
if provider == "openai_compatible":
# For openai_compatible provider, we need base URL + optional API key.
# Many local endpoints (LM Studio, vLLM) need no key; gateways like
# Open WebUI or LiteLLM require one. Models are fetched in the next step.
schema_dict = {
vol.Required(CONF_OPENAI_COMPATIBLE_URL): TextSelector(
TextSelectorConfig(type="text")
),
vol.Optional("openai_compatible_api_key", default=""): TextSelector(
TextSelectorConfig(type="password")
),
}
return self.async_show_form(
step_id="configure",
data_schema=vol.Schema(schema_dict),
errors=errors,
description_placeholders={
"token_label": "Local OpenAI-Compatible URL", # nosec B105 - UI label for config form, not a credential
"provider": PROVIDERS[provider],
},
)
if provider == "openai":
# For OpenAI provider, first step: API Key + Base URL
# Model selection happens in the next step after we fetch available models
schema_dict = {
vol.Required(token_field): TextSelector(
TextSelectorConfig(type="password")
),
vol.Optional(
CONF_OPENAI_BASE_URL,
default="https://api.openai.com/v1",
): TextSelector(TextSelectorConfig(type="text")),
}
return self.async_show_form(
step_id="configure",
data_schema=vol.Schema(schema_dict),
errors=errors,
description_placeholders={
"token_label": token_label,
"provider": PROVIDERS[provider],
},
)
# Build schema for other providers
schema_dict = {
vol.Required(token_field): TextSelector(
TextSelectorConfig(type="password")
),
}
# Add model selection if available
if available_models:
# For Gemini, fetch models dynamically
if provider == "gemini":
token_value = self.config_data.get("gemini_token")
model_list = await fetch_gemini_models(token_value)
if "Custom..." not in model_list:
model_list.insert(0, "Custom...")
model_options = model_list
else:
# Add predefined models + custom option (avoid duplicating "Custom...")
if "Custom..." in available_models:
model_options = available_models
else:
model_options = available_models + ["Custom..."]
schema_dict[vol.Optional("model", default=dropdown_default)] = (
SelectSelector(SelectSelectorConfig(options=model_options))
)
schema_dict[vol.Optional("custom_model")] = TextSelector(
TextSelectorConfig(type="text")
)
return self.async_show_form(
step_id="configure",
data_schema=vol.Schema(schema_dict),
errors=errors,
description_placeholders={
"token_label": token_label,
"provider": PROVIDERS[provider],
},
)
async def async_step_configure_openai_models(self, user_input=None):
"""Handle the OpenAI model selection step with dynamic model list."""
errors = {}
provider = "openai"
token = self.config_data.get("openai_token")
base_url = self.config_data.get(
CONF_OPENAI_BASE_URL, "https://api.openai.com/v1"
)
# Fetch available models dynamically
model_list = await fetch_openai_models(base_url, token)
# Ensure "Custom..." is always available
if "Custom..." not in model_list:
model_list.insert(0, "Custom...")
if user_input is not None:
try:
selected_model = user_input.get("model")
custom_model = user_input.get("custom_model")
# Initialize models dict if it doesn't exist
if "models" not in self.config_data:
self.config_data["models"] = {}
if custom_model and custom_model.strip():
self.config_data["models"][provider] = custom_model.strip()
elif selected_model and selected_model != "Custom...":
self.config_data["models"][provider] = selected_model
else:
self.config_data["models"][provider] = ""
return self.async_create_entry(
title=f"AI Agent HA ({PROVIDERS[provider]})",
data=self.config_data,
)
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception in OpenAI model selection")
errors["base"] = "unknown"
schema_dict = {
vol.Optional("model", default="Custom..."): SelectSelector(
SelectSelectorConfig(options=model_list)
),
vol.Optional("custom_model"): TextSelector(TextSelectorConfig(type="text")),
}
return self.async_show_form(
step_id="configure_openai_models",
data_schema=vol.Schema(schema_dict),
errors=errors,
description_placeholders={
"provider": PROVIDERS[provider],
},
)
async def async_step_configure_openai_compatible_models(self, user_input=None):
"""Handle the OpenAI-Compatible model selection step with dynamic model list."""
errors = {}
provider = "openai_compatible"
base_url = self.config_data.get(CONF_OPENAI_COMPATIBLE_URL, "")
api_key = self.config_data.get("openai_compatible_api_key") or ""
# Fetch available models dynamically if the endpoint supports it
model_list = await fetch_openai_compatible_models(base_url, api_key or None)
# Ensure "Custom..." is always available
if "Custom..." not in model_list:
model_list.insert(0, "Custom...")
if user_input is not None:
try:
selected_model = user_input.get("model")
custom_model = user_input.get("custom_model")
# Initialize models dict if it doesn't exist
if "models" not in self.config_data:
self.config_data["models"] = {}
if custom_model and custom_model.strip():
self.config_data["models"][provider] = custom_model.strip()
elif selected_model and selected_model != "Custom...":
self.config_data["models"][provider] = selected_model
else:
self.config_data["models"][provider] = ""
return self.async_create_entry(
title=f"AI Agent HA ({PROVIDERS[provider]})",
data=self.config_data,
)
except Exception: # pylint: disable=broad-except
_LOGGER.exception(
"Unexpected exception in OpenAI-Compatible model selection"
)
errors["base"] = "unknown"
schema_dict = {
vol.Optional("model", default="Custom..."): SelectSelector(
SelectSelectorConfig(options=model_list)
),
vol.Optional("custom_model"): TextSelector(TextSelectorConfig(type="text")),
}
return self.async_show_form(
step_id="configure_openai_compatible_models",
data_schema=vol.Schema(schema_dict),
errors=errors,
description_placeholders={
"provider": PROVIDERS[provider],
},
)
class InvalidApiKey(HomeAssistantError):
"""Error to indicate there is an invalid API key."""
class AiAgentHaOptionsFlowHandler(config_entries.OptionsFlow):
"""Handle options flow for AI Agent HA."""
def __init__(self):
"""Initialize options flow."""
self.options_data = {}
async def async_step_init(self, user_input=None):
"""Handle the initial options step - provider selection."""
current_provider = self.config_entry.data.get("ai_provider", DEFAULT_PROVIDER)
if user_input is not None:
# Store selected provider and move to configure step
self.options_data = {
"ai_provider": user_input["ai_provider"],
"current_provider": current_provider,
}
return await self.async_step_configure_options()
return self.async_show_form(
step_id="init",
data_schema=vol.Schema(
{
vol.Required(
"ai_provider", default=current_provider
): SelectSelector(
SelectSelectorConfig(
options=[
{"value": k, "label": v} for k, v in PROVIDERS.items()
]
)
),
}
),
description_placeholders={"current_provider": PROVIDERS[current_provider]},
)
async def async_step_configure_options(self, user_input=None):
"""Handle the configuration step for the selected provider in options."""
errors = {}
provider = self.options_data["ai_provider"]
current_provider = self.options_data["current_provider"]
token_field = TOKEN_FIELD_NAMES[provider]
token_label = TOKEN_LABELS[provider]
# Get current configuration
current_models = self.config_entry.data.get("models", {})
current_model = current_models.get(provider, DEFAULT_MODELS[provider])
# For Alter provider, if model is empty, default to "Custom..." for the dropdown
if provider == "alter" and not current_model:
current_model = "Custom..."
current_token = self.config_entry.data.get(token_field, "")
available_models = AVAILABLE_MODELS.get(provider, [DEFAULT_MODELS[provider]])
# Use current token if provider hasn't changed, otherwise empty
display_token = current_token if provider == current_provider else ""
# Determine if current model is a custom model (not in available models list)
# and prepare model dropdown and custom model field defaults
model_options = available_models
if "Custom..." not in model_options:
model_options = model_options + ["Custom..."]
# Check if current_model is a custom model (not in the available models)
# Remove "Custom..." from the check since it's the selector option, not a real model
available_models_without_custom = [
m for m in available_models if m != "Custom..."
]
is_custom_model = (
current_model
and current_model not in available_models_without_custom
and current_model != "Custom..."
)
if is_custom_model:
# Current model is a custom model - show "Custom..." in dropdown and populate custom field
model_default = "Custom..."
custom_model_default = current_model
else:
# Current model is a standard model or empty
model_default = current_model if current_model else "Custom..."
custom_model_default = ""
if user_input is not None:
try:
token_value = user_input.get(token_field)
if not token_value:
errors[token_field] = "required"
else:
# Prepare the updated configuration
updated_data = dict(self.config_entry.data)
updated_data["ai_provider"] = provider
updated_data[token_field] = token_value
# Update model configuration
selected_model = user_input.get("model")
custom_model = user_input.get("custom_model")
# For zai, update endpoint type
if provider == "zai":
endpoint_type = user_input.get("zai_endpoint", "general")
updated_data["zai_endpoint"] = endpoint_type
# For OpenAI, update Base URL (default to official if blank)
if provider == "openai":
base_url = (user_input.get(CONF_OPENAI_BASE_URL) or "").strip()
updated_data[CONF_OPENAI_BASE_URL] = (
base_url or "https://api.openai.com/v1"
)
# For OpenAI-Compatible, update the optional API key
if provider == "openai_compatible":
updated_data["openai_compatible_api_key"] = (
user_input.get("openai_compatible_api_key") or ""
).strip()
# Initialize models dict if it doesn't exist
if "models" not in updated_data:
updated_data["models"] = {}
if custom_model and custom_model.strip():
# Use custom model if provided and not empty
updated_data["models"][provider] = custom_model.strip()
elif selected_model and selected_model != "Custom...":
# Use selected model if it's not the "Custom..." option
updated_data["models"][provider] = selected_model
else:
# For local_ollama, openai_compatible, alter, and zai providers, allow empty model name
if provider in (
"local_ollama",
"openai_compatible",
"alter",
"zai",
):
updated_data["models"][provider] = ""
else:
# Ensure we keep the current model or use default for other providers
if provider not in updated_data["models"]:
updated_data["models"][provider] = DEFAULT_MODELS[
provider
]
_LOGGER.debug(
f"Options flow - Final model config for {provider}: {updated_data['models'].get(provider)}"
)
# Update the config entry
self.hass.config_entries.async_update_entry(
self.config_entry, data=updated_data
)
return self.async_create_entry(title="", data={})
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception in options flow")
errors["base"] = "unknown"
# Build schema for the selected provider in options
if provider == "zai":
current_endpoint = self.config_entry.data.get("zai_endpoint", "general")
model_options = AVAILABLE_MODELS.get("zai", ["glm-4.7"])
# Ensure "Custom..." is in model options
if "Custom..." not in model_options:
model_options = model_options + ["Custom..."]
schema_dict = {
vol.Required(token_field, default=display_token): TextSelector(
TextSelectorConfig(type="password")
),
vol.Optional("zai_endpoint", default=current_endpoint): SelectSelector(
SelectSelectorConfig(
options=[
{"value": "general", "label": "General Purpose"},
{"value": "coding", "label": "Coding (3× usage, 1/7 cost)"},
]
)
),
vol.Optional("model", default=model_default): SelectSelector(
SelectSelectorConfig(options=model_options)
),
vol.Optional(
"custom_model", default=custom_model_default
): TextSelector(TextSelectorConfig(type="text")),
}
return self.async_show_form(
step_id="configure_options",
data_schema=vol.Schema(schema_dict),
errors=errors,
description_placeholders={
"token_label": token_label,
"provider": PROVIDERS[provider],
},
)
if provider == "local_ollama":
# For local_ollama provider, we need both URL and optional model name
current_url = self.config_entry.data.get(CONF_LOCAL_OLLAMA_URL, "")
schema_dict = {
vol.Required(CONF_LOCAL_OLLAMA_URL, default=current_url): TextSelector(
TextSelectorConfig(type="text")
),
}
# Add model selection
model_options = AVAILABLE_MODELS.get("local_ollama", ["Custom..."])
# Ensure "Custom..." is in model options
if "Custom..." not in model_options:
model_options = model_options + ["Custom..."]
schema_dict[vol.Optional("model", default=model_default)] = SelectSelector(
SelectSelectorConfig(options=model_options)
)
schema_dict[vol.Optional("custom_model", default=custom_model_default)] = (
TextSelector(TextSelectorConfig(type="text"))
)
return self.async_show_form(
step_id="configure_options",
data_schema=vol.Schema(schema_dict),
errors=errors,
description_placeholders={
"token_label": "Local Ollama API URL", # nosec B105 - UI label string shown next to the URL field, not a credential
"provider": PROVIDERS[provider],
},
)
if provider == "openai_compatible":
# For openai_compatible provider, we need URL + optional API key + model
current_url = self.config_entry.data.get(CONF_OPENAI_COMPATIBLE_URL, "")
current_api_key = (
self.config_entry.data.get("openai_compatible_api_key") or ""
)
# Fetch available models dynamically if the endpoint supports it
model_list = await fetch_openai_compatible_models(
current_url, current_api_key or None
)
# Ensure "Custom..." is always available
if "Custom..." not in model_list:
model_list.insert(0, "Custom...")
schema_dict = {
vol.Required(
CONF_OPENAI_COMPATIBLE_URL, default=current_url
): TextSelector(TextSelectorConfig(type="text")),
vol.Optional(
"openai_compatible_api_key", default=current_api_key
): TextSelector(TextSelectorConfig(type="password")),
}
# Add model selection with dynamic list
schema_dict[vol.Optional("model", default=model_default)] = SelectSelector(
SelectSelectorConfig(options=model_list)
)
schema_dict[vol.Optional("custom_model", default=custom_model_default)] = (
TextSelector(TextSelectorConfig(type="text"))
)
return self.async_show_form(
step_id="configure_options",
data_schema=vol.Schema(schema_dict),
errors=errors,
description_placeholders={
"token_label": "Local OpenAI-Compatible URL", # nosec B105 - UI label for config form, not a credential
"provider": PROVIDERS[provider],
},
)
if provider == "openai":
# For OpenAI provider, we need token and optional Base URL
# Pre-fill with official endpoint if not set
current_base_url = (
self.config_entry.data.get(CONF_OPENAI_BASE_URL)
or "https://api.openai.com/v1"
)
# Fetch available models dynamically
current_token = self.config_entry.data.get("openai_token", "")
model_list = await fetch_openai_models(current_base_url, current_token)
# Ensure "Custom..." is always available
if "Custom..." not in model_list:
model_list.insert(0, "Custom...")
schema_dict = {
vol.Required(token_field, default=display_token): TextSelector(
TextSelectorConfig(type="password")
),
vol.Optional(
CONF_OPENAI_BASE_URL, default=current_base_url
): TextSelector(TextSelectorConfig(type="text")),
}
# Add model selection with dynamic list
schema_dict[vol.Optional("model", default=model_default)] = SelectSelector(
SelectSelectorConfig(options=model_list)
)
schema_dict[vol.Optional("custom_model", default=custom_model_default)] = (
TextSelector(TextSelectorConfig(type="text"))
)
return self.async_show_form(
step_id="configure_options",
data_schema=vol.Schema(schema_dict),
errors=errors,
description_placeholders={
"token_label": token_label,
"provider": PROVIDERS[provider],
},
)
# Build schema for other providers
schema_dict = {
vol.Required(token_field, default=display_token): TextSelector(
TextSelectorConfig(type="password")
),
}
# Add model selection if available
if available_models:
# For Gemini, fetch models dynamically
if provider == "gemini":
current_token = self.config_entry.data.get("gemini_token", "")
model_list = await fetch_gemini_models(current_token)
if "Custom..." not in model_list:
model_list.insert(0, "Custom...")
model_options = model_list
# model_options already has "Custom..." added above for other providers
schema_dict[vol.Optional("model", default=model_default)] = SelectSelector(
SelectSelectorConfig(options=model_options)
)
schema_dict[vol.Optional("custom_model", default=custom_model_default)] = (
TextSelector(TextSelectorConfig(type="text"))
)
return self.async_show_form(
step_id="configure_options",
data_schema=vol.Schema(schema_dict),
errors=errors,
description_placeholders={
"token_label": token_label,
"provider": PROVIDERS[provider],
},
)
-40
View File
@@ -1,40 +0,0 @@
"""Constants for the AI Agent HA integration."""
DOMAIN = "ai_agent_ha"
CONF_API_KEY = "api_key"
CONF_WEATHER_ENTITY = "weather_entity"
# AI Provider configuration keys
CONF_LLAMA_TOKEN = "llama_token" # nosec B105
CONF_OPENAI_TOKEN = "openai_token" # nosec B105
CONF_OPENAI_BASE_URL = (
"openai_base_url" # nosec B105 - configuration key, not a credential
)
CONF_GEMINI_TOKEN = "gemini_token" # nosec B105
CONF_OPENROUTER_TOKEN = "openrouter_token" # nosec B105
CONF_ANTHROPIC_TOKEN = "anthropic_token" # nosec B105
CONF_ALTER_TOKEN = "alter_token" # nosec B105
CONF_ZAI_TOKEN = "zai_token" # nosec B105
CONF_LOCAL_OLLAMA_URL = "local_ollama_url"
CONF_LOCAL_OLLAMA_MODEL = "local_ollama_model"
CONF_OPENAI_COMPATIBLE_URL = "openai_compatible_url"
CONF_LOCAL_URL = "local_url" # legacy alias for local_ollama_url
# Available AI providers
AI_PROVIDERS = [
"llama",
"openai",
"gemini",
"openrouter",
"anthropic",
"alter",
"zai",
"local_ollama",
"openai_compatible",
]
# AI Provider constants
CONF_MODELS = "models"
# Supported AI providers
DEFAULT_AI_PROVIDER = "openai"
@@ -1,390 +0,0 @@
"""
Dashboard templates and examples for AI agent to use when creating dashboards.
"""
# Basic dashboard templates for different use cases
DASHBOARD_TEMPLATES = {
"simple_lights": {
"title": "Lights Dashboard",
"url_path": "lights",
"icon": "mdi:lightbulb",
"show_in_sidebar": True,
"views": [
{
"title": "All Lights",
"cards": [
{
"type": "entities",
"title": "Living Room Lights",
"entities": [], # To be filled with actual light entities
},
{
"type": "light",
"entity": "", # To be filled with main light entity
},
],
}
],
},
"security": {
"title": "Security Dashboard",
"url_path": "security",
"icon": "mdi:security",
"show_in_sidebar": True,
"views": [
{
"title": "Security Overview",
"cards": [
{
"type": "entities",
"title": "Sensors",
"entities": [], # To be filled with sensor entities
},
{
"type": "entities",
"title": "Cameras",
"entities": [], # To be filled with camera entities
},
{
"type": "alarm-panel",
"entity": "", # To be filled with alarm panel entity
},
],
}
],
},
"climate": {
"title": "Climate Control",
"url_path": "climate",
"icon": "mdi:thermometer",
"show_in_sidebar": True,
"views": [
{
"title": "Temperature & Humidity",
"cards": [
{
"type": "thermostat",
"entity": "", # To be filled with climate.* thermostat entity (optional)
},
{
"type": "history-graph",
"title": "Temperature History",
"entities": [], # To be filled with temperature sensor entities
"hours_to_show": 24,
},
{
"type": "entities",
"title": "Temperature Sensors",
"entities": [], # To be filled with temperature sensor entities (device_class: temperature)
},
{
"type": "history-graph",
"title": "Humidity History",
"entities": [], # To be filled with humidity sensor entities
"hours_to_show": 24,
},
{
"type": "entities",
"title": "Humidity Sensors",
"entities": [], # To be filled with humidity sensor entities (device_class: humidity)
},
{
"type": "weather-forecast",
"entity": "", # To be filled with weather entity (optional)
},
],
}
],
},
"media": {
"title": "Media Center",
"url_path": "media",
"icon": "mdi:play",
"show_in_sidebar": True,
"views": [
{
"title": "Media Players",
"cards": [
{
"type": "media-control",
"entity": "", # To be filled with media player entity
},
{
"type": "entities",
"title": "All Media Players",
"entities": [], # To be filled with media player entities
},
],
}
],
},
"energy": {
"title": "Energy Monitoring",
"url_path": "energy",
"icon": "mdi:lightning-bolt",
"show_in_sidebar": True,
"views": [
{
"title": "Energy Usage",
"cards": [
{"type": "energy-distribution", "title": "Energy Distribution"},
{
"type": "entities",
"title": "Power Sensors",
"entities": [], # To be filled with power sensor entities
},
{
"type": "history-graph",
"title": "Power Usage",
"entities": [], # To be filled with power entities
},
],
}
],
},
}
# Card type examples and their typical use cases
CARD_EXAMPLES = {
"entities": {
"description": "Shows a list of entities with their states",
"example": {
"type": "entities",
"title": "Living Room",
"entities": [
"light.living_room_main",
"switch.living_room_fan",
"sensor.living_room_temperature",
],
},
},
"glance": {
"description": "Shows entities in a compact grid format",
"example": {
"type": "glance",
"title": "Quick Overview",
"entities": [
"binary_sensor.front_door",
"binary_sensor.back_door",
"binary_sensor.garage_door",
],
},
},
"thermostat": {
"description": "Controls and displays thermostat information",
"example": {"type": "thermostat", "entity": "climate.main_thermostat"},
},
"weather-forecast": {
"description": "Shows weather information and forecast",
"example": {
"type": "weather-forecast",
"entity": "weather.home",
"name": "Weather",
},
},
"media-control": {
"description": "Controls media players with full interface",
"example": {"type": "media-control", "entity": "media_player.living_room_tv"},
},
"light": {
"description": "Dedicated light control card",
"example": {"type": "light", "entity": "light.living_room_main"},
},
"alarm-panel": {
"description": "Security alarm panel interface",
"example": {"type": "alarm-panel", "entity": "alarm_control_panel.home_alarm"},
},
"picture-entity": {
"description": "Shows entity state with a background image",
"example": {
"type": "picture-entity",
"entity": "light.living_room",
"image": "/local/living_room.jpg",
},
},
"history-graph": {
"description": "Shows historical data as a graph",
"example": {
"type": "history-graph",
"title": "Temperature History",
"entities": ["sensor.temperature_indoor", "sensor.temperature_outdoor"],
"hours_to_show": 24,
},
},
"gauge": {
"description": "Shows a single entity value as a gauge",
"example": {
"type": "gauge",
"entity": "sensor.cpu_temperature",
"min": 0,
"max": 100,
"name": "CPU Temperature",
},
},
}
# Predefined color schemes and icons
COMMON_ICONS = {
"lights": "mdi:lightbulb",
"security": "mdi:security",
"climate": "mdi:thermometer",
"energy": "mdi:lightning-bolt",
"media": "mdi:play",
"kitchen": "mdi:chef-hat",
"bedroom": "mdi:bed",
"bathroom": "mdi:shower",
"living_room": "mdi:sofa",
"garage": "mdi:garage",
"garden": "mdi:flower",
"office": "mdi:desk",
"basement": "mdi:stairs-down",
"attic": "mdi:stairs-up",
}
def get_template_for_entities(entities, dashboard_type="general"):
"""Generate a dashboard template based on available entities."""
template = {
"title": f"{dashboard_type.title()} Dashboard",
"url_path": dashboard_type.lower().replace(" ", "-"),
"icon": COMMON_ICONS.get(dashboard_type, "mdi:view-dashboard"),
"show_in_sidebar": True,
"views": [],
}
# Group entities by domain and by device_class for sensors
entity_groups = {}
sensor_by_device_class = {}
for entity in entities:
if isinstance(entity, dict) and "entity_id" in entity:
entity_id = entity["entity_id"]
# Get device_class from attributes if available
device_class = entity.get("attributes", {}).get("device_class")
else:
entity_id = str(entity)
device_class = None
domain = entity_id.split(".")[0]
if domain not in entity_groups:
entity_groups[domain] = []
entity_groups[domain].append(entity_id)
# Categorize sensors by device_class
if domain == "sensor" and device_class:
if device_class not in sensor_by_device_class:
sensor_by_device_class[device_class] = []
sensor_by_device_class[device_class].append(entity_id)
# Create view with cards for each domain
view_cards = []
# Lights
if "light" in entity_groups:
view_cards.append(
{"type": "entities", "title": "Lights", "entities": entity_groups["light"]}
)
# Climate - handle both climate entities and temperature/humidity sensors
if "climate" in entity_groups:
for climate_entity in entity_groups["climate"]:
view_cards.append({"type": "thermostat", "entity": climate_entity})
# Temperature sensors - create history graphs for climate dashboards
if "temperature" in sensor_by_device_class:
temp_sensors = sensor_by_device_class["temperature"]
# Add history graph for temperature visualization
view_cards.append(
{
"type": "history-graph",
"title": "Temperature History",
"entities": temp_sensors,
"hours_to_show": 24,
}
)
# Add entity card for current values
view_cards.append(
{
"type": "entities",
"title": "Temperature Sensors",
"entities": temp_sensors,
}
)
# Humidity sensors - create gauge cards for climate dashboards
if "humidity" in sensor_by_device_class:
humidity_sensors = sensor_by_device_class["humidity"]
# Add history graph for humidity visualization
view_cards.append(
{
"type": "history-graph",
"title": "Humidity History",
"entities": humidity_sensors,
"hours_to_show": 24,
}
)
# Add entity card for current values
view_cards.append(
{
"type": "entities",
"title": "Humidity Sensors",
"entities": humidity_sensors,
}
)
# Media players
if "media_player" in entity_groups:
for media_entity in entity_groups["media_player"]:
view_cards.append({"type": "media-control", "entity": media_entity})
# Security entities
if "binary_sensor" in entity_groups:
view_cards.append(
{
"type": "entities",
"title": "Sensors",
"entities": entity_groups["binary_sensor"],
}
)
if "alarm_control_panel" in entity_groups:
for alarm_entity in entity_groups["alarm_control_panel"]:
view_cards.append({"type": "alarm-panel", "entity": alarm_entity})
# Other Sensors (not temperature or humidity)
if "sensor" in entity_groups:
# Filter out temperature and humidity sensors that we already handled
other_sensors = [
s
for s in entity_groups["sensor"]
if s not in sensor_by_device_class.get("temperature", [])
and s not in sensor_by_device_class.get("humidity", [])
]
if other_sensors:
view_cards.append(
{
"type": "entities",
"title": "Other Sensors",
"entities": other_sensors[:10], # Limit to first 10
}
)
# Switches
if "switch" in entity_groups:
view_cards.append(
{
"type": "entities",
"title": "Switches",
"entities": entity_groups["switch"],
}
)
# Weather
if "weather" in entity_groups:
view_cards.append(
{"type": "weather-forecast", "entity": entity_groups["weather"][0]}
)
template["views"] = [{"title": "Overview", "cards": view_cards}]
return template
@@ -1,8 +0,0 @@
panel_custom:
- name: AI Agent HA
sidebar_title: AI Agent HA
sidebar_icon: mdi:robot
url_path: ai_agent_ha
module_url: /local/ai_agent_ha/frontend/ai_agent_ha-panel.js
trust_external: true
require_admin: false
File diff suppressed because it is too large Load Diff
@@ -1,24 +0,0 @@
{
"domain": "ai_agent_ha",
"name": "AI Agent HA",
"after_dependencies": [
"history",
"recorder",
"lovelace"
],
"codeowners": [
"@sbenodiz"
],
"config_flow": true,
"dependencies": [
"http"
],
"documentation": "https://github.com/sbenodiz/ai_agent_ha",
"integration_type": "service",
"iot_class": "cloud_polling",
"issue_tracker": "https://github.com/sbenodiz/ai_agent_ha/issues",
"requirements": [
"aiohttp>=3.8.0"
],
"version": "1.14.1"
}
@@ -1,98 +0,0 @@
query:
name: "Query AI Agent with Home Assistant context"
description: "Run a custom AI prompt against your Home Assistant state dump."
fields:
prompt:
description: "The question or instruction to send to AI model."
example: "Turn on all the lights in the living room"
debug:
description: "Include a debug trace of the HA↔AI conversation (true/false)."
example: true
default: false
provider:
description: "The AI provider to use (openai, llama, gemini, openrouter, anthropic, alter, zai, local)"
example: "openai"
default: "openai"
selector:
select:
options:
- "openai"
- "llama"
- "gemini"
- "openrouter"
- "anthropic"
- "alter"
- "zai"
- "local"
create_dashboard:
name: "Create Dashboard via AI Agent"
description: "Create a new Home Assistant dashboard using AI assistance."
fields:
dashboard_config:
description: "The dashboard configuration as a JSON object."
example: '{"title": "My Dashboard", "url_path": "my-dashboard", "views": []}'
provider:
description: "The AI provider to use (openai, llama, gemini, openrouter, anthropic, alter, zai, local)"
example: "openai"
default: "openai"
selector:
select:
options:
- "openai"
- "llama"
- "gemini"
- "openrouter"
- "anthropic"
- "alter"
- "zai"
- "local"
create_automation:
name: "Create Automation via AI Agent"
description: "Create a new Home Assistant automation using AI assistance."
fields:
automation:
description: "The automation configuration as a JSON object."
example: '{"alias": "Turn off lights at 9 PM", "trigger": [{"platform": "time", "at": "21:00:00"}], "action": [{"service": "light.turn_off", "target": {"entity_id": "light.living_room"}}]}'
provider:
description: "The AI provider to use (openai, llama, gemini, openrouter, anthropic, alter, zai, local)"
example: "openai"
default: "openai"
selector:
select:
options:
- "openai"
- "llama"
- "gemini"
- "openrouter"
- "anthropic"
- "alter"
- "zai"
- "local"
update_dashboard:
name: "Update Dashboard via AI Agent"
description: "Update an existing Home Assistant dashboard using AI assistance."
fields:
dashboard_url:
description: "The URL path of dashboard to update."
example: "my-dashboard"
dashboard_config:
description: "The updated dashboard configuration as a JSON object."
example: '{"title": "Updated Dashboard", "views": []}'
provider:
description: "The AI provider to use (openai, llama, gemini, openrouter, anthropic, alter, zai, local)"
example: "openai"
default: "openai"
selector:
select:
options:
- "openai"
- "llama"
- "gemini"
- "openrouter"
- "anthropic"
- "alter"
- "zai"
- "local"
@@ -1,91 +0,0 @@
{
"config": {
"step": {
"user": {
"title": "Choose AI Provider",
"description": "Select your AI provider",
"data": {
"ai_provider": "AI Provider"
},
"data_description": {
"ai_provider": "Choose your preferred AI provider"
}
},
"configure": {
"title": "Configure {provider}",
"description": "Enter your {token_label} and optionally select a model",
"data": {
"llama_token": "Llama API Token",
"openai_token": "OpenAI API Key",
"gemini_token": "Google Gemini API Key",
"openrouter_token": "OpenRouter API Key",
"anthropic_token": "Anthropic API Key",
"alter_token": "Alter API Key",
"model": "Model (Optional)",
"custom_model": "Custom Model (Optional)"
},
"data_description": {
"llama_token": "Enter your Llama API token",
"openai_token": "Enter your OpenAI API key",
"gemini_token": "Enter your Google Gemini API key",
"openrouter_token": "Enter your OpenRouter API key",
"anthropic_token": "Enter your Anthropic API key",
"alter_token": "Enter your Alter API key",
"model": "Choose a predefined model or select 'Custom...' to enter your own",
"custom_model": "Enter a custom model name (only used if 'Custom...' is selected above)"
}
}
},
"error": {
"invalid_api_key": "Invalid API key format",
"unknown": "Unknown error occurred",
"llama_token": "Llama API token is required",
"openai_token": "OpenAI API key is required",
"gemini_token": "Google Gemini API key is required",
"openrouter_token": "OpenRouter API key is required",
"anthropic_token": "Anthropic API key is required",
"alter_token": "Alter API key is required"
},
"abort": {
"already_configured": "AI Agent HA is already configured"
}
},
"options": {
"step": {
"init": {
"title": "AI Provider Settings",
"description": "Current provider: {current_provider}. Select a provider to configure",
"data": {
"ai_provider": "AI Provider"
},
"data_description": {
"ai_provider": "Choose your preferred AI provider"
}
},
"configure_options": {
"title": "Configure {provider}",
"description": "Update your {token_label} and model settings",
"data": {
"llama_token": "Llama API Token",
"openai_token": "OpenAI API Key",
"gemini_token": "Google Gemini API Key",
"openrouter_token": "OpenRouter API Key",
"anthropic_token": "Anthropic API Key",
"alter_token": "Alter API Key",
"model": "Model",
"custom_model": "Custom Model (Optional)"
},
"data_description": {
"llama_token": "Enter your Llama API token",
"openai_token": "Enter your OpenAI API key",
"gemini_token": "Enter your Google Gemini API key",
"openrouter_token": "Enter your OpenRouter API key",
"anthropic_token": "Enter your Anthropic API key",
"alter_token": "Enter your Alter API key",
"model": "Choose a model or select 'Custom...' to enter your own",
"custom_model": "Enter a custom model name (only used if 'Custom...' is selected above)"
}
}
}
}
}
@@ -1,81 +0,0 @@
{
"config": {
"step": {
"user": {
"title": "Agent d'IA HA - Seleccionar proveïdor",
"description": "Tria el teu proveïdor d'IA",
"data": {
"ai_provider": "Proveïdor d'IA"
},
"data_description": {
"ai_provider": "Selecciona el teu proveïdor d'IA preferit"
}
},
"configure": {
"title": "Agent d'IA HA - Configurar {provider}",
"description": "Configura les teves credencials d'API i el model de {provider}",
"data": {
"llama_token": "Token de l'API de Llama",
"openai_token": "Clau de l'API d'OpenAI",
"gemini_token": "Clau de l'API de Google Gemini",
"openrouter_token": "Clau de l'API d'OpenRouter",
"model": "Model (Opcional)",
"custom_model": "Nom del model personalitzat (Opcional)"
},
"data_description": {
"llama_token": "Introdueix el teu token de l'API de Llama",
"openai_token": "Introdueix la teva clau de l'API d'OpenAI",
"gemini_token": "Introdueix la teva clau de l'API de Google Gemini",
"openrouter_token": "Introdueix la teva clau de l'API d'OpenRouter",
"model": "Tria un model predefinit o selecciona 'Personalitzat...' per utilitzar un model personalitzat",
"custom_model": "Introdueix un nom de model personalitzat (anul·la la selecció desplegable anterior)"
}
}
},
"error": {
"invalid_api_key": "Clau o token d'API no vàlid",
"llama_token": "Es requereix el token de l'API de Llama",
"openai_token": "Es requereix la clau de l'API d'OpenAI",
"gemini_token": "Es requereix la clau de l'API de Google Gemini",
"openrouter_token": "Es requereix la clau de l'API d'OpenRouter",
"unknown": "S'ha produït un error inesperat"
},
"abort": {
"already_configured": "L'Agent d'IA HA ja està configurat"
}
},
"options": {
"step": {
"init": {
"title": "Agent d'IA HA - Seleccionar proveïdor",
"description": "Tria el teu proveïdor d'IA (Actual: {current_provider})",
"data": {
"ai_provider": "Proveïdor d'IA"
},
"data_description": {
"ai_provider": "Selecciona el teu proveïdor d'IA preferit"
}
},
"configure_options": {
"title": "Agent d'IA HA - Configurar {provider}",
"description": "Actualitza les teves credencials d'API i el model de {provider}",
"data": {
"llama_token": "Token de l'API de Llama",
"openai_token": "Clau de l'API d'OpenAI",
"gemini_token": "Clau de l'API de Google Gemini",
"openrouter_token": "Clau de l'API d'OpenRouter",
"model": "Model (Opcional)",
"custom_model": "Nom del model personalitzat (Opcional)"
},
"data_description": {
"llama_token": "Introdueix el teu token de l'API de Llama",
"openai_token": "Introdueix la teva clau de l'API d'OpenAI",
"gemini_token": "Introdueix la teva clau de l'API de Google Gemini",
"openrouter_token": "Introdueix la teva clau de l'API d'OpenRouter",
"model": "Tria un model predefinit o selecciona 'Personalitzat...' per utilitzar un model personalitzat",
"custom_model": "Introdueix un nom de model personalitzat (anul·la la selecció desplegable anterior)"
}
}
}
}
}
@@ -1,86 +0,0 @@
{
"config": {
"step": {
"user": {
"title": "KI-Anbieter wählen",
"description": "Wähle deinen KI-Anbieter aus",
"data": {
"ai_provider": "KI-Anbieter"
},
"data_description": {
"ai_provider": "Wähle deinen bevorzugten KI-Anbieter"
}
},
"configure": {
"title": "{provider} konfigurieren",
"description": "Gib deinen {token_label} ein und wähle optional ein Model",
"data": {
"llama_token": "Llama API Token",
"openai_token": "OpenAI API Key",
"gemini_token": "Google Gemini API Key",
"openrouter_token": "OpenRouter API Key",
"anthropic_token": "Anthropic API Key",
"model": "Model (Optional)",
"custom_model": "Custom Model (Optional)"
},
"data_description": {
"llama_token": "Gib deinen Llama API Token ein",
"openai_token": "Gib deinen OpenAI API Key ein",
"gemini_token": "Gib deinen Google Gemini API Key ein",
"openrouter_token": "Gib deinen OpenRouter API Key ein",
"anthropic_token": "Gib deinen Anthropic API Key ein",
"model": "Wähle ein vordefiniertes Model oder 'Custom...' zum manuellen Eintrag",
"custom_model": "Gib einen benutzerdefinierten Model-Namen ein (nur relevant bei Auswahl von 'Custom...')"
}
}
},
"error": {
"invalid_api_key": "Ungültiges API-Key-Format",
"unknown": "Unbekannter Fehler aufgetreten",
"llama_token": "Llama API Token ist erforderlich",
"openai_token": "OpenAI API Key ist erforderlich",
"gemini_token": "Google Gemini API Key ist erforderlich",
"openrouter_token": "OpenRouter API Key ist erforderlich",
"anthropic_token": "Anthropic API Key ist erforderlich"
},
"abort": {
"already_configured": "AI Agent HA ist bereits konfiguriert"
}
},
"options": {
"step": {
"init": {
"title": "Einstellungen für KI-Anbieter",
"description": "Aktueller Anbieter: {current_provider}. Wähle einen Anbieter zur Konfiguration",
"data": {
"ai_provider": "KI-Anbieter"
},
"data_description": {
"ai_provider": "Wähle deinen bevorzugten KI-Anbieter"
}
},
"configure_options": {
"title": "{provider} konfigurieren",
"description": "Aktualisiere deinen {token_label} und Model-Einstellungen",
"data": {
"llama_token": "Llama API Token",
"openai_token": "OpenAI API Key",
"gemini_token": "Google Gemini API Key",
"openrouter_token": "OpenRouter API Key",
"anthropic_token": "Anthropic API Key",
"model": "Model",
"custom_model": "Custom Model (Optional)"
},
"data_description": {
"llama_token": "Gib deinen Llama API Token ein",
"openai_token": "Gib deinen OpenAI API Key ein",
"gemini_token": "Gib deinen Google Gemini API Key ein",
"openrouter_token": "Gib deinen OpenRouter API Key ein",
"anthropic_token": "Gib deinen Anthropic API Key ein",
"model": "Wähle ein Model oder 'Custom...' zum manuellen Eintrag",
"custom_model": "Gib einen benutzerdefinierten Model-Namen ein (nur relevant bei Auswahl von 'Custom...')"
}
}
}
}
}
@@ -1,91 +0,0 @@
{
"config": {
"step": {
"user": {
"title": "Choose AI Provider",
"description": "Select your AI provider",
"data": {
"ai_provider": "AI Provider"
},
"data_description": {
"ai_provider": "Choose your preferred AI provider"
}
},
"configure": {
"title": "Configure {provider}",
"description": "Enter your {token_label} and optionally select a model",
"data": {
"llama_token": "Llama API Token",
"openai_token": "OpenAI API Key",
"gemini_token": "Google Gemini API Key",
"openrouter_token": "OpenRouter API Key",
"anthropic_token": "Anthropic API Key",
"alter_token": "Alter API Key",
"model": "Model (Optional)",
"custom_model": "Custom Model (Optional)"
},
"data_description": {
"llama_token": "Enter your Llama API token",
"openai_token": "Enter your OpenAI API key",
"gemini_token": "Enter your Google Gemini API key",
"openrouter_token": "Enter your OpenRouter API key",
"anthropic_token": "Enter your Anthropic API key",
"alter_token": "Enter your Alter API key",
"model": "Choose a predefined model or select 'Custom...' to enter your own",
"custom_model": "Enter a custom model name (only used if 'Custom...' is selected above)"
}
}
},
"error": {
"invalid_api_key": "Invalid API key format",
"unknown": "Unknown error occurred",
"llama_token": "Llama API token is required",
"openai_token": "OpenAI API key is required",
"gemini_token": "Google Gemini API key is required",
"openrouter_token": "OpenRouter API key is required",
"anthropic_token": "Anthropic API key is required",
"alter_token": "Alter API key is required"
},
"abort": {
"already_configured": "AI Agent HA is already configured"
}
},
"options": {
"step": {
"init": {
"title": "AI Provider Settings",
"description": "Current provider: {current_provider}. Select a provider to configure",
"data": {
"ai_provider": "AI Provider"
},
"data_description": {
"ai_provider": "Choose your preferred AI provider"
}
},
"configure_options": {
"title": "Configure {provider}",
"description": "Update your {token_label} and model settings",
"data": {
"llama_token": "Llama API Token",
"openai_token": "OpenAI API Key",
"gemini_token": "Google Gemini API Key",
"openrouter_token": "OpenRouter API Key",
"anthropic_token": "Anthropic API Key",
"alter_token": "Alter API Key",
"model": "Model",
"custom_model": "Custom Model (Optional)"
},
"data_description": {
"llama_token": "Enter your Llama API token",
"openai_token": "Enter your OpenAI API key",
"gemini_token": "Enter your Google Gemini API key",
"openrouter_token": "Enter your OpenRouter API key",
"anthropic_token": "Enter your Anthropic API key",
"alter_token": "Enter your Alter API key",
"model": "Choose a model or select 'Custom...' to enter your own",
"custom_model": "Enter a custom model name (only used if 'Custom...' is selected above)"
}
}
}
}
}
@@ -1,81 +0,0 @@
{
"config": {
"step": {
"user": {
"title": "AI Agent HA - Select Provider",
"description": "Choose your AI provider",
"data": {
"ai_provider": "AI Provider"
},
"data_description": {
"ai_provider": "Select your preferred AI provider"
}
},
"configure": {
"title": "AI Agent HA - Configure {provider}",
"description": "Configure your {provider} API credentials and model",
"data": {
"llama_token": "Llama API Token",
"openai_token": "OpenAI API Key",
"gemini_token": "Google Gemini API Key",
"openrouter_token": "OpenRouter API Key",
"model": "Model (Optional)",
"custom_model": "Custom Model Name (Optional)"
},
"data_description": {
"llama_token": "Enter your Llama API token",
"openai_token": "Enter your OpenAI API key",
"gemini_token": "Enter your Google Gemini API key",
"openrouter_token": "Enter your OpenRouter API key",
"model": "Choose a predefined model or select 'Custom...' to use a custom model",
"custom_model": "Enter a custom model name (overrides the dropdown selection above)"
}
}
},
"error": {
"invalid_api_key": "Invalid API key or token",
"llama_token": "Llama API token is required",
"openai_token": "OpenAI API key is required",
"gemini_token": "Google Gemini API key is required",
"openrouter_token": "OpenRouter API key is required",
"unknown": "Unexpected error occurred"
},
"abort": {
"already_configured": "AI Agent HA is already configured"
}
},
"options": {
"step": {
"init": {
"title": "AI Agent HA - Select Provider",
"description": "Choose your AI provider (Current: {current_provider})",
"data": {
"ai_provider": "AI Provider"
},
"data_description": {
"ai_provider": "Select your preferred AI provider"
}
},
"configure_options": {
"title": "AI Agent HA - Configure {provider}",
"description": "Update your {provider} API credentials and model",
"data": {
"llama_token": "Llama API Token",
"openai_token": "OpenAI API Key",
"gemini_token": "Google Gemini API Key",
"openrouter_token": "OpenRouter API Key",
"model": "Model (Optional)",
"custom_model": "Custom Model Name (Optional)"
},
"data_description": {
"llama_token": "Enter your Llama API token",
"openai_token": "Enter your OpenAI API key",
"gemini_token": "Enter your Google Gemini API key",
"openrouter_token": "Enter your OpenRouter API key",
"model": "Choose a predefined model or select 'Custom...' to use a custom model",
"custom_model": "Enter a custom model name (overrides the dropdown selection above)"
}
}
}
}
}
@@ -1,81 +0,0 @@
{
"config": {
"step": {
"user": {
"title": "Agente de IA HA - Seleccionar proveedor",
"description": "Elige tu proveedor de IA",
"data": {
"ai_provider": "Proveedor de IA"
},
"data_description": {
"ai_provider": "Selecciona tu proveedor de IA preferido"
}
},
"configure": {
"title": "Agente de IA HA - Configurar {provider}",
"description": "Configura tus credenciales de API y modelo de {provider}",
"data": {
"llama_token": "Token de la API de Llama",
"openai_token": "Clave de la API de OpenAI",
"gemini_token": "Clave de la API de Google Gemini",
"openrouter_token": "Clave de la API de OpenRouter",
"model": "Modelo (Opcional)",
"custom_model": "Nombre de modelo personalizado (Opcional)"
},
"data_description": {
"llama_token": "Introduce tu token de la API de Llama",
"openai_token": "Introduce tu clave de la API de OpenAI",
"gemini_token": "Introduce tu clave de la API de Google Gemini",
"openrouter_token": "Introduce tu clave de la API de OpenRouter",
"model": "Elige un modelo predefinido o selecciona 'Personalizado...' para usar un modelo personalizado",
"custom_model": "Introduce un nombre de modelo personalizado (anula la selección desplegable anterior)"
}
}
},
"error": {
"invalid_api_key": "Clave o token de API no válido",
"llama_token": "Se requiere el token de la API de Llama",
"openai_token": "Se requiere la clave de la API de OpenAI",
"gemini_token": "Se requiere la clave de la API de Google Gemini",
"openrouter_token": "Se requiere la clave de la API de OpenRouter",
"unknown": "Ha ocurrido un error inesperado"
},
"abort": {
"already_configured": "El Agente de IA HA ya está configurado"
}
},
"options": {
"step": {
"init": {
"title": "Agente de IA HA - Seleccionar proveedor",
"description": "Elige tu proveedor de IA (Actual: {current_provider})",
"data": {
"ai_provider": "Proveedor de IA"
},
"data_description": {
"ai_provider": "Selecciona tu proveedor de IA preferido"
}
},
"configure_options": {
"title": "Agente de IA HA - Configurar {provider}",
"description": "Actualiza tus credenciales de API y modelo de {provider}",
"data": {
"llama_token": "Token de la API de Llama",
"openai_token": "Clave de la API de OpenAI",
"gemini_token": "Clave de la API de Google Gemini",
"openrouter_token": "Clave de la API de OpenRouter",
"model": "Modelo (Opcional)",
"custom_model": "Nombre de modelo personalizado (Opcional)"
},
"data_description": {
"llama_token": "Introduce tu token de la API de Llama",
"openai_token": "Introduce tu clave de la API de OpenAI",
"gemini_token": "Introduce tu clave de la API de Google Gemini",
"openrouter_token": "Introduce tu clave de la API de OpenRouter",
"model": "Elige un modelo predefinido o selecciona 'Personalizado...' para usar un modelo personalizado",
"custom_model": "Introduce un nombre de modelo personalizado (anula la selección desplegable anterior)"
}
}
}
}
}
@@ -54,6 +54,19 @@ from .const import (
DEFAULT_ICLOUD_IMAGE_SIZE,
ICLOUD_IMAGE_FULL,
ICLOUD_IMAGE_PREVIEW,
CONF_SYNOLOGY_URL,
CONF_SYNOLOGY_USERNAME,
CONF_SYNOLOGY_PASSWORD,
CONF_SYNOLOGY_DEVICE_ID,
CONF_SYNOLOGY_SPACE,
CONF_SYNOLOGY_ALBUM_ID,
CONF_SYNOLOGY_IMAGE_SIZE,
DEFAULT_SYNOLOGY_IMAGE_SIZE,
SYNOLOGY_SPACE_PERSONAL,
SYNOLOGY_SPACE_SHARED,
SYNOLOGY_IMAGE_SMALL,
SYNOLOGY_IMAGE_MEDIUM,
SYNOLOGY_IMAGE_LARGE,
DEFAULT_REVERSE_GEOCODE,
PROVIDER_GOOGLE_SHARED,
PROVIDER_LOCAL_FOLDER,
@@ -61,6 +74,7 @@ from .const import (
PROVIDER_IMMICH,
PROVIDER_PHOTOPRISM,
PROVIDER_ICLOUD,
PROVIDER_SYNOLOGY,
DEFAULT_RECURSIVE,
)
@@ -108,6 +122,13 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
self._pp_password: str | None = None
self._pp_albums: dict[str, str] = {}
self._pp_people: dict[str, str] = {}
# Synology flow state carried between steps.
self._syn_url: str | None = None
self._syn_username: str | None = None
self._syn_password: str | None = None
self._syn_device_id: str | None = None
self._syn_space: str = SYNOLOGY_SPACE_PERSONAL
self._syn_albums: dict[str, str] = {}
@staticmethod
@callback
@@ -143,6 +164,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
return await self.async_step_photoprism()
if self._provider == PROVIDER_ICLOUD:
return await self.async_step_icloud()
if self._provider == PROVIDER_SYNOLOGY:
return await self.async_step_synology()
return await self.async_step_google_shared()
schema = vol.Schema(
@@ -153,6 +176,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
PROVIDER_IMMICH: "Immich (direct API, full metadata)",
PROVIDER_PHOTOPRISM: "PhotoPrism (direct API, full metadata)",
PROVIDER_ICLOUD: "iCloud Shared Album",
PROVIDER_SYNOLOGY: "Synology Photos (direct API, full metadata)",
PROVIDER_MEDIA_SOURCE: "Media Source (any source, no metadata)",
})
}
@@ -651,6 +675,140 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
step_id="icloud", data_schema=schema, errors=errors
)
async def async_step_synology(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Collect the Synology URL + credentials (and optional 2FA code)."""
errors: dict[str, str] = {}
if user_input is not None:
url = user_input[CONF_SYNOLOGY_URL].strip()
username = user_input[CONF_SYNOLOGY_USERNAME].strip()
password = user_input.get(CONF_SYNOLOGY_PASSWORD) or ""
space = user_input.get(CONF_SYNOLOGY_SPACE, SYNOLOGY_SPACE_PERSONAL)
otp = (user_input.get("otp_code") or "").strip()
from . import synology as syn_api
client = syn_api.SynologyClient(
self.hass,
url,
username=username,
password=password,
space=space,
)
try:
await client.async_login(otp_code=otp or None)
albums = await client.async_list_albums()
except syn_api.SynologyOtpRequired:
errors["otp_code"] = "synology_otp_required"
except Exception: # noqa: BLE001 - any failure means bad URL/creds
errors["base"] = "synology_cannot_connect"
else:
self._syn_url = client.base_url
self._syn_username = username
self._syn_password = password
self._syn_space = space
# A trusted-device token is captured only on the OTP login;
# store it so future logins skip the 2FA prompt.
self._syn_device_id = client.captured_device_id
self._syn_albums = {
str(a["id"]): (a.get("name") or str(a["id"]))
for a in albums
if a.get("id") is not None
}
await client.async_logout()
return await self.async_step_synology_select()
schema = vol.Schema(
{
vol.Required(CONF_SYNOLOGY_URL): str,
vol.Required(CONF_SYNOLOGY_USERNAME): str,
vol.Required(CONF_SYNOLOGY_PASSWORD): selector.TextSelector(
selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD)
),
vol.Required(
CONF_SYNOLOGY_SPACE, default=SYNOLOGY_SPACE_PERSONAL
): vol.In(
{
SYNOLOGY_SPACE_PERSONAL: "Personal (My Photos)",
SYNOLOGY_SPACE_SHARED: "Shared Space",
}
),
vol.Optional("otp_code"): str,
}
)
return self.async_show_form(
step_id="synology", data_schema=schema, errors=errors
)
async def async_step_synology_select(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Pick a Synology album (or the whole space) and image size."""
errors: dict[str, str] = {}
if user_input is not None:
name = user_input[CONF_ALBUM_NAME].strip()
size = user_input.get(
CONF_SYNOLOGY_IMAGE_SIZE, DEFAULT_SYNOLOGY_IMAGE_SIZE
)
album_id = user_input.get("album")
if album_id in (None, "", "__all__") or album_id not in self._syn_albums:
album_id = ""
unique = (
f"{DOMAIN}:{PROVIDER_SYNOLOGY}:{self._syn_url}:"
f"{self._syn_space}:{album_id or 'all'}"
)
await self.async_set_unique_id(unique)
self._abort_if_unique_id_configured()
data = {
CONF_PROVIDER: PROVIDER_SYNOLOGY,
CONF_SYNOLOGY_URL: self._syn_url,
CONF_SYNOLOGY_USERNAME: self._syn_username,
CONF_SYNOLOGY_PASSWORD: self._syn_password,
CONF_SYNOLOGY_SPACE: self._syn_space,
CONF_SYNOLOGY_IMAGE_SIZE: size,
CONF_ALBUM_NAME: name,
}
if album_id:
data[CONF_SYNOLOGY_ALBUM_ID] = album_id
if self._syn_device_id:
data[CONF_SYNOLOGY_DEVICE_ID] = self._syn_device_id
return self.async_create_entry(title=name, data=data)
album_options = [
selector.SelectOptionDict(value="__all__", label="All photos in this space")
] + [
selector.SelectOptionDict(value=aid, label=aname)
for aid, aname in self._syn_albums.items()
]
schema = vol.Schema(
{
vol.Required(CONF_ALBUM_NAME): str,
vol.Optional("album", default="__all__"): selector.SelectSelector(
selector.SelectSelectorConfig(
options=album_options,
mode=selector.SelectSelectorMode.DROPDOWN,
custom_value=False,
)
),
vol.Optional(
CONF_SYNOLOGY_IMAGE_SIZE, default=DEFAULT_SYNOLOGY_IMAGE_SIZE
): vol.In(
{
SYNOLOGY_IMAGE_LARGE: "Large (best for slideshow)",
SYNOLOGY_IMAGE_MEDIUM: "Medium",
SYNOLOGY_IMAGE_SMALL: "Small (thumbnail, fastest)",
}
),
}
)
return self.async_show_form(
step_id="synology_select", data_schema=schema, errors=errors
)
class LocalFolderOptionsFlow(config_entries.OptionsFlow):
"""Options for local-folder entries.
@@ -57,6 +57,34 @@ PROVIDER_MEDIA_SOURCE = "media_source"
PROVIDER_IMMICH = "immich"
PROVIDER_PHOTOPRISM = "photoprism"
PROVIDER_ICLOUD = "icloud"
PROVIDER_SYNOLOGY = "synology"
# Synology Photos (direct API) provider. Talks to a DSM Photos package over its
# entry.cgi web API. The account password is stored so the coordinator can
# re-authenticate when the session id expires; accounts with 2FA are handled by
# capturing a trusted-device token during setup (see synology.py).
CONF_SYNOLOGY_URL = "synology_url"
CONF_SYNOLOGY_USERNAME = "synology_username"
CONF_SYNOLOGY_PASSWORD = "synology_password"
CONF_SYNOLOGY_DEVICE_ID = "synology_device_id"
CONF_SYNOLOGY_SPACE = "synology_space"
CONF_SYNOLOGY_ALBUM_ID = "synology_album_id"
CONF_SYNOLOGY_IMAGE_SIZE = "synology_image_size"
# Personal ("My Photos") vs shared ("Shared Space") library.
SYNOLOGY_SPACE_PERSONAL = "personal"
SYNOLOGY_SPACE_SHARED = "shared"
# Native Synology thumbnail sizes. ``xl`` is the largest (best for a slideshow).
SYNOLOGY_IMAGE_SMALL = "sm"
SYNOLOGY_IMAGE_MEDIUM = "m"
SYNOLOGY_IMAGE_LARGE = "xl"
SYNOLOGY_IMAGE_SIZE_OPTIONS = [
SYNOLOGY_IMAGE_SMALL,
SYNOLOGY_IMAGE_MEDIUM,
SYNOLOGY_IMAGE_LARGE,
]
DEFAULT_SYNOLOGY_IMAGE_SIZE = SYNOLOGY_IMAGE_LARGE
# iCloud Shared Album provider. The share token in the pasted link is the only
# credential; no account or password is involved.
@@ -44,6 +44,15 @@ from .const import (
CONF_ICLOUD_TOKEN,
CONF_ICLOUD_IMAGE_SIZE,
DEFAULT_ICLOUD_IMAGE_SIZE,
CONF_SYNOLOGY_URL,
CONF_SYNOLOGY_USERNAME,
CONF_SYNOLOGY_PASSWORD,
CONF_SYNOLOGY_DEVICE_ID,
CONF_SYNOLOGY_SPACE,
CONF_SYNOLOGY_ALBUM_ID,
CONF_SYNOLOGY_IMAGE_SIZE,
DEFAULT_SYNOLOGY_IMAGE_SIZE,
SYNOLOGY_SPACE_PERSONAL,
DEFAULT_REVERSE_GEOCODE,
DOMAIN,
PROVIDER_GOOGLE_SHARED,
@@ -52,6 +61,7 @@ from .const import (
PROVIDER_IMMICH,
PROVIDER_PHOTOPRISM,
PROVIDER_ICLOUD,
PROVIDER_SYNOLOGY,
)
from .store import SlideshowStore
@@ -939,6 +949,8 @@ class AlbumCoordinator(DataUpdateCoordinator):
data = await self._update_photoprism()
elif self.provider == PROVIDER_ICLOUD:
data = await self._update_icloud()
elif self.provider == PROVIDER_SYNOLOGY:
data = await self._update_synology()
else:
raise UpdateFailed(f"Unsupported provider: {self.provider}")
except UpdateFailed:
@@ -1512,6 +1524,87 @@ class AlbumCoordinator(DataUpdateCoordinator):
"items": items,
}
async def _update_synology(self) -> dict[str, Any]:
"""Fetch photos from a Synology Photos library via its web API.
Metadata (capture date, GPS, address, description) is returned inline
with each item, so every ``MediaItem`` is built fully here - there is
no background enrichment pass. Thumbnail URLs carry no SID; the session
cookie is stored on the coordinator and sent server-side by the camera
(like the Immich x-api-key), so the SID never reaches the browser.
"""
from . import synology as syn_api
url = self.entry.data.get(CONF_SYNOLOGY_URL)
username = self.entry.data.get(CONF_SYNOLOGY_USERNAME)
password = self.entry.data.get(CONF_SYNOLOGY_PASSWORD)
device_id = self.entry.data.get(CONF_SYNOLOGY_DEVICE_ID)
space = self.entry.data.get(CONF_SYNOLOGY_SPACE, SYNOLOGY_SPACE_PERSONAL)
album_id = self.entry.data.get(CONF_SYNOLOGY_ALBUM_ID)
size = self.entry.data.get(
CONF_SYNOLOGY_IMAGE_SIZE, DEFAULT_SYNOLOGY_IMAGE_SIZE
)
if not url or not username or not password:
raise UpdateFailed("Synology provider is missing URL or credentials")
client = syn_api.SynologyClient(
self.hass,
url,
username=username,
password=password,
device_id=device_id,
space=space,
)
try:
await client.async_login()
photos = await client.async_collect_assets(album_id or None)
except Exception as err:
raise UpdateFailed(f"Error querying Synology Photos: {err}") from err
if not photos:
await client.async_logout()
raise UpdateFailed("No images found for the selected Synology source")
# Store the session cookie so the camera can fetch thumbnail bytes
# server-side. Do not log out: the SID must stay valid until the next
# refresh re-authenticates.
self.image_request_headers = dict(client.image_headers)
items: list[MediaItem] = []
for p in photos:
ref = syn_api.thumbnail_ref(p)
if not ref:
continue
unit_id, cache_key = ref
meta = syn_api.parse_photo_meta(p)
items.append(
MediaItem(
url=syn_api.build_thumbnail_url(
client.base_url, unit_id, cache_key, size, space
),
width=meta.get("width"),
height=meta.get("height"),
mime_type=None,
filename=p.get("filename"),
captured_at=meta.get("captured_at"),
byte_size=meta.get("byte_size"),
latitude=meta.get("latitude"),
longitude=meta.get("longitude"),
location=meta.get("location"),
description=meta.get("description"),
source_id=str(p.get("id")) if p.get("id") is not None else None,
exif_scanned=True,
)
)
if not items:
raise UpdateFailed("Could not resolve any Synology images")
return {
"title": self.entry.title,
"items": items,
}
async def _enrich_immich_item(self, item: MediaItem) -> None:
"""Fetch one Immich asset's detail and fill location/description."""
from . import immich as immich_api
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"issue_tracker": "https://github.com/eyalgal/album_slideshow/issues",
"requirements": ["Pillow"],
"version": "1.4.0"
"version": "1.5.0"
}
+23 -1
View File
@@ -84,6 +84,26 @@
"icloud_url": "Shared album link",
"icloud_image_size": "Image quality"
}
},
"synology": {
"title": "Synology Photos",
"description": "Connect to the Photos package on your Synology NAS. Enter the DSM address (e.g. http://192.168.1.10:5000 or your HTTPS/QuickConnect URL) and an account. If the account has two-factor authentication, also enter a current 6-digit code once; a trusted-device token is then stored so future refreshes do not need a code. Tip: use a dedicated read-only Photos account rather than an admin login.",
"data": {
"synology_url": "DSM URL",
"synology_username": "Username",
"synology_password": "Password",
"synology_space": "Library",
"otp_code": "2FA code (only if enabled)"
}
},
"synology_select": {
"title": "Synology source",
"description": "Pick an album or show the whole library, and choose the image quality.",
"data": {
"album_name": "Album name",
"album": "Album",
"synology_image_size": "Image quality"
}
}
},
"error": {
@@ -100,7 +120,9 @@
"photoprism_token_required": "Enter an app password, or switch to Username + password.",
"photoprism_user_required": "Enter both a username and password, or switch to App password.",
"invalid_icloud_url": "That does not look like an iCloud Shared Album link.",
"icloud_cannot_connect": "Could not reach that album. Check the link is a current public share."
"icloud_cannot_connect": "Could not reach that album. Check the link is a current public share.",
"synology_cannot_connect": "Could not connect to Synology. Check the URL, username and password.",
"synology_otp_required": "This account needs a two-factor code. Enter a current 6-digit code to continue."
}
},
"options": {
@@ -84,6 +84,26 @@
"icloud_url": "Shared album link",
"icloud_image_size": "Image quality"
}
},
"synology": {
"title": "Synology Photos",
"description": "Connect to the Photos package on your Synology NAS. Enter the DSM address (e.g. http://192.168.1.10:5000 or your HTTPS/QuickConnect URL) and an account. If the account has two-factor authentication, also enter a current 6-digit code once; a trusted-device token is then stored so future refreshes do not need a code. Tip: use a dedicated read-only Photos account rather than an admin login.",
"data": {
"synology_url": "DSM URL",
"synology_username": "Username",
"synology_password": "Password",
"synology_space": "Library",
"otp_code": "2FA code (only if enabled)"
}
},
"synology_select": {
"title": "Synology source",
"description": "Pick an album or show the whole library, and choose the image quality.",
"data": {
"album_name": "Album name",
"album": "Album",
"synology_image_size": "Image quality"
}
}
},
"error": {
@@ -100,7 +120,9 @@
"photoprism_token_required": "Enter an app password, or switch to Username + password.",
"photoprism_user_required": "Enter both a username and password, or switch to App password.",
"invalid_icloud_url": "That does not look like an iCloud Shared Album link.",
"icloud_cannot_connect": "Could not reach that album. Check the link is a current public share."
"icloud_cannot_connect": "Could not reach that album. Check the link is a current public share.",
"synology_cannot_connect": "Could not connect to Synology. Check the URL, username and password.",
"synology_otp_required": "This account needs a two-factor code. Enter a current 6-digit code to continue."
}
},
"options": {
@@ -26,7 +26,7 @@
* tap_action: none # none | more-info
*/
const VERSION = "1.4.0";
const VERSION = "1.5.0";
const ANIMATED_TRANSITIONS = [
"fade",
+2 -1
View File
@@ -820,7 +820,8 @@ class AlexaMediaFlowHandler(config_entries.ConfigFlow):
if CONF_PASSWORD in user_input:
self.config[CONF_PASSWORD] = user_input[CONF_PASSWORD]
if CONF_URL in user_input:
self.config[CONF_URL] = user_input[CONF_URL]
# Remove accidental whitespace introduced by mobile keyboards/paste.
self.config[CONF_URL] = "".join(user_input[CONF_URL].split())
if CONF_PUBLIC_URL in user_input:
if not user_input[CONF_PUBLIC_URL].endswith("/"):
user_input[CONF_PUBLIC_URL] = user_input[CONF_PUBLIC_URL] + "/"
+1 -1
View File
@@ -14,5 +14,5 @@
"wrapt>=1.14.0",
"dictor>=0.1.12,<0.2"
],
"version": "5.15.6"
"version": "5.15.7"
}
+1 -1
View File
@@ -664,7 +664,7 @@ class AlexaMediaNotificationSensor(SensorEntity):
next_item["snoozedToTime"] = None
return value
def _is_active_notification(self, item, now):
def _is_active_notification(self, item, _now):
"""Return whether a notification should be considered active."""
status = item[1].get("status")
if status == "ON":
@@ -3,32 +3,32 @@
"abort": {
"forgot_password": "La page de réinitialisation du mot de passe a été détectée. Cela résulte généralement de trop nombreuses tentatives de connexion échouées. Amazon peut exiger une action avant qu'une nouvelle connexion ne puisse être tentée.",
"login_failed": "Alexa Media Player n'a pas réussi à se connecter.",
"reauth_successful": "Alexa Media Player s'est ré-authentifié avec succès. Veuillez ignorer le message \"Abandonné\" de Home Assistant."
"reauth_successful": "Alexa Media Player s'est ré-authentifié avec succès. Veuillez ignorer le message « Abandonné » de Home Assistant."
},
"error": {
"2fa_key_invalid": "{otp_secret} n'est pas valide",
"connection_error": "Erreur de connexion ; vérifiez le réseau et réessayez",
"connection_error": "Erreur de connexion ; vérifiez le réseau et réessayez",
"identifier_exists": "L'adresse e-mail pour cette URL Alexa est déjà enregistrée",
"invalid_auth": "La connexion a échoué. Veuillez vérifier votre adresse e-mail, votre mot de passe et votre clé d'authentification.",
"invalid_credentials": "Identifiants invalides",
"invalid_url": "L'URL n'est pas valide: {message}",
"invalid_url": "L'URL n'est pas valide : {message}",
"oauth_error": "Impossible de terminer la connexion OAuth. Veuillez réessayer.",
"unable_to_connect_hass_url": "Impossible de se connecter à l'URL locale de Home Assistant. Veuillez vérifier l'URL sous Paramètres > Système > Réseau > URL de Home Assistant > Réseau local",
"unknown_error": "Erreur inconnue : {message}"
"unknown_error": "Erreur inconnue : {message}"
},
"step": {
"proxy_warning": {
"data": {
"proxy_warning": "Ignorer et continuer - Je comprends qu'aucune assistance pour les problèmes de connexion ne sera fournie si je contourne cet avertissement."
},
"description": "Le serveur Home Assistant ne peut pas se connecter à l'URL fournie : {hass_url}.\n> {error}\n\nPour résoudre ce problème, veuillez confirmer que votre navigateur peut atteindre {hass_url}. Ce champ provient de Paramètres > Système > Réseau > URL de Home Assistant.\n\nSi vous êtes **certain** que votre navigateur peut accéder à cette URL, vous pouvez ignorer cet avertissement.",
"description": "Le serveur Home Assistant ne peut pas se connecter à l'URL fournie : {hass_url}.\n> {error}\n\nPour résoudre ce problème, veuillez confirmer que votre navigateur peut atteindre {hass_url}. Ce champ provient de Paramètres > Système > Réseau > URL de Home Assistant.\n\nSi vous êtes **certain** que votre navigateur peut accéder à cette URL, vous pouvez ignorer cet avertissement.",
"title": "Alexa Media Player - Impossible de se connecter à l'URL de HA"
},
"totp_register": {
"data": {
"registered": "Oui, le code OTP a été vérifié"
},
"description": "**{email} - alexa.{url}**\nAvez-vous vérifié le code OTP dans la validation en deux étapes Amazon ?\n> Code OTP : {message}",
"description": "**{email} - alexa.{url}**\nAvez-vous vérifié le code OTP dans la validation en deux étapes Amazon ?\n> Code OTP : {message}",
"title": "Alexa Media Player - Confirmation OTP"
},
"user": {
@@ -46,10 +46,10 @@
"scan_interval": "Intervalle d'interrogation programmé (secondes)",
"securitycode": "Mot de passe à usage unique (OTP)",
"should_get_network": "Découvrir le réseau Alexa",
"url": "Domaine de la région Amazon (ex : amazon.fr)"
"url": "Domaine de la région Amazon (ex : amazon.fr)"
},
"data_description": {
"debug": "Active une journalisation très détaillée, au niveau de la trace, pour un dépannage avancé. \n Non recommandé en fonctionnement normal en raison de l'augmentation du volume des journaux. \n Assurez-vous que le niveau de journalisation est défini sur DEBUG pour obtenir une sortie complète.",
"debug": "Active une journalisation très détaillée, au niveau de la trace, pour un dépannage avancé.\nNon recommandé en fonctionnement normal en raison de l'augmentation du volume des journaux.\nAssurez-vous que le niveau de journalisation est défini sur DEBUG pour obtenir une sortie complète.",
"otp_secret": "Exemple : 35T5 LQSY I5IO 3EFQ LGAJ I6YB JWBY JJPR PYT7 XPPW IDAK SQBJ CVXA"
}
}
@@ -58,7 +58,7 @@
"entity": {
"sensor": {
"air_quality": {
"name": "qualité de l'air"
"name": "Qualité de l'air"
},
"air_quality_carbon_monoxide": {
"name": "Monoxyde de carbone"
@@ -67,7 +67,7 @@
"name": "Humidité"
},
"air_quality_indoor_air_quality": {
"name": "qualité de l'air intérieur"
"name": "Qualité de l'air intérieur"
},
"air_quality_particulate_matter": {
"name": "Matières particulaires"
@@ -82,7 +82,7 @@
"name": "Prochain rappel"
},
"next_timer": {
"name": "La prochaine fois"
"name": "Prochain minuteur"
},
"temperature": {
"name": "Température"
@@ -117,11 +117,11 @@
"otp_secret": "Clé d'authentification à 52 caractères pour Amazon 2SV",
"public_url": "URL publique partagée avec les services externes hébergés",
"queue_delay": "Délai pour regrouper plusieurs commandes (secondes)",
"scan_interval": "Fréquence d'interrogation programmée (secondes)",
"scan_interval": "Intervalle d'interrogation programmé (secondes)",
"should_get_network": "Découvrir le réseau Alexa"
},
"data_description": {
"debug": "Active une journalisation très détaillée, au niveau de la trace, pour un dépannage avancé. \n Non recommandé en fonctionnement normal en raison de l'augmentation du volume des journaux. \n Assurez-vous que le niveau de journalisation est défini sur DEBUG pour obtenir une sortie complète.",
"debug": "Active une journalisation très détaillée, au niveau de la trace, pour un dépannage avancé.\nNon recommandé en fonctionnement normal en raison de l'augmentation du volume des journaux.\nAssurez-vous que le niveau de journalisation est défini sur DEBUG pour obtenir une sortie complète.",
"otp_secret": "Exemple : 35T5 LQSY I5IO 3EFQ LGAJ I6YB JWBY JJPR PYT7 XPPW IDAK SQBJ CVXA"
},
"description": "* Champs obligatoires",
@@ -135,7 +135,7 @@
"fields": {
"email": {
"description": "Adresse e-mail du compte Alexa ou liste d'adresses (facultatif). Si vide, tous les comptes connus seront actualisés.",
"name": "Adresse email"
"name": "Adresse e-mail"
}
},
"name": "Activer la découverte du réseau"
@@ -155,7 +155,7 @@
"fields": {
"entity_id": {
"description": "Entité pour laquelle obtenir l'historique",
"name": "Sélectionner le lecteur multimédia:"
"name": "Sélectionner le lecteur multimédia :"
},
"entries": {
"description": "Nombre d'entrées à récupérer",
@@ -169,7 +169,7 @@
"fields": {
"entity_id": {
"description": "Entité sur laquelle restaurer le niveau de volume précédent",
"name": "Sélectionner le lecteur multimédia:"
"name": "Sélectionner le lecteur multimédia :"
}
},
"name": "Restaurer le volume précédent"
+2 -1
View File
@@ -1000,7 +1000,8 @@ def _is_path_allowed_for_read(
Allowed:
- Files directly in config dir: configuration.yaml, automations.yaml, etc.
- Files in allowed directories: www/, themes/, custom_templates/
- Files in allowed directories: www/, themes/, custom_templates/,
dashboards/, blueprints/ (blueprints is read-only — issue #1965)
- Files matching patterns: <packages-folder>/*.yaml, custom_components/**/*.py
- User-configured extra directories (``extra_dirs``), granted read+write
(issue #1567)
+12 -3
View File
@@ -24,7 +24,7 @@ DOMAIN = "ha_mcp_tools"
# manifest bump that forgets this constant (or vice-versa) fails in CI. The
# capability negotiation — not this version — gates each WS command (see
# ``websocket_api.CAPABILITIES``).
COMPONENT_VERSION = "1.2.2"
COMPONENT_VERSION = "1.2.3"
# Config-entry discriminator (``entry.data[CONF_ENTRY_TYPE]``). A missing value
# means "tools" so the pre-existing services entry keeps working across the
@@ -40,8 +40,17 @@ TOOLS_ENTRY_TITLE = "HA-MCP File & YAML Tools"
TOOLS_ENTRY_LEGACY_TITLE = "HA MCP Tools"
MIN_EMBEDDED_HOME_ASSISTANT_VERSION = "2026.6.0"
# Allowed directories for file operations (relative to config dir)
ALLOWED_READ_DIRS = ["www", "themes", "custom_templates", "dashboards"]
# Allowed directories for file operations (relative to config dir).
# "blueprints" is read-only BY DEFAULT — in ALLOWED_READ_DIRS but not
# ALLOWED_WRITE_DIRS, so ha_write_file / ha_delete_file reject it (raw blueprint
# reads are safe: community YAML, no secrets — issue #1965). This is the default
# allowlist, not an absolute guarantee: an admin who adds "blueprints" as a
# custom extra directory (issue #1567, see _current_extra_dirs) grants it
# read+write, since extra_dirs are honored on the write path too. Blueprint
# writes should instead go through ha_import_blueprint (which invokes the
# blueprint/save WS command internally). Prefer ha_get_blueprint for the parsed
# body; raw read is the escape hatch for the exact on-disk text.
ALLOWED_READ_DIRS = ["www", "themes", "custom_templates", "dashboards", "blueprints"]
ALLOWED_WRITE_DIRS = ["www", "themes", "custom_templates", "dashboards"]
# NON-OVERRIDABLE deny floor for the user-configurable extra read/write
@@ -1568,6 +1568,64 @@ _PENDING_INSTALL_DONE: threading.Event | None = None
_CACHED_IMPORT_VERSION: str | None = None
def _safe_invalidate_caches() -> None:
"""Run ``importlib.invalidate_caches()``, completing it if a finder breaks.
On Python 3.14 with ``homeassistant`` installed as a setuptools *editable*
package (the official HA container image), ``PathFinder.invalidate_caches()``
raises ``KeyError`` at its ``del sys.path_importer_cache[name]`` line: the
synthetic ``__editable__.<dist>.finder.__path_hook__`` placeholder is not an
absolute path, so CPython takes the ``del`` branch on a key an earlier
iteration already removed a CPython 3.14 bug (the ``del`` should be a
``pop``). That aborts ``importlib.invalidate_caches()`` partway and, before
this guard, crashed in-process server bring-up on every boot (issues #1891,
#1985).
On that ``KeyError`` we do CPython's own cleanup ourselves — prune the dead /
relative ``sys.path_importer_cache`` entries with ``pop`` instead of the
buggy ``del`` (the stale placeholder that trips the sweep is among them)
then re-run ``importlib.invalidate_caches()``. With the offending entries
gone the retry completes CPython's full sweep itself: every live path-entry
finder invalidated, the namespace-path epoch advanced, and the metadata
finder refreshed. Delegating the second pass keeps us off private internals
(no ``_NamespacePath`` / ``_path_isabs`` poking) and faithful to whatever the
running Python's ``invalidate_caches`` does. Recovery only ever runs on the
broken 3.14 path (the top-level call succeeds everywhere else); every
non-``KeyError`` still propagates.
The retry is itself guarded: a concurrent import on another HA-core thread
could re-add a stale placeholder in the window between the prune and the
retry, so a *second* ``KeyError`` is tolerated (logged, best-effort) rather
than re-raised a partial cache refresh must never re-crash bring-up, which
is the whole point of this helper. The recovery is logged at WARNING (it
recurs on every version check on an affected install), so it is visible for
diagnosis rather than a silent workaround.
"""
try:
importlib.invalidate_caches()
return
except KeyError as err:
_LOGGER.warning(
"importlib.invalidate_caches() raised KeyError from a broken "
"(setuptools editable / Python 3.14) finder; pruning stale "
"sys.path_importer_cache entries and retrying: %s",
err,
)
for name in list(sys.path_importer_cache):
if sys.path_importer_cache.get(name) is None or not os.path.isabs(name):
sys.path_importer_cache.pop(name, None)
try:
importlib.invalidate_caches()
except KeyError as err:
_LOGGER.warning(
"importlib.invalidate_caches() still raised KeyError after pruning "
"stale sys.path_importer_cache entries; continuing with a best-effort "
"cache state (a concurrent import may have re-added the placeholder): "
"%s",
err,
)
def _purge_ha_mcp_modules() -> None:
"""Drop every cached ``ha_mcp`` module so the next import loads fresh code.
@@ -1595,7 +1653,7 @@ def _purge_ha_mcp_modules() -> None:
return
for name in purged:
sys.modules.pop(name, None)
importlib.invalidate_caches()
_safe_invalidate_caches()
_LOGGER.debug("Purged %d cached ha_mcp module(s) before worker start", len(purged))
@@ -1608,7 +1666,7 @@ def _installed_ha_mcp_version(preferred_dist: str | None = None) -> str | None:
provided, checks that channel first so stale metadata from a failed
best-effort conflicting uninstall cannot mask the package just installed.
"""
importlib.invalidate_caches()
_safe_invalidate_caches()
# Metadata alone is not proof: a channel switch's best-effort uninstall
# can leave ORPHANED .dist-info whose files are gone (the shared ha_mcp/
# tree belongs to whichever dist installed last). Require the import
@@ -1631,7 +1689,7 @@ def _dist_installed(dist_name: str) -> bool:
Invalidates the import caches first so a just-completed (un)install is seen.
"""
importlib.invalidate_caches()
_safe_invalidate_caches()
try:
importlib.metadata.version(dist_name)
except importlib.metadata.PackageNotFoundError:
@@ -1648,7 +1706,7 @@ def _installed_dist_version(dist_name: str) -> str | None:
the auto-update check compares the newest PyPI build against the version of
the channel actually installed.
"""
importlib.invalidate_caches()
_safe_invalidate_caches()
try:
return importlib.metadata.version(dist_name)
except importlib.metadata.PackageNotFoundError:
+1 -1
View File
@@ -22,5 +22,5 @@
"requirements": [
"ruamel.yaml>=0.18.0"
],
"version": "1.2.2"
"version": "1.2.3"
}
+29 -3
View File
@@ -486,6 +486,13 @@ def _register_metadata_views(hass: HomeAssistant) -> None:
"""
if hass.data.get(_OAUTH_VIEWS_REGISTERED_KEY):
return
# Set the flag only AFTER every view registers (issue #1978): it must mean
# "the full bundle is bound", so a partial bind stays distinguishable from a
# complete one. Marking it bound early would let a later setup assign a
# provider and advertise discovery while some RFC metadata routes are still
# unbound — a 404 for the clients that probe them. On a partial bind the flag
# stays unset; the none-mode caller then fails open (the retry's duplicate
# register is caught harmlessly) while ha_auth/legacy fail closed.
for view in _metadata_views(hass):
hass.http.register_view(view)
hass.data[_OAUTH_VIEWS_REGISTERED_KEY] = True
@@ -735,9 +742,28 @@ async def async_register_webhook(
# login (issue #1969). Both view bundles bind at most once per
# HA session; the per-request resolvers gate them on this cfg,
# so a none<->ha_auth switch needs no restart.
_register_metadata_views(hass)
bind_autoapprove_views(hass)
cfg[CFG_AUTOAPPROVE_PROVIDER] = AutoApproveProvider()
#
# Fails OPEN, unlike ha_auth/legacy (issue #1978): none mode is
# intentionally unauthenticated, and this discovery is an
# enhancement layered on a webhook that (already registered
# above) otherwise always forwards. A failure here must NOT fall
# through to the outer teardown and take down a webhook the user
# configured to need no auth — it only means claude.ai's rare
# OAuth-discovery fallback goes unassisted. Mirrors the add-on's
# _setup_none_autoapprove. Provider is assigned last, so a
# partial bind leaves none-autoapprove inactive (plain proxy)
# rather than half-enabled.
try:
_register_metadata_views(hass)
bind_autoapprove_views(hass)
cfg[CFG_AUTOAPPROVE_PROVIDER] = AutoApproveProvider()
except Exception:
_LOGGER.exception(
"MCP webhook: failed to set up none-mode auto-approve "
"discovery; continuing as a plain unauthenticated proxy "
"(the webhook still forwards — only claude.ai's rare "
"OAuth-discovery fallback is unassisted)."
)
except Exception:
# Never leave a live endpoint (or a leaked session) behind a failed
# auth-setup path. suppress: the ORIGINAL error must be what
@@ -185,6 +185,18 @@ class AutoApproveAuthorizeView(HomeAssistantView):
open-redirect gate, then issues a PKCE-bound one-time code and redirects
straight back to the client. No login page and no consent screen render, so
claude.ai's OAuth flow completes invisibly (issue #1969).
ACCEPTED RISK (issue #1978): this endpoint is anonymous by design — none
mode requires zero HA login so it consults neither the webhook id nor a
client identity. Anyone who knows the HA origin can therefore fill the
shared pending-code store (``MAX_PENDING_CODES``) with S256 challenges bound
to the public claude.ai callback, at which point a *brand-new* connector's
handshake gets ``temporarily_unavailable`` until those codes expire
(``AUTH_CODE_TTL``, 5 min). Accepted because it is self-healing, exposes no
data, and grants no access: completing the flow needs the PKCE verifier the
attacker never has, and the issued token is cosmetic (none mode ignores
bearers). The webhook URL itself keeps forwarding throughout only the rare
OAuth-discovery fallback for a *first* connect is briefly delayed.
"""
requires_auth = False
@@ -298,6 +310,11 @@ def bind_autoapprove_views(hass: HomeAssistant) -> None:
"""
if hass.data.get(_AUTOAPPROVE_VIEWS_REGISTERED_KEY):
return
# Set the flag only AFTER both views register (issue #1978): see
# mcp_webhook._register_metadata_views. Marking the bundle bound before
# /token registers would let a later none-mode setup assign the provider and
# advertise OAuth with an unbound /token — a 404 on the token exchange. The
# flag must mean the full bundle succeeded; a partial bind leaves it unset.
hass.http.register_view(AutoApproveAuthorizeView(hass))
hass.http.register_view(AutoApproveTokenView(hass))
hass.data[_AUTOAPPROVE_VIEWS_REGISTERED_KEY] = True
@@ -436,7 +436,11 @@ class PKCECodeStore:
Returns True only for a live, unexpired code whose stored
``redirect_uri`` matches and whose ``code_challenge`` equals
``base64url(SHA-256(code_verifier))``. The code is popped (one-shot)
before any check that can fail, so a failed attempt still burns it.
once the verifier clears the cheap RFC 7636 shape guard below, so every
well-formed attempt burns it a wrong verifier, expired code, or
redirect mismatch still consumes the code. A malformed verifier is
rejected before the pop and does NOT burn the code, so a client that
sends a syntactically broken verifier can retry.
"""
# Validate the verifier shape per RFC 7636 §4.1 before doing any
# crypto. A confused client passing an empty/short verifier should be
+12 -8
View File
@@ -58,7 +58,10 @@ list_files:
fields:
path:
name: Path
description: Relative path from config directory (e.g., "www/", "themes/")
description: >-
Relative path from config directory. Allowed: www/, themes/,
custom_templates/, dashboards/, blueprints/, plus your configured
packages/ folder (e.g., "www/", "blueprints/automation").
required: true
example: "www/"
selector:
@@ -81,7 +84,8 @@ read_file:
Relative path from config directory. Allowed paths include
configuration.yaml, automations.yaml, scripts.yaml, scenes.yaml,
secrets.yaml (values masked), home-assistant.log, www/**, themes/**,
custom_templates/**, packages/*.yaml, custom_components/**/*.py
custom_templates/**, dashboards/**, blueprints/**, packages/*.yaml,
custom_components/**/*.py
required: true
example: "configuration.yaml"
selector:
@@ -119,13 +123,13 @@ read_file:
write_file:
name: Write File
description: Write a file to allowed directories (www/, themes/, custom_templates/).
description: Write a file to allowed directories (www/, themes/, custom_templates/, dashboards/).
fields:
path:
name: Path
description: >-
Relative path from config directory. Must be in www/, themes/, or
custom_templates/.
Relative path from config directory. Must be in www/, themes/,
custom_templates/, or dashboards/.
required: true
example: "www/custom.css"
selector:
@@ -155,13 +159,13 @@ write_file:
delete_file:
name: Delete File
description: Delete a file from allowed directories (www/, themes/, custom_templates/).
description: Delete a file from allowed directories (www/, themes/, custom_templates/, dashboards/).
fields:
path:
name: Path
description: >-
Relative path from config directory. Must be in www/, themes/, or
custom_templates/.
Relative path from config directory. Must be in www/, themes/,
custom_templates/, or dashboards/.
required: true
example: "www/old-file.css"
selector:
+15
View File
@@ -18,6 +18,7 @@
from __future__ import annotations
import asyncio
import json
import logging
from pathlib import Path
@@ -1035,6 +1036,20 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
manager = hass.data[DOMAIN].pop(entry.entry_id)
await manager.async_shutdown()
# Settle registry: mark any still-RUNNING tasks for this entry as
# cancelled so they don't appear as zombies after reload.
from . import task_registry as _task_registry
_cancelled_tasks = _task_registry.get_registry(hass).cancel_entry_tasks(entry.entry_id)
# Drain cancelled WS-spawned tasks so their finally blocks (lock releases)
# complete before we remove the write lock below.
if _cancelled_tasks:
await asyncio.gather(*_cancelled_tasks, return_exceptions=True)
# Release the per-entry write lock so it doesn't block the next setup.
from .ws_api import _WS_WRITE_LOCKS_KEY
hass.data.get(_WS_WRITE_LOCKS_KEY, {}).pop(entry.entry_id, None)
# When the last WashData entry is removed, tear down the shared panel/sidebar
# so no stale registration flags or sidebar entry linger.
if not hass.data.get(DOMAIN):
+11 -4
View File
@@ -67,6 +67,10 @@ def find_best_alignment(
n_curr = len(curr)
n_ref = len(ref)
# Guard: cross-correlation crashes on empty or single-element arrays.
if n_curr < 2 or n_ref < 2:
return 0.0, {"corr": 0.0, "mae_score": 0.0}, 0
# 1. Coarse Alignment (Cross-Correlation)
# Downsample for speed if arrays are large
ds_factor = 1
@@ -251,11 +255,12 @@ def _dtw_component_score(
band: float,
derivative: bool,
scale: float,
curr_resampled: np.ndarray | None = None,
) -> float:
"""DTW similarity in [0,1] for one candidate: resample both series to a
common grid, warp (level or derivative), and express the distance relative
to the current peak (behaviour-neutral at MATCH_MAE_REF_PEAK)."""
a = _resample_to(curr_arr, MATCH_DTW_RESAMPLE_N)
a = curr_resampled if curr_resampled is not None else _resample_to(curr_arr, MATCH_DTW_RESAMPLE_N)
b = _resample_to(sample_arr, MATCH_DTW_RESAMPLE_N)
dtw_dist = compute_dtw_lite(a, b, band_width_ratio=band, derivative=derivative)
norm_dist = dtw_dist / MATCH_DTW_RESAMPLE_N
@@ -326,6 +331,8 @@ def compute_matches_worker(
l1_scale = float(config.get("dtw_l1_scale", MATCH_DTW_DIST_SCALE))
ddtw_scale = float(config.get("dtw_ddtw_scale", MATCH_DDTW_DIST_SCALE))
ensemble_w = float(config.get("dtw_ensemble_w", MATCH_DTW_ENSEMBLE_W))
# Resample the current trace once — it's the same for every candidate.
curr_resampled = _resample_to(curr_arr, MATCH_DTW_RESAMPLE_N)
for cand in to_refine:
sample_arr = np.array(cand["sample"])
@@ -340,8 +347,8 @@ def compute_matches_worker(
elif dtw_mode == "ensemble":
# Blend the level-based (L1) and shape-based (derivative) DTW
# scores; they are complementary signals.
s_l1 = _dtw_component_score(curr_arr, sample_arr, current_peak, dtw_bandwidth, False, l1_scale)
s_dd = _dtw_component_score(curr_arr, sample_arr, current_peak, dtw_bandwidth, True, ddtw_scale)
s_l1 = _dtw_component_score(curr_arr, sample_arr, current_peak, dtw_bandwidth, False, l1_scale, curr_resampled=curr_resampled)
s_dd = _dtw_component_score(curr_arr, sample_arr, current_peak, dtw_bandwidth, True, ddtw_scale, curr_resampled=curr_resampled)
dtw_score = ensemble_w * s_l1 + (1.0 - ensemble_w) * s_dd
norm_dist = 0.0 # composite; per-component distance not meaningful
else:
@@ -352,7 +359,7 @@ def compute_matches_worker(
use_deriv = dtw_mode == "ddtw"
scale = ddtw_scale if use_deriv else l1_scale
dtw_score = _dtw_component_score(
curr_arr, sample_arr, current_peak, dtw_bandwidth, use_deriv, scale
curr_arr, sample_arr, current_peak, dtw_bandwidth, use_deriv, scale, curr_resampled=curr_resampled
)
norm_dist = 0.0
+103
View File
@@ -297,6 +297,27 @@ MATCH_RANKING_HISTORY_MAX = 500
# hard limit: this only surfaces "running longer than usual" for the UI. Kept
# below the zombie threshold so it lights up well before any termination.
CYCLE_OVERRUN_ANOMALY_RATIO = 1.5
# Duration-anchored hard-finalize backstop in the ENDING state. Smart Termination
# is (deliberately) gated on a confident, non-ambiguous match; an ambiguous /
# prefix-ambiguous match therefore skips it and relies on the power+energy fallback
# timeout, which a low standby baseline (below stop_threshold but energetic enough
# to trip the energy gate) can hold open until the 8 h cap / zombie-kill — the
# #296/#311 "finishes hours/1000+ min late" reports. This SEPARATE backstop
# finalizes a matched cycle that has sat in ENDING well past its expected duration
# AND been genuinely quiet (below stop_threshold) for a sustained span. It is
# asymmetric (can only ever SHORTEN a stuck wait, never end a cycle early) and the
# sustained-quiet guard is what keeps it from truncating a longer program that was
# mismatched to a shorter profile — a real longer program has high-power phases
# that keep resetting the below-threshold timer, so it never accumulates the
# required continuous quiet. Sits between CYCLE_OVERRUN_ANOMALY_RATIO (1.5, soft
# visible signal) and the manager's 3x zombie-kill, so it fires well after any
# legitimate overrun but well before the hard kill.
ENDING_HARD_FINALIZE_RATIO = 2.0
ENDING_HARD_FINALIZE_MIN_QUIET_S = 600.0 # continuous sub-threshold span floor
# NOTE: STANDBY_BAND_* constants live further down, after the DEVICE_TYPE_*
# definitions they reference (search "Standby-band stuck-in-RUNNING finalize").
# Underrun anomaly: a cycle that finishes in less than this fraction of its
# matched profile's median duration is flagged "underrun" (post-cycle only,
# never a live signal — computed in _async_process_cycle_end after the cycle
@@ -536,6 +557,68 @@ DEVICE_TYPES = {
DEVICE_TYPE_OTHER: "Threshold Device",
}
# Standby-band stuck-in-RUNNING finalize (#296). Some appliances finish but hold
# a small, flat "anti-crease" / display standby draw that sits ABOVE stop_threshold_w
# (e.g. a ~2.5-3.2 W baseline while stop_threshold is ~1.2 W). Because power never
# drops below the stop threshold, _time_below_threshold never accumulates, so the
# cycle never reaches PAUSED/ENDING and runs until the 8 h RUNNING cap — and the
# anti-wrinkle handler can't help because entering it requires a completed
# TIMEOUT/SMART finish that never happens (chicken-and-egg). This detector spots a
# sustained, FLAT, tiny-fraction-of-peak plateau *past* the expected duration and
# finalizes the cycle (as a normal TIMEOUT completion, so an anti-wrinkle-enabled
# washer/dryer still routes into ANTI_WRINKLE afterwards). Heavily gated so it can
# never end an active low-power phase: it needs a matched profile, elapsed >= 2x
# expected, and the whole recent window flat + below a small fraction of the cycle's
# own peak. Restricted to wet appliances where a stuck baseline is unambiguously
# anomalous (bread-maker keep-warm, pump, air-fryer etc. have legitimate holds and
# are excluded).
STANDBY_BAND_FINALIZE_DEVICE_TYPES = (
DEVICE_TYPE_WASHING_MACHINE,
DEVICE_TYPE_WASHER_DRYER,
DEVICE_TYPE_DRYER,
)
STANDBY_BAND_MIN_RATIO = 2.0 # only past 2x the expected duration
STANDBY_BAND_WINDOW_S = 600.0 # require a >=10 min flat plateau
STANDBY_BAND_MAX_FRACTION = 0.10 # plateau level <= 10% of the cycle's peak
STANDBY_BAND_FLATNESS_FRACTION = 0.03 # window (max-min) <= 3% of the cycle's peak
STANDBY_BAND_FLATNESS_FLOOR_W = 2.0 # absolute flatness floor for low-peak devices
# Issue #296 follow-up: anti-crease ("Knitterschutz") back-to-back handling.
#
# After a wash finishes, some machines (e.g. Miele) hold a low-power tumble tail -
# a constant baseline plus periodic sub-``anti_wrinkle_max_power`` bursts, NO
# heating - until the door is opened. To the power-off detector this looks like
# continued RUNNING (the bursts recur faster than off_delay and keep reviving the
# cycle out of ENDING), so the cycle never finalises into STATE_ANTI_WRINKLE - the
# state that is designed to absorb the tail and split off the next wash. If a
# second load is started before the door is opened, the whole sequence
# (wash -> tail -> wash -> tail) merges into one multi-hour "cycle".
#
# Two coordinated mechanisms fix it, BOTH opt-in via ``anti_wrinkle_enabled`` and
# gated to the anti-wrinkle device types (see ``anti_wrinkle_active`` in
# cycle_detector):
#
# * a proactive finalise (``_is_anticrease_tail`` -> Smart Termination into
# ANTI_WRINKLE): once a *matched* cycle is past its expected duration AND the
# recent window shows only the low-power tail, finalise without waiting to reach
# ENDING through the burst-defeated off_delay path;
# * a match freeze (``_try_profile_match`` guard) under the SAME condition, so
# re-matching on the growing flat tail cannot drift the label to a longer
# near-duplicate profile (which would push expected_duration out and break the
# finalise gate) - the field failure that breaks Smart Termination.
#
# Gated on ``elapsed >= expected * ratio`` so a legitimate mid-wash low-power phase
# can NEVER trigger it: a washer spends most of its cycle below
# ``anti_wrinkle_max_power`` (only brief heating spikes exceed it), but every
# observed clean cycle's mid-cycle sub-max_power gap ENDS well before its expected
# duration, while the anti-crease tail BEGINS after it. Also requires a genuinely
# energetic cycle (peak above ``anti_wrinkle_max_power``) so a low-power program
# that never heats is left alone. Asymmetric (finalise-only, can only shorten the
# wait) and self-correcting (a new wash's heating burst leaves the regime and
# re-arms matching).
ANTI_CREASE_FINALIZE_RATIO = 0.98 # elapsed must reach 98% of expected duration
ANTI_CREASE_CONFIRM_WINDOW_S = 180.0 # recent window that must hold no reading > max_power
# Device Type Defaults
# Device Type Defaults (Maps)
@@ -618,6 +701,26 @@ DISHWASHER_END_SPIKE_QUIET_RELEASE_SECONDS = 600.0
# a shorter window can never fire it mid-cycle.
DISHWASHER_SMART_TERMINATION_DEBOUNCE_SECONDS = 300.0
# Sustained "true off" (power below stop_threshold_w) window that cancels a
# user-paused STARTING state back to OFF. A user pause during STARTING is held
# indefinitely (issue #306) waiting for Resume Cycle, but a genuinely paused
# appliance keeps standby power above the stop threshold; sustained power below it
# means the machine was switched off, so without this the detector could stay
# pinned in STARTING forever. Generous enough not to abort a real pause whose
# standby briefly dips (and well above the issue-#306 test's 10 s hold), while
# bounding the pinned-forever case.
STARTING_PAUSED_TRUE_OFF_TIMEOUT_SECONDS = 300.0
# Upper bound on the washer / washer-dryer Smart-Termination debounce, which is
# otherwise derived as max(180, min_off_gap * 0.5). At the shipped defaults this
# is 240 s (washing machine, min_off_gap 480) / 300 s (washer-dryer, min_off_gap
# 600) and the cap never bites. It only bounds the case where a suggested or
# hand-set min_off_gap (e.g. 1800 s) would inflate the quiet-time requirement to
# 15 min, starving end-detection for confident non-ambiguous matches the same way
# the old dishwasher off_delay*0.25 coupling did. 600 s leaves ample headroom for
# a washer's longest legitimate mid-cycle soak trough while capping the pathology.
WASHER_SMART_TERMINATION_DEBOUNCE_MAX_SECONDS = 600.0
DEFAULT_OFF_DELAY_BY_DEVICE = {
DEVICE_TYPE_DISHWASHER: 1800, # 30 min (Drying)
DEVICE_TYPE_BREAD_MAKER: 300, # 5 min (Keep-warm phase after baking)
+517 -9
View File
@@ -18,6 +18,7 @@
from __future__ import annotations
import itertools
import logging
import math
from dataclasses import dataclass
@@ -51,9 +52,21 @@ from .const import (
DISHWASHER_END_SPIKE_QUIET_RELEASE_SECONDS,
DISHWASHER_END_SPIKE_WAIT_SECONDS,
DISHWASHER_SMART_TERMINATION_DEBOUNCE_SECONDS,
WASHER_SMART_TERMINATION_DEBOUNCE_MAX_SECONDS,
STARTING_PAUSED_TRUE_OFF_TIMEOUT_SECONDS,
DISHWASHER_MATCH_FREEZE_QUIET_SECONDS,
DISHWASHER_MIN_CYCLE_DURATION_S,
TERMINAL_DROP_OFF_DELAY_SECONDS,
ENDING_HARD_FINALIZE_RATIO,
ENDING_HARD_FINALIZE_MIN_QUIET_S,
STANDBY_BAND_FINALIZE_DEVICE_TYPES,
STANDBY_BAND_MIN_RATIO,
STANDBY_BAND_WINDOW_S,
STANDBY_BAND_MAX_FRACTION,
STANDBY_BAND_FLATNESS_FRACTION,
STANDBY_BAND_FLATNESS_FLOOR_W,
ANTI_CREASE_FINALIZE_RATIO,
ANTI_CREASE_CONFIRM_WINDOW_S,
)
# The dishwasher end-spike wait window is shared between two code paths
@@ -312,6 +325,11 @@ class CycleDetector:
# OFF only when the machine has clearly been switched off rather
# than briefly dipped.
self._delay_wait_true_off_seconds: float = 0.0
# _starting_paused_off_since anchors the first below-stop reading while
# a user-paused STARTING state is held (issue #306). Elapsed time is
# measured from this anchor, not accumulated per-dt, so a single large
# (but sub-outage) interval cannot prematurely credit minutes of quiet time.
self._starting_paused_off_since: datetime | None = None
# _delay_wait_high_start anchors the first high-power reading
# observed inside DELAY_WAIT. We only transition to STARTING
# when the high-power streak has lasted at least
@@ -383,6 +401,18 @@ class CycleDetector:
):
return
# Terminal-tail match freeze (anti-crease, #296): once a washer/dryer with
# anti-wrinkle enabled is past its expected duration and has settled into
# the low-power tumble tail, re-matching on the growing flat tail drifts the
# label toward a LONGER near-duplicate (its expected duration grows), which
# pushes the anti-crease finalize gate out and breaks Smart Termination -
# the field failure that merges back-to-back washes. Keep the good
# pre-tail match instead. Self-correcting: a new wash's heating burst above
# anti_wrinkle_max_power leaves the tail regime, so this stops applying and
# matching re-arms for the next cycle.
if self._matched_profile and self._in_anticrease_freeze(timestamp):
return
# Rate limiting
if not force and self._last_match_time:
elapsed = (timestamp - self._last_match_time).total_seconds()
@@ -459,6 +489,23 @@ class CycleDetector:
Can be called by the matcher callback directly or asynchronously.
"""
# Terminal-tail match freeze (anti-crease, #296). This is the single sink
# for ALL match updates - the detector's own _try_profile_match AND the
# manager's async 5-min matcher (manager.py calls update_match directly).
# Once a washer/dryer with anti-wrinkle enabled is past its expected
# duration and has settled into the low-power tumble tail, re-matching on
# the growing flat tail drifts the label toward a LONGER near-duplicate (or
# flips it ambiguous), which pushes out expected_duration and would block
# the anti-crease finalize - the field failure that merges back-to-back
# washes. Keep the good pre-tail match instead. Self-correcting: a new
# wash's heating burst above anti_wrinkle_max_power leaves the tail regime,
# so this stops applying and matching re-arms for the next cycle.
if (
self._matched_profile
and self._power_readings
and self._in_anticrease_freeze(self._power_readings[-1][0])
):
return
# Unpack 5 elements (or 4 for backward compatibility if needed, but wrapper is updated)
# wrapper returns (name, confidence, duration, phase, is_mismatch)
# Or MatchResult object if refactored, but currently wrapper returns tuple.
@@ -582,6 +629,13 @@ class CycleDetector:
self._time_below_threshold = 0.0
self._last_match_time = None
self._matched_profile = None
# Clear stale match state so the next cycle starts with clean defaults.
# _expected_duration left at 0 tells the dishwasher end-spike gate that
# no profile is matched yet; stale non-zero would mis-gate the spike check.
self._expected_duration = 0.0
self._last_match_confidence = 0.0
self._match_ambiguous = False
self._match_prefix_ambiguous = False
self._ignore_power_until_idle = False # Reset lockout
self._lockout_high_seconds = 0.0
# Clear the verified-pause flag so it can't leak into the next cycle (B6):
@@ -597,6 +651,7 @@ class CycleDetector:
self._delay_band_seconds = 0.0
self._delay_band_peak = 0.0
self._delay_wait_true_off_seconds = 0.0
self._starting_paused_off_since = None
self._delay_wait_high_start = None
@property
@@ -972,6 +1027,10 @@ class CycleDetector:
self._power_readings.append((timestamp, power))
self._cycle_max_power = max(self._cycle_max_power, power)
if is_high:
# Power back up - clear any accumulated "true off" hold time.
self._starting_paused_off_since = None
if self._time_above_threshold >= self._config.start_duration_threshold:
if self._energy_since_idle_wh >= self._config.start_energy_threshold:
self._transition_to(STATE_RUNNING, timestamp)
@@ -982,7 +1041,41 @@ class CycleDetector:
# that the low power is intentional, not a false start.
if not is_high and self._time_below_threshold > 1.0: # 1s grace period
if getattr(self, "_verified_pause", False):
pass # user pause holds; wait for Resume Cycle
# User pause holds; wait for Resume Cycle (issue #306). But a
# genuinely paused appliance keeps standby power above the stop
# threshold - sustained power *below* it means the machine was
# switched off, so fall back to OFF rather than pinning STARTING
# forever.
if power < self._config.stop_threshold_w:
# An outage-sized gap since the last reading is NOT observed
# quiet (the machine may still be paused, we just lost
# telemetry): reset anchor so only genuinely-sampled
# sustained-off time can cancel a paused STARTING.
if dt > self._outage_threshold_s():
self._starting_paused_off_since = None
elif self._starting_paused_off_since is None:
# First below-stop reading: anchor the timestamp.
# Don't credit the preceding dt interval — we only
# know the device is off *now*, not how long before
# this sample it went quiet.
self._starting_paused_off_since = timestamp
observed_off_s = (
(timestamp - self._starting_paused_off_since).total_seconds()
if self._starting_paused_off_since is not None
else 0.0
)
if observed_off_s >= STARTING_PAUSED_TRUE_OFF_TIMEOUT_SECONDS:
self._logger.info(
"Paused STARTING cancelled: power off (%.1fW) for "
"%.0fs → OFF",
power,
observed_off_s,
)
self._transition_to(STATE_OFF, timestamp)
return
else:
# Power recovered — clear the off anchor.
self._starting_paused_off_since = None
else:
# False start
self._logger.debug(
@@ -999,6 +1092,13 @@ class CycleDetector:
self._power_readings.append((timestamp, power))
self._cycle_max_power = max(self._cycle_max_power, power)
# Anti-crease finalize (#296): a matched cycle past its expected
# duration that has settled into the low-power tumble tail is done -
# finalize into anti-wrinkle now instead of letting the periodic
# bursts keep reviving RUNNING until a second wash merges in.
if self._maybe_finalize_anticrease_tail(timestamp):
return
# Use dynamic threshold
thresh = self._dynamic_pause_threshold
if self._time_below_threshold >= thresh:
@@ -1008,16 +1108,71 @@ class CycleDetector:
# Periodic profile matching
self._try_profile_match(timestamp)
# Standby-band stuck finalize (#296): an appliance holding a flat
# low standby draw ABOVE stop_threshold never accumulates
# _time_below_threshold, so it never reaches PAUSED/ENDING. Detect
# the plateau and finalize as a normal completion (so anti-wrinkle
# still engages). Cheaply gated on being well past expected before
# the window scan runs.
if self._is_standby_band_stuck(timestamp):
start_time = self._current_cycle_start or timestamp
current_duration = (timestamp - start_time).total_seconds()
# The plateau sits ABOVE stop_threshold, so it keeps advancing
# _last_active_time and the default keep_tail=False trim would NOT
# remove it - inflating the stored duration/energy with minutes of
# standby. Snap the end back to the last real activity (the last
# reading above the plateau ceiling) and drop the trailing plateau.
level_ceiling = float(self._cycle_max_power) * STANDBY_BAND_MAX_FRACTION
plateau_start_idx = None
for i in range(len(self._power_readings) - 1, -1, -1):
if float(self._power_readings[i][1]) > level_ceiling:
plateau_start_idx = i
break
if (
plateau_start_idx is not None
and plateau_start_idx < len(self._power_readings) - 1
):
self._power_readings = self._power_readings[: plateau_start_idx + 1]
self._last_active_time = self._power_readings[-1][0]
current_duration = (
self._last_active_time - start_time
).total_seconds()
self._logger.info(
"Standby-band finalize: flat plateau ~%.1fW (peak %.0fW) held "
"past expected %.0fs — appliance finished but holds a standby "
"draw above stop_threshold; finalizing (plateau trimmed, "
"duration %.0fs).",
power,
self._cycle_max_power,
self._expected_duration,
current_duration,
)
self._finish_cycle(
timestamp,
status="completed",
termination_reason=TerminationReason.TIMEOUT,
keep_tail=False,
)
return
# Max duration safety
if (
self._current_cycle_start
and (timestamp - self._current_cycle_start).total_seconds() > 28800
): # 8h safety
self._finish_cycle(timestamp, status="force_stopped")
self._finish_cycle(
timestamp,
status="force_stopped",
termination_reason=TerminationReason.FORCE_STOPPED,
)
elif self._state == STATE_PAUSED:
self._power_readings.append((timestamp, power))
# Anti-crease finalize (#296) - see the RUNNING branch.
if self._maybe_finalize_anticrease_tail(timestamp):
return
if is_high:
# Resume to RUNNING
self._transition_to(STATE_RUNNING, timestamp)
@@ -1032,6 +1187,25 @@ class CycleDetector:
elif self._state == STATE_ENDING:
self._power_readings.append((timestamp, power))
# Hard cap: ENDING must not run longer than RUNNING's 8 h safety limit.
# Without this a standby baseline can hold the state open indefinitely.
if (
self._current_cycle_start
and (timestamp - self._current_cycle_start).total_seconds() > 28800
):
self._finish_cycle(
timestamp,
status="force_stopped",
termination_reason=TerminationReason.FORCE_STOPPED,
)
return
# Anti-crease finalize (#296) - see the RUNNING branch. Fires ahead of
# the is_high end-spike handling so a sub-max_power tail burst finalizes
# into anti-wrinkle instead of reviving RUNNING.
if self._maybe_finalize_anticrease_tail(timestamp):
return
if is_high:
start_time = self._current_cycle_start or timestamp
current_duration = (timestamp - start_time).total_seconds()
@@ -1196,8 +1370,14 @@ class CycleDetector:
# the soak-bridging min_off_gap before committing
# Smart Termination, so a near-duplicate profile
# doesn't cut a long cycle short during a mid-cycle
# power trough.
smart_debounce = max(180.0, self._config.min_off_gap * 0.5)
# power trough. Bounded above so a large suggested /
# hand-set min_off_gap can't inflate the quiet-time
# requirement and starve end-detection (see
# WASHER_SMART_TERMINATION_DEBOUNCE_MAX_SECONDS).
smart_debounce = min(
WASHER_SMART_TERMINATION_DEBOUNCE_MAX_SECONDS,
max(180.0, self._config.min_off_gap * 0.5),
)
else:
smart_debounce = 120.0
@@ -1276,6 +1456,64 @@ class CycleDetector:
)
return
# --- DURATION-ANCHORED HARD FINALIZE (backstop) ---
# Separate safety net for a matched cycle whose Smart
# Termination is blocked (ambiguous / prefix-ambiguous match)
# and whose fallback energy gate is held open by a low standby
# baseline: without this it sits in ENDING until the 8 h cap /
# zombie-kill (#296/#311). Fires only well past the expected
# duration AND after a long *continuous* sub-threshold span, so
# it can never truncate a longer program mismatched to a shorter
# profile (a real longer program has high-power phases that keep
# resetting the quiet timer) — asymmetric, shorten-only. Not
# for user-paused cycles.
required_quiet = max(
ENDING_HARD_FINALIZE_MIN_QUIET_S,
float(max(self._config.off_delay, self._config.min_off_gap)),
)
# Require the required_quiet tail to be actually SAMPLED (no
# outage-sized gap): otherwise a telemetry outage that inflated
# _time_below_threshold could finalize an active cycle early.
# Walk in reverse so we can capture the boundary reading (the
# first reading outside the window) — an outage right before the
# window would be invisible if we only passed in-window timestamps.
quiet_ts: list[datetime] = []
_boundary_q: datetime | None = None
for _ts, _ in reversed(self._power_readings):
if (timestamp - _ts).total_seconds() <= required_quiet:
quiet_ts.append(_ts)
elif quiet_ts:
_boundary_q = _ts
break
if _boundary_q is not None:
quiet_ts.append(_boundary_q)
if (
self._expected_duration > 0
and current_duration
>= self._expected_duration * ENDING_HARD_FINALIZE_RATIO
and self._time_below_threshold >= required_quiet
and not self._verified_pause
and not self._window_has_outage_gap(quiet_ts)
):
self._logger.info(
"Duration-anchored finalize: cycle in ENDING at %.0fs "
"(%.1fx expected %.0fs), quiet %.0fs — Smart Termination "
"was blocked (ambiguous=%s prefix=%s); finalizing.",
current_duration,
current_duration / self._expected_duration,
self._expected_duration,
self._time_below_threshold,
self._match_ambiguous,
self._match_prefix_ambiguous,
)
self._finish_cycle(
timestamp,
status="completed",
termination_reason=TerminationReason.TIMEOUT,
keep_tail=False,
)
return
# --- FALLBACK TIMEOUT CHECK ---
# Rule: To separate cycles, we must wait at least min_off_gap.
effective_off_delay = max(self._config.off_delay, self._config.min_off_gap)
@@ -1333,11 +1571,15 @@ class CycleDetector:
if self._time_below_threshold >= effective_off_delay:
recent_window = [
r
for r in self._power_readings
if (timestamp - r[0]).total_seconds() <= gate_window
]
# Walk from the tail — readings are chronological so we can
# break as soon as we exceed the gate window (O(window) not O(n)).
recent_window = []
for r in reversed(self._power_readings):
if (timestamp - r[0]).total_seconds() <= gate_window:
recent_window.append(r)
else:
break
recent_window.reverse()
if not recent_window:
# Check deferred finish for matched profiles
@@ -1408,6 +1650,10 @@ class CycleDetector:
self._delay_wait_high_start = None
self._delay_wait_high_power = None
self._preserve_delay_band_on_off = False
# Clear the paused-STARTING true-off accumulator so a later STARTING
# cycle cannot inherit stale hold time and finalize to OFF prematurely
# (this path is also reached via the paused-STARTING cancellation).
self._starting_paused_off_since = None
# Reset end spike tracker when entering ENDING state
if new_state == STATE_ENDING:
@@ -1433,6 +1679,8 @@ class CycleDetector:
elif new_state == STATE_STARTING:
# Reset idle time if exiting ANTI_WRINKLE to STARTING (high-power burst resumed)
self._anti_wrinkle_idle_time = 0.0
# Fresh STARTING cycle: never inherit a prior cycle's true-off hold.
self._starting_paused_off_since = None
elif new_state == STATE_RUNNING:
self._delay_band_start = None
self._delay_band_seconds = 0.0
@@ -1478,6 +1726,266 @@ class CycleDetector:
self._ml_end_cache = (now_ts, exp, start, result)
return result
def _window_has_outage_gap(self, window_ts: list[datetime]) -> bool:
"""Whether a 'sustained window' contains a data-outage-sized hole.
The span + coverage checks in the standby / anti-crease window scans accept
e.g. three readings spanning the window even if a long unobserved gap sits
between them (a sensor dropout, or a sparse burst next to one old reading).
Finalizing on such a window could wrongly cut an active cycle, so reject it.
The gap ceiling is the sensor's own data-driven outage threshold
(``energy_gap_threshold_s`` over the full trace), so a change-only sensor's
legitimately-sparse stable stretches (tens of seconds between reports) are
NOT rejected while a genuine dropout is.
"""
if len(window_ts) < 2:
return True # too few points to trust as a sustained window
max_gap = self._outage_threshold_s()
ordered = sorted(t.timestamp() for t in window_ts)
return any((b - a) > max_gap for a, b in itertools.pairwise(ordered))
def _outage_threshold_s(self) -> float:
"""Sensor-adaptive gap ceiling (seconds): intervals longer than this are
treated as telemetry outages, not observed quiet. Data-driven from the
trace's own cadence (`energy_gap_threshold_s`), so a change-only sensor's
sparse-but-real stable stretches are not mistaken for a dropout.
"""
all_ts = np.array(
[r[0].timestamp() for r in self._power_readings], dtype=float
)
return energy_gap_threshold_s(all_ts)
def _is_standby_band_stuck(self, timestamp: datetime) -> bool:
"""Whether a RUNNING cycle is stuck on a flat standby plateau (#296).
Returns True only when ALL of the following hold, so this can never end
an active low-power phase:
* the device is a wet appliance where a stuck baseline is unambiguously
anomalous (``STANDBY_BAND_FINALIZE_DEVICE_TYPES``);
* a profile is matched and elapsed >= ``STANDBY_BAND_MIN_RATIO`` x the
expected duration (well past when it should have ended);
* not user-paused;
* the most recent >= ``STANDBY_BAND_WINDOW_S`` of readings are ALL at or
below ``STANDBY_BAND_MAX_FRACTION`` of the cycle's own peak power AND
span no more than ``STANDBY_BAND_FLATNESS_FRACTION`` of the peak (a flat
plateau, not fluctuating activity).
The expensive window scan runs only after the cheap duration gate passes,
so normal cycles never pay for it.
"""
if self._config.device_type not in STANDBY_BAND_FINALIZE_DEVICE_TYPES:
return False
if getattr(self, "_verified_pause", False):
return False
if not (self._matched_profile and self._expected_duration > 0):
return False
start = self._current_cycle_start
if start is None:
return False
current_duration = (timestamp - start).total_seconds()
if current_duration < self._expected_duration * STANDBY_BAND_MIN_RATIO:
return False
peak = float(self._cycle_max_power)
if peak <= 0:
return False
level_ceiling = peak * STANDBY_BAND_MAX_FRACTION
# Walk the tail; readings are chronological so we can break once outside
# the window (O(window), not O(n)).
window: list[float] = []
window_ts: list[datetime] = []
oldest_in_window: datetime | None = None
saw_older = False # a reading older than the window exists -> full coverage
_standby_boundary_ts: datetime | None = None
for ts, p in reversed(self._power_readings):
if (timestamp - ts).total_seconds() <= STANDBY_BAND_WINDOW_S:
window.append(float(p))
window_ts.append(ts)
oldest_in_window = ts
else:
saw_older = True
_standby_boundary_ts = ts # boundary: last reading before the window
break
# The plateau must actually SPAN the required window (data exists from
# before it), not just a couple of recent samples, and have enough points
# to judge. `saw_older` (rather than an exact span >= WINDOW check) is
# robust to sample phase/granularity: with e.g. 30 s sampling the oldest
# in-window reading is typically only ~570-599 s old, which an exact check
# would wrongly reject. A coverage sanity (oldest >= 90% of the window)
# plus an adjacent-gap check (``_window_has_outage_gap``) guard against a
# sparse burst of samples sitting next to one old reading across a dropout.
if (
oldest_in_window is None
or not saw_older
or len(window) < 3
or (timestamp - oldest_in_window).total_seconds()
< STANDBY_BAND_WINDOW_S * 0.9
or self._window_has_outage_gap(
[_standby_boundary_ts, *window_ts]
if _standby_boundary_ts is not None
else window_ts
)
):
return False
hi = max(window)
lo = min(window)
if hi > level_ceiling:
return False # a real active reading in the window - not standby
flatness_limit = max(
STANDBY_BAND_FLATNESS_FLOOR_W, peak * STANDBY_BAND_FLATNESS_FRACTION
)
if (hi - lo) > flatness_limit:
return False # fluctuating - still doing work
return True
def _anticrease_gate_open(self, timestamp: datetime) -> bool:
"""Core anti-crease gate (#296): everything except the current power level
and the low-power-window check. Shared by the match freeze
(``_in_anticrease_freeze``) and the finalise (``_is_anticrease_tail``).
True only when a genuinely energetic, confidently-matched cycle for an
anti-wrinkle device is PAST its expected duration - the discriminator that
separates the post-wash anti-crease tail from a mid-wash low-power trough
(a washer spends most of its cycle below ``anti_wrinkle_max_power``, but a
mid-wash trough is always BEFORE the expected duration, the tail after it).
"""
if not self._config.anti_wrinkle_enabled:
return False
if self._config.device_type not in (
DEVICE_TYPE_WASHING_MACHINE,
DEVICE_TYPE_DRYER,
DEVICE_TYPE_WASHER_DRYER,
):
return False
if getattr(self, "_verified_pause", False):
return False
if not (self._matched_profile and self._expected_duration > 0):
return False
if self._last_match_confidence < 0.4:
return False
if self._match_ambiguous or self._match_prefix_ambiguous:
return False
if self._cycle_max_power <= float(self._config.anti_wrinkle_max_power):
return False # never a hot/energetic cycle - leave low-power programs alone
start = self._current_cycle_start
if start is None:
return False
current_duration = (timestamp - start).total_seconds()
if current_duration < self._expected_duration * ANTI_CREASE_FINALIZE_RATIO:
return False
return True
def _in_anticrease_freeze(self, timestamp: datetime) -> bool:
"""Whether match updates should be frozen (#296): the anti-crease gate is
open AND the most recent reading is in the low-power regime (at or below
``anti_wrinkle_max_power``).
Deliberately lighter than ``_is_anticrease_tail`` - it does NOT wait for the
full ``ANTI_CREASE_CONFIRM_WINDOW_S``, so the confident pre-tail match is
preserved from the instant the cycle crosses its expected duration in a
low-power state, before the window accrues. Without this a match that
degrades to ambiguous within the first window's worth of tail would
deadlock both the freeze and the finalise (both require an unambiguous
match). Self-correcting: a heating burst above ``anti_wrinkle_max_power``
leaves the regime and re-arms matching.
"""
if not self._power_readings:
return False
if float(self._power_readings[-1][1]) > float(
self._config.anti_wrinkle_max_power
):
return False
return self._anticrease_gate_open(timestamp)
def _is_anticrease_tail(self, timestamp: datetime) -> bool:
"""Whether a matched, past-expected cycle has settled into the anti-crease
tumble tail (#296) - the trigger for the finalise into STATE_ANTI_WRINKLE.
Miele-style "Knitterschutz": after the wash proper ends, the machine holds
a constant baseline plus periodic sub-``anti_wrinkle_max_power`` tumble
bursts (no heating) until the door is opened. Because those bursts recur
faster than off_delay they keep reviving the cycle out of ENDING, so the
normal power-off path never finalises it and STATE_ANTI_WRINKLE - which is
built to absorb the tail and split off the next wash - never engages.
Recognising the tail lets us finalise into anti-wrinkle directly.
Requires the core gate (``_anticrease_gate_open``) AND that the most recent
>= ``ANTI_CREASE_CONFIRM_WINDOW_S`` of readings are ALL at or below
``anti_wrinkle_max_power`` (we are in the low-power tail, clear of the final
spin and not mid-heating). The expensive window scan runs only after the
cheap gate passes, so normal cycles never pay for it.
"""
if not self._anticrease_gate_open(timestamp):
return False
max_power = float(self._config.anti_wrinkle_max_power)
# Walk the tail; readings are chronological so we can break once outside the
# window (O(window), not O(n)).
window: list[float] = []
window_ts: list[datetime] = []
oldest_in_window: datetime | None = None
saw_older = False
_ac_boundary_ts: datetime | None = None
for ts, p in reversed(self._power_readings):
if (timestamp - ts).total_seconds() <= ANTI_CREASE_CONFIRM_WINDOW_S:
window.append(float(p))
window_ts.append(ts)
oldest_in_window = ts
else:
saw_older = True
_ac_boundary_ts = ts # boundary: last reading before the window
break
# The low-power tail must actually SPAN the window (data exists from before
# it) and have enough points to judge - not just a couple of recent samples.
# ``saw_older`` plus a coverage sanity and an adjacent-gap check
# (``_window_has_outage_gap``) is robust to sample phase/granularity while
# rejecting a dropout-sized hole (mirrors _is_standby_band_stuck).
if (
oldest_in_window is None
or not saw_older
or len(window) < 3
or (timestamp - oldest_in_window).total_seconds()
< ANTI_CREASE_CONFIRM_WINDOW_S * 0.9
or self._window_has_outage_gap(
[_ac_boundary_ts, *window_ts]
if _ac_boundary_ts is not None
else window_ts
)
):
return False
if max(window) > max_power:
return False # a heating / high-spin reading in the window - still washing
return True
def _maybe_finalize_anticrease_tail(self, timestamp: datetime) -> bool:
"""Finalise a cycle that has entered the anti-crease tail into
STATE_ANTI_WRINKLE (#296). Returns True if the cycle was finalised.
Shared by the RUNNING / PAUSED / ENDING branches so the finalise fires no
matter which state a burst left the detector in. Uses Smart Termination
(in ``ANTI_WRINKLE_ELIGIBLE_REASONS``) so ``_finish_cycle`` routes into
STATE_ANTI_WRINKLE, which then absorbs the tail and splits off any next
wash on its first heating burst above ``anti_wrinkle_max_power``.
"""
if not self._is_anticrease_tail(timestamp):
return False
start_time = self._current_cycle_start or timestamp
current_duration = (timestamp - start_time).total_seconds()
self._logger.info(
"Anti-crease finalize: matched '%s' past expected %.0fs (elapsed %.0fs), "
"settled into the low-power tumble tail — finalizing into anti-wrinkle.",
self._matched_profile,
self._expected_duration,
current_duration,
)
self._finish_cycle(
timestamp,
status="completed",
termination_reason=TerminationReason.SMART,
keep_tail=True,
)
return True
def _is_terminal_drop(self) -> bool:
"""Whether the current low-power event is an anomalously-early hard drop.
+17 -3
View File
@@ -56,10 +56,24 @@ class LovelaceResourceItem(TypedDict, total=False):
def get_cache_buster(filename: str = CARD_NAME) -> str:
"""Generate a stable cache buster based on a www asset's mtime."""
"""Generate a stable cache buster based on a www asset's mtime.
Also considers the translations/panel/ directory mtime so that
translation-only releases (e.g. GitLocalize merges) still bust the
browser cache for both the panel JS and the per-language JSON files.
"""
try:
src = Path(__file__).parent / "www" / filename
return str(int(os.path.getmtime(src)))
base = Path(__file__).parent
src_mtime = os.path.getmtime(base / "www" / filename)
try:
panel_dir = base / "translations" / "panel"
trans_mtime = max(
(os.path.getmtime(f) for f in panel_dir.iterdir() if f.is_file()),
default=0.0,
)
except OSError:
trans_mtime = 0.0
return str(int(max(src_mtime, trans_mtime)))
except OSError:
# Deterministic fallback when file is unavailable.
return "1"
+43 -16
View File
@@ -140,6 +140,7 @@ class LearningManager:
self._sample_interval_model = StatisticalModel(max_samples=200)
self._last_suggestion_update: datetime | None = None
self._last_batch_simulation_count: int = 0 # track when to re-run batch
self._last_suggestions_labeled_count: int = 0 # gate model/detection passes
def _apply_suggestions_and_notify(self, suggestions: dict[str, Any]) -> None:
"""Apply suggestions that pass quality gates."""
@@ -232,9 +233,17 @@ class LearningManager:
predicted_duration: Expected duration in seconds
match_result: MatchResult from profile_store.async_match_profile() (optional)
"""
# 1. Trigger background simulation to find optimal parameters for this cycle
if cycle_data.get("power_data"):
# Offload to executor since simulation can be heavy
# 1. Trigger single-cycle simulation — only for cleanly-completed, labeled,
# non-noise cycles. Skipping force_stopped/unlabeled/noise avoids deriving
# start/stop thresholds from mis-detected or truncated cycles.
_profile = detected_profile or cycle_data.get("profile_name")
_is_clean = (
cycle_data.get("power_data")
and _profile
and _profile != "noise"
and cycle_data.get("status") == "completed"
)
if _is_clean:
self.hass.async_create_task(self._async_run_simulation(cycle_data))
# 2. Check if we should request feedback
@@ -242,11 +251,20 @@ class LearningManager:
cycle_data, detected_profile, confidence, predicted_duration, match_result
)
# 3. Update model-based suggestions (durations etc)
self._update_model_suggestions()
# 3b. Update statistical detection suggestions (thresholds, gates, etc.)
self._update_detection_suggestions()
# 3+3b. Heavy per-profile suggestion passes — only run when the labeled
# cycle count has grown since the last update (skips passes for unlabeled /
# noise / duplicate ends with no new data).
labeled_count = sum(
1 for c in self.profile_store.get_past_cycles()
if isinstance(c, dict)
and c.get("profile_name")
and c.get("profile_name") != "noise"
and c.get("status") in ("completed", "force_stopped")
)
if labeled_count > self._last_suggestions_labeled_count:
self._last_suggestions_labeled_count = labeled_count
self._update_model_suggestions()
self._update_detection_suggestions()
# 4. Run multi-cycle batch simulation when enough new labeled cycles have accumulated
self._maybe_run_batch_simulation()
@@ -517,18 +535,27 @@ class LearningManager:
# even if the matcher was confident. Downgrade to a feedback request so
# the user can verify the match; this catches confident but wrong labels.
ml_quality = cycle_data.get("ml_quality_score")
ml_suspicious = (
isinstance(ml_quality, float)
and ml_quality >= ML_QUALITY_SUSPICIOUS_THRESHOLD
)
# Use float() so numpy scalars (float32/float64) returned by resolve_scorer
# are accepted — isinstance(numpy_float, float) is False in NumPy ≥ 2.0.
# Wrap in try/except so non-numeric sentinel values are silently ignored.
try:
ml_suspicious = (
ml_quality is not None
and float(ml_quality) >= ML_QUALITY_SUSPICIOUS_THRESHOLD
)
except (TypeError, ValueError):
ml_suspicious = False
# Also downgrade when the cycle's power trace is mostly outside the
# profile envelope band (low conformance = the shape matched but the
# actual power levels are inconsistent with the profile).
_conformance = cycle_data.get("envelope_conformance")
envelope_suspicious = (
isinstance(_conformance, float)
and _conformance < 0.40
)
try:
envelope_suspicious = (
_conformance is not None
and float(_conformance) < 0.40
)
except (TypeError, ValueError):
envelope_suspicious = False
if route_conf >= auto_label_conf:
if ml_suspicious or envelope_suspicious:
if ml_suspicious:
+184 -39
View File
@@ -25,7 +25,9 @@ import hashlib
import inspect
import math
import uuid
import asyncio
from asyncio import Task
from collections.abc import Coroutine
from datetime import datetime, timedelta
from typing import TYPE_CHECKING, Any, cast
import numpy as np
@@ -380,6 +382,7 @@ class WashDataManager:
self._notify_finish_services: list[str] = []
self._notify_live_services: list[str] = []
self._notify_actions: list[dict[str, Any]] = []
self._notify_script: Any = None # cached Script; invalidated on options reload
self._notify_people: list[str] = []
self._notify_cycle_timers: list[dict[str, Any]] = []
self._fired_cycle_timers: set[int] = set()
@@ -485,6 +488,12 @@ class WashDataManager:
self._sample_intervals: list[float] = []
self._sample_interval_stats: dict[str, Any] = {}
self._matching_task: Task[Any] | None = None
self._cycle_end_task: Task[Any] | None = None
# Detached store-touching tasks (matching trigger, active-cycle clear,
# post-cycle processing) tracked so async_shutdown can cancel them before a
# reload/unload swaps the ProfileStore out from under them.
self._background_tasks: set[Task[Any]] = set()
self._is_shutdown: bool = False
self._last_state_save = 0.0
self._last_cycle_end_time: datetime | None = None
self._remove_state_expiry_timer = None
@@ -503,7 +512,9 @@ class WashDataManager:
self.entry_id,
min_duration_ratio=config_entry.options.get(
CONF_PROFILE_MATCH_MIN_DURATION_RATIO,
DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO,
DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO_BY_DEVICE.get(
self.device_type, DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO
),
),
max_duration_ratio=config_entry.options.get(
CONF_PROFILE_MATCH_MAX_DURATION_RATIO,
@@ -769,7 +780,7 @@ class WashDataManager:
# Snapshotted for thread safety indirectly by task logic
# We don't need a wrapper task if we unify with _update_estimates matching
# but for now let's keep the detector callback as a trigger
self.hass.async_create_task(self._async_perform_combined_matching(readings))
self._spawn_tracked(self._async_perform_combined_matching(readings))
return (None, 0.0, 0.0, None)
self.detector = CycleDetector(
@@ -2060,7 +2071,9 @@ class WashDataManager:
new_min_ratio = float(
config_entry.options.get(
CONF_PROFILE_MATCH_MIN_DURATION_RATIO,
DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO,
DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO_BY_DEVICE.get(
self.device_type, DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO
),
)
)
new_max_ratio = float(
@@ -2074,6 +2087,10 @@ class WashDataManager:
self.profile_store.set_duration_ratio_limits(
min_ratio=new_min_ratio, max_ratio=new_max_ratio
)
# Keep the detector's copy in sync: _should_defer_finish() reads
# detector.config.min_duration_ratio, which otherwise keeps the
# construction-time value until a restart (diverging from the matcher).
self.detector.config.min_duration_ratio = new_min_ratio
self._logger.info(
"Updated duration ratios: min %.2f%.2f, max %.2f%.2f",
old_min_ratio,
@@ -2106,6 +2123,7 @@ class WashDataManager:
self._notify_actions = list(
cast(list[dict[str, Any]], config_entry.options.get(CONF_NOTIFY_ACTIONS, []) or [])
)
self._notify_script = None
self._notify_people = list(
config_entry.options.get(CONF_NOTIFY_PEOPLE, []) or []
)
@@ -2200,8 +2218,43 @@ class WashDataManager:
self._logger.info("Configuration reloaded successfully")
def _spawn_tracked(self, coro: Coroutine[Any, Any, Any]) -> Task[Any]:
"""Create a detached task and track it so shutdown can cancel it.
Use for fire-and-forget tasks that touch the ProfileStore (matching
trigger, active-cycle clear, post-cycle processing): if a reload/unload
swaps the store out mid-flight, an untracked task would keep writing to the
stale store. The task auto-removes itself from the set when it finishes.
"""
task = self.hass.async_create_task(coro)
# Real HA always returns a Task; guard for degenerate returns (e.g. a
# mocked hass in tests) so tracking never breaks the caller.
if task is not None and hasattr(task, "add_done_callback"):
self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)
return task
async def async_shutdown(self) -> None:
"""Shutdown."""
self._is_shutdown = True
# Cancel in-flight matching and cycle-end tasks so they don't race a
# freshly-loaded ProfileStore on reload_config_entry.
_to_await: list[Task[Any]] = []
if self._matching_task and not self._matching_task.done():
self._matching_task.cancel()
_to_await.append(self._matching_task)
if self._cycle_end_task and not self._cycle_end_task.done():
self._cycle_end_task.cancel()
_to_await.append(self._cycle_end_task)
# Cancel every other tracked detached task (matching trigger, active-cycle
# clear, post-cycle processing) for the same reason.
for task in list(self._background_tasks):
if not task.done():
task.cancel()
_to_await.append(task)
# Drain cancelled tasks so they don't race the freshly-reloaded ProfileStore.
if _to_await:
await asyncio.gather(*_to_await, return_exceptions=True)
if self._remove_listener:
self._remove_listener()
if self._remove_external_trigger_listener:
@@ -2759,10 +2812,32 @@ class WashDataManager:
now = dt_util.now()
# Throttle updates to avoid CPU overload on noisy sensors
# BUT always allow updates if power is below min_power (critical end-of-cycle signal).
# Throttle updates to avoid CPU overload on noisy sensors.
# Low-power readings bypass throttling when:
# (a) a cycle is active (RUNNING/ENDING/PAUSED) — critical end-of-cycle signal, or
# (b) this is a genuine power DROP from above min_power — captures power-off events
# that occur before the detector has processed the previous above-threshold reading.
# Without the guard, an idle device at 0W fires an update on every sensor poll (typically
# every 15 s), flooding the detector with zero-value no-ops.
min_p = float(self.detector.config.min_power)
is_low_power = power < min_p
# For the "genuine drop" bypass, compare against the previous RAW sensor
# value (old_state), not _current_power: the latter is only updated after a
# reading passes the throttle, so a suppressed high reading would leave it
# low and the following low reading would be throttled too, missing a short
# high->low transition. old_state reflects the plug's actual prior value.
prev_raw_power = self._current_power
old_state = cast(State | None, event_data.get("old_state"))
if old_state is not None and old_state.state not in (
STATE_UNKNOWN, STATE_UNAVAILABLE
):
try:
prev_raw_power = float(old_state.state)
except ValueError:
pass
is_low_power = power < min_p and (
self.detector.state in (STATE_RUNNING, STATE_PAUSED, STATE_ENDING)
or prev_raw_power >= min_p # genuine drop from active power
)
if (
not is_low_power
@@ -3191,6 +3266,31 @@ class WashDataManager:
self._notify_update()
return
# Secondary zombie guard for unmatched cycles (expected == 0 when no profile
# has been matched). The detector hard-caps RUNNING at 8h but a chatty sensor
# that never goes silent can keep the watchdog from reaching the end state.
# Kill here after 4h so a stuck false-start doesn't run indefinitely.
elif (
expected == 0
and not self._is_user_paused
and not _verified_pause_zombie
and elapsed > 14400
# Gate on the active detector state, not _current_program: the latter is
# only set to "detecting..." on the RUNNING transition, so a cycle stuck
# in STARTING keeps a stale program and would never hit this 4h guard.
and self.detector.state in (
STATE_STARTING, STATE_RUNNING, STATE_PAUSED, STATE_ENDING
)
):
self._logger.warning(
"Watchdog: Unmatched cycle exceeded 4h (%.0fs). Force-ending.",
elapsed,
)
self.detector.force_end(now)
self._current_power = 0.0
self._notify_update()
return
# 1. GHOST CYCLE SUPPRESSOR
# If we are "detecting" for more than 10 minutes and haven't seen an update for 5 minutes,
# it's likely a pump-out spike or an accidental start (ghost cycle).
@@ -3457,6 +3557,14 @@ class WashDataManager:
},
)
self._start_event_fired = True
# Mark the start fully handled ONLY when there is no push to send, so
# the restart-recovery fallback does not re-enter for event-only
# configs. When a push service/action IS configured, leave
# _notified_start False so the push block below still fires: a config
# with both events and push must get both (event delivery is tracked
# separately by _start_event_fired).
if not (self._notify_start_services or self._notify_actions):
self._notified_start = True
# Fire push notification immediately - do not wait for profile matching.
if not self._notified_start and (self._notify_start_services or self._notify_actions):
@@ -3512,7 +3620,7 @@ class WashDataManager:
stranded in a terminal state with a stale active snapshot until the next
cycle. Deliberately does NOT persist, notify, or run the learning pipeline.
"""
self.hass.async_create_task(self.profile_store.async_clear_active_cycle())
self._spawn_tracked(self.profile_store.async_clear_active_cycle())
# Anchor the terminal state so _handle_state_expiry (and power-off) can act,
# then arm the expiry timer that resets terminal -> Off after the reset delay.
self._cycle_completed_time = dt_util.now()
@@ -3606,9 +3714,10 @@ class WashDataManager:
# detector into a fresh RUNNING before post-processing completes). See B1 in
# _async_process_cycle_end.
end_token = self._ranking_snapshot_cycle_id
self.hass.async_create_task(
self._async_process_cycle_end(cycle_data, cycle_token=end_token)
)
if not self._is_shutdown:
self._cycle_end_task = self._spawn_tracked(
self._async_process_cycle_end(cycle_data, cycle_token=end_token)
)
def _ml_end_confidence(
self, points: list[tuple[float, float]], expected_duration: float
@@ -3811,7 +3920,11 @@ class WashDataManager:
self._logger,
)
def _compute_cycle_quality_score(self, cycle_data: dict[str, Any]) -> None:
def _compute_cycle_quality_score(
self,
cycle_data: dict[str, Any],
past_cycles_snapshot: list[dict[str, Any]] | None = None,
) -> None:
"""Score a just-finished cycle with the hybrid_curve_quality model (opt-in).
When ML models are enabled for this device, computes P(cycle is a problem)
@@ -3842,7 +3955,16 @@ class WashDataManager:
durations: list[float] = []
energies: list[float] = []
peaks: list[float] = []
for c in self.profile_store.get_past_cycles():
# This function runs in an executor thread and the event loop may
# append cycles concurrently; iterating (or even copying) the live list
# here is a data race. Prefer the snapshot taken on the event loop at
# the call site; only fall back to a local copy for direct callers.
cycles = (
past_cycles_snapshot
if past_cycles_snapshot is not None
else list(self.profile_store.get_past_cycles())
)
for c in cycles:
if c.get("profile_name") != profile_name:
continue
if c.get("duration") is not None:
@@ -4265,8 +4387,12 @@ class WashDataManager:
from .ml.engine import ml_models_enabled # noqa: PLC0415
if ml_models_enabled(self.config_entry.options):
# Snapshot past_cycles on the event loop before offloading: iterating
# (or copying) the live list inside the executor races the loop
# appending this just-finished cycle.
past_cycles_snapshot = list(self.profile_store.get_past_cycles())
await self.hass.async_add_executor_job(
self._compute_cycle_quality_score, cycle_data
self._compute_cycle_quality_score, cycle_data, past_cycles_snapshot
)
# Add cycle to store immediately (still sync but offloadable parts optimized
@@ -4331,10 +4457,10 @@ class WashDataManager:
# If a new cycle started during the awaits above, it now owns the active
# snapshot; clearing it here would strip the new cycle's restart-resilience.
if cycle_token is None or self._ranking_snapshot_cycle_id == cycle_token:
self.hass.async_create_task(self.profile_store.async_clear_active_cycle())
self._spawn_tracked(self.profile_store.async_clear_active_cycle())
# Auto post-process: merge fragmented cycles from last 3 hours
self.hass.async_create_task(self._run_post_cycle_processing())
self._spawn_tracked(self._run_post_cycle_processing())
# Prepare cycle data for event (enrich if needed)
# IMPORTANT: Exclude large fields to prevent exceeding HA's 32KB event data limit
@@ -5079,33 +5205,52 @@ class WashDataManager:
if not actions:
return False
try:
script = script_helper.Script(
self.hass,
actions,
name=f"{self.config_entry.title} notification",
domain=DOMAIN,
logger=_LOGGER,
)
except (ValueError, TypeError, HomeAssistantError) as err:
self._logger.error(
"Invalid notification action configuration for %s: %s",
self.config_entry.title,
err,
)
return False
except Exception as err:
self._logger.exception(
"Unexpected error while building notification actions for %s: %s",
self.config_entry.title,
err,
)
return False
if self._notify_script is None:
try:
self._notify_script = script_helper.Script(
self.hass,
actions,
name=f"{self.config_entry.title} notification",
domain=DOMAIN,
logger=_LOGGER,
)
except (ValueError, TypeError, HomeAssistantError) as err:
self._logger.error(
"Invalid notification action configuration for %s: %s",
self.config_entry.title,
err,
)
return False
except Exception as err:
self._logger.exception(
"Unexpected error while building notification actions for %s: %s",
self.config_entry.title,
err,
)
return False
script = self._notify_script
try:
self.hass.async_create_task(
action_task = self.hass.async_create_task(
script.async_run(variables, context=Context())
)
# This method is synchronous, so the script runs fire-and-forget: a
# failure inside async_run() would otherwise land after we return True
# and be swallowed. Surface it via a done-callback so an action-only
# setup at least logs the drop. (A user-visible fallback would require
# awaiting, i.e. making the whole notification-dispatch chain async.)
def _log_action_failure(task: Task[Any]) -> None:
if task.cancelled():
return
exc = task.exception()
if exc is not None:
self._logger.warning(
"Notification action execution failed for %s: %s",
self.config_entry.title,
exc,
)
action_task.add_done_callback(_log_action_failure)
return True
except HomeAssistantError as err:
self._logger.warning(
@@ -6118,7 +6263,7 @@ class WashDataManager:
@property
def samples_recorded(self):
"""Return the number of power samples recorded in current cycle."""
return len(self.detector.get_power_trace())
return self.detector.samples_recorded
@property
def sample_interval_stats(self):
+1 -1
View File
@@ -21,5 +21,5 @@
"requirements": [
"numpy"
],
"version": "0.5.1"
"version": "0.5.2"
}
@@ -116,6 +116,28 @@ def resolve_scorer(capability: str, store: object | None):
# classifier and regression capability keys are disjoint today, but this
# guard keeps it safe if a key were ever reused.
if isinstance(spec, dict) and spec.get("kind") != "standardized_linear":
# Feature-column schema guard: a spec promoted under an older
# FEATURE_COLUMNS must be dropped rather than silently scoring on a
# stale/neutral-filled schema. The call-time guard already catches
# shape mismatches, but this catches them at load time and logs
# clearly, so users see a single warm-up warning instead of a
# per-inference warning storm.
module_name = _MODEL_MODULES.get(capability)
if module_name is not None:
try:
_bm = importlib.import_module(f"{__package__}.{module_name}")
_expected = list(getattr(_bm, "FEATURE_COLUMNS", []))
_stored = list(spec.get("feature_columns") or [])
if _expected and _stored and _stored != _expected:
_LOGGER.warning(
"Promoted spec for %r has stale feature schema "
"(%d cols vs current %d); reverting to baseline.",
capability, len(_stored), len(_expected),
)
return _baseline()
except Exception: # noqa: BLE001 - schema check must not break inference
pass
from .trainer import score_spec
def _on_device_score(feats, _s=spec):
@@ -165,6 +187,21 @@ def resolve_regressor(capability: str, store: object | None):
record = versions.get(capability)
spec = record.get("spec") if isinstance(record, dict) else None
if isinstance(spec, dict) and spec.get("kind") == "standardized_linear":
# Feature-column schema guard for regression specs.
try:
from .feature_extraction import PROGRESS_FEATURE_COLUMNS
_expected_r = list(PROGRESS_FEATURE_COLUMNS)
_stored_r = list(spec.get("feature_columns") or [])
if _expected_r and _stored_r and _stored_r != _expected_r:
_LOGGER.warning(
"Promoted regression spec for %r has stale feature schema "
"(%d cols vs current %d); reverting to inert.",
capability, len(_stored_r), len(_expected_r),
)
return (None, None)
except Exception: # noqa: BLE001 - schema check must not break inference
pass
from .trainer import predict_value_spec
def _on_device_predict(feats, _s=spec):
@@ -469,7 +469,9 @@ def _train_regression_capability(
}
preds = np.clip(T.predict_matrix_spec(spec_probe, X_te), 0.0, 1.0)
metrics = T.regression_metrics(y_te, preds)
model_mae = float(metrics.get("mae") or 1.0)
# Explicit None check — `or 1.0` would coerce MAE=0.0 to 1.0, rejecting a
# perfect regressor and blocking promotion.
model_mae = float(metrics.get("mae") if metrics.get("mae") is not None else 1.0)
naive_col = columns.index("elapsed_over_expected") if "elapsed_over_expected" in columns else 0
naive = np.clip(X_te[:, naive_col], 0.0, 1.0)
+24 -7
View File
@@ -107,6 +107,10 @@ DEFAULT_RECENT_CYCLES = 20
MAX_BATCH_CYCLES = 50
# Cap the per-cycle event log so a pathological trace cannot bloat the payload.
MAX_EVENTS_PER_CYCLE = 300
# Cap the per-cycle timeline series so a very long cycle (4h dishwasher = ~2800 pts)
# does not bloat the task result. Points are thinned at finalize time — evenly-spaced,
# so the shape is preserved rather than truncated.
MAX_SERIES_PER_CYCLE = 600
# Override keys the Playground honours, mapped to CycleDetectorConfig fields.
# Only detection-relevant knobs matter; everything else in settings_override is
@@ -232,7 +236,7 @@ def _build_match_snapshots(
) -> tuple[list[dict[str, Any]], dict[str, Any], dict[str, list[str]], dict[str, Any]]:
"""Prepare the matcher snapshots + config once from the store.
Mirrors :meth:`ProfileStore.match_profile`: one snapshot per profile using
Mirrors the store's async matching path: one snapshot per profile using
its sample cycle's decompressed trace, plus the store's live matching config
(with any on-device tuned weight overrides merged in).
@@ -357,7 +361,7 @@ def _readings_from_cycle(
_PROGRESS_STATES = (STATE_RUNNING, STATE_PAUSED, STATE_ENDING)
# States in which no progress estimate is shown (mirrors _update_remaining_only).
_DEAD_STATES = (STATE_OFF, STATE_UNKNOWN, STATE_IDLE)
_SIM_SERIES_THROTTLE_S = 5.0 # production recomputes progress every 5s
_SIM_SERIES_THROTTLE_S = 30.0 # cap estimator calls; 5s matched cadence made this a no-op
def simulate_cycle_detail(
@@ -954,12 +958,20 @@ class _DetailSim:
alerts.append({"code": "underrun", "severity": "warn",
"detail": f"Finished at {ratio:.0%} of typical duration."})
series = self.series
if len(series) > MAX_SERIES_PER_CYCLE:
# Thin evenly so the shape is preserved (first + last always kept).
# Span (len-1)/(N-1) so the final index lands on the true last point
# (a plain len/N tops out below it and drops the terminal sample).
step = (len(series) - 1) / (MAX_SERIES_PER_CYCLE - 1)
series = [series[round(i * step)] for i in range(MAX_SERIES_PER_CYCLE)]
return {
"cycle_id": self.cycle.get("id"),
"label": self.label,
"duration_s": self.stored_duration,
"config_summary": _sim_config_summary(self.config),
"series": self.series,
"series": series,
"events": self.events,
"alerts": alerts,
"outcome": outcome,
@@ -1030,10 +1042,13 @@ def _run_rows(
settings_override: dict[str, Any] | None,
options: dict[str, Any],
price: float | None,
prebuilt: tuple[Any, Any, Any, Any] | None = None,
) -> list[dict[str, Any]]:
# Snapshots are store-derived (independent of the cycle and the detector-level
# settings_override), so build them ONCE and reuse across all cycles/values.
prebuilt = _build_match_snapshots(store)
# Callers that drive many chunks should pass prebuilt= to avoid rebuilding per chunk.
if prebuilt is None:
prebuilt = _build_match_snapshots(store)
rows: list[dict[str, Any]] = []
for cycle in cycles:
detail = simulate_cycle_detail(
@@ -1054,6 +1069,7 @@ def run_playground_history(
options: dict[str, Any] | None,
price: float | None,
concurrency: int,
prebuilt: tuple[Any, Any, Any, Any] | None = None,
) -> dict[str, Any]:
"""Per-cycle rows for the Test-on-history table, plus a before/after diff when
``settings_override`` is set. Executor-safe; never raises."""
@@ -1076,11 +1092,11 @@ def run_playground_history(
selected = selected[:concurrency]
override = settings_override or None
rows = _run_rows(store, selected, base_config, override, options, price)
rows = _run_rows(store, selected, base_config, override, options, price, prebuilt)
payload: dict[str, Any] = {"rows": rows, "summary": _rows_summary(rows)}
if override:
base_rows = _run_rows(store, selected, base_config, None, options, price)
base_rows = _run_rows(store, selected, base_config, None, options, price, prebuilt)
payload["baseline_rows"] = base_rows
payload["baseline_summary"] = _rows_summary(base_rows)
payload["diff"] = _diff_rows(base_rows, rows)
@@ -1272,6 +1288,7 @@ def run_playground_sweep(
concurrency: int,
param_y: str | None = None,
values_y: list[float] | None = None,
prebuilt: tuple[Any, Any, Any, Any] | None = None,
) -> dict[str, Any]:
"""Sweep one param (1D curve) or two params (2D heatmap) and score each point
by ``objective`` computed from the per-cycle rows. Executor-safe; never raises.
@@ -1296,7 +1313,7 @@ def run_playground_sweep(
selected = selected[:concurrency]
def _metric_for(override: dict[str, Any]) -> tuple[float | None, dict[str, Any]]:
rows = _run_rows(store, selected, base_config, override, options, price)
rows = _run_rows(store, selected, base_config, override, options, price, prebuilt)
return objective_metric(rows, objective), _rows_summary(rows)
current_x = _sim_config_summary(base_config).get(

Some files were not shown because too many files have changed in this diff Show More