348 files
This commit is contained in:
@@ -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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
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],
|
||||
},
|
||||
)
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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] + "/"
|
||||
|
||||
@@ -14,5 +14,5 @@
|
||||
"wrapt>=1.14.0",
|
||||
"dictor>=0.1.12,<0.2"
|
||||
],
|
||||
"version": "5.15.6"
|
||||
"version": "5.15.7"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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:
|
||||
|
||||
@@ -22,5 +22,5 @@
|
||||
"requirements": [
|
||||
"ruamel.yaml>=0.18.0"
|
||||
],
|
||||
"version": "1.2.2"
|
||||
"version": "1.2.3"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 1–5 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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user